adjust upgrade procedure, #73185
[freeside.git] / FS / FS / cust_main_county.pm
index a61d67e..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,6 +139,7 @@ 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
     ;
 
@@ -241,10 +249,7 @@ 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.
 
 of the taxable items.  All of these links must be resolved as the objects
 are inserted.
 
-In addition to calculating the tax for the line items, this will calculate
-any appropriate tax exemptions and attach them to the line items.
-
-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>
@@ -257,6 +262,10 @@ tax exemption limit if there is one.
 
 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';
@@ -271,31 +280,20 @@ sub taxline {
   my $dbh = dbh;
 
   my $name = $self->taxname || 'Tax';
   my $dbh = dbh;
 
   my $name = $self->taxname || 'Tax';
-  my $taxable_cents = 0;
+  my $taxable_total = 0;
   my $tax_cents = 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.
@@ -313,73 +311,59 @@ sub taxline {
   my @tax_location;
 
   foreach my $cust_bill_pkg (@$taxables) {
   my @tax_location;
 
   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;
-
+    # 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 ) {
+    # 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;
 
 
-      push @new_exemptions, FS::cust_tax_exempt_pkg->new({
-          amount => $cust_bill_pkg->setup,
-          exempt_setup => 'Y'
-      });
-      $taxable_charged -= $cust_bill_pkg->setup;
-
-    }
-    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...
@@ -392,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
@@ -408,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,
@@ -417,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          &&
@@ -430,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++;
@@ -454,39 +454,52 @@ sub taxline {
           $year++;
         }
 
           $year++;
         }
 
-      } #foreach $which_month
-    } # if exempt_amount
-
-    $_->taxnum($self->taxnum) foreach @new_exemptions;
-
-    # attach them to the line item
-    push @{ $cust_bill_pkg->cust_tax_exempt_pkg }, @new_exemptions;
-    push @existing_exemptions, @new_exemptions;
+      }
+    } # if exempt_amount and $cust_main
 
     $taxable_charged = sprintf( "%.2f", $taxable_charged);
     next if $taxable_charged == 0;
 
 
     $taxable_charged = sprintf( "%.2f", $taxable_charged);
     next if $taxable_charged == 0;
 
-    my $this_tax_cents = int($taxable_charged * $self->tax);
+    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);
+    }
+
     my $location = FS::cust_bill_pkg_tax_location->new({
         'taxnum'      => $self->taxnum,
         'taxtype'     => ref($self),
         'cents'       => $this_tax_cents,
         'pkgnum'      => $cust_bill_pkg->pkgnum,
     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' => $cust_bill_pkg->cust_pkg->tax_locationnum,
+        'locationnum' => $locationnum,
         'taxable_cust_bill_pkg' => $cust_bill_pkg,
         'tax_cust_bill_pkg'     => $tax_item,
     });
     push @tax_location, $location;
 
         'taxable_cust_bill_pkg' => $cust_bill_pkg,
         'tax_cust_bill_pkg'     => $tax_item,
     });
     push @tax_location, $location;
 
-    $taxable_cents += $taxable_charged;
+    $taxable_total += $taxable_charged;
     $tax_cents += $this_tax_cents;
   } #foreach $cust_bill_pkg
     $tax_cents += $this_tax_cents;
   } #foreach $cust_bill_pkg
-  
-  # now round and distribute
-  my $extra_cents = sprintf('%.2f', $taxable_cents * $self->tax / 100) * 100
-                    - $tax_cents;
-  # make sure we have an integer
-  $extra_cents = sprintf('%.0f', $extra_cents);
+
+
+  # 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
+  );
+
+  # 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";
   }
   if ( $extra_cents < 0 ) {
     die "nonsense extra_cents value $extra_cents";
   }
@@ -628,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