diff options
author | Mark Wells <mark@freeside.biz> | 2015-02-12 14:16:39 -0600 |
---|---|---|
committer | Mark Wells <mark@freeside.biz> | 2015-02-12 14:16:54 -0600 |
commit | ca64920f7bd3c6599c164b5fcb126a6a1c0f7c42 (patch) | |
tree | a0d96cf15dc1bb8212773a4fb256b7bf3f39dafd | |
parent | 9954eac1ec11d4bf1d6e7925895ce675fcdc6e22 (diff) |
exempt customers from specific taxes under CCH, #18509
-rw-r--r-- | FS/FS/Schema.pm | 7 | ||||
-rw-r--r-- | FS/FS/cust_bill_pkg.pm | 259 | ||||
-rw-r--r-- | FS/FS/cust_main/Billing.pm | 363 | ||||
-rw-r--r-- | FS/FS/cust_main_county.pm | 109 | ||||
-rw-r--r-- | FS/FS/cust_tax_exempt_pkg.pm | 34 | ||||
-rw-r--r-- | FS/FS/cust_tax_exempt_pkg_void.pm | 10 | ||||
-rw-r--r-- | FS/FS/part_pkg/voip_cdr.pm | 2 | ||||
-rw-r--r-- | FS/FS/tax_rate.pm | 286 |
8 files changed, 674 insertions, 396 deletions
diff --git a/FS/FS/Schema.pm b/FS/FS/Schema.pm index 6301df2da..8087304d7 100644 --- a/FS/FS/Schema.pm +++ b/FS/FS/Schema.pm @@ -885,6 +885,7 @@ sub tables_hashref { 'taxratelocationnum', 'int', '', '', '', '', 'amount', @money_type, '', '', 'taxable_billpkgnum', 'int', 'NULL', '', '', '', + 'taxclass', 'varchar', 'NULL', 10, '', '', ], 'primary_key' => 'billpkgtaxratelocationnum', 'unique' => [], @@ -3057,6 +3058,7 @@ sub tables_hashref { #'custnum', 'int', '', '', '', '' 'billpkgnum', 'int', '', '', '', '', 'taxnum', 'int', '', '', '', '', + 'taxtype', 'varchar', 'NULL', $char_d, '', '', 'year', 'int', 'NULL', '', '', '', 'month', 'int', 'NULL', '', '', '', 'creditbillpkgnum', 'int', 'NULL', '', '', '', @@ -3072,7 +3074,7 @@ sub tables_hashref { 'unique' => [], 'index' => [ [ 'taxnum', 'year', 'month' ], [ 'billpkgnum' ], - [ 'taxnum' ], + [ 'taxnum', 'taxtype' ], [ 'creditbillpkgnum' ], ], }, @@ -3083,6 +3085,7 @@ sub tables_hashref { #'custnum', 'int', '', '', '', '' 'billpkgnum', 'int', '', '', '', '', 'taxnum', 'int', '', '', '', '', + 'taxtype', 'varchar', 'NULL', $char_d, '', '', 'year', 'int', 'NULL', '', '', '', 'month', 'int', 'NULL', '', '', '', 'creditbillpkgnum', 'int', 'NULL', '', '', '', @@ -3098,7 +3101,7 @@ sub tables_hashref { 'unique' => [], 'index' => [ [ 'taxnum', 'year', 'month' ], [ 'billpkgnum' ], - [ 'taxnum' ], + [ 'taxnum', 'taxtype' ], [ 'creditbillpkgnum' ], ], }, diff --git a/FS/FS/cust_bill_pkg.pm b/FS/FS/cust_bill_pkg.pm index 4718d1824..13f027b07 100644 --- a/FS/FS/cust_bill_pkg.pm +++ b/FS/FS/cust_bill_pkg.pm @@ -203,10 +203,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, @@ -222,8 +225,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', ''); @@ -247,18 +250,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 ) { @@ -550,6 +553,138 @@ sub regularize_details { return; } +=item set_exemptions TAXOBJECT, OPTIONS + +Sets up tax exemptions. TAXOBJECT is the L<FS::cust_main_county> or +L<FS::tax_rate> 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<FS::cust_bill>) for this invoice line item. @@ -811,71 +946,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 @@ -944,7 +1055,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_fee diff --git a/FS/FS/cust_main/Billing.pm b/FS/FS/cust_main/Billing.pm index a6810802e..a3da33161 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; @@ -887,7 +888,9 @@ sub calculate_taxes { # $taxlisthash is a hashref # keys are identifiers, values are arrayrefs # each arrayref starts with a tax object (cust_main_county or tax_rate) - # then any cust_bill_pkg objects the tax applies to + # then a cust_bill_pkg object the tax applies to, then the charge class + # on that object (setup, recur, a usage class number, or '') + # For internal taxes the charge class is always undef. local($DEBUG) = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG; @@ -895,88 +898,140 @@ sub calculate_taxes { #.Dumper($self, $cust_bill_pkg, $taxlisthash, $invoice_time). "\n" if $DEBUG > 2; - my @tax_line_items = (); - - # keys are tax names (as printed on invoices / itemdesc ) - # values are arrayrefs of taxlisthash keys (internal identifiers) + # The main tax accumulator. One bin for each tax name (itemdesc). + # For each subdivision of tax under this name, push a cust_bill_pkg item + # for the calculated tax into the arrayref. + # keys are tax names + # values are arrayrefs of tax lines my %taxname = (); # keys are taxlisthash keys (internal identifiers) # values are (cumulative) amounts my %tax_amount = (); - # keys are taxlisthash keys (internal identifiers) - # values are arrayrefs of cust_bill_pkg_tax_location hashrefs - my %tax_location = (); - - # keys are taxlisthash keys (internal identifiers) - # values are arrayrefs of cust_bill_pkg_tax_rate_location hashrefs - my %tax_rate_location = (); - - # keys are taxlisthash keys (internal identifiers!) + # keys are taxlisthash keys # values are arrayrefs of cust_tax_exempt_pkg objects my %tax_exemption; - foreach my $tax ( keys %$taxlisthash ) { - # $tax is a tax identifier (intersection of a tax definition record - # and a cust_bill_pkg record) - my $tax_object = shift @{ $taxlisthash->{$tax} }; + # keys are cust_bill_pkg objects (taxable items) + # values are hashrefs + # keys are taxlisthash keys + # values are the taxlines generated for those taxes + tie my %item_has_tax, 'Tie::RefHash', + map { $_ => {} } @$cust_bill_pkg; + + foreach my $tax_id ( keys %$taxlisthash ) { + # $tax_id: the identifier of the tax we are calculating in this pass + + my $taxables = $taxlisthash->{$tax_id}; + my $tax_object = shift @$taxables; # $tax_object is a cust_main_county or tax_rate # (with billpkgnum, pkgnum, locationnum set) - # the rest of @{ $taxlisthash->{$tax} } is cust_bill_pkg component objects - # (setup, recurring, usage classes) - warn "found ". $tax_object->taxname. " as $tax\n" if $DEBUG > 2; - warn " ". join('/', @{ $taxlisthash->{$tax} } ). "\n" if $DEBUG > 2; + # the rest of @{ $taxlisthash->{$tax_id} } is cust_bill_pkg objects, + # optionally followed by their charge classes. + warn "found ". $tax_object->taxname. " as $tax_id\n" if $DEBUG > 2; + # taxline calculates the tax on all cust_bill_pkgs in the - # first (arrayref) argument, and returns a hashref of 'name' - # (the line item description) and 'amount'. - # It also calculates exemptions and attaches them to the cust_bill_pkgs - # in the argument. - my $taxables = $taxlisthash->{$tax}; - my $exemptions = $tax_exemption{$tax} ||= []; - my $taxline = $tax_object->taxline( - $taxables, - 'custnum' => $self->custnum, - 'invoice_time' => $invoice_time, - 'exemptions' => $exemptions, - ); - return $taxline unless ref($taxline); - - unshift @{ $taxlisthash->{$tax} }, $tax_object; - - if ( $tax_object->isa('FS::cust_main_county') ) { - # then $taxline is a real line item + # first (arrayref) argument. + # + # Note that non-monthly exemptions have already been calculated and + # attached to the items. Monthly exemptions will be attached in this + # step. + my $exemptions = $tax_exemption{$tax_id} ||= []; + if ( $tax_object->isa('FS::tax_rate') ) { # EXTERNAL TAXES + # STILL have tax_rate-specific crap in here... + my @taxlines = $tax_object->taxline( $taxables, + 'custnum' => $self->custnum, + 'invoice_time' => $invoice_time, + 'exemptions' => $exemptions, + ); + next if !@taxlines; + if (!ref $taxlines[0]) { + # it's an error string + warn "error evaluating $tax_id on custnum ".$self->custnum."\n"; + return $taxlines[0]; + } + foreach my $taxline (@taxlines) { + push @{ $taxname{ $taxline->itemdesc } }, $taxline; + my $link = $taxline->get('cust_bill_pkg_tax_rate_location')->[0]; + my $taxable_item = $link->taxable_cust_bill_pkg; + $item_has_tax{$taxable_item}->{$tax_id} = $taxline; + } + } else { # INTERNAL TAXES + # we can do this in a single taxline, because it's not stupid + + my $taxline = $tax_object->taxline( $taxables, + 'custnum' => $self->custnum, + 'invoice_time' => $invoice_time, + 'exemptions' => $exemptions, + ); + next if !$taxline; + if (!ref $taxline) { + # it's an error string + warn "error evaluating $tax_id on custnum ".$self->custnum."\n"; + return $taxline; + } + # if the calculated tax is zero, don't even keep it + next if $taxline->setup < 0.001; push @{ $taxname{ $taxline->itemdesc } }, $taxline; - - } else { - # leave this as is for now - - my $name = $taxline->{'name'}; - my $amount = $taxline->{'amount'}; - - #warn "adding $amount as $name\n"; - $taxname{ $name } ||= []; - push @{ $taxname{ $name } }, $tax; - - $tax_amount{ $tax } += $amount; - - # link records between cust_main_county/tax_rate and cust_location - $tax_rate_location{ $tax } ||= []; - my $taxratelocationnum = - $tax_object->tax_rate_location->taxratelocationnum; - push @{ $tax_rate_location{ $tax } }, - { - 'taxnum' => $tax_object->taxnum, - 'taxtype' => ref($tax_object), - 'amount' => sprintf('%.2f', $amount ), - 'locationtaxid' => $tax_object->location, - 'taxratelocationnum' => $taxratelocationnum, - }; - } #if ref($tax_object)... - } #foreach keys %$taxlisthash + } + } + $DB::single = 1; # XXX + + # all first-tier taxes are calculated. now for tax on tax: + + foreach my $taxable_item ( @$cust_bill_pkg ) { + # taxes that apply to this item + my $this_has_tax = $item_has_tax{$taxable_item}; + + my $location = $taxable_item->tax_location; + foreach my $tax_id (keys %$this_has_tax) { + my ($class, $taxnum) = split(' ', $tax_id); + # internal taxes don't support tax_on_tax, so we don't bother with + # them here. + next unless $class eq 'FS::tax_rate'; + + # for each tax item that was calculated in phase 1, get the + # tax definition + my $tax_object = FS::tax_rate->by_key($taxnum); + # and find all taxes that apply to it in this location + my @tot = $tax_object->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 $tot_id = ref($tot) . ' ' . $tot->taxnum; + warn "checking taxnum ".$tot->taxnum. + " which we call ". $tot->taxname ."\n" + if $DEBUG > 2; + if ( exists $this_has_tax->{ $tot_id } ) { + warn "calculating tax on tax: taxnum ".$tot->taxnum." on $taxnum\n" + if $DEBUG; + my @taxlines = $tot->taxline( + $this_has_tax->{ $tax_id }, # the first-stage tax + 'custnum' => $self->custnum, + 'invoice_time' => $invoice_time, + ); + next if (!@taxlines); # it didn't apply after all + if (!ref($taxlines[0])) { + warn "error evaluating $tot_id TOT on custnum ". + $self->custnum."\n"; + return $taxlines[0]; + } + foreach my $taxline (@taxlines) { + push @{ $taxname{ $taxline->itemdesc } }, $taxline; + } + } # if $has_tax + } # foreach my $tot (tax-on-tax rate definition) + } # foreach $taxnum (first-tier rate definition) + } # foreach $taxable_item #consolidate and create tax line items warn "consolidating and generating...\n" if $DEBUG > 2; + my %final_tax_items; # taxname => item foreach my $taxname ( keys %taxname ) { my @cust_bill_pkg_tax_location; my @cust_bill_pkg_tax_rate_location; @@ -994,22 +1049,23 @@ sub calculate_taxes { my %seen = (); warn "adding $taxname\n" if $DEBUG > 1; foreach my $taxitem ( @{ $taxname{$taxname} } ) { - if ( ref($taxitem) eq 'FS::cust_bill_pkg' ) { - # then we need to transfer the amount and the links from the - # line item to the new one we're creating. - $tax_total += $taxitem->setup; - foreach my $link ( @{ $taxitem->get('cust_bill_pkg_tax_location') } ) { - $link->set('tax_cust_bill_pkg', $tax_cust_bill_pkg); + next if $taxitem->get('setup') == 0; + # if ( ref($taxitem) eq 'FS::cust_bill_pkg' ) # always true + # then we need to transfer the amount and the links from the + # line item to the new one we're creating. + $tax_total += $taxitem->setup; + my @links = @{ + $taxitem->get('cust_bill_pkg_tax_location') || + $taxitem->get('cust_bill_pkg_tax_rate_location') || + [] + }; + foreach my $link ( @links ) { + $link->set('tax_cust_bill_pkg', $tax_cust_bill_pkg); + if ($link->isa('FS::cust_bill_pkg_tax_location')) { push @cust_bill_pkg_tax_location, $link; + } elsif ($link->isa('FS::cust_bill_pkg_tax_rate_location')) { + push @cust_bill_pkg_tax_rate_location, $link; } - } else { - # the tax_rate way - next if $seen{$taxitem}++; - warn "adding $tax_amount{$taxitem}\n" if $DEBUG > 1; - $tax_total += $tax_amount{$taxitem}; - push @cust_bill_pkg_tax_rate_location, - map { new FS::cust_bill_pkg_tax_rate_location $_ } - @{ $tax_rate_location{ $taxitem } }; } } next unless $tax_total; @@ -1037,10 +1093,21 @@ sub calculate_taxes { } $tax_cust_bill_pkg->set('display', \@display); - push @tax_line_items, $tax_cust_bill_pkg; + $final_tax_items{$taxname} = $tax_cust_bill_pkg; + } # foreach $taxname + + # fix ToT backlinks for taxes that have been consolidated + # (has to be done in a separate pass) + foreach my $tax_item (values %final_tax_items) { + foreach my $taxable_link (@{ $tax_item->cust_bill_pkg_tax_rate_location }) { + my $taxed_item = $taxable_link->taxable_cust_bill_pkg; + next if $taxed_item->pkgnum > 0; # primary taxes + my $taxname = $taxed_item->itemdesc; + $taxable_link->set('taxable_cust_bill_pkg', $final_tax_items{ $taxname }); + } } - \@tax_line_items; + [ values %final_tax_items ] } sub _make_lines { @@ -1489,6 +1556,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<set_exemptions> method of L<FS::cust_bill_pkg>) and +attach them. This is the only place C<set_exemptions> is called in normal +invoice processing. + =cut sub _handle_taxes { @@ -1518,85 +1590,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 - } } } @@ -1636,6 +1696,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/cust_main_county.pm b/FS/FS/cust_main_county.pm index e4d9c8041..d9cd634d2 100644 --- a/FS/FS/cust_main_county.pm +++ b/FS/FS/cust_main_county.pm @@ -241,9 +241,6 @@ 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. -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_time' in case the cust_bill_pkg objects belong to an invoice that hasn't been inserted yet. @@ -257,6 +254,10 @@ tax exemption limit if there is one. 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'; @@ -283,20 +284,6 @@ sub taxline { 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; - # Gather any exemptions that are already attached to these cust_bill_pkgs # so that we can deduct them from the customer's monthly limit. my @existing_exemptions = @{ $opt{'exemptions'} }; @@ -314,70 +301,18 @@ sub taxline { foreach my $cust_bill_pkg (@$taxables) { - my $cust_pkg = $cust_bill_pkg->cust_pkg; - my $part_pkg = $cust_bill_pkg->part_pkg; - my $part_fee = $cust_bill_pkg->part_fee; - - my $locationnum = $cust_pkg - ? $cust_pkg->locationnum - : $cust_main->bill_locationnum; - - 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 $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; } + + my $locationnum = $cust_bill_pkg->tax_locationnum; - my $setup_exempt = ( ($part_fee and not $part_fee->taxable) - or ($part_pkg and $part_pkg->setuptax) - or $self->setuptax ); - - if ( $setup_exempt - 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; - - } - - my $recur_exempt = ( ($part_fee and not $part_fee->taxable) - or ($part_pkg and $part_pkg->recurtax) - or $self->recurtax ); - - if ( $recur_exempt - 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 and $taxable_charged > 0 ) { # If the billing period extends across multiple calendar months, @@ -476,13 +411,21 @@ sub taxline { : $remaining_exemption; $addl = $taxable_charged if $addl > $taxable_charged; - push @new_exemptions, FS::cust_tax_exempt_pkg->new({ + my $new_exemption = + FS::cust_tax_exempt_pkg->new({ amount => sprintf('%.2f', $addl), exempt_monthly => 'Y', year => $year, month => $mon, + taxnum => $self->taxnum, + taxtype => ref($self) }); $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; } # if they're using multiple months of exemption for a multi-month # package, then record the exemptions in separate months @@ -495,12 +438,6 @@ sub taxline { } } # 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; - $taxable_charged = sprintf( "%.2f", $taxable_charged); next if $taxable_charged == 0; diff --git a/FS/FS/cust_tax_exempt_pkg.pm b/FS/FS/cust_tax_exempt_pkg.pm index bbabb5b0a..29f631473 100644 --- a/FS/FS/cust_tax_exempt_pkg.pm +++ b/FS/FS/cust_tax_exempt_pkg.pm @@ -6,6 +6,7 @@ use FS::Record qw( qsearch qsearchs ); use FS::cust_main_Mixin; use FS::cust_bill_pkg; use FS::cust_main_county; +use FS::tax_rate; use FS::cust_credit_bill_pkg; use FS::UID qw(dbh); use FS::upgrade_journal; @@ -50,6 +51,9 @@ currently supported: =item billpkgnum - invoice line item (see L<FS::cust_bill_pkg>) that was exempted from tax. +=item taxtype - the object class of the tax record ('FS::cust_main_county' +or 'FS::tax_rate'). + =item taxnum - tax rate (see L<FS::cust_main_county>) =item year - the year in which the exemption occurred. NULL if this @@ -138,7 +142,7 @@ sub check { my $error = $self->ut_numbern('exemptnum') || $self->ut_foreign_key('billpkgnum', 'cust_bill_pkg', 'billpkgnum') - || $self->ut_foreign_key('taxnum', 'cust_main_county', 'taxnum') + || $self->ut_enum('taxtype', [ 'FS::cust_main_county', 'FS::tax_rate' ]) || $self->ut_foreign_keyn('creditbillpkgnum', 'cust_credit_bill_pkg', 'creditbillpkgnum') @@ -152,6 +156,10 @@ sub check { || $self->SUPER::check ; + $self->get('taxtype') =~ /^FS::(\w+)$/; + my $rate_table = $1; + $error ||= $self->ut_foreign_key('taxnum', $rate_table, 'taxnum'); + return $error if $error; if ( $self->get('exempt_cust') ) { @@ -178,6 +186,8 @@ sub check { =item cust_main_county +=item tax_rate + Returns the associated tax definition if it still exists in the database. Otherwise returns false. @@ -185,7 +195,14 @@ Otherwise returns false. sub cust_main_county { my $self = shift; - qsearchs( 'cust_main_county', { 'taxnum', $self->taxnum } ); + my $class = $self->taxtype; + $class->by_key($self->taxnum); +} + +sub tax_rate { + my $self = shift; + my $class = $self->taxtype; + $class->by_key($self->taxnum); } sub _upgrade_data { @@ -198,6 +215,19 @@ sub _upgrade_data { dbh->do($sql) or die dbh->errstr; FS::upgrade_journal->set_done($journal); } + + $journal = 'cust_tax_exempt_pkg_taxtype'; + if ( !FS::upgrade_journal->is_done($journal) ) { + my $sql = "UPDATE cust_tax_exempt_pkg ". + "SET taxtype = 'FS::cust_main_county' WHERE taxtype IS NULL"; + dbh->do($sql) or die dbh->errstr; + $sql = "UPDATE cust_tax_exempt_pkg_void ". + "SET taxtype = 'FS::cust_main_county' WHERE taxtype IS NULL"; + dbh->do($sql) or die dbh->errstr; + FS::upgrade_journal->set_done($journal); + } + + } =back diff --git a/FS/FS/cust_tax_exempt_pkg_void.pm b/FS/FS/cust_tax_exempt_pkg_void.pm index bfbc8c739..ed793d53b 100644 --- a/FS/FS/cust_tax_exempt_pkg_void.pm +++ b/FS/FS/cust_tax_exempt_pkg_void.pm @@ -110,10 +110,11 @@ and replace methods. sub check { my $self = shift; - my $error = + my $error = $self->ut_number('exemptpkgnum') || $self->ut_foreign_key('billpkgnum', 'cust_bill_pkg_void', 'billpkgnum' ) - || $self->ut_foreign_key('taxnum', 'cust_main_county', 'taxnum') + || $self->ut_enum('taxtype', [ 'FS::cust_main_county', 'FS::tax_rate' ]) + || $self->ut_number('taxnum') || $self->ut_numbern('year') || $self->ut_numbern('month') || $self->ut_numbern('creditbillpkgnum') #no FK check, will have been del'ed @@ -124,6 +125,11 @@ sub check { || $self->ut_flag('exempt_cust_taxname') || $self->ut_flag('exempt_monthly') ; + + $self->get('taxtype') =~ /^FS::(\w+)$/; + my $rate_table = $1; + $error ||= $self->ut_foreign_key('taxnum', $rate_table, 'taxnum'); + return $error if $error; $self->SUPER::check; diff --git a/FS/FS/part_pkg/voip_cdr.pm b/FS/FS/part_pkg/voip_cdr.pm index 89cb3de26..cd3ce7e76 100644 --- a/FS/FS/part_pkg/voip_cdr.pm +++ b/FS/FS/part_pkg/voip_cdr.pm @@ -453,7 +453,7 @@ sub calc_usage { 'disable_src' => $self->option('disable_src'), 'default_prefix' => $self->option('default_prefix'), 'cdrtypenum' => $self->option('use_cdrtypenum'), - 'calltypenum' => $self->option('use_calltypenum'), + 'calltypenum' => $self->option('use_calltypenum', 1), 'status' => '', 'for_update' => 1, ); # $last_bill, $$sdate ) diff --git a/FS/FS/tax_rate.pm b/FS/FS/tax_rate.pm index 9f07f369b..ab1a69eab 100644 --- a/FS/FS/tax_rate.pm +++ b/FS/FS/tax_rate.pm @@ -18,6 +18,7 @@ use MIME::Base64; 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; @@ -382,57 +383,76 @@ sub passtype_name { $tax_passtypes{$self->passtype}; } -=item taxline TAXABLES +#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. -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. +=item taxline TAXABLES_ARRAYREF, [ OPTION => VALUE ... ] -=cut +Takes an arrayref of L<FS::cust_bill_pkg> objects representing taxable +line items, and returns some number of new L<FS::cust_bill_pkg> objects +representing the tax on them under this tax rate. Each returned object +will correspond to a single input line item. -sub taxline { - my $self = shift; - # this used to accept a hash of options but none of them did anything - # so it's been removed. +For accurate calculation of per-customer or per-location taxes, ALL items +appearing on the invoice MUST be passed to this method together. - my $taxables; +Optionally, any of the L<FS::cust_bill_pkg> objects may be followed in the +array by a charge class: 'setup', 'recur', '' (for unclassified usage), or an +integer denoting an L<FS::usage_class> number. In this case, the tax will +only be charged on that portion of the line item. - if (ref($_[0]) eq 'ARRAY') { - $taxables = shift; - }else{ - $taxables = [ @_ ]; - #exemptions would be broken in this case - } +Each returned object will have a pseudo-field, +"cust_bill_pkg_tax_rate_location", containing a single +L<FS::cust_bill_pkg_tax_rate_location> object. This 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. + +If the tax is disabled, this method will return nothing. Be prepared for +that. + +In addition to calculating the tax for the line items, this will calculate +tax exemptions and attach them to the line items. I<Currently this only +supports customer exemptions.> + +Options may include 'custnum' and 'invoice_time' in case the cust_bill_pkg +objects belong to an invoice that hasn't been inserted yet. + +The 'exemptions' option allowed in L<FS::cust_main_county::taxline> does +nothing here, since monthly exemptions aren't supported. + +=cut + +sub taxline { + my( $self, $taxables, %opt) = @_; + $taxables = [ $taxables ] unless ref($taxables) eq 'ARRAY'; 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; # tax is disabled, skip it + 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? + + # XXX a certain amount of false laziness with FS::cust_main_county + my $cust_bill = $taxables->[0]->cust_bill; + my $custnum = $cust_bill ? $cust_bill->custnum : $opt{'custnum'}; + my $cust_main = FS::cust_main->by_key($custnum) if $custnum > 0; + if (!$cust_main) { + die "unable to calculate taxes for an unknown customer\n"; } - my $taxable_charged = 0; - my @cust_bill_pkg = grep { $taxable_charged += $_ unless ref; ref; } - @$taxables; + my $taxratelocationnum = $self->tax_rate_location->taxratelocationnum + or die "no tax_rate_location linked to tax_rate #".$self->taxnum."\n"; warn "calculating taxes for ". $self->taxnum. " on ". - join (",", map { $_->pkgnum } @cust_bill_pkg) + join (",", map { $_->pkgnum } @$taxables) 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 @@ -454,54 +474,139 @@ sub taxline { $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) { + my $tax_cents = 0; - if (( $self->unittype || 0 ) == 0) { #access line - my %seen = (); - foreach (@cust_bill_pkg) { - $taxable_units += $_->units - unless $seen{$_->pkgnum}++; - } + while (@$taxables) { + my $cust_bill_pkg = shift @$taxables; + my $class = 'all'; + if ( defined($taxables->[0]) and !ref($taxables->[0]) ) { + $class = shift @$taxables; + } + + 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 }; - } elsif ($self->unittype == 1) { #minute - return $self->_fatal_or_null( 'fee with minute unit type' ); + if ( $self->tax > 0 ) { - } elsif ($self->unittype == 2) { #account + 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; + } - 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}++; + 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; } + + $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_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 + $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 - } - - # 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 + # check bracket maxima; throw an error if we've gone over, because + # we don't really implement them - 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 @@ -510,17 +615,42 @@ sub taxline { 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 { |