add customer fields option with agent, display_custnum, status and name, RT#73721
[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 Exporter;
8 use FS::Record qw( qsearch qsearchs dbh );
9 use FS::cust_bill_pkg;
10 use FS::cust_bill;
11 use FS::cust_pkg;
12 use FS::part_pkg;
13 use FS::cust_tax_exempt;
14 use FS::cust_tax_exempt_pkg;
15 use FS::upgrade_journal;
16
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 In addition to calculating the tax for the line items, this will calculate
253 any appropriate tax exemptions and attach them to the line items.
254
255 Options may include 'custnum' and 'invoice_time' in case the cust_bill_pkg
256 objects belong to an invoice that hasn't been inserted yet.
257
258 Options may include 'exemptions', an arrayref of L<FS::cust_tax_exempt_pkg>
259 objects belonging to the same customer, to be counted against the monthly 
260 tax exemption limit if there is one.
261
262 =cut
263
264 # XXX change tax_rate.pm to work like this
265
266 sub taxline {
267   my( $self, $taxables, %opt ) = @_;
268   return 'taxline called with no line items' unless @$taxables;
269
270   local $SIG{HUP} = 'IGNORE';
271   local $SIG{INT} = 'IGNORE';
272   local $SIG{QUIT} = 'IGNORE';
273   local $SIG{TERM} = 'IGNORE';
274   local $SIG{TSTP} = 'IGNORE';
275   local $SIG{PIPE} = 'IGNORE';
276
277   my $oldAutoCommit = $FS::UID::AutoCommit;
278   local $FS::UID::AutoCommit = 0;
279   my $dbh = dbh;
280
281   my $name = $self->taxname || 'Tax';
282   my $taxable_cents = 0;
283   my $tax_cents = 0;
284
285   my $cust_bill = $taxables->[0]->cust_bill;
286   my $custnum   = $cust_bill ? $cust_bill->custnum : $opt{'custnum'};
287   my $invoice_time = $cust_bill ? $cust_bill->_date : $opt{'invoice_time'};
288   my $cust_main = FS::cust_main->by_key($custnum) if $custnum;
289   # (to avoid complications with estimated tax on quotations, assume it's
290   # taxable if there is no customer)
291   #if (!$cust_main) {
292     #die "unable to calculate taxes for an unknown customer\n";
293   #}
294
295   # set a flag if the customer is tax-exempt
296   my ($exempt_cust, $exempt_cust_taxname);
297   my $conf = FS::Conf->new;
298   if ( $cust_main ) {
299     if ( $conf->exists('cust_class-tax_exempt') ) {
300       my $cust_class = $cust_main->cust_class;
301       $exempt_cust = $cust_class->tax if $cust_class;
302     } else {
303       $exempt_cust = $cust_main->tax;
304     }
305
306     # set a flag if the customer is exempt from this tax here
307     if ( $self->taxname ) {
308       $exempt_cust_taxname = $cust_main->tax_exemption($self->taxname);
309     }
310   }
311
312   # Gather any exemptions that are already attached to these cust_bill_pkgs
313   # so that we can deduct them from the customer's monthly limit.
314   my @existing_exemptions = @{ $opt{'exemptions'} };
315   push @existing_exemptions, @{ $_->cust_tax_exempt_pkg }
316     for @$taxables;
317
318   my $tax_item = FS::cust_bill_pkg->new({
319       'pkgnum'    => 0,
320       'recur'     => 0,
321       'sdate'     => '',
322       'edate'     => '',
323       'itemdesc'  => $name,
324   });
325   my @tax_location;
326
327   foreach my $cust_bill_pkg (@$taxables) {
328     # careful... may be a cust_bill_pkg or a quotation_pkg
329
330     my $cust_pkg  = $cust_bill_pkg->cust_pkg;
331     my $part_pkg  = $cust_bill_pkg->part_pkg;
332     my $part_fee  = $cust_bill_pkg->part_fee;
333
334     my $locationnum = $cust_bill_pkg->tax_locationnum
335                       || $cust_main->ship_locationnum;
336
337     my @new_exemptions;
338     my $taxable_charged = $cust_bill_pkg->setup + $cust_bill_pkg->recur
339       or next; # don't create zero-amount exemptions
340
341     # XXX the following procedure should probably be in cust_bill_pkg
342
343     if ( $exempt_cust ) {
344
345       push @new_exemptions, FS::cust_tax_exempt_pkg->new({
346           amount => $taxable_charged,
347           exempt_cust => 'Y',
348         });
349       $taxable_charged = 0;
350
351     } elsif ( $exempt_cust_taxname ) {
352
353       push @new_exemptions, FS::cust_tax_exempt_pkg->new({
354           amount => $taxable_charged,
355           exempt_cust_taxname => 'Y',
356         });
357       $taxable_charged = 0;
358
359     }
360
361     my $setup_exempt = ( ($part_fee and not $part_fee->taxable)
362                       or ($part_pkg and $part_pkg->setuptax)
363                       or $self->setuptax );
364
365     if ( $setup_exempt
366         and $cust_bill_pkg->setup > 0
367         and $taxable_charged > 0 ) {
368
369       push @new_exemptions, FS::cust_tax_exempt_pkg->new({
370           amount => $cust_bill_pkg->setup,
371           exempt_setup => 'Y'
372       });
373       $taxable_charged -= $cust_bill_pkg->setup;
374
375     }
376
377     my $recur_exempt = ( ($part_fee and not $part_fee->taxable)
378                       or ($part_pkg and $part_pkg->recurtax)
379                       or $self->recurtax );
380
381     if ( $recur_exempt
382         and $cust_bill_pkg->recur > 0
383         and $taxable_charged > 0 ) {
384
385       push @new_exemptions, FS::cust_tax_exempt_pkg->new({
386           amount => $cust_bill_pkg->recur,
387           exempt_recur => 'Y'
388       });
389       $taxable_charged -= $cust_bill_pkg->recur;
390     
391     }
392   
393     if ( $self->exempt_amount && $self->exempt_amount > 0 
394       and $taxable_charged > 0
395       and $cust_main ) {
396
397       # XXX monthly exemptions currently don't work on quotations
398
399       # If the billing period extends across multiple calendar months, 
400       # there may be several months of exemption available.
401       my $sdate = $cust_bill_pkg->sdate || $invoice_time;
402       my $start_month = (localtime($sdate))[4] + 1;
403       my $start_year  = (localtime($sdate))[5] + 1900;
404       my $edate = $cust_bill_pkg->edate || $invoice_time;
405       my $end_month   = (localtime($edate))[4] + 1;
406       my $end_year    = (localtime($edate))[5] + 1900;
407
408       # If the partial last month + partial first month <= one month,
409       # don't use the exemption in the last month
410       # (unless the last month is also the first month, e.g. one-time
411       # charges)
412       if ( (localtime($sdate))[3] >= (localtime($edate))[3]
413            and ($start_month != $end_month or $start_year != $end_year)
414       ) { 
415         $end_month--;
416         if ( $end_month == 0 ) {
417           $end_year--;
418           $end_month = 12;
419         }
420       }
421
422       # number of months of exemption available
423       my $freq = ($end_month - $start_month) +
424                  ($end_year  - $start_year) * 12 +
425                  1;
426
427       # divide equally among all of them
428       my $permonth = sprintf('%.2f', $taxable_charged / $freq);
429
430       #call the whole thing off if this customer has any old
431       #exemption records...
432       my @cust_tax_exempt =
433         qsearch( 'cust_tax_exempt' => { custnum=> $custnum } );
434       if ( @cust_tax_exempt ) {
435         $dbh->rollback if $oldAutoCommit;
436         return
437           'this customer still has old-style tax exemption records; '.
438           'run bin/fs-migrate-cust_tax_exempt?';
439       }
440
441       my ($mon, $year) = ($start_month, $start_year);
442       while ($taxable_charged > 0.005 and 
443              ($year < $end_year or
444                ($year == $end_year and $mon <= $end_month)
445              )
446       ) {
447  
448         # find the sum of the exemption used by this customer, for this tax,
449         # in this month
450         my $sql = "
451           SELECT SUM(amount)
452             FROM cust_tax_exempt_pkg
453               LEFT JOIN cust_bill_pkg USING ( billpkgnum )
454               LEFT JOIN cust_bill     USING ( invnum     )
455             WHERE custnum = ?
456               AND taxnum  = ?
457               AND year    = ?
458               AND month   = ?
459               AND exempt_monthly = 'Y'
460         ";
461         my $sth = dbh->prepare($sql) or do {
462           $dbh->rollback if $oldAutoCommit;
463           return "fatal: can't lookup existing exemption: ". dbh->errstr;
464         };
465         $sth->execute(
466           $custnum,
467           $self->taxnum,
468           $year,
469           $mon,
470         ) or do {
471           $dbh->rollback if $oldAutoCommit;
472           return "fatal: can't lookup existing exemption: ". dbh->errstr;
473         };
474         my $existing_exemption = $sth->fetchrow_arrayref->[0] || 0;
475
476         # add any exemption we're already using for another line item
477         foreach ( grep { $_->taxnum == $self->taxnum &&
478                          $_->exempt_monthly eq 'Y'   &&
479                          $_->month  == $mon          &&
480                          $_->year   == $year 
481                        } @existing_exemptions
482                 )
483         {
484           $existing_exemption += $_->amount;
485         }
486
487         my $remaining_exemption =
488           $self->exempt_amount - $existing_exemption;
489         if ( $remaining_exemption > 0 ) {
490           my $addl = $remaining_exemption > $permonth
491             ? $permonth
492             : $remaining_exemption;
493           $addl = $taxable_charged if $addl > $taxable_charged;
494
495           push @new_exemptions, FS::cust_tax_exempt_pkg->new({
496               amount          => sprintf('%.2f', $addl),
497               exempt_monthly  => 'Y',
498               year            => $year,
499               month           => $mon,
500             });
501           $taxable_charged -= $addl;
502         }
503         # if they're using multiple months of exemption for a multi-month
504         # package, then record the exemptions in separate months
505         $mon++;
506         if ( $mon > 12 ) {
507           $mon -= 12;
508           $year++;
509         }
510
511       }
512     } # if exempt_amount and $cust_main
513
514     $_->taxnum($self->taxnum) foreach @new_exemptions;
515
516     # attach them to the line item
517     push @{ $cust_bill_pkg->cust_tax_exempt_pkg }, @new_exemptions;
518     push @existing_exemptions, @new_exemptions;
519
520     $taxable_charged = sprintf( "%.2f", $taxable_charged);
521     next if $taxable_charged == 0;
522
523     my $this_tax_cents = int($taxable_charged * $self->tax);
524     my $location = FS::cust_bill_pkg_tax_location->new({
525         'taxnum'      => $self->taxnum,
526         'taxtype'     => ref($self),
527         'cents'       => $this_tax_cents,
528         'pkgnum'      => $cust_bill_pkg->pkgnum,
529         'locationnum' => $locationnum,
530         'taxable_cust_bill_pkg' => $cust_bill_pkg,
531         'tax_cust_bill_pkg'     => $tax_item,
532     });
533     push @tax_location, $location;
534
535     $taxable_cents += $taxable_charged;
536     $tax_cents += $this_tax_cents;
537   } #foreach $cust_bill_pkg
538   
539   # now round and distribute
540   my $extra_cents = sprintf('%.2f', $taxable_cents * $self->tax / 100) * 100
541                     - $tax_cents;
542   # make sure we have an integer
543   $extra_cents = sprintf('%.0f', $extra_cents);
544   if ( $extra_cents < 0 ) {
545     die "nonsense extra_cents value $extra_cents";
546   }
547   $tax_cents += $extra_cents;
548   my $i = 0;
549   foreach (@tax_location) { # can never require more than a single pass, yes?
550     my $cents = $_->get('cents');
551     if ( $extra_cents > 0 ) {
552       $cents++;
553       $extra_cents--;
554     }
555     $_->set('amount', sprintf('%.2f', $cents/100));
556   }
557   $tax_item->set('setup' => sprintf('%.2f', $tax_cents / 100));
558   $tax_item->set('cust_bill_pkg_tax_location', \@tax_location);
559   
560   return $tax_item;
561 }
562
563 =back
564
565 =head1 SUBROUTINES
566
567 =over 4
568
569 =item regionselector [ COUNTY STATE COUNTRY [ PREFIX [ ONCHANGE [ DISABLED ] ] ] ]
570
571 =cut
572
573 sub regionselector {
574   my ( $selected_county, $selected_state, $selected_country,
575        $prefix, $onchange, $disabled ) = @_;
576
577   $prefix = '' unless defined $prefix;
578
579   $countyflag = 0;
580
581 #  unless ( @cust_main_county ) { #cache 
582     @cust_main_county = qsearch('cust_main_county', {} );
583     foreach my $c ( @cust_main_county ) {
584       $countyflag=1 if $c->county;
585       #push @{$cust_main_county{$c->country}{$c->state}}, $c->county;
586       $cust_main_county{$c->country}{$c->state}{$c->county} = 1;
587     }
588 #  }
589   $countyflag=1 if $selected_county;
590
591   my $script_html = <<END;
592     <SCRIPT>
593     function opt(what,value,text) {
594       var optionName = new Option(text, value, false, false);
595       var length = what.length;
596       what.options[length] = optionName;
597     }
598     function ${prefix}country_changed(what) {
599       country = what.options[what.selectedIndex].text;
600       for ( var i = what.form.${prefix}state.length; i >= 0; i-- )
601           what.form.${prefix}state.options[i] = null;
602 END
603       #what.form.${prefix}state.options[0] = new Option('', '', false, true);
604
605   foreach my $country ( sort keys %cust_main_county ) {
606     $script_html .= "\nif ( country == \"$country\" ) {\n";
607     foreach my $state ( sort keys %{$cust_main_county{$country}} ) {
608       ( my $dstate = $state ) =~ s/[\n\r]//g;
609       my $text = $dstate || '(n/a)';
610       $script_html .= qq!opt(what.form.${prefix}state, "$dstate", "$text");\n!;
611     }
612     $script_html .= "}\n";
613   }
614
615   $script_html .= <<END;
616     }
617     function ${prefix}state_changed(what) {
618 END
619
620   if ( $countyflag ) {
621     $script_html .= <<END;
622       state = what.options[what.selectedIndex].text;
623       country = what.form.${prefix}country.options[what.form.${prefix}country.selectedIndex].text;
624       for ( var i = what.form.${prefix}county.length; i >= 0; i-- )
625           what.form.${prefix}county.options[i] = null;
626 END
627
628     foreach my $country ( sort keys %cust_main_county ) {
629       $script_html .= "\nif ( country == \"$country\" ) {\n";
630       foreach my $state ( sort keys %{$cust_main_county{$country}} ) {
631         $script_html .= "\nif ( state == \"$state\" ) {\n";
632           #foreach my $county ( sort @{$cust_main_county{$country}{$state}} ) {
633           foreach my $county ( sort keys %{$cust_main_county{$country}{$state}} ) {
634             my $text = $county || '(n/a)';
635             $script_html .=
636               qq!opt(what.form.${prefix}county, "$county", "$text");\n!;
637           }
638         $script_html .= "}\n";
639       }
640       $script_html .= "}\n";
641     }
642   }
643
644   $script_html .= <<END;
645     }
646     </SCRIPT>
647 END
648
649   my $county_html = $script_html;
650   if ( $countyflag ) {
651     $county_html .= qq!<SELECT NAME="${prefix}county" onChange="$onchange" $disabled>!;
652     $county_html .= '</SELECT>';
653   } else {
654     $county_html .=
655       qq!<INPUT TYPE="hidden" NAME="${prefix}county" VALUE="$selected_county">!;
656   }
657
658   my $state_html = qq!<SELECT NAME="${prefix}state" !.
659                    qq!onChange="${prefix}state_changed(this); $onchange" $disabled>!;
660   foreach my $state ( sort keys %{ $cust_main_county{$selected_country} } ) {
661     my $text = $state || '(n/a)';
662     my $selected = $state eq $selected_state ? 'SELECTED' : '';
663     $state_html .= qq(\n<OPTION $selected VALUE="$state">$text</OPTION>);
664   }
665   $state_html .= '</SELECT>';
666
667   $state_html .= '</SELECT>';
668
669   my $country_html = qq!<SELECT NAME="${prefix}country" !.
670                      qq!onChange="${prefix}country_changed(this); $onchange" $disabled>!;
671   my $countrydefault = $conf->config('countrydefault') || 'US';
672   foreach my $country (
673     sort { ($b eq $countrydefault) <=> ($a eq $countrydefault) or $a cmp $b }
674       keys %cust_main_county
675   ) {
676     my $selected = $country eq $selected_country ? ' SELECTED' : '';
677     $country_html .= qq(\n<OPTION$selected VALUE="$country">$country</OPTION>");
678   }
679   $country_html .= '</SELECT>';
680
681   ($county_html, $state_html, $country_html);
682
683 }
684
685 sub _merge_into {
686   # for internal use: takes another cust_main_county object, transfers
687   # all existing references to this record to that one, and deletes this
688   # one.
689   my $record = shift;
690   my $other = shift or die "record to merge into must be provided";
691   my $new_taxnum = $other->taxnum;
692   my $old_taxnum = $record->taxnum;
693   if ($other->tax != $record->tax or
694       $other->exempt_amount != $record->exempt_amount) {
695     # don't assume these are the same.
696     warn "Found duplicate taxes (#$new_taxnum and #$old_taxnum) but they have different rates and can't be merged.\n";
697   } else {
698     warn "Merging tax #$old_taxnum into #$new_taxnum\n";
699     foreach my $table (qw(
700       cust_bill_pkg_tax_location
701       cust_bill_pkg_tax_location_void
702       cust_tax_exempt_pkg
703       cust_tax_exempt_pkg_void
704     )) {
705       foreach my $row (qsearch($table, { 'taxnum' => $old_taxnum })) {
706         $row->set('taxnum' => $new_taxnum);
707         my $error = $row->replace;
708         die $error if $error;
709       }
710     }
711     my $error = $record->delete;
712     die $error if $error;
713   }
714 }
715
716 sub _upgrade_data {
717   my $class = shift;
718   # assume taxes in Washington with district numbers, and null name, or 
719   # named 'sales tax', are looked up via the wa_sales method. mark them.
720   my $journal = 'cust_main_county__source_wa_sales_201611';
721   if (!FS::upgrade_journal->is_done($journal)) {
722     my @taxes = qsearch({
723         'table'     => 'cust_main_county',
724         'extra_sql' => " WHERE tax > 0 AND country = 'US' AND state = 'WA'".
725                        " AND district IS NOT NULL AND ( taxname IS NULL OR ".
726                        " taxname ~* 'sales tax' )",
727     });
728     if ( @taxes ) {
729       warn "Flagging Washington state sales taxes: ".scalar(@taxes)." records.\n";
730       foreach (@taxes) {
731         $_->set('source', 'wa_sales');
732         my $error = $_->replace;
733         die $error if $error;
734       }
735     }
736     FS::upgrade_journal->set_done($journal);
737   }
738   my @key_fields = (qw(city county state country district taxname taxclass));
739
740   # trim whitespace and convert to uppercase in the 'city' field.
741   foreach my $record (qsearch({
742     table => 'cust_main_county',
743     extra_sql => " WHERE city LIKE ' %' OR city LIKE '% ' OR city != UPPER(city)",
744   })) {
745     # any with-trailing-space records probably duplicate other records
746     # from the same city, and if we just fix the record in place, we'll
747     # create an exact duplicate.
748     # so find the record this one would duplicate, and merge them.
749     $record->check; # trims whitespace
750     my %match = map { $_ => $record->get($_) } @key_fields;
751     my $other = qsearchs('cust_main_county', \%match);
752     if ($other) {
753       $record->_merge_into($other);
754     } else {
755       # else there is no record this one duplicates, so just fix it
756       my $error = $record->replace;
757       die $error if $error;
758     }
759   } # foreach $record
760
761   # separate wa_sales taxes by tax class as needed
762   my $district_taxname = $conf->config('tax_district_taxname');
763   $journal = 'cust_main_county__district_taxclass';
764   if (!FS::upgrade_journal->is_done($journal)
765       and $conf->exists('enable_taxclasses')) {
766     eval "use FS::part_pkg_taxclass";
767     my @taxes = qsearch({
768         'table'     => 'cust_main_county',
769         'extra_sql' => " WHERE tax > 0 AND country = 'US' AND state = 'WA'".
770                        " AND district IS NOT NULL AND  source = 'wa_sales'".
771                        " AND taxclass IS NULL"
772     });
773     my @classes = FS::part_pkg_taxclass->taxclass_names;
774     if ( @taxes ) {
775       warn "Separating WA sales taxes: ".scalar(@taxes)." records.\n";
776       foreach my $oldtax (@taxes) {
777         my $error;
778         my $taxnum = $oldtax->taxnum;
779         warn "Separating tax #$taxnum into classes\n";
780         foreach my $taxclass (@classes) {
781           # ensure that we end up with a single copy of the tax in this
782           # jurisdiction+class. there may already be one (or more) there.
783           # if so, they all represent the same tax; merge them together.
784           my %newtax_hash = (
785             'country'   => 'US',
786             'state'     => 'WA',
787             'city'      => $oldtax->city,
788             'district'  => $oldtax->district,
789             'taxclass'  => $taxclass,
790             'source'    => 'wa_sales',
791           );
792           my @taxes_in_class = qsearch('cust_main_county', {
793             %newtax_hash,
794             'tax'       => { op => '>', value => 0 },
795             'setuptax'  => '',
796             'recurtax'  => '',
797           });
798           my $newtax = shift @taxes_in_class;
799           if ($newtax) {
800             foreach (@taxes_in_class) {
801               # allow the merge, even if this somehow differs.
802               $_->set('tax', $newtax->tax);
803               $_->_merge_into($newtax);
804             }
805           }
806           $newtax ||= FS::cust_main_county->new(\%newtax_hash);
807           # copy properties from the pre-split tax
808           $newtax->set('tax', $oldtax->tax);
809           $newtax->set('setuptax', $oldtax->setuptax);
810           $newtax->set('recurtax', $oldtax->recurtax);
811           # and assign the defined tax name
812           $newtax->set('taxname', $district_taxname);
813           $error = ($newtax->taxnum ? $newtax->replace : $newtax->insert);
814           die "splitting taxnum ".$oldtax->taxnum.": $error\n" if $error;
815         } # foreach $taxclass
816         $oldtax->set('tax', 0);
817         $error = $oldtax->replace;
818         die "splitting taxnum ".$oldtax->taxnum.": $error\n" if $error;
819       }
820     }
821     FS::upgrade_journal->set_done($journal);
822   }
823
824   # also ensure they all have the chosen taxname now
825   if ($district_taxname) {
826     my @taxes = qsearch('cust_main_county', {
827       'source'  => 'wa_sales',
828       'taxname' => { op => '!=', value => $district_taxname }
829     });
830     if (@taxes) {
831       warn "Renaming WA sales taxes: ".scalar(@taxes)." records.\n";
832       foreach my $tax (@taxes) {
833         $tax->set('taxname', $district_taxname);
834         my $error = $tax->replace;
835         die "renaming taxnum ".$tax->taxnum.": $error\n" if $error;
836       }   
837     }
838   }
839
840   # remove duplicates (except disabled records)
841   my @duplicate_sets = qsearch({
842     table => 'cust_main_county',
843     select => FS::Record::group_concat_sql('taxnum', ',') . ' AS taxnums, ' .
844               join(',', @key_fields),
845     extra_sql => ' WHERE tax > 0
846       GROUP BY city, county, state, country, district, taxname, taxclass
847       HAVING COUNT(*) > 1'
848   });
849   warn "Found ".scalar(@duplicate_sets)." set(s) of duplicate tax definitions\n"
850     if @duplicate_sets;
851   foreach my $set (@duplicate_sets) {
852     my @taxnums = split(',', $set->get('taxnums'));
853     my $first = FS::cust_main_county->by_key(shift @taxnums);
854     foreach my $taxnum (@taxnums) {
855       my $record = FS::cust_main_county->by_key($taxnum);
856       $record->_merge_into($first);
857     }
858   }
859
860  
861   '';
862 }
863
864 =back
865
866 =head1 BUGS
867
868 regionselector?  putting web ui components in here?  they should probably live
869 somewhere else...
870
871 =head1 SEE ALSO
872
873 L<FS::Record>, L<FS::cust_main>, L<FS::cust_bill>, schema.html from the base
874 documentation.
875
876 =cut
877
878 1;
879