[freeside-commits] branch FREESIDE_3_BRANCH updated. ca64920f7bd3c6599c164b5fcb126a6a1c0f7c42
Mark Wells
mark at 420.am
Thu Feb 12 12:17:02 PST 2015
The branch, FREESIDE_3_BRANCH has been updated
via ca64920f7bd3c6599c164b5fcb126a6a1c0f7c42 (commit)
from 9954eac1ec11d4bf1d6e7925895ce675fcdc6e22 (commit)
Those revisions listed above that are new to this repository have
not appeared on any other notification email; so we list those
revisions in full, below.
- Log -----------------------------------------------------------------
commit ca64920f7bd3c6599c164b5fcb126a6a1c0f7c42
Author: Mark Wells <mark at freeside.biz>
Date: Thu Feb 12 14:16:39 2015 -0600
exempt customers from specific taxes under CCH, #18509
diff --git a/FS/FS/Schema.pm b/FS/FS/Schema.pm
index 6301df2..8087304 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 4718d18..13f027b 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 a681080..a3da331 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 e4d9c80..d9cd634 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 bbabb5b..29f6314 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 bfbc8c7..ed793d5 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 89cb3de..cd3ce7e 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 9f07f36..ab1a69e 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 {
-----------------------------------------------------------------------
Summary of changes:
FS/FS/Schema.pm | 7 +-
FS/FS/cust_bill_pkg.pm | 259 ++++++++++++++++++--------
FS/FS/cust_main/Billing.pm | 363 ++++++++++++++++++++++---------------
FS/FS/cust_main_county.pm | 109 +++--------
FS/FS/cust_tax_exempt_pkg.pm | 34 +++-
FS/FS/cust_tax_exempt_pkg_void.pm | 10 +-
FS/FS/part_pkg/voip_cdr.pm | 2 +-
FS/FS/tax_rate.pm | 286 +++++++++++++++++++++--------
8 files changed, 674 insertions(+), 396 deletions(-)
More information about the freeside-commits
mailing list