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