adjust upgrade procedure, #73185
[freeside.git] / FS / FS / cust_main_county.pm
1 package FS::cust_main_county;
2
3 use strict;
4 use vars qw( @ISA @EXPORT_OK $conf
5              @cust_main_county %cust_main_county $countyflag ); # $cityflag );
6 use Exporter;
7 use FS::Record qw( qsearch qsearchs dbh );
8 use FS::cust_bill_pkg;
9 use FS::cust_bill;
10 use FS::cust_pkg;
11 use FS::part_pkg;
12 use FS::cust_tax_exempt;
13 use FS::cust_tax_exempt_pkg;
14 use FS::upgrade_journal;
15
16 @ISA = qw( FS::Record );
17 @EXPORT_OK = qw( regionselector );
18
19 @cust_main_county = ();
20 $countyflag = '';
21 #$cityflag = '';
22
23 #ask FS::UID to run this stuff for us later
24 $FS::UID::callback{'FS::cust_main_county'} = sub { 
25   $conf = new FS::Conf;
26 };
27
28 =head1 NAME
29
30 FS::cust_main_county - Object methods for cust_main_county objects
31
32 =head1 SYNOPSIS
33
34   use FS::cust_main_county;
35
36   $record = new FS::cust_main_county \%hash;
37   $record = new FS::cust_main_county { 'column' => 'value' };
38
39   $error = $record->insert;
40
41   $error = $new_record->replace($old_record);
42
43   $error = $record->delete;
44
45   $error = $record->check;
46
47   ($county_html, $state_html, $country_html) =
48     FS::cust_main_county::regionselector( $county, $state, $country );
49
50 =head1 DESCRIPTION
51
52 An FS::cust_main_county object represents a tax rate, defined by locale.
53 FS::cust_main_county inherits from FS::Record.  The following fields are
54 currently supported:
55
56 =over 4
57
58 =item taxnum - primary key (assigned automatically for new tax rates)
59
60 =item district - tax district (optional)
61
62 =item city
63
64 =item county
65
66 =item state
67
68 =item country
69
70 =item tax - percentage
71
72 =item taxclass
73
74 =item exempt_amount
75
76 =item taxname - if defined, printed on invoices instead of "Tax"
77
78 =item setuptax - if 'Y', this tax does not apply to setup fees
79
80 =item recurtax - if 'Y', this tax does not apply to recurring fees
81
82 =item source - the tax lookup method that created this tax record. For records
83 created manually, this will be null.
84
85 =back
86
87 =head1 METHODS
88
89 =over 4
90
91 =item new HASHREF
92
93 Creates a new tax rate.  To add the tax rate to the database, see L<"insert">.
94
95 =cut
96
97 sub table { 'cust_main_county'; }
98
99 =item insert
100
101 Adds this tax rate to the database.  If there is an error, returns the error,
102 otherwise returns false.
103
104 =item delete
105
106 Deletes this tax rate from the database.  If there is an error, returns the
107 error, otherwise returns false.
108
109 =item replace OLD_RECORD
110
111 Replaces the OLD_RECORD with this one in the database.  If there is an error,
112 returns the error, otherwise returns false.
113
114 =item check
115
116 Checks all fields to make sure this is a valid tax rate.  If there is an error,
117 returns the error, otherwise returns false.  Called by the insert and replace
118 methods.
119
120 =cut
121
122 sub check {
123   my $self = shift;
124
125   $self->trim_whitespace(qw(district city county state country));
126   $self->set('city', uc($self->get('city'))); # also county?
127
128   $self->exempt_amount(0) unless $self->exempt_amount;
129
130   $self->ut_numbern('taxnum')
131     || $self->ut_alphan('district')
132     || $self->ut_textn('city')
133     || $self->ut_textn('county')
134     || $self->ut_anything('state')
135     || $self->ut_text('country')
136     || $self->ut_float('tax')
137     || $self->ut_textn('taxclass') # ...
138     || $self->ut_money('exempt_amount')
139     || $self->ut_textn('taxname')
140     || $self->ut_enum('setuptax', [ '', 'Y' ] )
141     || $self->ut_enum('recurtax', [ '', 'Y' ] )
142     || $self->ut_textn('source')
143     || $self->SUPER::check
144     ;
145
146 }
147
148 =item label OPTIONS
149
150 Returns a label looking like "Anytown, Alameda County, CA, US".
151
152 If the taxname field is set, it will look like
153 "CA Sales Tax (Anytown, Alameda County, CA, US)".
154
155 If the taxclass is set, then it will be
156 "Anytown, Alameda County, CA, US (International)".
157
158 OPTIONS may contain "with_taxclass", "with_city", and "with_district" to show
159 those fields.  It may also contain "out", in which case, if this region 
160 (district+city+county+state+country) contains no non-zero taxes, the label 
161 will read "Out of taxable region(s)".
162
163 =cut
164
165 sub label {
166   my ($self, %opt) = @_;
167   if ( $opt{'out'} 
168        and $self->tax == 0
169        and !defined(qsearchs('cust_main_county', {
170            'district' => $self->district,
171            'city'     => $self->city,
172            'county'   => $self->county,
173            'state'    => $self->state,
174            'country'  => $self->country,
175            'tax'  => { op => '>', value => 0 },
176         })) )
177   {
178     return 'Out of taxable region(s)';
179   }
180   my $label = $self->country;
181   $label = $self->state.", $label" if $self->state;
182   $label = $self->county." County, $label" if $self->county;
183   if ($opt{with_city}) {
184     $label = $self->city.", $label" if $self->city;
185     if ($opt{with_district} and $self->district) {
186       $label = $self->district . ", $label";
187     }
188   }
189   # ugly labels when taxclass and taxname are both non-null...
190   # but this is how the tax report does it
191   if ($opt{with_taxclass}) {
192     $label = "$label (".$self->taxclass.')' if $self->taxclass;
193   }
194   $label = $self->taxname." ($label)" if $self->taxname;
195
196   $label;
197 }
198
199 =item sql_taxclass_sameregion
200
201 Returns an SQL WHERE fragment or the empty string to search for entries
202 with different tax classes.
203
204 =cut
205
206 #hmm, description above could be better...
207
208 sub sql_taxclass_sameregion {
209   my $self = shift;
210
211   my $same_query = 'SELECT DISTINCT taxclass FROM cust_main_county '.
212                    ' WHERE taxnum != ? AND country = ?';
213   my @same_param = ( 'taxnum', 'country' );
214   foreach my $opt_field (qw( state county )) {
215     if ( $self->$opt_field() ) {
216       $same_query .= " AND $opt_field = ?";
217       push @same_param, $opt_field;
218     } else {
219       $same_query .= " AND $opt_field IS NULL";
220     }
221   }
222
223   my @taxclasses = $self->_list_sql( \@same_param, $same_query );
224
225   return '' unless scalar(@taxclasses);
226
227   '( taxclass IS NULL OR ( '.  #only if !$self->taxclass ??
228      join(' AND ', map { 'taxclass != '.dbh->quote($_) } @taxclasses ). 
229   ' ) ) ';
230 }
231
232 sub _list_sql {
233   my( $self, $param, $sql ) = @_;
234   my $sth = dbh->prepare($sql) or die dbh->errstr;
235   $sth->execute( map $self->$_(), @$param )
236     or die "Unexpected error executing statement $sql: ". $sth->errstr;
237   map $_->[0], @{ $sth->fetchall_arrayref };
238 }
239
240 =item taxline TAXABLES_ARRAYREF, [ OPTION => VALUE ... ]
241
242 Takes an arrayref of L<FS::cust_bill_pkg> objects representing taxable
243 line items, and returns a new L<FS::cust_bill_pkg> object representing
244 the tax on them under this tax rate.
245
246 This will have a pseudo-field, "cust_bill_pkg_tax_location", containing 
247 an arrayref of L<FS::cust_bill_pkg_tax_location> objects.  Each of these 
248 will in turn have a "taxable_cust_bill_pkg" pseudo-field linking it to one
249 of the taxable items.  All of these links must be resolved as the objects
250 are inserted.
251
252 Options may include 'custnum' and 'invoice_time' in case the cust_bill_pkg
253 objects belong to an invoice that hasn't been inserted yet.
254
255 Options may include 'exemptions', an arrayref of L<FS::cust_tax_exempt_pkg>
256 objects belonging to the same customer, to be counted against the monthly 
257 tax exemption limit if there is one.
258
259 =cut
260
261 # XXX change tax_rate.pm to work like this
262
263 sub taxline {
264   my( $self, $taxables, %opt ) = @_;
265   $taxables = [ $taxables ] unless ref($taxables) eq 'ARRAY';
266   # remove any charge class identifiers; they're not supported here
267   @$taxables = grep { ref $_ } @$taxables;
268
269   return 'taxline called with no line items' unless @$taxables;
270
271   local $SIG{HUP} = 'IGNORE';
272   local $SIG{INT} = 'IGNORE';
273   local $SIG{QUIT} = 'IGNORE';
274   local $SIG{TERM} = 'IGNORE';
275   local $SIG{TSTP} = 'IGNORE';
276   local $SIG{PIPE} = 'IGNORE';
277
278   my $oldAutoCommit = $FS::UID::AutoCommit;
279   local $FS::UID::AutoCommit = 0;
280   my $dbh = dbh;
281
282   my $name = $self->taxname || 'Tax';
283   my $taxable_total = 0;
284   my $tax_cents = 0;
285
286   my $round_per_line_item = $conf->exists('tax-round_per_line_item');
287
288   my $cust_bill = $taxables->[0]->cust_bill;
289   my $custnum   = $cust_bill ? $cust_bill->custnum : $opt{'custnum'};
290   my $invoice_time = $cust_bill ? $cust_bill->_date : $opt{'invoice_time'};
291   my $cust_main = FS::cust_main->by_key($custnum) if $custnum > 0;
292   # (to avoid complications with estimated tax on quotations, assume it's
293   # taxable if there is no customer)
294   #if (!$cust_main) {
295     #die "unable to calculate taxes for an unknown customer\n";
296   #}
297
298   # Gather any exemptions that are already attached to these cust_bill_pkgs
299   # so that we can deduct them from the customer's monthly limit.
300   my @existing_exemptions = @{ $opt{'exemptions'} };
301   push @existing_exemptions, @{ $_->cust_tax_exempt_pkg }
302     for @$taxables;
303
304   my $tax_item = FS::cust_bill_pkg->new({
305       'pkgnum'    => 0,
306       'recur'     => 0,
307       'sdate'     => '',
308       'edate'     => '',
309       'itemdesc'  => $name,
310   });
311   my @tax_location;
312
313   foreach my $cust_bill_pkg (@$taxables) {
314     # careful... may be a cust_bill_pkg or a quotation_pkg
315
316     my $taxable_charged = $cust_bill_pkg->setup + $cust_bill_pkg->recur;
317     foreach ( grep { $_->taxnum == $self->taxnum }
318               @{ $cust_bill_pkg->cust_tax_exempt_pkg }
319     ) {
320       # deal with exemptions that have been set on this line item, and 
321       # pertain to this tax def
322       $taxable_charged -= $_->amount;
323     }
324
325     # can't determine the tax_locationnum directly for fees; they're not
326     # yet linked to an invoice
327     my $locationnum = $cust_bill_pkg->tax_locationnum
328                    || $cust_main->ship_locationnum;
329
330     ### Monthly capped exemptions ### 
331     if ( $self->exempt_amount && $self->exempt_amount > 0 
332       and $taxable_charged > 0
333       and $cust_main ) {
334
335       # XXX monthly exemptions currently don't work on quotations
336
337       # If the billing period extends across multiple calendar months, 
338       # there may be several months of exemption available.
339       my $sdate = $cust_bill_pkg->sdate || $invoice_time;
340       my $start_month = (localtime($sdate))[4] + 1;
341       my $start_year  = (localtime($sdate))[5] + 1900;
342       my $edate = $cust_bill_pkg->edate || $invoice_time;
343       my $end_month   = (localtime($edate))[4] + 1;
344       my $end_year    = (localtime($edate))[5] + 1900;
345
346       # If the partial last month + partial first month <= one month,
347       # don't use the exemption in the last month
348       # (unless the last month is also the first month, e.g. one-time
349       # charges)
350       if ( (localtime($sdate))[3] >= (localtime($edate))[3]
351            and ($start_month != $end_month or $start_year != $end_year)
352       ) { 
353         $end_month--;
354         if ( $end_month == 0 ) {
355           $end_year--;
356           $end_month = 12;
357         }
358       }
359
360       # number of months of exemption available
361       my $freq = ($end_month - $start_month) +
362                  ($end_year  - $start_year) * 12 +
363                  1;
364
365       # divide equally among all of them
366       my $permonth = sprintf('%.2f', $taxable_charged / $freq);
367
368       #call the whole thing off if this customer has any old
369       #exemption records...
370       my @cust_tax_exempt =
371         qsearch( 'cust_tax_exempt' => { custnum=> $custnum } );
372       if ( @cust_tax_exempt ) {
373         $dbh->rollback if $oldAutoCommit;
374         return
375           'this customer still has old-style tax exemption records; '.
376           'run bin/fs-migrate-cust_tax_exempt?';
377       }
378
379       my ($mon, $year) = ($start_month, $start_year);
380       while ($taxable_charged > 0.005 and 
381              ($year < $end_year or
382                ($year == $end_year and $mon <= $end_month)
383              )
384       ) {
385  
386         # find the sum of the exemption used by this customer, for this tax,
387         # in this month
388         my $sql = "
389           SELECT SUM(amount)
390             FROM cust_tax_exempt_pkg
391               LEFT JOIN cust_bill_pkg USING ( billpkgnum )
392               LEFT JOIN cust_bill     USING ( invnum     )
393             WHERE custnum = ?
394               AND taxnum  = ?
395               AND year    = ?
396               AND month   = ?
397               AND exempt_monthly = 'Y'
398         ";
399         my $sth = dbh->prepare($sql) or do {
400           $dbh->rollback if $oldAutoCommit;
401           return "fatal: can't lookup existing exemption: ". dbh->errstr;
402         };
403         $sth->execute(
404           $custnum,
405           $self->taxnum,
406           $year,
407           $mon,
408         ) or do {
409           $dbh->rollback if $oldAutoCommit;
410           return "fatal: can't lookup existing exemption: ". dbh->errstr;
411         };
412         my $existing_exemption = $sth->fetchrow_arrayref->[0] || 0;
413
414         # add any exemption we're already using for another line item
415         foreach ( grep { $_->taxnum == $self->taxnum &&
416                          $_->exempt_monthly eq 'Y'   &&
417                          $_->month  == $mon          &&
418                          $_->year   == $year 
419                        } @existing_exemptions
420                 )
421         {
422           $existing_exemption += $_->amount;
423         }
424
425         my $remaining_exemption =
426           $self->exempt_amount - $existing_exemption;
427         if ( $remaining_exemption > 0 ) {
428           my $addl = $remaining_exemption > $permonth
429             ? $permonth
430             : $remaining_exemption;
431           $addl = $taxable_charged if $addl > $taxable_charged;
432
433           my $new_exemption = 
434             FS::cust_tax_exempt_pkg->new({
435               amount          => sprintf('%.2f', $addl),
436               exempt_monthly  => 'Y',
437               year            => $year,
438               month           => $mon,
439               taxnum          => $self->taxnum,
440               taxtype         => ref($self)
441             });
442           $taxable_charged -= $addl;
443
444           # create a record of it
445           push @{ $cust_bill_pkg->cust_tax_exempt_pkg }, $new_exemption;
446           # and allow it to be counted against the limit for other packages
447           push @existing_exemptions, $new_exemption;
448         }
449         # if they're using multiple months of exemption for a multi-month
450         # package, then record the exemptions in separate months
451         $mon++;
452         if ( $mon > 12 ) {
453           $mon -= 12;
454           $year++;
455         }
456
457       }
458     } # if exempt_amount and $cust_main
459
460     $taxable_charged = sprintf( "%.2f", $taxable_charged);
461     next if $taxable_charged == 0;
462
463     my $this_tax_cents = $taxable_charged * $self->tax;
464     if ( $round_per_line_item ) {
465       # Round the tax to the nearest cent for each line item, instead of
466       # across the whole invoice.
467       $this_tax_cents = sprintf('%.0f', $this_tax_cents);
468     } else {
469       # Otherwise truncate it so that rounding error is always positive.
470       $this_tax_cents = int($this_tax_cents);
471     }
472
473     my $location = FS::cust_bill_pkg_tax_location->new({
474         'taxnum'      => $self->taxnum,
475         'taxtype'     => ref($self),
476         'cents'       => $this_tax_cents,
477         'pkgnum'      => $cust_bill_pkg->pkgnum,
478         'locationnum' => $locationnum,
479         'taxable_cust_bill_pkg' => $cust_bill_pkg,
480         'tax_cust_bill_pkg'     => $tax_item,
481     });
482     push @tax_location, $location;
483
484     $taxable_total += $taxable_charged;
485     $tax_cents += $this_tax_cents;
486   } #foreach $cust_bill_pkg
487
488
489   # calculate tax and rounding error for the whole group: total taxable
490   # amount times tax rate (as cents per dollar), minus the tax already
491   # charged
492   # and force 0.5 to round up
493   my $extra_cents = sprintf('%.0f',
494     ($taxable_total * $self->tax) - $tax_cents + 0.00000001
495   );
496
497   # if we're rounding per item, then ignore that and don't distribute any
498   # extra cents.
499   if ( $round_per_line_item ) {
500     $extra_cents = 0;
501   }
502
503   if ( $extra_cents < 0 ) {
504     die "nonsense extra_cents value $extra_cents";
505   }
506   $tax_cents += $extra_cents;
507   my $i = 0;
508   foreach (@tax_location) { # can never require more than a single pass, yes?
509     my $cents = $_->get('cents');
510     if ( $extra_cents > 0 ) {
511       $cents++;
512       $extra_cents--;
513     }
514     $_->set('amount', sprintf('%.2f', $cents/100));
515   }
516   $tax_item->set('setup' => sprintf('%.2f', $tax_cents / 100));
517   $tax_item->set('cust_bill_pkg_tax_location', \@tax_location);
518   
519   return $tax_item;
520 }
521
522 =back
523
524 =head1 SUBROUTINES
525
526 =over 4
527
528 =item regionselector [ COUNTY STATE COUNTRY [ PREFIX [ ONCHANGE [ DISABLED ] ] ] ]
529
530 =cut
531
532 sub regionselector {
533   my ( $selected_county, $selected_state, $selected_country,
534        $prefix, $onchange, $disabled ) = @_;
535
536   $prefix = '' unless defined $prefix;
537
538   $countyflag = 0;
539
540 #  unless ( @cust_main_county ) { #cache 
541     @cust_main_county = qsearch('cust_main_county', {} );
542     foreach my $c ( @cust_main_county ) {
543       $countyflag=1 if $c->county;
544       #push @{$cust_main_county{$c->country}{$c->state}}, $c->county;
545       $cust_main_county{$c->country}{$c->state}{$c->county} = 1;
546     }
547 #  }
548   $countyflag=1 if $selected_county;
549
550   my $script_html = <<END;
551     <SCRIPT>
552     function opt(what,value,text) {
553       var optionName = new Option(text, value, false, false);
554       var length = what.length;
555       what.options[length] = optionName;
556     }
557     function ${prefix}country_changed(what) {
558       country = what.options[what.selectedIndex].text;
559       for ( var i = what.form.${prefix}state.length; i >= 0; i-- )
560           what.form.${prefix}state.options[i] = null;
561 END
562       #what.form.${prefix}state.options[0] = new Option('', '', false, true);
563
564   foreach my $country ( sort keys %cust_main_county ) {
565     $script_html .= "\nif ( country == \"$country\" ) {\n";
566     foreach my $state ( sort keys %{$cust_main_county{$country}} ) {
567       ( my $dstate = $state ) =~ s/[\n\r]//g;
568       my $text = $dstate || '(n/a)';
569       $script_html .= qq!opt(what.form.${prefix}state, "$dstate", "$text");\n!;
570     }
571     $script_html .= "}\n";
572   }
573
574   $script_html .= <<END;
575     }
576     function ${prefix}state_changed(what) {
577 END
578
579   if ( $countyflag ) {
580     $script_html .= <<END;
581       state = what.options[what.selectedIndex].text;
582       country = what.form.${prefix}country.options[what.form.${prefix}country.selectedIndex].text;
583       for ( var i = what.form.${prefix}county.length; i >= 0; i-- )
584           what.form.${prefix}county.options[i] = null;
585 END
586
587     foreach my $country ( sort keys %cust_main_county ) {
588       $script_html .= "\nif ( country == \"$country\" ) {\n";
589       foreach my $state ( sort keys %{$cust_main_county{$country}} ) {
590         $script_html .= "\nif ( state == \"$state\" ) {\n";
591           #foreach my $county ( sort @{$cust_main_county{$country}{$state}} ) {
592           foreach my $county ( sort keys %{$cust_main_county{$country}{$state}} ) {
593             my $text = $county || '(n/a)';
594             $script_html .=
595               qq!opt(what.form.${prefix}county, "$county", "$text");\n!;
596           }
597         $script_html .= "}\n";
598       }
599       $script_html .= "}\n";
600     }
601   }
602
603   $script_html .= <<END;
604     }
605     </SCRIPT>
606 END
607
608   my $county_html = $script_html;
609   if ( $countyflag ) {
610     $county_html .= qq!<SELECT NAME="${prefix}county" onChange="$onchange" $disabled>!;
611     $county_html .= '</SELECT>';
612   } else {
613     $county_html .=
614       qq!<INPUT TYPE="hidden" NAME="${prefix}county" VALUE="$selected_county">!;
615   }
616
617   my $state_html = qq!<SELECT NAME="${prefix}state" !.
618                    qq!onChange="${prefix}state_changed(this); $onchange" $disabled>!;
619   foreach my $state ( sort keys %{ $cust_main_county{$selected_country} } ) {
620     my $text = $state || '(n/a)';
621     my $selected = $state eq $selected_state ? 'SELECTED' : '';
622     $state_html .= qq(\n<OPTION $selected VALUE="$state">$text</OPTION>);
623   }
624   $state_html .= '</SELECT>';
625
626   $state_html .= '</SELECT>';
627
628   my $country_html = qq!<SELECT NAME="${prefix}country" !.
629                      qq!onChange="${prefix}country_changed(this); $onchange" $disabled>!;
630   my $countrydefault = $conf->config('countrydefault') || 'US';
631   foreach my $country (
632     sort { ($b eq $countrydefault) <=> ($a eq $countrydefault) or $a cmp $b }
633       keys %cust_main_county
634   ) {
635     my $selected = $country eq $selected_country ? ' SELECTED' : '';
636     $country_html .= qq(\n<OPTION$selected VALUE="$country">$country</OPTION>");
637   }
638   $country_html .= '</SELECT>';
639
640   ($county_html, $state_html, $country_html);
641
642 }
643
644 sub _merge_into {
645   # for internal use: takes another cust_main_county object, transfers
646   # all existing references to this record to that one, and deletes this
647   # one.
648   my $record = shift;
649   my $other = shift or die "record to merge into must be provided";
650   my $new_taxnum = $other->taxnum;
651   my $old_taxnum = $record->taxnum;
652   if ($other->tax != $record->tax or
653       $other->exempt_amount != $record->exempt_amount) {
654     # don't assume these are the same.
655     warn "Found duplicate taxes (#$new_taxnum and #$old_taxnum) but they have different rates and can't be merged.\n";
656   } else {
657     warn "Merging tax #$old_taxnum into #$new_taxnum\n";
658     foreach my $table (qw(
659       cust_bill_pkg_tax_location
660       cust_bill_pkg_tax_location_void
661       cust_tax_exempt_pkg
662       cust_tax_exempt_pkg_void
663     )) {
664       foreach my $row (qsearch($table, { 'taxnum' => $old_taxnum })) {
665         $row->set('taxnum' => $new_taxnum);
666         my $error = $row->replace;
667         die $error if $error;
668       }
669     }
670     my $error = $record->delete;
671     die $error if $error;
672   }
673 }
674
675 sub _upgrade_data {
676   my $class = shift;
677   # assume taxes in Washington with district numbers, and null name, or 
678   # named 'sales tax', are looked up via the wa_sales method. mark them.
679   my $journal = 'cust_main_county__source_wa_sales_201611';
680   if (!FS::upgrade_journal->is_done($journal)) {
681     my @taxes = qsearch({
682         'table'     => 'cust_main_county',
683         'extra_sql' => " WHERE tax > 0 AND country = 'US' AND state = 'WA'".
684                        " AND district IS NOT NULL AND ( taxname IS NULL OR ".
685                        " taxname ~* 'sales tax' )",
686     });
687     if ( @taxes ) {
688       warn "Flagging Washington state sales taxes: ".scalar(@taxes)." records.\n";
689       foreach (@taxes) {
690         $_->set('source', 'wa_sales');
691         my $error = $_->replace;
692         die $error if $error;
693       }
694     }
695     FS::upgrade_journal->set_done($journal);
696   }
697   my @key_fields = (qw(city county state country district taxname taxclass));
698
699   # trim whitespace and convert to uppercase in the 'city' field.
700   foreach my $record (qsearch({
701     table => 'cust_main_county',
702     extra_sql => " WHERE city LIKE ' %' OR city LIKE '% ' OR city != UPPER(city)",
703   })) {
704     # any with-trailing-space records probably duplicate other records
705     # from the same city, and if we just fix the record in place, we'll
706     # create an exact duplicate.
707     # so find the record this one would duplicate, and merge them.
708     $record->check; # trims whitespace
709     my %match = map { $_ => $record->get($_) } @key_fields;
710     my $other = qsearchs('cust_main_county', \%match);
711     if ($other) {
712       $record->_merge_into($other);
713     } else {
714       # else there is no record this one duplicates, so just fix it
715       my $error = $record->replace;
716       die $error if $error;
717     }
718   } # foreach $record
719
720   # separate wa_sales taxes by tax class as needed
721   my $district_taxname = $conf->config('tax_district_taxname');
722   $journal = 'cust_main_county__district_taxclass';
723   if (!FS::upgrade_journal->is_done($journal)
724       and $conf->exists('enable_taxclasses')) {
725     eval "use FS::part_pkg_taxclass";
726     my @taxes = qsearch({
727         'table'     => 'cust_main_county',
728         'extra_sql' => " WHERE tax > 0 AND country = 'US' AND state = 'WA'".
729                        " AND district IS NOT NULL AND  source = 'wa_sales'".
730                        " AND taxclass IS NULL"
731     });
732     my @classes = FS::part_pkg_taxclass->taxclass_names;
733     if ( @taxes ) {
734       warn "Separating WA sales taxes: ".scalar(@taxes)." records.\n";
735       foreach my $oldtax (@taxes) {
736         my $error;
737         my $taxnum = $oldtax->taxnum;
738         warn "Separating tax #$taxnum into classes\n";
739         foreach my $taxclass (@classes) {
740           # ensure that we end up with a single copy of the tax in this
741           # jurisdiction+class. there may already be one (or more) there.
742           # if so, they all represent the same tax; merge them together.
743           my %newtax_hash = (
744             'country'   => 'US',
745             'state'     => 'WA',
746             'city'      => $oldtax->city,
747             'district'  => $oldtax->district,
748             'taxclass'  => $taxclass,
749             'source'    => 'wa_sales',
750           );
751           my @taxes_in_class = qsearch('cust_main_county', {
752             %newtax_hash,
753             'tax'       => { op => '>', value => 0 },
754             'setuptax'  => '',
755             'recurtax'  => '',
756           });
757           my $newtax = shift @taxes_in_class;
758           if ($newtax) {
759             foreach (@taxes_in_class) {
760               # allow the merge, even if this somehow differs.
761               $_->set('tax', $newtax->tax);
762               $_->_merge_into($newtax);
763             }
764           }
765           $newtax ||= FS::cust_main_county->new(\%newtax_hash);
766           # copy properties from the pre-split tax
767           $newtax->set('tax', $oldtax->tax);
768           $newtax->set('setuptax', $oldtax->setuptax);
769           $newtax->set('recurtax', $oldtax->recurtax);
770           # and assign the defined tax name
771           $newtax->set('taxname', $district_taxname);
772           $error = ($newtax->taxnum ? $newtax->replace : $newtax->insert);
773           die "splitting taxnum ".$oldtax->taxnum.": $error\n" if $error;
774         } # foreach $taxclass
775         $oldtax->set('tax', 0);
776         $error = $oldtax->replace;
777         die "splitting taxnum ".$oldtax->taxnum.": $error\n" if $error;
778       }
779     }
780     FS::upgrade_journal->set_done($journal);
781   }
782
783   # also ensure they all have the chosen taxname now
784   if ($district_taxname) {
785     my @taxes = qsearch('cust_main_county', {
786       'source'  => 'wa_sales',
787       'taxname' => { op => '!=', value => $district_taxname }
788     });
789     if (@taxes) {
790       warn "Renaming WA sales taxes: ".scalar(@taxes)." records.\n";
791       foreach my $tax (@taxes) {
792         $tax->set('taxname', $district_taxname);
793         my $error = $tax->replace;
794         die "renaming taxnum ".$tax->taxnum.": $error\n" if $error;
795       }   
796     }
797   }
798
799   # remove duplicates (except disabled records)
800   my @duplicate_sets = qsearch({
801     table => 'cust_main_county',
802     select => FS::Record::group_concat_sql('taxnum', ',') . ' AS taxnums, ' .
803               join(',', @key_fields),
804     extra_sql => ' WHERE tax > 0
805       GROUP BY city, county, state, country, district, taxname, taxclass
806       HAVING COUNT(*) > 1'
807   });
808   warn "Found ".scalar(@duplicate_sets)." set(s) of duplicate tax definitions\n"
809     if @duplicate_sets;
810   foreach my $set (@duplicate_sets) {
811     my @taxnums = split(',', $set->get('taxnums'));
812     my $first = FS::cust_main_county->by_key(shift @taxnums);
813     foreach my $taxnum (@taxnums) {
814       my $record = FS::cust_main_county->by_key($taxnum);
815       $record->_merge_into($first);
816     }
817   }
818
819  
820   '';
821 }
822
823 =back
824
825 =head1 BUGS
826
827 regionselector?  putting web ui components in here?  they should probably live
828 somewhere else...
829
830 =head1 SEE ALSO
831
832 L<FS::Record>, L<FS::cust_main>, L<FS::cust_bill>, schema.html from the base
833 documentation.
834
835 =cut
836
837 1;
838