optionally round tax to the nearest cent for each line item, #39487
[freeside.git] / FS / FS / cust_main_county.pm
index a61d67e..e7597c0 100644 (file)
@@ -241,10 +241,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 +254,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';
@@ -274,28 +275,17 @@ sub taxline {
   my $taxable_cents = 0;
   my $tax_cents = 0;
 
   my $taxable_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 +303,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 ) {
-
-      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...
@@ -392,9 +368,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 +390,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 +399,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 +413,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,25 +446,28 @@ 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,
     });
         'taxable_cust_bill_pkg' => $cust_bill_pkg,
         'tax_cust_bill_pkg'     => $tax_item,
     });
@@ -481,12 +476,19 @@ sub taxline {
     $taxable_cents += $taxable_charged;
     $tax_cents += $this_tax_cents;
   } #foreach $cust_bill_pkg
     $taxable_cents += $taxable_charged;
     $tax_cents += $this_tax_cents;
   } #foreach $cust_bill_pkg
-  
-  # now round and distribute
+  # calculate tax and rounding error for the whole group
   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);
   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);
+
+  # 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";
   }