From 76d6fe17d02b77301619065ad43d7300432e977c Mon Sep 17 00:00:00 2001 From: Mark Wells Date: Wed, 1 Apr 2015 01:54:21 -0500 Subject: [PATCH] CCH tax exemptions + 4.x tax system, #34223 --- FS/FS/Schema.pm | 10 +- FS/FS/TaxEngine.pm | 83 +++++++++----- FS/FS/TaxEngine/cch.pm | 248 +++++++++++++++++++++++----------------- FS/FS/TaxEngine/internal.pm | 16 +-- FS/FS/cust_bill_pkg.pm | 259 ++++++++++++++++++++++++++++++------------ FS/FS/cust_main/Billing.pm | 131 ++++++++++----------- FS/FS/tax_rate.pm | 271 ++++++++++++++++++++++++++++++++------------ 7 files changed, 656 insertions(+), 362 deletions(-) diff --git a/FS/FS/Schema.pm b/FS/FS/Schema.pm index c0dd2b4bc..4bc3598fe 100644 --- a/FS/FS/Schema.pm +++ b/FS/FS/Schema.pm @@ -1151,6 +1151,7 @@ sub tables_hashref { 'amount', @money_type, '', '', 'currency', 'char', 'NULL', 3, '', '', 'taxable_billpkgnum', 'int', 'NULL', '', '', '', + 'taxclass', 'varchar', 'NULL', 10, '', '', ], 'primary_key' => 'billpkgtaxratelocationnum', 'unique' => [], @@ -4534,6 +4535,7 @@ sub tables_hashref { #'custnum', 'int', '', '', '', '' 'billpkgnum', 'int', '', '', '', '', 'taxnum', 'int', '', '', '', '', + 'taxtype', 'varchar', 'NULL', $char_d, '', '', 'year', 'int', 'NULL', '', '', '', 'month', 'int', 'NULL', '', '', '', 'creditbillpkgnum', 'int', 'NULL', '', '', '', @@ -4549,16 +4551,13 @@ sub tables_hashref { 'unique' => [], 'index' => [ [ 'taxnum', 'year', 'month' ], [ 'billpkgnum' ], - [ 'taxnum' ], + [ 'taxnum', 'taxtype' ], [ 'creditbillpkgnum' ], ], 'foreign_keys' => [ { columns => [ 'billpkgnum' ], table => 'cust_bill_pkg', }, - { columns => [ 'taxnum' ], - table => 'cust_main_county', - }, { columns => [ 'creditbillpkgnum' ], table => 'cust_credit_bill_pkg', }, @@ -4571,6 +4570,7 @@ sub tables_hashref { #'custnum', 'int', '', '', '', '' 'billpkgnum', 'int', '', '', '', '', 'taxnum', 'int', '', '', '', '', + 'taxtype', 'varchar', 'NULL', $char_d, '', '', 'year', 'int', 'NULL', '', '', '', 'month', 'int', 'NULL', '', '', '', 'creditbillpkgnum', 'int', 'NULL', '', '', '', @@ -4586,7 +4586,7 @@ sub tables_hashref { 'unique' => [], 'index' => [ [ 'taxnum', 'year', 'month' ], [ 'billpkgnum' ], - [ 'taxnum' ], + [ 'taxnum', 'taxtype' ], [ 'creditbillpkgnum' ], ], 'foreign_keys' => [ diff --git a/FS/FS/TaxEngine.pm b/FS/FS/TaxEngine.pm index a146c54d1..0972fb74d 100644 --- a/FS/FS/TaxEngine.pm +++ b/FS/FS/TaxEngine.pm @@ -14,22 +14,22 @@ FS::TaxEngine - Base class for tax calculation engines. =head1 USAGE 1. At the start of creating an invoice, create an FS::TaxEngine object. -2. Each time a sale item is added to the invoice, call C on the +2. Each time a sale item is added to the invoice, call L on the TaxEngine. - -- If the TaxEngine is "batch" style (Billsoft): 3. Set the "pending" flag on the invoice. 4. Insert the invoice and its line items. + +- If the TaxEngine is "batch" style (Billsoft): 5. After creating all invoices for the day, call FS::TaxEngine::process_tax_batch. This will create the tax items for all of the pending invoices, clear the "pending" flag, and call - C on each of the billed customers. + L on each of the billed customers. - If not (the internal tax system, CCH): -3. After adding all sale items, call C on the TaxEngine to +5. After adding all sale items, call L on the TaxEngine to produce a list of tax line items. -4. Append the tax line items to the invoice. -5. Insert the invoice. +6. Append the tax line items to the invoice. +7. Update the invoice with the new charged amount and clear the pending flag. =head1 CLASS METHODS @@ -48,15 +48,15 @@ indicate that the package is being billed on cancellation. sub new { my $class = shift; my %opt = @_; + my $conf = FS::Conf->new; if ($class eq 'FS::TaxEngine') { - my $conf = FS::Conf->new; my $subclass = $conf->config('enable_taxproducts') || 'internal'; $class .= "::$subclass"; local $@; eval "use $class"; die "couldn't load $class: $@\n" if $@; } - my $self = { items => [], taxes => {}, %opt }; + my $self = { items => [], taxes => {}, conf => $conf, %opt }; bless $self, $class; } @@ -84,33 +84,36 @@ Returns a hashref of metadata about this tax method, including: Adds the CUST_BILL_PKG object as a taxable sale on this invoice. -=item calculate_taxes CUST_BILL +=item calculate_taxes INVOICE Calculates the taxes on the taxable sales and returns a list of -L objects to add to the invoice. There is a base -implementation of this, which calls the C method to calculate -each individual tax. +L objects to add to the invoice. The base implementation +is to call L to produce a list of "raw" tax line items, +then L to combine those with the same itemdesc. =cut sub calculate_taxes { my $self = shift; - my $conf = FS::Conf->new; - my $cust_bill = shift; - my @tax_line_items; - # keys are tax names (as printed on invoices / itemdesc ) - # values are arrayrefs of taxlines - my %taxname; + my @raw_taxlines = $self->make_taxlines($cust_bill); - # keys are taxnums - # values are (cumulative) amounts - my %tax_amount; + my @real_taxlines = $self->consolidate_taxlines(@raw_taxlines); - # keys are taxnums - # values are arrayrefs of cust_tax_exempt_pkg objects - my %tax_exemption; + if ( $cust_bill and $cust_bill->get('invnum') ) { + $_->set('invnum', $cust_bill->get('invnum')) foreach @real_taxlines; + } + return \@real_taxlines; +} + +sub make_taxlines { + my $self = shift; + my $conf = $self->{conf}; + + my $cust_bill = shift; + + my @taxlines; # For each distinct tax rate definition, calculate the tax and exemptions. foreach my $taxnum ( keys %{ $self->{taxes} } ) { @@ -127,10 +130,35 @@ sub calculate_taxes { # with their link records die $taxline unless ref($taxline); - push @{ $taxname{ $taxline->itemdesc } }, $taxline; + push @taxlines, $taxline; } #foreach $taxnum + return @taxlines; +} + +sub consolidate_taxlines { + + my $self = shift; + my $conf = $self->{conf}; + + my @raw_taxlines = @_; + my @tax_line_items; + + # keys are tax names (as printed on invoices / itemdesc ) + # values are arrayrefs of taxlines + my %taxname; + # collate these by itemdesc + foreach my $taxline (@raw_taxlines) { + my $taxname = $taxline->itemdesc; + $taxname{$taxname} ||= []; + push @{ $taxname{$taxname} }, $taxline; + } + + # keys are taxnums + # values are (cumulative) amounts + my %tax_amount; + my $link_table = $self->info->{link_table}; # For each distinct tax name (the values set as $taxline->itemdesc), # create a consolidated tax item with the total amount and all the links @@ -138,7 +166,6 @@ sub calculate_taxes { foreach my $taxname ( keys %taxname ) { my @tax_links; my $tax_cust_bill_pkg = FS::cust_bill_pkg->new({ - 'invnum' => $cust_bill->invnum, 'pkgnum' => 0, 'recur' => 0, 'sdate' => '', @@ -185,7 +212,7 @@ sub calculate_taxes { push @tax_line_items, $tax_cust_bill_pkg; } - \@tax_line_items; + @tax_line_items; } =head1 CLASS METHODS diff --git a/FS/FS/TaxEngine/cch.pm b/FS/FS/TaxEngine/cch.pm index 6bad69e0d..4e6dbaf7e 100644 --- a/FS/FS/TaxEngine/cch.pm +++ b/FS/FS/TaxEngine/cch.pm @@ -8,7 +8,7 @@ use FS::Conf; =head1 SUMMARY -FS::TaxEngine::cch CCH published tax tables. Uses multiple tables: +FS::TaxEngine::cch - CCH published tax tables. Uses multiple tables: - tax_rate: definition of specific taxes, based on tax class and geocode. - cust_tax_location: definition of geocodes, using zip+4 codes. - tax_class: definition of tax classes. @@ -27,91 +27,74 @@ $DEBUG = 0; my %part_pkg_cache; -sub add_sale { - my ($self, $cust_bill_pkg, %options) = @_; +=item add_sale LINEITEM - my $part_item = $options{part_item} || $cust_bill_pkg->part_X; - my $location = $options{location} || $cust_bill_pkg->tax_location; +Takes LINEITEM (a L object) and adds it to three internal +data structures: - push @{ $self->{items} }, $cust_bill_pkg; +- C, an arrayref of all items on this invoice. +- C, a hashref of taxnum => arrayref containing the items that are + taxable under that tax definition. +- C, a hashref of taxnum => arrayref containing the tax class + names parallel to the C array for the same tax. - my $conf = FS::Conf->new; +The item will appear on C once for each tax class (setup, recur, +or a usage class number) that's taxable under that class and appears on +the item. - my @classes; - push @classes, $cust_bill_pkg->usage_classes if $cust_bill_pkg->usage; - # debatable - push @classes, 'setup' if ($cust_bill_pkg->setup && !$self->{cancel}); - push @classes, 'recur' if ($cust_bill_pkg->recur && !$self->{cancel}); +C will also determine any exemptions that apply to the item +and attach them to LINEITEM. - my %taxes_for_class; - - my $exempt = $conf->exists('cust_class-tax_exempt') - ? ( $self->cust_class ? $self->cust_class->tax : '' ) - : $self->{cust_main}->tax; - # standardize this just to be sure - $exempt = ($exempt eq 'Y') ? 'Y' : ''; - - if ( !$exempt ) { +=cut - foreach my $class (@classes) { - my $err_or_ref = $self->_gather_taxes( $part_item, $class, $location ); - return $err_or_ref unless ref($err_or_ref); - $taxes_for_class{$class} = $err_or_ref; - } - unless (exists $taxes_for_class{''}) { - my $err_or_ref = $self->_gather_taxes( $part_item, '', $location ); - return $err_or_ref unless ref($err_or_ref); - $taxes_for_class{''} = $err_or_ref; - } +sub add_sale { + my ($self, $cust_bill_pkg) = @_; - } + my $part_item = $cust_bill_pkg->part_X; + my $location = $cust_bill_pkg->tax_location; + my $custnum = $self->{cust_main}->custnum; - my %tax_cust_bill_pkg = $cust_bill_pkg->disintegrate; # grrr - foreach my $key (keys %tax_cust_bill_pkg) { - # $key is "setup", "recur", or a usage class name. ('' is a usage class.) - # $tax_cust_bill_pkg{$key} is a cust_bill_pkg for that component of - # the line item. - # $taxes_for_class{$key} is an arrayref of tax_rate objects that - # apply to $key-class charges. - my @taxes = @{ $taxes_for_class{$key} || [] }; - my $tax_cust_bill_pkg = $tax_cust_bill_pkg{$key}; + push @{ $self->{items} }, $cust_bill_pkg; - my %localtaxlisthash = (); - foreach my $tax ( @taxes ) { + my $conf = FS::Conf->new; - my $taxnum = $tax->taxnum; - $self->{taxes}{$taxnum} ||= [ $tax ]; - push @{ $self->{taxes}{$taxnum} }, $tax_cust_bill_pkg; + my @classes; + my $usage = $cust_bill_pkg->usage || 0; + push @classes, $cust_bill_pkg->usage_classes if $cust_bill_pkg->usage; + if (!$self->{cancel}) { + push @classes, 'setup' if $cust_bill_pkg->setup > 0; + push @classes, 'recur' if ($cust_bill_pkg->recur - $usage) > 0; + } - $localtaxlisthash{ $taxnum } ||= [ $tax ]; - push @{ $localtaxlisthash{$taxnum} }, $tax_cust_bill_pkg; + # About $self->{cancel}: This protects against charging per-line or + # per-customer or other flat-rate surcharges on a package that's being + # billed on cancellation (which is an out-of-cycle bill and should only + # have usage charges). See RT#29443. - } + # only calculate exemptions once for each tax rate, even if it's used for + # multiple classes. + my %tax_seen; - warn "finding taxed taxes...\n" if $DEBUG > 2; - foreach my $taxnum ( keys %localtaxlisthash ) { - my $tax_object = shift @{ $localtaxlisthash{$taxnum} }; + foreach my $class (@classes) { + my $err_or_ref = $self->_gather_taxes($part_item, $class, $location); + return $err_or_ref unless ref($err_or_ref); + my @taxes = @$err_or_ref; - foreach my $tot ( $tax_object->tax_on_tax( $location ) ) { - my $totnum = $tot->taxnum; + next if !@taxes; - # I'm not sure why, but for some reason we only add ToT if that - # tax_rate already applies to a non-tax item on the same invoice. - next unless exists( $localtaxlisthash{ $totnum } ); - warn "adding #$totnum to taxed taxes\n" if $DEBUG > 2; - # calculate the tax amount that the tax_on_tax will apply to - my $taxline = - $self->taxline( 'tax' => $tax_object, - 'sales' => $localtaxlisthash{$taxnum} - ); - return $taxline unless ref $taxline; - # and append it to the list of taxable items - $self->{taxes}->{$totnum} ||= [ $tot ]; - push @{ $self->{taxes}->{$totnum} }, $taxline->setup; - - } # foreach $tot (tax-on-tax) - } # foreach $tax - } # foreach $key (i.e. usage class) + foreach my $tax (@taxes) { + my $taxnum = $tax->taxnum; + $self->{taxes}{$taxnum} ||= []; + $self->{taxclass}{$taxnum} ||= []; + push @{ $self->{taxes}{$taxnum} }, $cust_bill_pkg; + push @{ $self->{taxclass}{$taxnum} }, $class; + + if ( !$tax_seen{$taxnum} ) { + $cust_bill_pkg->set_exemptions( $tax, 'custnum' => $custnum ); + $tax_seen{$taxnum}++; + } + } #foreach $tax + } #foreach $class } sub _gather_taxes { # interface for this sucks @@ -129,44 +112,95 @@ sub _gather_taxes { # interface for this sucks if $DEBUG; \@taxes; - } -sub taxline { - # FS::tax_rate::taxline() ridiculously returns a description and amount - # instead of a real line item. Fix that here. - # - # XXX eventually move the code from tax_rate to here - # but that's not necessary yet - my ($self, %opt) = @_; - my $tax_object = $opt{tax}; - my $taxables = $opt{sales}; - my $hashref = $tax_object->taxline_cch($taxables); - return $hashref unless ref $hashref; # it's an error message - - my $tax_amount = sprintf('%.2f', $hashref->{amount}); - my $tax_item = FS::cust_bill_pkg->new({ - 'itemdesc' => $hashref->{name}, - 'pkgnum' => 0, - 'recur' => 0, - 'sdate' => '', - 'edate' => '', - 'setup' => $tax_amount, - }); - my $tax_link = FS::cust_bill_pkg_tax_rate_location->new({ - 'taxnum' => $tax_object->taxnum, - 'taxtype' => ref($tax_object), #redundant - 'amount' => $tax_amount, - 'locationtaxid' => $tax_object->location, - 'taxratelocationnum' => - $tax_object->tax_rate_location->taxratelocationnum, - 'tax_cust_bill_pkg' => $tax_item, - # XXX still need to get taxable_cust_bill_pkg in here - # but that requires messing around in the taxline code - }); - $tax_item->set('cust_bill_pkg_tax_rate_location', [ $tax_link ]); - - return $tax_item; +# differs from stock make_taxlines because we need another pass to do +# tax on tax +sub make_taxlines { + my $self = shift; + my $cust_bill = shift; + + my @raw_taxlines; + my %taxable_location; # taxable billpkgnum => cust_location + my %item_has_tax; # taxable billpkgnum => taxnum + foreach my $taxnum ( keys %{ $self->{taxes} } ) { + my $tax_rate = FS::tax_rate->by_key($taxnum); + my $taxables = $self->{taxes}{$taxnum}; + my $charge_classes = $self->{taxclass}{$taxnum}; + foreach (@$taxables) { + $taxable_location{ $_->billpkgnum } ||= $_->tax_location; + } + + my @taxlines = $tax_rate->taxline_cch( $taxables, $charge_classes ); + + next if !@taxlines; + if (!ref $taxlines[0]) { + # it's an error string + warn "error evaluating tax#$taxnum\n"; + return $taxlines[0]; + } + + my $billpkgnum = -1; # the current one + my $fragments; # $item_has_tax{$billpkgnum}{taxnum} + + foreach my $taxline (@taxlines) { + next if $taxline->setup == 0; + + my $link = $taxline->get('cust_bill_pkg_tax_rate_location')->[0]; + # store this tax fragment, indexed by taxable item, then by taxnum + if ( $billpkgnum != $link->taxable_billpkgnum ) { + $billpkgnum = $link->taxable_billpkgnum; + $item_has_tax{$billpkgnum} ||= {}; + $fragments = $item_has_tax{$billpkgnum}{$taxnum} ||= []; + } + + $taxline->set('invnum', $cust_bill->invnum); + push @$fragments, $taxline; # so we can ToT it + push @raw_taxlines, $taxline; # so we actually bill it + } + } # foreach $taxnum + + # all first-tier taxes are calculated. now for tax on tax + # (has to be done on a per-taxable-item basis) + foreach my $billpkgnum (keys %item_has_tax) { + # taxes that apply to this item + my $this_has_tax = $item_has_tax{$billpkgnum}; + my $location = $taxable_location{$billpkgnum}; + foreach my $taxnum (keys %$this_has_tax) { + my $tax_rate = FS::tax_rate->by_key($taxnum); + # find all taxes that apply to it in this location + my @tot = $tax_rate->tax_on_tax( $location ); + next if !@tot; + + warn "found possible taxed taxnum $taxnum\n" + if $DEBUG > 2; + # Calculate ToT separately for each taxable item, and only if _that + # item_ is already taxed under the ToT. This is counterintuitive. + # See RT#5243. + foreach my $tot (@tot) { + my $totnum = $tot->taxnum; + warn "checking taxnum ".$tot->taxnum. + " which we call ". $tot->taxname ."\n" + if $DEBUG > 2; + if ( exists $this_has_tax->{ $totnum } ) { + warn "calculating tax on tax: taxnum ".$tot->taxnum." on $taxnum\n" + if $DEBUG; + my @taxlines = $tot->taxline_cch( + $this_has_tax->{ $taxnum }, # the first-stage tax (in an arrayref) + ); + next if (!@taxlines); # it didn't apply after all + if (!ref($taxlines[0])) { + warn "error evaluating TOT ($totnum on $taxnum)\n"; + return $taxlines[0]; + } + # add these to the taxline queue + push @raw_taxlines, @taxlines; + } # if $this_has_tax->{$totnum} + } # foreach my $tot (tax-on-tax rate definition) + } # foreach $taxnum (first-tier rate definition) + } # foreach $taxable_item + + return @raw_taxlines; } sub cust_tax_locations { diff --git a/FS/FS/TaxEngine/internal.pm b/FS/FS/TaxEngine/internal.pm index 60f7aad27..3b13510b3 100644 --- a/FS/FS/TaxEngine/internal.pm +++ b/FS/FS/TaxEngine/internal.pm @@ -15,10 +15,11 @@ my %part_pkg_cache; sub add_sale { my ($self, $cust_bill_pkg) = @_; - my $cust_pkg = $cust_bill_pkg->cust_pkg; - my $pkgpart = $cust_bill_pkg->pkgpart_override || $cust_pkg->pkgpart; - my $part_pkg = $part_pkg_cache{$pkgpart} ||= FS::part_pkg->by_key($pkgpart) - or die "pkgpart $pkgpart not found"; + + my $part_item = $cust_bill_pkg->part_X; + my $location = $cust_bill_pkg->tax_location; + my $custnum = $self->{cust_main}->custnum; + push @{ $self->{items} }, $cust_bill_pkg; my $location = $cust_pkg->tax_location; # cacheable? @@ -46,9 +47,10 @@ sub add_sale { $taxhash_elim{ shift(@elim) } = ''; } while ( !scalar(@taxes) && scalar(@elim) ); - foreach (@taxes) { - my $taxnum = $_->taxnum; - $self->{taxes}->{$taxnum} ||= [ $_ ]; + foreach my $tax (@taxes) { + my $taxnum = $tax->taxnum; + $self->{taxes}->{$taxnum} ||= [ $tax ]; + $cust_bill_pkg->set_exemptions( $tax, 'custnum' => $custnum ); push @{ $self->{taxes}->{$taxnum} }, $cust_bill_pkg; } } diff --git a/FS/FS/cust_bill_pkg.pm b/FS/FS/cust_bill_pkg.pm index aa25f8c4b..156ab5bf0 100644 --- a/FS/FS/cust_bill_pkg.pm +++ b/FS/FS/cust_bill_pkg.pm @@ -202,10 +202,13 @@ sub insert { } } - my $tax_location = $self->get('cust_bill_pkg_tax_location'); - if ( $tax_location ) { + foreach my $tax_link_table (qw(cust_bill_pkg_tax_location + cust_bill_pkg_tax_rate_location)) + { + my $tax_location = $self->get($tax_link_table) || []; foreach my $link ( @$tax_location ) { - next if $link->billpkgtaxlocationnum; # don't try to double-insert + my $pkey = $link->primary_key; + next if $link->get($pkey); # don't try to double-insert # This cust_bill_pkg can be linked on either side (i.e. it can be the # tax or the taxed item). If the other side is already inserted, # then set billpkgnum to ours, and insert the link. Otherwise, @@ -221,8 +224,8 @@ sub insert { my $taxable_cust_bill_pkg = $link->get('taxable_cust_bill_pkg'); if ( $taxable_cust_bill_pkg && $taxable_cust_bill_pkg->billpkgnum ) { $link->set('taxable_billpkgnum', $taxable_cust_bill_pkg->billpkgnum); - # XXX if we ever do tax-on-tax for these, this will have to change - # since pkgnum will be zero + # XXX pkgnum is zero for tax on tax; it might be better to use + # the underlying package? $link->set('pkgnum', $taxable_cust_bill_pkg->pkgnum); $link->set('locationnum', $taxable_cust_bill_pkg->tax_locationnum); $link->set('taxable_cust_bill_pkg', ''); @@ -246,18 +249,18 @@ sub insert { } # someday you will be as awesome as cust_bill_pkg_tax_location... - # but not today - my $tax_rate_location = $self->get('cust_bill_pkg_tax_rate_location'); - if ( $tax_rate_location ) { - foreach my $cust_bill_pkg_tax_rate_location ( @$tax_rate_location ) { - $cust_bill_pkg_tax_rate_location->billpkgnum($self->billpkgnum); - $error = $cust_bill_pkg_tax_rate_location->insert; - if ( $error ) { - $dbh->rollback if $oldAutoCommit; - return "error inserting cust_bill_pkg_tax_rate_location: $error"; - } - } - } + # and today is that day + #my $tax_rate_location = $self->get('cust_bill_pkg_tax_rate_location'); + #if ( $tax_rate_location ) { + # foreach my $cust_bill_pkg_tax_rate_location ( @$tax_rate_location ) { + # $cust_bill_pkg_tax_rate_location->billpkgnum($self->billpkgnum); + # $error = $cust_bill_pkg_tax_rate_location->insert; + # if ( $error ) { + # $dbh->rollback if $oldAutoCommit; + # return "error inserting cust_bill_pkg_tax_rate_location: $error"; + # } + # } + #} my $fee_links = $self->get('cust_bill_pkg_fee'); if ( $fee_links ) { @@ -556,6 +559,138 @@ sub regularize_details { return; } +=item set_exemptions TAXOBJECT, OPTIONS + +Sets up tax exemptions. TAXOBJECT is the L or +L record for the tax. + +This will deal with the following cases: + +=over 4 + +=item Fully exempt customers (cust_main.tax flag) or customer classes +(cust_class.tax). + +=item Customers exempt from specific named taxes (cust_main_exemption +records). + +=item Taxes that don't apply to setup or recurring fees +(cust_main_county.setuptax and recurtax, tax_rate.setuptax and recurtax). + +=item Packages that are marked as tax-exempt (part_pkg.setuptax, +part_pkg.recurtax). + +=item Fees that aren't marked as taxable (part_fee.taxable). + +=back + +It does NOT deal with monthly tax exemptions, which need more context +than this humble little method cares to deal with. + +OPTIONS should include "custnum" => the customer number if this tax line +hasn't been inserted (which it probably hasn't). + +Returns a list of exemption objects, which will also be attached to the +line item as the 'cust_tax_exempt_pkg' pseudo-field. Inserting the line +item will insert these records as well. + +=cut + +sub set_exemptions { + my $self = shift; + my $tax = shift; + my %opt = @_; + + my $part_pkg = $self->part_pkg; + my $part_fee = $self->part_fee; + + my $cust_main; + my $custnum = $opt{custnum}; + $custnum ||= $self->cust_bill->custnum if $self->cust_bill; + + $cust_main = FS::cust_main->by_key( $custnum ) + or die "set_exemptions can't identify customer (pass custnum option)\n"; + + my @new_exemptions; + my $taxable_charged = $self->setup + $self->recur; + return unless $taxable_charged > 0; + + ### Fully exempt customer ### + 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; + } + + ### Exemption from named tax ### + my $exempt_cust_taxname; + if ( !$exempt_cust and $tax->taxname ) { + $exempt_cust_taxname = $cust_main->tax_exemption($tax->taxname); + } + + 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 $exempt_setup = ( ($part_fee and not $part_fee->taxable) + or ($part_pkg and $part_pkg->setuptax) + or $tax->setuptax ); + + if ( $exempt_setup + and $self->setup > 0 + and $taxable_charged > 0 ) { + + push @new_exemptions, FS::cust_tax_exempt_pkg->new({ + amount => $self->setup, + exempt_setup => 'Y' + }); + $taxable_charged -= $self->setup; + + } + + my $exempt_recur = ( ($part_fee and not $part_fee->taxable) + or ($part_pkg and $part_pkg->recurtax) + or $tax->recurtax ); + + if ( $exempt_recur + and $self->recur > 0 + and $taxable_charged > 0 ) { + + push @new_exemptions, FS::cust_tax_exempt_pkg->new({ + amount => $self->recur, + exempt_recur => 'Y' + }); + $taxable_charged -= $self->recur; + + } + + foreach (@new_exemptions) { + $_->set('taxnum', $tax->taxnum); + $_->set('taxtype', ref($tax)); + } + + push @{ $self->cust_tax_exempt_pkg }, @new_exemptions; + return @new_exemptions; + +} + =item cust_bill Returns the invoice (see L) for this invoice line item. @@ -810,71 +945,47 @@ recur) of charge. sub disintegrate { my $self = shift; # XXX this goes away with cust_bill_pkg refactor + # or at least I wish it would, but it turns out to be harder than + # that. - my $cust_bill_pkg = new FS::cust_bill_pkg { $self->hash }; + #my $cust_bill_pkg = new FS::cust_bill_pkg { $self->hash }; # wha huh? my %cust_bill_pkg = (); - $cust_bill_pkg{setup} = $cust_bill_pkg if $cust_bill_pkg->setup; - $cust_bill_pkg{recur} = $cust_bill_pkg if $cust_bill_pkg->recur; - - - #split setup and recur - if ($cust_bill_pkg->setup && $cust_bill_pkg->recur) { - my $cust_bill_pkg_recur = new FS::cust_bill_pkg { $cust_bill_pkg->hash }; - $cust_bill_pkg->set('details', []); - $cust_bill_pkg->recur(0); - $cust_bill_pkg->unitrecur(0); - $cust_bill_pkg->type(''); - $cust_bill_pkg_recur->setup(0); - $cust_bill_pkg_recur->unitsetup(0); - $cust_bill_pkg{recur} = $cust_bill_pkg_recur; - + my $usage_total; + foreach my $classnum ($self->usage_classes) { + my $amount = $self->usage($classnum); + next if $amount == 0; # though if so we shouldn't be here + my $usage_item = FS::cust_bill_pkg->new({ + $self->hash, + 'setup' => 0, + 'recur' => $amount, + 'taxclass' => $classnum, + 'inherit' => $self + }); + $cust_bill_pkg{$classnum} = $usage_item; + $usage_total += $amount; } - #split usage from recur - my $usage = sprintf( "%.2f", $cust_bill_pkg{recur}->usage ) - if exists($cust_bill_pkg{recur}); - warn "usage is $usage\n" if $DEBUG > 1; - if ($usage) { - my $cust_bill_pkg_usage = - new FS::cust_bill_pkg { $cust_bill_pkg{recur}->hash }; - $cust_bill_pkg_usage->recur( $usage ); - $cust_bill_pkg_usage->type( 'U' ); - my $recur = sprintf( "%.2f", $cust_bill_pkg{recur}->recur - $usage ); - $cust_bill_pkg{recur}->recur( $recur ); - $cust_bill_pkg{recur}->type( '' ); - $cust_bill_pkg{recur}->set('details', []); - $cust_bill_pkg{''} = $cust_bill_pkg_usage; + foreach (qw(setup recur)) { + next if ($self->get($_) == 0); + my $item = FS::cust_bill_pkg->new({ + $self->hash, + 'setup' => 0, + 'recur' => 0, + 'taxclass' => $_, + 'inherit' => $self, + }); + $item->set($_, $self->get($_)); + $cust_bill_pkg{$_} = $item; } - #subdivide usage by usage_class - if (exists($cust_bill_pkg{''})) { - foreach my $class (grep { $_ } $self->usage_classes) { - my $usage = sprintf( "%.2f", $cust_bill_pkg{''}->usage($class) ); - my $cust_bill_pkg_usage = - new FS::cust_bill_pkg { $cust_bill_pkg{''}->hash }; - $cust_bill_pkg_usage->recur( $usage ); - $cust_bill_pkg_usage->set('details', []); - my $classless = sprintf( "%.2f", $cust_bill_pkg{''}->recur - $usage ); - $cust_bill_pkg{''}->recur( $classless ); - $cust_bill_pkg{$class} = $cust_bill_pkg_usage; - } - warn "Unexpected classless usage value: ". $cust_bill_pkg{''}->recur - if ($cust_bill_pkg{''}->recur && $cust_bill_pkg{''}->recur < 0); - delete $cust_bill_pkg{''} - unless ($cust_bill_pkg{''}->recur && $cust_bill_pkg{''}->recur > 0); + if ($usage_total) { + $cust_bill_pkg{recur}->set('recur', + sprintf('%.2f', $cust_bill_pkg{recur}->get('recur') - $usage_total) + ); } -# # sort setup,recur,'', and the rest numeric && return -# my @result = map { $cust_bill_pkg{$_} } -# sort { my $ad = ($a=~/^\d+$/); my $bd = ($b=~/^\d+$/); -# ( $ad cmp $bd ) || ( $ad ? $a<=>$b : $b cmp $a ) -# } -# keys %cust_bill_pkg; -# -# return (@result); - - %cust_bill_pkg; + %cust_bill_pkg; } =item usage CLASSNUM @@ -949,7 +1060,7 @@ sub usage_classes { sub cust_tax_exempt_pkg { my ( $self ) = @_; - $self->{Hash}->{cust_tax_exempt_pkg} ||= []; + my $array = $self->{Hash}->{cust_tax_exempt_pkg} ||= []; } =item cust_bill_pkg_tax_Xlocation diff --git a/FS/FS/cust_main/Billing.pm b/FS/FS/cust_main/Billing.pm index 9bfab96ef..8f6234880 100644 --- a/FS/FS/cust_main/Billing.pm +++ b/FS/FS/cust_main/Billing.pm @@ -8,6 +8,7 @@ use List::Util qw( min ); use FS::UID qw( dbh ); use FS::Record qw( qsearch qsearchs dbdef ); use FS::Misc::DateTime qw( day_end ); +use Tie::RefHash; use FS::cust_bill; use FS::cust_bill_pkg; use FS::cust_bill_pkg_display; @@ -1389,6 +1390,11 @@ If not supplied, part_item will be inferred from the pkgnum or feepart of the cust_bill_pkg, and location from the pkgnum (or, for fees, the invnum and the customer's default service location). +This method will also calculate exemptions for any taxes that apply to the +line item (using the C method of L) and +attach them. This is the only place C is called in normal +invoice processing. + =cut sub _handle_taxes { @@ -1418,85 +1424,73 @@ sub _handle_taxes { my %taxes = (); my @classes; - push @classes, $cust_bill_pkg->usage_classes if $cust_bill_pkg->usage; + my $usage = $cust_bill_pkg->usage || 0; + push @classes, $cust_bill_pkg->usage_classes if $usage; push @classes, 'setup' if $cust_bill_pkg->setup and !$options{cancel}; - push @classes, 'recur' if $cust_bill_pkg->recur and !$options{cancel}; - - my $exempt = $conf->exists('cust_class-tax_exempt') - ? ( $self->cust_class ? $self->cust_class->tax : '' ) - : $self->tax; + push @classes, 'recur' if ($cust_bill_pkg->recur - $usage) + and !$options{cancel}; + # that's better--probably don't even need $options{cancel} now + # but leave it for now, just to be safe + # + # About $options{cancel}: This protects against charging per-line or + # per-customer or other flat-rate surcharges on a package that's being + # billed on cancellation (which is an out-of-cycle bill and should only + # have usage charges). See RT#29443. + + # customer exemption is now handled in the 'taxline' method + #my $exempt = $conf->exists('cust_class-tax_exempt') + # ? ( $self->cust_class ? $self->cust_class->tax : '' ) + # : $self->tax; # standardize this just to be sure - $exempt = ($exempt eq 'Y') ? 'Y' : ''; - - if ( !$exempt ) { + #$exempt = ($exempt eq 'Y') ? 'Y' : ''; + # + #if ( !$exempt ) { + + unless (exists $taxes{''}) { + # unsure what purpose this serves, but last time I deleted something + # from here just because I didn't see the point, it actually did + # something important. + my $err_or_ref = $self->_gather_taxes($part_item, '', $location); + return $err_or_ref unless ref($err_or_ref); + $taxes{''} = $err_or_ref; + } - foreach my $class (@classes) { - my $err_or_ref = $self->_gather_taxes($part_item, $class, $location); - return $err_or_ref unless ref($err_or_ref); - $taxes{$class} = $err_or_ref; - } + # NO DISINTEGRATIONS. + # my %tax_cust_bill_pkg = $cust_bill_pkg->disintegrate; + # + # do not call taxline() with any argument except the entire set of + # cust_bill_pkgs on an invoice that are eligible for the tax. - unless (exists $taxes{''}) { - my $err_or_ref = $self->_gather_taxes($part_item, '', $location); - return $err_or_ref unless ref($err_or_ref); - $taxes{''} = $err_or_ref; - } + # only calculate exemptions once for each tax rate, even if it's used + # for multiple classes + my %tax_seen = (); + + foreach my $class (@classes) { + my $err_or_ref = $self->_gather_taxes($part_item, $class, $location); + return $err_or_ref unless ref($err_or_ref); + my @taxes = @$err_or_ref; - } + next if !@taxes; - my %tax_cust_bill_pkg = $cust_bill_pkg->disintegrate; # grrr - foreach my $key (keys %tax_cust_bill_pkg) { - # $key is "setup", "recur", or a usage class name. ('' is a usage class.) - # $tax_cust_bill_pkg{$key} is a cust_bill_pkg for that component of - # the line item. - # $taxes{$key} is an arrayref of cust_main_county or tax_rate objects that - # apply to $key-class charges. - my @taxes = @{ $taxes{$key} || [] }; - my $tax_cust_bill_pkg = $tax_cust_bill_pkg{$key}; - - my %localtaxlisthash = (); foreach my $tax ( @taxes ) { - # this is the tax identifier, not the taxname - my $taxname = ref( $tax ). ' '. $tax->taxnum; - # $taxlisthash: keys are "setup", "recur", and usage classes. + my $tax_id = ref( $tax ). ' '. $tax->taxnum; + # $taxlisthash: keys are tax identifiers ('FS::tax_rate 123456'). # Values are arrayrefs, first the tax object (cust_main_county - # or tax_rate) and then any cust_bill_pkg objects that the - # tax applies to. - $taxlisthash->{ $taxname } ||= [ $tax ]; - push @{ $taxlisthash->{ $taxname } }, $tax_cust_bill_pkg; - - $localtaxlisthash{ $taxname } ||= [ $tax ]; - push @{ $localtaxlisthash{ $taxname } }, $tax_cust_bill_pkg; - - } + # or tax_rate), then the cust_bill_pkg object that the + # tax applies to, then the tax class (setup, recur, usage classnum). + $taxlisthash->{ $tax_id } ||= [ $tax ]; + push @{ $taxlisthash->{ $tax_id } }, $cust_bill_pkg, $class; + + # determine any exemptions that apply + if (!$tax_seen{$tax_id}) { + $cust_bill_pkg->set_exemptions( $tax, custnum => $self->custnum ); + $tax_seen{$tax_id} = 1; + } - warn "finding taxed taxes...\n" if $DEBUG > 2; - foreach my $tax ( keys %localtaxlisthash ) { - my $tax_object = shift @{ $localtaxlisthash{$tax} }; - warn "found possible taxed tax ". $tax_object->taxname. " we call $tax\n" - if $DEBUG > 2; - next unless $tax_object->can('tax_on_tax'); - - foreach my $tot ( $tax_object->tax_on_tax( $location ) ) { - my $totname = ref( $tot ). ' '. $tot->taxnum; - - warn "checking $totname which we call ". $tot->taxname. " as applicable\n" - if $DEBUG > 2; - next unless exists( $localtaxlisthash{ $totname } ); # only increase - # existing taxes - warn "adding $totname to taxed taxes\n" if $DEBUG > 2; - # calculate the tax amount that the tax_on_tax will apply to - my $hashref_or_error = - $tax_object->taxline( $localtaxlisthash{$tax} ); - return $hashref_or_error - unless ref($hashref_or_error); - - # and append it to the list of taxable items - $taxlisthash->{ $totname } ||= [ $tot ]; - push @{ $taxlisthash->{ $totname } }, $hashref_or_error->{amount}; + # tax on tax will be done later, when we actually create the tax + # line items - } } } @@ -1536,6 +1530,7 @@ sub _handle_taxes { foreach (@taxes) { my $tax_id = 'cust_main_county '.$_->taxnum; $taxlisthash->{$tax_id} ||= [ $_ ]; + $cust_bill_pkg->set_exemptions($_, custnum => $self->custnum); push @{ $taxlisthash->{$tax_id} }, $cust_bill_pkg; } diff --git a/FS/FS/tax_rate.pm b/FS/FS/tax_rate.pm index 0047f9d5f..8579020e1 100644 --- a/FS/FS/tax_rate.pm +++ b/FS/FS/tax_rate.pm @@ -18,6 +18,7 @@ use HTTP::Response; use DBIx::DBSchema; use DBIx::DBSchema::Table; use DBIx::DBSchema::Column; +use List::Util 'sum'; use FS::Record qw( qsearch qsearchs dbh dbdef ); use FS::Conf; use FS::tax_class; @@ -379,57 +380,66 @@ sub passtype_name { $tax_passtypes{$self->passtype}; } -=item taxline_cch TAXABLES, [ OPTIONSHASH ] +=item taxline_cch TAXABLES, CLASSES -Returns a listref of a name and an amount of tax calculated for the list -of packages/amounts referenced by TAXABLES. If an error occurs, a message -is returned as a scalar. +Takes an arrayref of L objects representing taxable line +items, and an arrayref of charge classes ('setup', 'recur', '' for +unclassified usage, or an L number). Calculates the tax on +each item under this tax definition and returns a list of new +L objects for the taxes charged. Each returned object +will have a pseudo-field, "cust_bill_pkg_tax_rate_location", containing a +single L object linking the tax rate +back to this tax, and to its originating sale. + +If the taxable objects are linked to an invoice, this will also calculate +per-customer exemptions (cust_exempt and cust_taxname_exempt) and attach them +to the line items in the 'cust_tax_exempt_pkg' pseudo-field. + +For accurate calculation of per-customer or per-location taxes, ALL items +appearing on the invoice (and subject to this tax) MUST be passed to this +method together, and NO items from any other invoice should be included. =cut +# future optimization: it would probably suffice to return only the link +# records, and let the consolidation routine build the cust_bill_pkgs + sub taxline_cch { my $self = shift; # this used to accept a hash of options but none of them did anything # so it's been removed. - my $taxables; - - if (ref($_[0]) eq 'ARRAY') { - $taxables = shift; - }else{ - $taxables = [ @_ ]; - #exemptions would be broken in this case - } + my $taxables = shift; + my $classes = shift || []; my $name = $self->taxname; $name = 'Other surcharges' if ($self->passtype == 2); my $amount = 0; - - if ( $self->disabled ) { # we always know how to handle disabled taxes - return { - 'name' => $name, - 'amount' => $amount, - }; - } + + return unless @$taxables; # nothing to do + return if $self->disabled; + return if $self->passflag eq 'N'; # tax can't be passed to the customer + # but should probably still appear on the liability report--create a + # cust_tax_exempt_pkg record for it? + + # in 4.x, the invoice is _already inserted_ before we try to calculate + # tax on it. though it may be a quotation, so be careful. + + my $cust_main; + my $cust_bill = $taxables->[0]->cust_bill; + $cust_main = $cust_bill->cust_main if $cust_bill; my $taxable_charged = 0; my @cust_bill_pkg = grep { $taxable_charged += $_ unless ref; ref; } @$taxables; + my $taxratelocationnum = $self->tax_rate_location->taxratelocationnum; + warn "calculating taxes for ". $self->taxnum. " on ". join (",", map { $_->pkgnum } @cust_bill_pkg) if $DEBUG; - if ($self->passflag eq 'N') { - # return "fatal: can't (yet) handle taxes not passed to the customer"; - # until someone needs to track these in freeside - return { - 'name' => $name, - 'amount' => 0, - }; - } - my $maxtype = $self->maxtype || 0; if ($maxtype != 0 && $maxtype != 1 && $maxtype != 14 && $maxtype != 15 @@ -451,54 +461,144 @@ sub taxline_cch { $self->_fatal_or_null( 'tax with "'. $self->basetype_name. '" basis' ); } - unless ($self->setuptax =~ /^Y$/i) { - $taxable_charged += $_->setup foreach @cust_bill_pkg; - } - unless ($self->recurtax =~ /^Y$/i) { - $taxable_charged += $_->recur foreach @cust_bill_pkg; - } + my @tax_locations; + my %seen; # locationnum or pkgnum => 1 + my $taxable_cents = 0; my $taxable_units = 0; - unless ($self->recurtax =~ /^Y$/i) { - - if (( $self->unittype || 0 ) == 0) { #access line - my %seen = (); - foreach (@cust_bill_pkg) { - $taxable_units += $_->units - unless $seen{$_->pkgnum}++; + my $tax_cents = 0; + + while (@$taxables) { + my $cust_bill_pkg = shift @$taxables; + my $class = shift @$classes; + $class = 'all' if !defined($class); + + my %usage_map = map { $_ => $cust_bill_pkg->usage($_) } + $cust_bill_pkg->usage_classes; + my $usage_total = sum( values(%usage_map), 0 ); + + # determine if the item has exemptions that apply to this tax def + my @exemptions = grep { $_->taxnum == $self->taxnum } + @{ $cust_bill_pkg->cust_tax_exempt_pkg }; + + if ( $self->tax > 0 ) { + + my $taxable_charged = 0; + if ($class eq 'all') { + $taxable_charged = $cust_bill_pkg->setup + $cust_bill_pkg->recur; + } elsif ($class eq 'setup') { + $taxable_charged = $cust_bill_pkg->setup; + } elsif ($class eq 'recur') { + $taxable_charged = $cust_bill_pkg->recur - $usage_total; + } else { + $taxable_charged = $usage_map{$class} || 0; } - } elsif ($self->unittype == 1) { #minute - return $self->_fatal_or_null( 'fee with minute unit type' ); - - } elsif ($self->unittype == 2) { #account + foreach my $ex (@exemptions) { + # the only cases where the exemption doesn't apply: + # if it's a setup exemption and $class is not 'setup' or 'all' + # if it's a recur exemption and $class is 'setup' + if ( ( $ex->exempt_recur and $class eq 'setup' ) + or ( $ex->exempt_setup and $class ne 'setup' and $class ne 'all' ) + ) { + next; + } - my $conf = new FS::Conf; - if ( $conf->exists('tax-pkg_address') ) { - #number of distinct locations - my %seen = (); - foreach (@cust_bill_pkg) { - $taxable_units++ - unless $seen{$_->cust_pkg->locationnum}++; + $taxable_charged -= $ex->amount; + } + # cust_main_county handles monthly capped exemptions; this doesn't. + # + # $taxable_charged can also be less than zero at this point + # (recur exemption + usage class breakdown); treat that as zero. + next if $taxable_charged <= 0; + + # yeah, some false laziness with cust_main_county + my $this_tax_cents = int(100 * $taxable_charged * $self->tax); + my $tax_location = FS::cust_bill_pkg_tax_rate_location->new({ + 'taxnum' => $self->taxnum, + 'taxtype' => ref($self), + 'cents' => $this_tax_cents, # not a real field + 'locationtaxid' => $self->location, # fundamentally silly + 'taxable_billpkgnum' => $cust_bill_pkg->billpkgnum, + 'taxable_cust_bill_pkg' => $cust_bill_pkg, + 'taxratelocationnum' => $taxratelocationnum, + 'taxclass' => $class, + }); + push @tax_locations, $tax_location; + + $taxable_cents += 100 * $taxable_charged; + $tax_cents += $this_tax_cents; + + } elsif ( $self->fee > 0 ) { + # most CCH taxes are this type, because nearly every county has a 911 + # fee + my $units = 0; + + # since we don't support partial exemptions (except setup/recur), + # if there's an exemption that applies to this package and taxrate, + # don't charge ANY per-unit fees + next if @exemptions; + + # don't apply fees to usage classes (maybe if we ever get per-minute + # fees?) + next unless $class eq 'setup' + or $class eq 'recur' + or $class eq 'all'; + + if ( $self->unittype == 0 ) { + if ( !$seen{$cust_bill_pkg->pkgnum} ) { + # per access line + $units = $cust_bill_pkg->units; + $seen{$cust_bill_pkg->pkgnum} = 1; + } # else it's been seen, leave it at zero units + + } elsif ($self->unittype == 1) { # per minute + # STILL not supported...fortunately these only exist if you happen + # to be in Idaho or Little Rock, Arkansas + # + # though a voip_cdr package could easily report minutes of usage... + return $self->_fatal_or_null( 'fee with minute unit type' ); + + } elsif ( $self->unittype == 2 ) { + + # per account + my $locationnum = $cust_bill_pkg->tax_locationnum; + if (!$locationnum and $cust_main) { + $locationnum = $cust_main->ship_locationnum; } + # the other case is that it's a quotation + + $units = 1 unless $seen{$cust_bill_pkg->tax_locationnum}; + $seen{$cust_bill_pkg->tax_locationnum} = 1; + } else { - $taxable_units = 1; + # Unittype 19 is used for prepaid wireless E911 charges in many states. + # Apparently "per retail purchase", which for us would mean per invoice. + # Unittype 20 is used for some 911 surcharges and I have no idea what + # it means. + return $self->_fatal_or_null( 'unknown unit type in tax'. $self->taxnum ); } + my $this_tax_cents = int($units * $self->fee * 100); + my $tax_location = FS::cust_bill_pkg_tax_rate_location->new({ + 'taxnum' => $self->taxnum, + 'taxtype' => ref($self), + 'cents' => $this_tax_cents, + 'locationtaxid' => $self->location, + 'taxable_cust_bill_pkg' => $cust_bill_pkg, + 'taxratelocationnum' => $taxratelocationnum, + }); + push @tax_locations, $tax_location; + + $taxable_units += $units; + $tax_cents += $this_tax_cents; - } else { - return $self->_fatal_or_null( 'unknown unit type in tax'. $self->taxnum ); } + } # foreach $cust_bill_pkg - } + # check bracket maxima; throw an error if we've gone over, because + # we don't really implement them - # XXX handle excessrate (use_excessrate) / excessfee / - # taxbase/feebase / taxmax/feemax - # and eventually exemptions - # - # the tax or fee is applied to taxbase or feebase and then - # the excessrate or excess fee is applied to taxmax or feemax - - if ( ($self->taxmax > 0 and $taxable_charged > $self->taxmax) or + if ( ($self->taxmax > 0 and $taxable_cents > $self->taxmax*100 ) or ($self->feemax > 0 and $taxable_units > $self->feemax) ) { # throw an error # (why not just cap taxable_charged/units at the taxmax/feemax? because @@ -507,17 +607,42 @@ sub taxline_cch { return $self->_fatal_or_null( 'tax base > taxmax/feemax for tax'.$self->taxnum ); } - $amount += $taxable_charged * $self->tax; - $amount += $taxable_units * $self->fee; - - warn "calculated taxes as [ $name, $amount ]\n" - if $DEBUG; + # round and distribute + my $total_tax_cents = sprintf('%.0f', + ($taxable_cents * $self->tax) + ($taxable_units * $self->fee * 100) + ); + my $extra_cents = sprintf('%.0f', $total_tax_cents - $tax_cents); + $tax_cents += $extra_cents; + my $i = 0; + foreach (@tax_locations) { # 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)); + } - return { - 'name' => $name, - 'amount' => $amount, - }; + # just transform each CBPTRL record into a tax line item. + # calculate_taxes will consolidate them, but before that happens we have + # to do tax on tax calculation. + my @tax_items; + foreach (@tax_locations) { + next if $_->amount == 0; + my $tax_item = FS::cust_bill_pkg->new({ + 'pkgnum' => 0, + 'recur' => 0, + 'setup' => $_->amount, + 'sdate' => '', # $_->sdate? + 'edate' => '', + 'itemdesc' => $name, + 'cust_bill_pkg_tax_rate_location' => [ $_ ], + }); + $_->set('tax_cust_bill_pkg' => $tax_item); + push @tax_items, $tax_item; + } + return @tax_items; } sub _fatal_or_null { -- 2.11.0