SureTax, #31639, #33015, #34598
[freeside.git] / FS / FS / TaxEngine / cch.pm
1 package FS::TaxEngine::cch;
2
3 use strict;
4 use vars qw( $DEBUG );
5 use base 'FS::TaxEngine';
6 use FS::Record qw(dbh qsearch qsearchs);
7 use FS::Conf;
8 use List::Util qw(sum);
9
10 =head1 SUMMARY
11
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 
19   taxproducts.
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
22   as wildcards.
23 - part_pkg_taxoverride: manual link from a part_pkg to a specific tax class.
24
25 =cut
26
27 $DEBUG = 0;
28
29 my %part_pkg_cache;
30
31 =item add_sale LINEITEM
32
33 Takes LINEITEM (a L<FS::cust_bill_pkg> object) and adds it to three internal
34 data structures:
35
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.
41
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
44 the item.
45
46 C<add_sale> will also determine any exemptions that apply to the item
47 and attach them to LINEITEM.
48
49 =cut
50
51 sub add_sale {
52   my ($self, $cust_bill_pkg) = @_;
53
54   my $part_item = $cust_bill_pkg->part_X;
55   my $location = $cust_bill_pkg->tax_location;
56   my $custnum = $self->{cust_main}->custnum;
57
58   push @{ $self->{items} }, $cust_bill_pkg;
59
60   my $conf = FS::Conf->new;
61
62   my @classes;
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;
68   }
69
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.
74
75   # only calculate exemptions once for each tax rate, even if it's used for
76   # multiple classes.
77   my %tax_seen;
78
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;
83
84     next if !@taxes;
85
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;
92
93       if ( !$tax_seen{$taxnum} ) {
94         $cust_bill_pkg->set_exemptions( $tax, 'custnum' => $custnum );
95         $tax_seen{$taxnum}++;
96       }
97     } #foreach $tax
98   } #foreach $class
99 }
100
101 sub _gather_taxes { # interface for this sucks
102   my $self = shift;
103   my $part_item = shift;
104   my $class = shift;
105   my $location = shift;
106
107   my $geocode = $location->geocode('cch');
108
109   my @taxes = $part_item->tax_rates('cch', $geocode, $class);
110
111   warn "Found taxes ".
112        join(',', map{ ref($_). " ". $_->get($_->primary_key) } @taxes). "\n"
113    if $DEBUG;
114
115   \@taxes;
116 }
117
118 # differs from stock make_taxlines because we need another pass to do
119 # tax on tax
120 sub make_taxlines {
121   my $self = shift;
122   my $cust_bill = shift;
123
124   my @raw_taxlines;
125   my %taxable_location; # taxable billpkgnum => cust_location
126   my %item_has_tax; # taxable billpkgnum => 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;
133     }
134
135     foreach my $link ( $tax_rate->taxline_cch( $taxables, $charge_classes ) ) {
136       if (!ref $link) {
137       # it's an error string
138         die "error evaluating tax#$taxnum: $link\n";
139       }
140       next if $link->amount == 0;
141
142       # store this tax fragment, indexed by taxable item, then by taxnum
143       my $billpkgnum = $link->taxable_billpkgnum;
144       $item_has_tax{$billpkgnum} ||= {};
145       my $fragments = $item_has_tax{$billpkgnum}{$taxnum} ||= [];
146
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
150     }
151   } # foreach $taxnum
152
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};
159     foreach my $taxnum (keys %$this_has_tax) {
160       # $this_has_tax->{$taxnum} = an arrayref of the tax links for taxdef 
161       # $taxnum on taxable item $billpkgnum
162
163       my $tax_rate = FS::tax_rate->by_key($taxnum);
164       # find all taxes that apply to it in this location
165       my @tot = $tax_rate->tax_on_tax( $location );
166       next if !@tot;
167
168       warn "found possible taxed taxnum $taxnum\n"
169         if $DEBUG > 2;
170       # Calculate ToT separately for each taxable item, and only if _that 
171       # item_ is already taxed under the ToT.  This is counterintuitive.
172       # See RT#5243.
173       my $temp_lineitem;
174       foreach my $tot (@tot) { 
175         my $totnum = $tot->taxnum;
176         warn "checking taxnum ".$tot->taxnum. 
177              " which we call ". $tot->taxname ."\n"
178           if $DEBUG > 2;
179         if ( exists $this_has_tax->{ $totnum } ) {
180           warn "calculating tax on tax: taxnum ".$tot->taxnum." on $taxnum\n"
181             if $DEBUG; 
182           # construct a line item to calculate tax on
183           $temp_lineitem ||= FS::cust_bill_pkg->new({
184               'pkgnum'    => 0,
185               'invnum'    => $cust_bill->invnum,
186               'setup'     => sum(map $_->amount, @{ $this_has_tax->{$taxnum} }),
187               'recur'     => 0,
188               'itemdesc'  => $tax_rate->taxname,
189               'cust_bill_pkg_tax_rate_location' => $this_has_tax->{$taxnum},
190           });
191           my @new_taxlines = $tot->taxline_cch( [ $temp_lineitem ] );
192           next if (!@new_taxlines); # it didn't apply after all
193           if (!ref($new_taxlines[0])) {
194             die "error evaluating TOT ($totnum on $taxnum): $new_taxlines[0]\n";
195           }
196           # add these to the taxline queue
197           push @raw_taxlines, @new_taxlines;
198         } # if $this_has_tax->{$totnum}
199       } # foreach my $tot (tax-on-tax rate definition)
200     } # foreach $taxnum (first-tier rate definition)
201   } # foreach $taxable_item
202
203   return @raw_taxlines;
204 }
205
206 sub cust_tax_locations {
207   my $class = shift;
208   my $location = shift;
209   $location = FS::cust_location->new($location) if ref($location) eq 'HASH';
210
211   # limit to CCH zip code prefix records, not zip+4 range records
212   my $hashref = { 'data_vendor' => 'cch-zip' };
213   if ( $location->country eq 'CA' ) {
214     # weird CCH convention: treat Canadian provinces as localities, using
215     # their one-letter postal codes.
216     $hashref->{zip} = substr($location->zip, 0, 1);
217   } elsif ( $location->country eq 'US' ) {
218     $hashref->{zip} = substr($location->zip, 0, 5);
219   } else {
220     return ();
221   }
222
223   return qsearch('cust_tax_location', $hashref);
224 }
225
226 sub info {
227  +{
228     batch               => 0,
229     override            => 1,
230     manual_tax_location => 1,
231     rate_table          => 'tax_rate',
232     link_table          => 'cust_bill_pkg_tax_rate_location',
233   }
234 }
235
236 1;