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