1 package FS::TaxEngine::cch;
5 use base 'FS::TaxEngine';
6 use FS::Record qw(dbh qsearch qsearchs);
8 use List::Util qw(sum);
12 FS::TaxEngine::cch - CCH published tax tables. Uses multiple tables:
13 - tax_rate: definition of specific taxes, based on tax class and geocode.
14 - cust_tax_location: definition of geocodes, using zip+4 codes.
15 - tax_class: definition of tax classes.
16 - part_pkg_taxproduct: definition of taxable products (foreign key in
17 part_pkg.taxproductnum and the "usage_taxproductnum_*" part_pkg options).
18 The 'taxproduct' string in this table can implicitly include other
20 - part_pkg_taxrate: links (geocode, taxproductnum) of a sold product to a
21 tax class. Many records here have partial-length geocodes which act
23 - part_pkg_taxoverride: manual link from a part_pkg to a specific tax class.
31 =item add_sale LINEITEM
33 Takes LINEITEM (a L<FS::cust_bill_pkg> object) and adds it to three internal
36 - C<items>, an arrayref of all items on this invoice.
37 - C<taxes>, a hashref of taxnum => arrayref containing the items that are
38 taxable under that tax definition.
39 - C<taxclass>, a hashref of taxnum => arrayref containing the tax class
40 names parallel to the C<taxes> array for the same tax.
42 The item will appear on C<taxes> once for each tax class (setup, recur,
43 or a usage class number) that's taxable under that class and appears on
46 C<add_sale> will also determine any exemptions that apply to the item
47 and attach them to LINEITEM.
52 my ($self, $cust_bill_pkg) = @_;
54 my $part_item = $cust_bill_pkg->part_X;
55 my $location = $cust_bill_pkg->tax_location;
56 my $custnum = $self->{cust_main}->custnum;
58 push @{ $self->{items} }, $cust_bill_pkg;
60 my $conf = FS::Conf->new;
63 my $usage = $cust_bill_pkg->usage || 0;
64 push @classes, $cust_bill_pkg->usage_classes if $cust_bill_pkg->usage;
65 if (!$self->{cancel}) {
66 push @classes, 'setup' if $cust_bill_pkg->setup > 0;
67 push @classes, 'recur' if ($cust_bill_pkg->recur - $usage) > 0;
70 # About $self->{cancel}: This protects against charging per-line or
71 # per-customer or other flat-rate surcharges on a package that's being
72 # billed on cancellation (which is an out-of-cycle bill and should only
73 # have usage charges). See RT#29443.
75 # only calculate exemptions once for each tax rate, even if it's used for
79 foreach my $class (@classes) {
80 my $err_or_ref = $self->_gather_taxes($part_item, $class, $location);
81 return $err_or_ref unless ref($err_or_ref);
82 my @taxes = @$err_or_ref;
86 foreach my $tax (@taxes) {
87 my $taxnum = $tax->taxnum;
88 $self->{taxes}{$taxnum} ||= [];
89 $self->{taxclass}{$taxnum} ||= [];
90 push @{ $self->{taxes}{$taxnum} }, $cust_bill_pkg;
91 push @{ $self->{taxclass}{$taxnum} }, $class;
93 if ( !$tax_seen{$taxnum} ) {
94 $cust_bill_pkg->set_exemptions( $tax, 'custnum' => $custnum );
101 sub _gather_taxes { # interface for this sucks
103 my $part_item = shift;
105 my $location = shift;
107 my $geocode = $location->geocode('cch');
109 my @taxes = $part_item->tax_rates('cch', $geocode, $class);
112 join(',', map{ ref($_). " ". $_->get($_->primary_key) } @taxes). "\n"
118 # differs from stock make_taxlines because we need another pass to do
122 my $cust_bill = shift;
125 my %taxable_location; # taxable billpkgnum => cust_location
126 my %item_has_tax; # taxable billpkgnum => charge class => taxnum
127 foreach my $taxnum ( keys %{ $self->{taxes} } ) {
128 my $tax_rate = FS::tax_rate->by_key($taxnum);
129 my $taxables = $self->{taxes}{$taxnum};
130 my $charge_classes = $self->{taxclass}{$taxnum};
131 foreach (@$taxables) {
132 $taxable_location{ $_->billpkgnum } ||= $_->tax_location;
135 foreach my $link ( $tax_rate->taxline_cch( $taxables, $charge_classes ) ) {
137 # it's an error string
138 die "error evaluating tax#$taxnum: $link\n";
140 next if $link->amount == 0;
142 # store this tax fragment, indexed by taxable item, then by taxnum
143 my $billpkgnum = $link->taxable_billpkgnum;
144 my $fragments = $item_has_tax{$billpkgnum}{$link->taxclass}{$taxnum}
147 push @raw_taxlines, $link; # this will go into final consolidation
148 push @$fragments, $link; # this will go into a temporary cust_bill_pkg
149 # for ToT calculation
153 # all first-tier taxes are calculated. now for tax on tax
154 # (has to be done on a per-taxable-item basis)
155 foreach my $billpkgnum (keys %item_has_tax) {
156 # taxes that apply to this item
157 my $this_has_tax = $item_has_tax{$billpkgnum};
158 my $location = $taxable_location{$billpkgnum};
160 foreach my $charge_class (keys %$this_has_tax) {
161 # taxes that apply to this item and charge class
162 my $this_class_has_tax = $this_has_tax->{$charge_class};
163 foreach my $taxnum (keys %$this_class_has_tax) {
165 my $tax_rate = FS::tax_rate->by_key($taxnum);
166 # find all taxes that apply to it in this location
167 my @tot = $tax_rate->tax_on_tax( $location );
170 warn "found possible taxed taxnum $taxnum\n"
172 # Calculate ToT separately for each taxable item and class, and only
173 # if _that class on the item_ is already taxed under the ToT. This is
175 # See RT#5243 and RT#36380.
177 foreach my $tot (@tot) {
178 my $totnum = $tot->taxnum;
179 warn "checking taxnum ".$tot->taxnum.
180 " which we call ". $tot->taxname ."\n"
182 # note: if the _null class_ on this item is taxed under the ToT,
183 # then this specific class is taxed also (because null class
184 # includes all classes) and so ToT is applicable.
186 exists $this_class_has_tax->{ $totnum }
187 or exists $this_has_tax->{''}{ $totnum }
189 warn "calculating tax on tax: taxnum ".$tot->taxnum." on $taxnum\n"
191 # construct a line item to calculate tax on
192 $temp_lineitem ||= FS::cust_bill_pkg->new({
194 'invnum' => $cust_bill->invnum,
195 'setup' => sum(map $_->amount, @{ $this_class_has_tax->{$taxnum} }),
197 'itemdesc' => $tax_rate->taxname,
198 'cust_bill_pkg_tax_rate_location' => $this_class_has_tax->{$taxnum},
200 my @new_taxlines = $tot->taxline_cch( [ $temp_lineitem ] );
201 next if (!@new_taxlines); # it didn't apply after all
202 if (!ref($new_taxlines[0])) {
203 die "error evaluating TOT ($totnum on $taxnum): $new_taxlines[0]\n";
205 # add these to the taxline queue
206 push @raw_taxlines, @new_taxlines;
207 } # if $this_has_tax->{$totnum}
208 } # foreach my $tot (tax-on-tax rate definition)
209 } # foreach $taxnum (first-tier rate definition)
210 } # foreach $charge_class
211 } # foreach $taxable_item
213 return @raw_taxlines;
216 sub cust_tax_locations {
218 my $location = shift;
219 $location = FS::cust_location->new($location) if ref($location) eq 'HASH';
221 # limit to CCH zip code prefix records, not zip+4 range records
222 my $hashref = { 'data_vendor' => 'cch-zip' };
223 if ( $location->country eq 'CA' ) {
224 # weird CCH convention: treat Canadian provinces as localities, using
225 # their one-letter postal codes.
226 $hashref->{zip} = substr($location->zip, 0, 1);
227 } elsif ( $location->country eq 'US' ) {
228 $hashref->{zip} = substr($location->zip, 0, 5);
233 return qsearch('cust_tax_location', $hashref);
240 manual_tax_location => 1,
241 rate_table => 'tax_rate',
242 link_table => 'cust_bill_pkg_tax_rate_location',