svc_hardware: better error messages for bad hw_addr when not validating as a MAC...
[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 =head1 find_wa_tax_dupes
566
567 Return a list of cust_main_county Record objects that are detected
568 as duplicate washington state sales tax rows (source=wa_state)
569 within their respective tax classes
570
571 =cut
572
573 sub find_wa_tax_dupes {
574   my %cust_main_county;
575   my @dupes;
576
577   for my $row ( qsearch( cust_main_county => { source => 'wa_sales' } ) ) {
578     my $taxclass = $row->taxclass || 'none';
579     $cust_main_county{$taxclass} ||= {};
580
581     my $district = $row->district || 'none';
582     $cust_main_county{$taxclass}->{$district} ||= [];
583
584     push @{ $cust_main_county{$taxclass}->{$district} }, $row;
585   }
586
587   for my $taxclass ( keys %cust_main_county ) {
588     for my $district ( keys %{ $cust_main_county{$taxclass} } ) {
589       my $tax_rows = $cust_main_county{$taxclass}->{$district};
590       if ( scalar @$tax_rows > 1 ) {
591         push @dupes, @$tax_rows;
592       }
593     }
594   }
595
596   @dupes;
597 }
598
599 =back
600
601 =head1 SUBROUTINES
602
603 =over 4
604
605 =item regionselector [ COUNTY STATE COUNTRY [ PREFIX [ ONCHANGE [ DISABLED ] ] ] ]
606
607 =cut
608
609 sub regionselector {
610   my ( $selected_county, $selected_state, $selected_country,
611        $prefix, $onchange, $disabled ) = @_;
612
613   $prefix = '' unless defined $prefix;
614
615   $countyflag = 0;
616
617 #  unless ( @cust_main_county ) { #cache 
618     @cust_main_county = qsearch('cust_main_county', {} );
619     foreach my $c ( @cust_main_county ) {
620       $countyflag=1 if $c->county;
621       #push @{$cust_main_county{$c->country}{$c->state}}, $c->county;
622       $cust_main_county{$c->country}{$c->state}{$c->county} = 1;
623     }
624 #  }
625   $countyflag=1 if $selected_county;
626
627   my $script_html = <<END;
628     <SCRIPT>
629     function opt(what,value,text) {
630       var optionName = new Option(text, value, false, false);
631       var length = what.length;
632       what.options[length] = optionName;
633     }
634     function ${prefix}country_changed(what) {
635       country = what.options[what.selectedIndex].text;
636       for ( var i = what.form.${prefix}state.length; i >= 0; i-- )
637           what.form.${prefix}state.options[i] = null;
638 END
639       #what.form.${prefix}state.options[0] = new Option('', '', false, true);
640
641   foreach my $country ( sort keys %cust_main_county ) {
642     $script_html .= "\nif ( country == \"$country\" ) {\n";
643     foreach my $state ( sort keys %{$cust_main_county{$country}} ) {
644       ( my $dstate = $state ) =~ s/[\n\r]//g;
645       my $text = $dstate || '(n/a)';
646       $script_html .= qq!opt(what.form.${prefix}state, "$dstate", "$text");\n!;
647     }
648     $script_html .= "}\n";
649   }
650
651   $script_html .= <<END;
652     }
653     function ${prefix}state_changed(what) {
654 END
655
656   if ( $countyflag ) {
657     $script_html .= <<END;
658       state = what.options[what.selectedIndex].text;
659       country = what.form.${prefix}country.options[what.form.${prefix}country.selectedIndex].text;
660       for ( var i = what.form.${prefix}county.length; i >= 0; i-- )
661           what.form.${prefix}county.options[i] = null;
662 END
663
664     foreach my $country ( sort keys %cust_main_county ) {
665       $script_html .= "\nif ( country == \"$country\" ) {\n";
666       foreach my $state ( sort keys %{$cust_main_county{$country}} ) {
667         $script_html .= "\nif ( state == \"$state\" ) {\n";
668           #foreach my $county ( sort @{$cust_main_county{$country}{$state}} ) {
669           foreach my $county ( sort keys %{$cust_main_county{$country}{$state}} ) {
670             my $text = $county || '(n/a)';
671             $script_html .=
672               qq!opt(what.form.${prefix}county, "$county", "$text");\n!;
673           }
674         $script_html .= "}\n";
675       }
676       $script_html .= "}\n";
677     }
678   }
679
680   $script_html .= <<END;
681     }
682     </SCRIPT>
683 END
684
685   my $county_html = $script_html;
686   if ( $countyflag ) {
687     $county_html .= qq!<SELECT NAME="${prefix}county" onChange="$onchange" $disabled>!;
688     $county_html .= '</SELECT>';
689   } else {
690     $county_html .=
691       qq!<INPUT TYPE="hidden" NAME="${prefix}county" VALUE="$selected_county">!;
692   }
693
694   my $state_html = qq!<SELECT NAME="${prefix}state" !.
695                    qq!onChange="${prefix}state_changed(this); $onchange" $disabled>!;
696   foreach my $state ( sort keys %{ $cust_main_county{$selected_country} } ) {
697     my $text = $state || '(n/a)';
698     my $selected = $state eq $selected_state ? 'SELECTED' : '';
699     $state_html .= qq(\n<OPTION $selected VALUE="$state">$text</OPTION>);
700   }
701   $state_html .= '</SELECT>';
702
703   $state_html .= '</SELECT>';
704
705   my $country_html = qq!<SELECT NAME="${prefix}country" !.
706                      qq!onChange="${prefix}country_changed(this); $onchange" $disabled>!;
707   my $countrydefault = $conf->config('countrydefault') || 'US';
708   foreach my $country (
709     sort { ($b eq $countrydefault) <=> ($a eq $countrydefault) or $a cmp $b }
710       keys %cust_main_county
711   ) {
712     my $selected = $country eq $selected_country ? ' SELECTED' : '';
713     $country_html .= qq(\n<OPTION$selected VALUE="$country">$country</OPTION>");
714   }
715   $country_html .= '</SELECT>';
716
717   ($county_html, $state_html, $country_html);
718
719 }
720
721 sub _merge_into {
722   # For internal use:
723   #
724   # When given two cust_main_county row objects, rewrite all database foreign
725   # key references referring to $row_to_merge->taxnum as references to
726   # $row_to_keep->taxnum, so $row_to_merge can be safely deleted from
727   # cust_main_county
728   #
729   # Usage (class method):
730   #    $row_to_merge->_merge_into( $row_to_keep )
731   #
732   # Usage (package function):
733   #    FS::cust_main_county::_merge_into( $row_to_merge, $row_to_keep )
734   #
735   # Optionally, allow merge when records don't match
736   #      (useful during tax table update routines)
737   #     $row_to_merge->_merge_info(
738   #       $row_to_keep,
739   #       { identical_record_check => 0 }
740   #     );
741
742   my $row_to_merge = shift;
743   my $row_to_keep  = shift
744     or croak 'record to merge into must be provided';
745
746   my $args = shift || { identical_record_check => 1 };
747   croak 'invalid arguments hashref' unless ref $args;
748
749   my $log = FS::Log->new('FS::cust_main_county');
750
751   my $keep_taxnum  = $row_to_keep->taxnum;
752   my $merge_taxnum = $row_to_merge->taxnum;
753
754   if (
755     $args->{identical_record_check}
756     && (
757       $row_to_keep->tax != $row_to_merge->tax
758       || $row_to_keep->exempt_amount != $row_to_merge->exempt_amount
759     )
760   ) {
761     my $msg = "Found duplicate taxes (#$keep_taxnum and #$merge_taxnum) "
762             . "but they have different rates and can't be merged.";
763     $log->warn( $msg );
764     warn "$msg\n";
765     return;
766   }
767
768   my $msg = "Merging tax #$merge_taxnum into #$keep_taxnum";
769   $log->warn( $msg );
770   warn "$msg\n";
771
772   foreach my $table (qw(
773     cust_bill_pkg_tax_location
774     cust_bill_pkg_tax_location_void
775     cust_tax_exempt_pkg
776     cust_tax_exempt_pkg_void
777   )) {
778     foreach my $row (qsearch($table, { 'taxnum' => $merge_taxnum })) {
779       $row->set('taxnum' => $keep_taxnum);
780       if ( my $error = $row->replace ) {
781         $log->error( $error );
782         die $error;
783       }
784     }
785   }
786
787   if ( my $error = $row_to_merge->delete ) {
788     $log->error( $error );
789     die $error;
790   }
791 }
792
793 sub _upgrade_data {
794   my $class = shift;
795   # assume taxes in Washington with district numbers, and null name, or 
796   # named 'sales tax', are looked up via the wa_sales method. mark them.
797   my $journal = 'cust_main_county__source_wa_sales_201611';
798   if (!FS::upgrade_journal->is_done($journal)) {
799     my @taxes = qsearch({
800         'table'     => 'cust_main_county',
801         'extra_sql' => " WHERE tax > 0 AND country = 'US' AND state = 'WA'".
802                        " AND district IS NOT NULL AND ( taxname IS NULL OR ".
803                        " taxname ~* 'sales tax' )",
804     });
805     if ( @taxes ) {
806       warn "Flagging Washington state sales taxes: ".scalar(@taxes)." records.\n";
807       foreach (@taxes) {
808         $_->set('source', 'wa_sales');
809         my $error = $_->replace;
810         die $error if $error;
811       }
812     }
813     FS::upgrade_journal->set_done($journal);
814   }
815   my @key_fields = (qw(city county state country district taxname taxclass));
816
817   # trim whitespace and convert to uppercase in the 'city' field.
818   foreach my $record (qsearch({
819     table => 'cust_main_county',
820     extra_sql => " WHERE city LIKE ' %' OR city LIKE '% ' OR city != UPPER(city)",
821   })) {
822     # any with-trailing-space records probably duplicate other records
823     # from the same city, and if we just fix the record in place, we'll
824     # create an exact duplicate.
825     # so find the record this one would duplicate, and merge them.
826     $record->check; # trims whitespace
827     my %match = map { $_ => $record->get($_) } @key_fields;
828     my $other = qsearchs('cust_main_county', \%match);
829     if ($other) {
830       $record->_merge_into($other);
831     } else {
832       # else there is no record this one duplicates, so just fix it
833       my $error = $record->replace;
834       die $error if $error;
835     }
836   } # foreach $record
837
838   # separate wa_sales taxes by tax class as needed
839   my $district_taxname = $conf->config('tax_district_taxname');
840   $journal = 'cust_main_county__district_taxclass';
841   if (!FS::upgrade_journal->is_done($journal)
842       and $conf->exists('enable_taxclasses')) {
843     eval "use FS::part_pkg_taxclass";
844     my @taxes = qsearch({
845         'table'     => 'cust_main_county',
846         'extra_sql' => " WHERE tax > 0 AND country = 'US' AND state = 'WA'".
847                        " AND district IS NOT NULL AND  source = 'wa_sales'".
848                        " AND taxclass IS NULL"
849     });
850     my @classes = FS::part_pkg_taxclass->taxclass_names;
851     if ( @taxes ) {
852       warn "Separating WA sales taxes: ".scalar(@taxes)." records.\n";
853       foreach my $oldtax (@taxes) {
854         my $error;
855         my $taxnum = $oldtax->taxnum;
856         warn "Separating tax #$taxnum into classes\n";
857         foreach my $taxclass (@classes) {
858           # ensure that we end up with a single copy of the tax in this
859           # jurisdiction+class. there may already be one (or more) there.
860           # if so, they all represent the same tax; merge them together.
861           my %newtax_hash = (
862             'country'   => 'US',
863             'state'     => 'WA',
864             'city'      => $oldtax->city,
865             'district'  => $oldtax->district,
866             'taxclass'  => $taxclass,
867             'source'    => 'wa_sales',
868           );
869           my @taxes_in_class = qsearch('cust_main_county', {
870             %newtax_hash,
871             'tax'       => { op => '>', value => 0 },
872             'setuptax'  => '',
873             'recurtax'  => '',
874           });
875           my $newtax = shift @taxes_in_class;
876           if ($newtax) {
877             foreach (@taxes_in_class) {
878               # allow the merge, even if this somehow differs.
879               $_->set('tax', $newtax->tax);
880               $_->_merge_into($newtax);
881             }
882           }
883           $newtax ||= FS::cust_main_county->new(\%newtax_hash);
884           # copy properties from the pre-split tax
885           $newtax->set('tax', $oldtax->tax);
886           $newtax->set('setuptax', $oldtax->setuptax);
887           $newtax->set('recurtax', $oldtax->recurtax);
888           # and assign the defined tax name
889           $newtax->set('taxname', $district_taxname);
890           $error = ($newtax->taxnum ? $newtax->replace : $newtax->insert);
891           die "splitting taxnum ".$oldtax->taxnum.": $error\n" if $error;
892         } # foreach $taxclass
893         $oldtax->set('tax', 0);
894         $error = $oldtax->replace;
895         die "splitting taxnum ".$oldtax->taxnum.": $error\n" if $error;
896       }
897     }
898     FS::upgrade_journal->set_done($journal);
899   }
900
901   # also ensure they all have the chosen taxname now
902   if ($district_taxname) {
903     my @taxes = qsearch('cust_main_county', {
904       'source'  => 'wa_sales',
905       'taxname' => { op => '!=', value => $district_taxname }
906     });
907     if (@taxes) {
908       warn "Renaming WA sales taxes: ".scalar(@taxes)." records.\n";
909       foreach my $tax (@taxes) {
910         $tax->set('taxname', $district_taxname);
911         my $error = $tax->replace;
912         die "renaming taxnum ".$tax->taxnum.": $error\n" if $error;
913       }   
914     }
915   }
916
917   # remove duplicates (except disabled records)
918   my @duplicate_sets = qsearch({
919     table => 'cust_main_county',
920     select => FS::Record::group_concat_sql('taxnum', ',') . ' AS taxnums, ' .
921               join(',', @key_fields),
922     extra_sql => ' WHERE tax > 0
923       GROUP BY city, county, state, country, district, taxname, taxclass
924       HAVING COUNT(*) > 1'
925   });
926   warn "Found ".scalar(@duplicate_sets)." set(s) of duplicate tax definitions\n"
927     if @duplicate_sets;
928   foreach my $set (@duplicate_sets) {
929     my @taxnums = split(',', $set->get('taxnums'));
930     my $first = FS::cust_main_county->by_key(shift @taxnums);
931     foreach my $taxnum (@taxnums) {
932       my $record = FS::cust_main_county->by_key($taxnum);
933       $record->_merge_into($first);
934     }
935   }
936
937  
938   '';
939 }
940
941 =back
942
943 =head1 BUGS
944
945 regionselector?  putting web ui components in here?  they should probably live
946 somewhere else...
947
948 =head1 SEE ALSO
949
950 L<FS::Record>, L<FS::cust_main>, L<FS::cust_bill>, schema.html from the base
951 documentation.
952
953 =cut
954
955 1;
956