adjust upgrade procedure, #73185
[freeside.git] / FS / FS / cust_main_county.pm
index 87c1ca7..d5e9ec7 100644 (file)
@@ -11,6 +11,7 @@ use FS::cust_pkg;
 use FS::part_pkg;
 use FS::cust_tax_exempt;
 use FS::cust_tax_exempt_pkg;
 use FS::part_pkg;
 use FS::cust_tax_exempt;
 use FS::cust_tax_exempt_pkg;
+use FS::upgrade_journal;
 
 @ISA = qw( FS::Record );
 @EXPORT_OK = qw( regionselector );
 
 @ISA = qw( FS::Record );
 @EXPORT_OK = qw( regionselector );
@@ -78,6 +79,9 @@ currently supported:
 
 =item recurtax - if 'Y', this tax does not apply to recurring fees
 
 
 =item recurtax - if 'Y', this tax does not apply to recurring fees
 
+=item source - the tax lookup method that created this tax record. For records
+created manually, this will be null.
+
 =back
 
 =head1 METHODS
 =back
 
 =head1 METHODS
@@ -118,6 +122,9 @@ methods.
 sub check {
   my $self = shift;
 
 sub check {
   my $self = shift;
 
+  $self->trim_whitespace(qw(district city county state country));
+  $self->set('city', uc($self->get('city'))); # also county?
+
   $self->exempt_amount(0) unless $self->exempt_amount;
 
   $self->ut_numbern('taxnum')
   $self->exempt_amount(0) unless $self->exempt_amount;
 
   $self->ut_numbern('taxnum')
@@ -132,38 +139,12 @@ sub check {
     || $self->ut_textn('taxname')
     || $self->ut_enum('setuptax', [ '', 'Y' ] )
     || $self->ut_enum('recurtax', [ '', 'Y' ] )
     || $self->ut_textn('taxname')
     || $self->ut_enum('setuptax', [ '', 'Y' ] )
     || $self->ut_enum('recurtax', [ '', 'Y' ] )
+    || $self->ut_textn('source')
     || $self->SUPER::check
     ;
 
 }
 
     || $self->SUPER::check
     ;
 
 }
 
-sub taxname {
-  my $self = shift;
-  if ( $self->dbdef_table->column('taxname') ) {
-    return $self->setfield('taxname', $_[0]) if @_;
-    return $self->getfield('taxname');
-  }  
-  return '';
-}
-
-sub setuptax {
-  my $self = shift;
-  if ( $self->dbdef_table->column('setuptax') ) {
-    return $self->setfield('setuptax', $_[0]) if @_;
-    return $self->getfield('setuptax');
-  }  
-  return '';
-}
-
-sub recurtax {
-  my $self = shift;
-  if ( $self->dbdef_table->column('recurtax') ) {
-    return $self->setfield('recurtax', $_[0]) if @_;
-    return $self->getfield('recurtax');
-  }  
-  return '';
-}
-
 =item label OPTIONS
 
 Returns a label looking like "Anytown, Alameda County, CA, US".
 =item label OPTIONS
 
 Returns a label looking like "Anytown, Alameda County, CA, US".
@@ -174,13 +155,10 @@ If the taxname field is set, it will look like
 If the taxclass is set, then it will be
 "Anytown, Alameda County, CA, US (International)".
 
 If the taxclass is set, then it will be
 "Anytown, Alameda County, CA, US (International)".
 
-Currently it will not contain the district, even if the city+county+state
-is not unique.
-
-OPTIONS may contain "no_taxclass" (hides taxclass) and/or "no_city"
-(hides city).  It may also contain "out", in which case, if this 
-region (district+city+county+state+country) contains no non-zero 
-taxes, the label will read "Out of taxable region(s)".
+OPTIONS may contain "with_taxclass", "with_city", and "with_district" to show
+those fields.  It may also contain "out", in which case, if this region 
+(district+city+county+state+country) contains no non-zero taxes, the label 
+will read "Out of taxable region(s)".
 
 =cut
 
 
 =cut
 
@@ -202,12 +180,15 @@ sub label {
   my $label = $self->country;
   $label = $self->state.", $label" if $self->state;
   $label = $self->county." County, $label" if $self->county;
   my $label = $self->country;
   $label = $self->state.", $label" if $self->state;
   $label = $self->county." County, $label" if $self->county;
-  if (!$opt{no_city}) {
+  if ($opt{with_city}) {
     $label = $self->city.", $label" if $self->city;
     $label = $self->city.", $label" if $self->city;
+    if ($opt{with_district} and $self->district) {
+      $label = $self->district . ", $label";
+    }
   }
   # ugly labels when taxclass and taxname are both non-null...
   # but this is how the tax report does it
   }
   # ugly labels when taxclass and taxname are both non-null...
   # but this is how the tax report does it
-  if (!$opt{no_taxclass}) {
+  if ($opt{with_taxclass}) {
     $label = "$label (".$self->taxclass.')' if $self->taxclass;
   }
   $label = $self->taxname." ($label)" if $self->taxname;
     $label = "$label (".$self->taxclass.')' if $self->taxclass;
   }
   $label = $self->taxname." ($label)" if $self->taxname;
@@ -258,15 +239,17 @@ sub _list_sql {
 
 =item taxline TAXABLES_ARRAYREF, [ OPTION => VALUE ... ]
 
 
 =item taxline TAXABLES_ARRAYREF, [ OPTION => VALUE ... ]
 
-Returns an hashref of a name and an amount of tax calculated for the 
-line items (L<FS::cust_bill_pkg> objects) in TAXABLES_ARRAYREF.  The line 
-items must come from the same invoice.  Returns a scalar error message 
-on error.
+Takes an arrayref of L<FS::cust_bill_pkg> objects representing taxable
+line items, and returns a new L<FS::cust_bill_pkg> object representing
+the tax on them under this tax rate.
 
 
-In addition to calculating the tax for the line items, this will calculate
-any appropriate tax exemptions and attach them to the line items.
+This will have a pseudo-field, "cust_bill_pkg_tax_location", containing 
+an arrayref of L<FS::cust_bill_pkg_tax_location> objects.  Each of these 
+will in turn have a "taxable_cust_bill_pkg" pseudo-field linking it to one
+of the taxable items.  All of these links must be resolved as the objects
+are inserted.
 
 
-Options may include 'custnum' and 'invoice_date' in case the cust_bill_pkg
+Options may include 'custnum' and 'invoice_time' in case the cust_bill_pkg
 objects belong to an invoice that hasn't been inserted yet.
 
 Options may include 'exemptions', an arrayref of L<FS::cust_tax_exempt_pkg>
 objects belong to an invoice that hasn't been inserted yet.
 
 Options may include 'exemptions', an arrayref of L<FS::cust_tax_exempt_pkg>
@@ -275,11 +258,14 @@ tax exemption limit if there is one.
 
 =cut
 
 
 =cut
 
-# XXX this should just return a cust_bill_pkg object for the tax,
-# but that requires changing stuff in tax_rate.pm also.
+# XXX change tax_rate.pm to work like this
 
 sub taxline {
   my( $self, $taxables, %opt ) = @_;
 
 sub taxline {
   my( $self, $taxables, %opt ) = @_;
+  $taxables = [ $taxables ] unless ref($taxables) eq 'ARRAY';
+  # remove any charge class identifiers; they're not supported here
+  @$taxables = grep { ref $_ } @$taxables;
+
   return 'taxline called with no line items' unless @$taxables;
 
   local $SIG{HUP} = 'IGNORE';
   return 'taxline called with no line items' unless @$taxables;
 
   local $SIG{HUP} = 'IGNORE';
@@ -294,30 +280,20 @@ sub taxline {
   my $dbh = dbh;
 
   my $name = $self->taxname || 'Tax';
   my $dbh = dbh;
 
   my $name = $self->taxname || 'Tax';
-  my $amount = 0;
+  my $taxable_total = 0;
+  my $tax_cents = 0;
+
+  my $round_per_line_item = $conf->exists('tax-round_per_line_item');
 
   my $cust_bill = $taxables->[0]->cust_bill;
   my $custnum   = $cust_bill ? $cust_bill->custnum : $opt{'custnum'};
 
   my $cust_bill = $taxables->[0]->cust_bill;
   my $custnum   = $cust_bill ? $cust_bill->custnum : $opt{'custnum'};
-  my $invoice_date = $cust_bill ? $cust_bill->_date : $opt{'invoice_date'};
+  my $invoice_time = $cust_bill ? $cust_bill->_date : $opt{'invoice_time'};
   my $cust_main = FS::cust_main->by_key($custnum) if $custnum > 0;
   my $cust_main = FS::cust_main->by_key($custnum) if $custnum > 0;
-  if (!$cust_main) {
-    # better way to handle this?  should we just assume that it's taxable?
-    die "unable to calculate taxes for an unknown customer\n";
-  }
-
-  # set a flag if the customer is tax-exempt
-  my $exempt_cust;
-  my $conf = FS::Conf->new;
-  if ( $conf->exists('cust_class-tax_exempt') ) {
-    my $cust_class = $cust_main->cust_class;
-    $exempt_cust = $cust_class->tax if $cust_class;
-  } else {
-    $exempt_cust = $cust_main->tax;
-  }
-
-  # set a flag if the customer is exempt from this tax here
-  my $exempt_cust_taxname = $cust_main->tax_exemption($self->taxname)
-    if $self->taxname;
+  # (to avoid complications with estimated tax on quotations, assume it's
+  # taxable if there is no customer)
+  #if (!$cust_main) {
+    #die "unable to calculate taxes for an unknown customer\n";
+  #}
 
   # Gather any exemptions that are already attached to these cust_bill_pkgs
   # so that we can deduct them from the customer's monthly limit.
 
   # Gather any exemptions that are already attached to these cust_bill_pkgs
   # so that we can deduct them from the customer's monthly limit.
@@ -325,74 +301,69 @@ sub taxline {
   push @existing_exemptions, @{ $_->cust_tax_exempt_pkg }
     for @$taxables;
 
   push @existing_exemptions, @{ $_->cust_tax_exempt_pkg }
     for @$taxables;
 
-  foreach my $cust_bill_pkg (@$taxables) {
-
-    my $cust_pkg  = $cust_bill_pkg->cust_pkg;
-    my $part_pkg  = $cust_bill_pkg->part_pkg;
-
-    my @new_exemptions;
-    my $taxable_charged = $cust_bill_pkg->setup + $cust_bill_pkg->recur
-      or next; # don't create zero-amount exemptions
-
-    # XXX the following procedure should probably be in cust_bill_pkg
-
-    if ( $exempt_cust ) {
-
-      push @new_exemptions, FS::cust_tax_exempt_pkg->new({
-          amount => $taxable_charged,
-          exempt_cust => 'Y',
-        });
-      $taxable_charged = 0;
-
-    } elsif ( $exempt_cust_taxname ) {
-
-      push @new_exemptions, FS::cust_tax_exempt_pkg->new({
-          amount => $taxable_charged,
-          exempt_cust_taxname => 'Y',
-        });
-      $taxable_charged = 0;
+  my $tax_item = FS::cust_bill_pkg->new({
+      'pkgnum'    => 0,
+      'recur'     => 0,
+      'sdate'     => '',
+      'edate'     => '',
+      'itemdesc'  => $name,
+  });
+  my @tax_location;
 
 
+  foreach my $cust_bill_pkg (@$taxables) {
+    # careful... may be a cust_bill_pkg or a quotation_pkg
+
+    my $taxable_charged = $cust_bill_pkg->setup + $cust_bill_pkg->recur;
+    foreach ( grep { $_->taxnum == $self->taxnum }
+              @{ $cust_bill_pkg->cust_tax_exempt_pkg }
+    ) {
+      # deal with exemptions that have been set on this line item, and 
+      # pertain to this tax def
+      $taxable_charged -= $_->amount;
     }
 
     }
 
-    if ( ($part_pkg->setuptax eq 'Y' or $self->setuptax eq 'Y')
-        and $cust_bill_pkg->setup > 0 and $taxable_charged > 0 ) {
-
-      push @new_exemptions, FS::cust_tax_exempt_pkg->new({
-          amount => $cust_bill_pkg->setup,
-          exempt_setup => 'Y'
-      });
-      $taxable_charged -= $cust_bill_pkg->setup;
+    # can't determine the tax_locationnum directly for fees; they're not
+    # yet linked to an invoice
+    my $locationnum = $cust_bill_pkg->tax_locationnum
+                   || $cust_main->ship_locationnum;
 
 
-    }
-    if ( ($part_pkg->recurtax eq 'Y' or $self->recurtax eq 'Y')
-        and $cust_bill_pkg->recur > 0 and $taxable_charged > 0 ) {
-
-      push @new_exemptions, FS::cust_tax_exempt_pkg->new({
-          amount => $cust_bill_pkg->recur,
-          exempt_recur => 'Y'
-      });
-      $taxable_charged -= $cust_bill_pkg->recur;
-    
-    }
-  
+    ### Monthly capped exemptions ### 
     if ( $self->exempt_amount && $self->exempt_amount > 0 
     if ( $self->exempt_amount && $self->exempt_amount > 0 
-      and $taxable_charged > 0 ) {
-      #my ($mon,$year) = (localtime($cust_bill_pkg->sdate) )[4,5];
-      my ($mon,$year) =
-        (localtime( $cust_bill_pkg->sdate || $invoice_date ) )[4,5];
-      $mon++;
-      $year += 1900;
-      my $freq = $cust_bill_pkg->freq;
-      unless ($freq) {
-        $freq = $part_pkg->freq || 1;  # less trustworthy fallback
-      }
-      if ( $freq !~ /(\d+)$/ ) {
-        $dbh->rollback if $oldAutoCommit;
-        return "daily/weekly package definitions not (yet?)".
-               " compatible with monthly tax exemptions";
+      and $taxable_charged > 0
+      and $cust_main ) {
+
+      # XXX monthly exemptions currently don't work on quotations
+
+      # If the billing period extends across multiple calendar months, 
+      # there may be several months of exemption available.
+      my $sdate = $cust_bill_pkg->sdate || $invoice_time;
+      my $start_month = (localtime($sdate))[4] + 1;
+      my $start_year  = (localtime($sdate))[5] + 1900;
+      my $edate = $cust_bill_pkg->edate || $invoice_time;
+      my $end_month   = (localtime($edate))[4] + 1;
+      my $end_year    = (localtime($edate))[5] + 1900;
+
+      # If the partial last month + partial first month <= one month,
+      # don't use the exemption in the last month
+      # (unless the last month is also the first month, e.g. one-time
+      # charges)
+      if ( (localtime($sdate))[3] >= (localtime($edate))[3]
+           and ($start_month != $end_month or $start_year != $end_year)
+      ) { 
+        $end_month--;
+        if ( $end_month == 0 ) {
+          $end_year--;
+          $end_month = 12;
+        }
       }
       }
-      my $taxable_per_month =
-        sprintf("%.2f", $taxable_charged / $freq );
+
+      # number of months of exemption available
+      my $freq = ($end_month - $start_month) +
+                 ($end_year  - $start_year) * 12 +
+                 1;
+
+      # divide equally among all of them
+      my $permonth = sprintf('%.2f', $taxable_charged / $freq);
 
       #call the whole thing off if this customer has any old
       #exemption records...
 
       #call the whole thing off if this customer has any old
       #exemption records...
@@ -405,9 +376,15 @@ sub taxline {
           'run bin/fs-migrate-cust_tax_exempt?';
       }
 
           'run bin/fs-migrate-cust_tax_exempt?';
       }
 
-      foreach my $which_month ( 1 .. $freq ) {
-  
-        #maintain the new exemption table now
+      my ($mon, $year) = ($start_month, $start_year);
+      while ($taxable_charged > 0.005 and 
+             ($year < $end_year or
+               ($year == $end_year and $mon <= $end_month)
+             )
+      ) {
+        # find the sum of the exemption used by this customer, for this tax,
+        # in this month
         my $sql = "
           SELECT SUM(amount)
             FROM cust_tax_exempt_pkg
         my $sql = "
           SELECT SUM(amount)
             FROM cust_tax_exempt_pkg
@@ -421,7 +398,7 @@ sub taxline {
         ";
         my $sth = dbh->prepare($sql) or do {
           $dbh->rollback if $oldAutoCommit;
         ";
         my $sth = dbh->prepare($sql) or do {
           $dbh->rollback if $oldAutoCommit;
-          return "fatal: can't lookup exising exemption: ". dbh->errstr;
+          return "fatal: can't lookup existing exemption: ". dbh->errstr;
         };
         $sth->execute(
           $custnum,
         };
         $sth->execute(
           $custnum,
@@ -430,10 +407,11 @@ sub taxline {
           $mon,
         ) or do {
           $dbh->rollback if $oldAutoCommit;
           $mon,
         ) or do {
           $dbh->rollback if $oldAutoCommit;
-          return "fatal: can't lookup exising exemption: ". dbh->errstr;
+          return "fatal: can't lookup existing exemption: ". dbh->errstr;
         };
         my $existing_exemption = $sth->fetchrow_arrayref->[0] || 0;
 
         };
         my $existing_exemption = $sth->fetchrow_arrayref->[0] || 0;
 
+        # add any exemption we're already using for another line item
         foreach ( grep { $_->taxnum == $self->taxnum &&
                          $_->exempt_monthly eq 'Y'   &&
                          $_->month  == $mon          &&
         foreach ( grep { $_->taxnum == $self->taxnum &&
                          $_->exempt_monthly eq 'Y'   &&
                          $_->month  == $mon          &&
@@ -443,22 +421,31 @@ sub taxline {
         {
           $existing_exemption += $_->amount;
         }
         {
           $existing_exemption += $_->amount;
         }
-        
+
         my $remaining_exemption =
           $self->exempt_amount - $existing_exemption;
         if ( $remaining_exemption > 0 ) {
         my $remaining_exemption =
           $self->exempt_amount - $existing_exemption;
         if ( $remaining_exemption > 0 ) {
-          my $addl = $remaining_exemption > $taxable_per_month
-            ? $taxable_per_month
+          my $addl = $remaining_exemption > $permonth
+            ? $permonth
             : $remaining_exemption;
             : $remaining_exemption;
-          push @new_exemptions, FS::cust_tax_exempt_pkg->new({
+          $addl = $taxable_charged if $addl > $taxable_charged;
+
+          my $new_exemption = 
+            FS::cust_tax_exempt_pkg->new({
               amount          => sprintf('%.2f', $addl),
               exempt_monthly  => 'Y',
               year            => $year,
               month           => $mon,
               amount          => sprintf('%.2f', $addl),
               exempt_monthly  => 'Y',
               year            => $year,
               month           => $mon,
+              taxnum          => $self->taxnum,
+              taxtype         => ref($self)
             });
           $taxable_charged -= $addl;
             });
           $taxable_charged -= $addl;
+
+          # create a record of it
+          push @{ $cust_bill_pkg->cust_tax_exempt_pkg }, $new_exemption;
+          # and allow it to be counted against the limit for other packages
+          push @existing_exemptions, $new_exemption;
         }
         }
-        last if $taxable_charged < 0.005;
         # if they're using multiple months of exemption for a multi-month
         # package, then record the exemptions in separate months
         $mon++;
         # if they're using multiple months of exemption for a multi-month
         # package, then record the exemptions in separate months
         $mon++;
@@ -467,42 +454,69 @@ sub taxline {
           $year++;
         }
 
           $year++;
         }
 
-      } #foreach $which_month
-    } # if exempt_amount
-
-    $_->taxnum($self->taxnum) foreach @new_exemptions;
-
-    #if ( $cust_bill_pkg->billpkgnum ) {
+      }
+    } # if exempt_amount and $cust_main
 
 
-      #no, need to do this to e.g. calculate tax credit amounts
-      #die "tried to calculate tax exemptions on a previously billed line item\n";
+    $taxable_charged = sprintf( "%.2f", $taxable_charged);
+    next if $taxable_charged == 0;
 
 
-      # this is unnecessary
-#      foreach my $cust_tax_exempt_pkg (@new_exemptions) {
-#        my $error = $cust_tax_exempt_pkg->insert;
-#        if ( $error ) {
-#          $dbh->rollback if $oldAutoCommit;
-#          return "can't insert cust_tax_exempt_pkg: $error";
-#        }
-#      }
-    #}
+    my $this_tax_cents = $taxable_charged * $self->tax;
+    if ( $round_per_line_item ) {
+      # Round the tax to the nearest cent for each line item, instead of
+      # across the whole invoice.
+      $this_tax_cents = sprintf('%.0f', $this_tax_cents);
+    } else {
+      # Otherwise truncate it so that rounding error is always positive.
+      $this_tax_cents = int($this_tax_cents);
+    }
 
 
-    # attach them to the line item
-    push @{ $cust_bill_pkg->cust_tax_exempt_pkg }, @new_exemptions;
-    push @existing_exemptions, @new_exemptions;
+    my $location = FS::cust_bill_pkg_tax_location->new({
+        'taxnum'      => $self->taxnum,
+        'taxtype'     => ref($self),
+        'cents'       => $this_tax_cents,
+        'pkgnum'      => $cust_bill_pkg->pkgnum,
+        'locationnum' => $locationnum,
+        'taxable_cust_bill_pkg' => $cust_bill_pkg,
+        'tax_cust_bill_pkg'     => $tax_item,
+    });
+    push @tax_location, $location;
+
+    $taxable_total += $taxable_charged;
+    $tax_cents += $this_tax_cents;
+  } #foreach $cust_bill_pkg
 
 
-    # If we were smart, we'd also generate a cust_bill_pkg_tax_location 
-    # record at this point, but that would require redesigning more stuff.
-    $taxable_charged = sprintf( "%.2f", $taxable_charged);
 
 
-    $amount += $taxable_charged * $self->tax / 100;
-  } #foreach $cust_bill_pkg
+  # calculate tax and rounding error for the whole group: total taxable
+  # amount times tax rate (as cents per dollar), minus the tax already
+  # charged
+  # and force 0.5 to round up
+  my $extra_cents = sprintf('%.0f',
+    ($taxable_total * $self->tax) - $tax_cents + 0.00000001
+  );
 
 
-  return {
-    'name'   => $name,
-    'amount' => $amount,
-  };
+  # if we're rounding per item, then ignore that and don't distribute any
+  # extra cents.
+  if ( $round_per_line_item ) {
+    $extra_cents = 0;
+  }
 
 
+  if ( $extra_cents < 0 ) {
+    die "nonsense extra_cents value $extra_cents";
+  }
+  $tax_cents += $extra_cents;
+  my $i = 0;
+  foreach (@tax_location) { # can never require more than a single pass, yes?
+    my $cents = $_->get('cents');
+    if ( $extra_cents > 0 ) {
+      $cents++;
+      $extra_cents--;
+    }
+    $_->set('amount', sprintf('%.2f', $cents/100));
+  }
+  $tax_item->set('setup' => sprintf('%.2f', $tax_cents / 100));
+  $tax_item->set('cust_bill_pkg_tax_location', \@tax_location);
+  
+  return $tax_item;
 }
 
 =back
 }
 
 =back
@@ -627,6 +641,185 @@ END
 
 }
 
 
 }
 
+sub _merge_into {
+  # for internal use: takes another cust_main_county object, transfers
+  # all existing references to this record to that one, and deletes this
+  # one.
+  my $record = shift;
+  my $other = shift or die "record to merge into must be provided";
+  my $new_taxnum = $other->taxnum;
+  my $old_taxnum = $record->taxnum;
+  if ($other->tax != $record->tax or
+      $other->exempt_amount != $record->exempt_amount) {
+    # don't assume these are the same.
+    warn "Found duplicate taxes (#$new_taxnum and #$old_taxnum) but they have different rates and can't be merged.\n";
+  } else {
+    warn "Merging tax #$old_taxnum into #$new_taxnum\n";
+    foreach my $table (qw(
+      cust_bill_pkg_tax_location
+      cust_bill_pkg_tax_location_void
+      cust_tax_exempt_pkg
+      cust_tax_exempt_pkg_void
+    )) {
+      foreach my $row (qsearch($table, { 'taxnum' => $old_taxnum })) {
+        $row->set('taxnum' => $new_taxnum);
+        my $error = $row->replace;
+        die $error if $error;
+      }
+    }
+    my $error = $record->delete;
+    die $error if $error;
+  }
+}
+
+sub _upgrade_data {
+  my $class = shift;
+  # assume taxes in Washington with district numbers, and null name, or 
+  # named 'sales tax', are looked up via the wa_sales method. mark them.
+  my $journal = 'cust_main_county__source_wa_sales_201611';
+  if (!FS::upgrade_journal->is_done($journal)) {
+    my @taxes = qsearch({
+        'table'     => 'cust_main_county',
+        'extra_sql' => " WHERE tax > 0 AND country = 'US' AND state = 'WA'".
+                       " AND district IS NOT NULL AND ( taxname IS NULL OR ".
+                       " taxname ~* 'sales tax' )",
+    });
+    if ( @taxes ) {
+      warn "Flagging Washington state sales taxes: ".scalar(@taxes)." records.\n";
+      foreach (@taxes) {
+        $_->set('source', 'wa_sales');
+        my $error = $_->replace;
+        die $error if $error;
+      }
+    }
+    FS::upgrade_journal->set_done($journal);
+  }
+  my @key_fields = (qw(city county state country district taxname taxclass));
+
+  # trim whitespace and convert to uppercase in the 'city' field.
+  foreach my $record (qsearch({
+    table => 'cust_main_county',
+    extra_sql => " WHERE city LIKE ' %' OR city LIKE '% ' OR city != UPPER(city)",
+  })) {
+    # any with-trailing-space records probably duplicate other records
+    # from the same city, and if we just fix the record in place, we'll
+    # create an exact duplicate.
+    # so find the record this one would duplicate, and merge them.
+    $record->check; # trims whitespace
+    my %match = map { $_ => $record->get($_) } @key_fields;
+    my $other = qsearchs('cust_main_county', \%match);
+    if ($other) {
+      $record->_merge_into($other);
+    } else {
+      # else there is no record this one duplicates, so just fix it
+      my $error = $record->replace;
+      die $error if $error;
+    }
+  } # foreach $record
+
+  # separate wa_sales taxes by tax class as needed
+  my $district_taxname = $conf->config('tax_district_taxname');
+  $journal = 'cust_main_county__district_taxclass';
+  if (!FS::upgrade_journal->is_done($journal)
+      and $conf->exists('enable_taxclasses')) {
+    eval "use FS::part_pkg_taxclass";
+    my @taxes = qsearch({
+        'table'     => 'cust_main_county',
+        'extra_sql' => " WHERE tax > 0 AND country = 'US' AND state = 'WA'".
+                       " AND district IS NOT NULL AND  source = 'wa_sales'".
+                       " AND taxclass IS NULL"
+    });
+    my @classes = FS::part_pkg_taxclass->taxclass_names;
+    if ( @taxes ) {
+      warn "Separating WA sales taxes: ".scalar(@taxes)." records.\n";
+      foreach my $oldtax (@taxes) {
+        my $error;
+        my $taxnum = $oldtax->taxnum;
+        warn "Separating tax #$taxnum into classes\n";
+        foreach my $taxclass (@classes) {
+          # ensure that we end up with a single copy of the tax in this
+          # jurisdiction+class. there may already be one (or more) there.
+          # if so, they all represent the same tax; merge them together.
+          my %newtax_hash = (
+            'country'   => 'US',
+            'state'     => 'WA',
+            'city'      => $oldtax->city,
+            'district'  => $oldtax->district,
+            'taxclass'  => $taxclass,
+            'source'    => 'wa_sales',
+          );
+          my @taxes_in_class = qsearch('cust_main_county', {
+            %newtax_hash,
+            'tax'       => { op => '>', value => 0 },
+            'setuptax'  => '',
+            'recurtax'  => '',
+          });
+          my $newtax = shift @taxes_in_class;
+          if ($newtax) {
+            foreach (@taxes_in_class) {
+              # allow the merge, even if this somehow differs.
+              $_->set('tax', $newtax->tax);
+              $_->_merge_into($newtax);
+            }
+          }
+          $newtax ||= FS::cust_main_county->new(\%newtax_hash);
+          # copy properties from the pre-split tax
+          $newtax->set('tax', $oldtax->tax);
+          $newtax->set('setuptax', $oldtax->setuptax);
+          $newtax->set('recurtax', $oldtax->recurtax);
+          # and assign the defined tax name
+          $newtax->set('taxname', $district_taxname);
+          $error = ($newtax->taxnum ? $newtax->replace : $newtax->insert);
+          die "splitting taxnum ".$oldtax->taxnum.": $error\n" if $error;
+        } # foreach $taxclass
+        $oldtax->set('tax', 0);
+        $error = $oldtax->replace;
+        die "splitting taxnum ".$oldtax->taxnum.": $error\n" if $error;
+      }
+    }
+    FS::upgrade_journal->set_done($journal);
+  }
+
+  # also ensure they all have the chosen taxname now
+  if ($district_taxname) {
+    my @taxes = qsearch('cust_main_county', {
+      'source'  => 'wa_sales',
+      'taxname' => { op => '!=', value => $district_taxname }
+    });
+    if (@taxes) {
+      warn "Renaming WA sales taxes: ".scalar(@taxes)." records.\n";
+      foreach my $tax (@taxes) {
+        $tax->set('taxname', $district_taxname);
+        my $error = $tax->replace;
+        die "renaming taxnum ".$tax->taxnum.": $error\n" if $error;
+      }   
+    }
+  }
+
+  # remove duplicates (except disabled records)
+  my @duplicate_sets = qsearch({
+    table => 'cust_main_county',
+    select => FS::Record::group_concat_sql('taxnum', ',') . ' AS taxnums, ' .
+              join(',', @key_fields),
+    extra_sql => ' WHERE tax > 0
+      GROUP BY city, county, state, country, district, taxname, taxclass
+      HAVING COUNT(*) > 1'
+  });
+  warn "Found ".scalar(@duplicate_sets)." set(s) of duplicate tax definitions\n"
+    if @duplicate_sets;
+  foreach my $set (@duplicate_sets) {
+    my @taxnums = split(',', $set->get('taxnums'));
+    my $first = FS::cust_main_county->by_key(shift @taxnums);
+    foreach my $taxnum (@taxnums) {
+      my $record = FS::cust_main_county->by_key($taxnum);
+      $record->_merge_into($first);
+    }
+  }
+
+  '';
+}
+
 =back
 
 =head1 BUGS
 =back
 
 =head1 BUGS