CCH tax exemptions + 4.x tax system, #34223
[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
9 =head1 SUMMARY
10
11 FS::TaxEngine::cch - CCH published tax tables.  Uses multiple tables:
12 - tax_rate: definition of specific taxes, based on tax class and geocode.
13 - cust_tax_location: definition of geocodes, using zip+4 codes.
14 - tax_class: definition of tax classes.
15 - part_pkg_taxproduct: definition of taxable products (foreign key in 
16   part_pkg.taxproductnum and the "usage_taxproductnum_*" part_pkg options).
17   The 'taxproduct' string in this table can implicitly include other 
18   taxproducts.
19 - part_pkg_taxrate: links (geocode, taxproductnum) of a sold product to a 
20   tax class.  Many records here have partial-length geocodes which act
21   as wildcards.
22 - part_pkg_taxoverride: manual link from a part_pkg to a specific tax class.
23
24 =cut
25
26 $DEBUG = 0;
27
28 my %part_pkg_cache;
29
30 =item add_sale LINEITEM
31
32 Takes LINEITEM (a L<FS::cust_bill_pkg> object) and adds it to three internal
33 data structures:
34
35 - C<items>, an arrayref of all items on this invoice.
36 - C<taxes>, a hashref of taxnum => arrayref containing the items that are
37   taxable under that tax definition.
38 - C<taxclass>, a hashref of taxnum => arrayref containing the tax class
39   names parallel to the C<taxes> array for the same tax.
40
41 The item will appear on C<taxes> once for each tax class (setup, recur,
42 or a usage class number) that's taxable under that class and appears on
43 the item.
44
45 C<add_sale> will also determine any exemptions that apply to the item
46 and attach them to LINEITEM.
47
48 =cut
49
50 sub add_sale {
51   my ($self, $cust_bill_pkg) = @_;
52
53   my $part_item = $cust_bill_pkg->part_X;
54   my $location = $cust_bill_pkg->tax_location;
55   my $custnum = $self->{cust_main}->custnum;
56
57   push @{ $self->{items} }, $cust_bill_pkg;
58
59   my $conf = FS::Conf->new;
60
61   my @classes;
62   my $usage = $cust_bill_pkg->usage || 0;
63   push @classes, $cust_bill_pkg->usage_classes if $cust_bill_pkg->usage;
64   if (!$self->{cancel}) {
65     push @classes, 'setup' if $cust_bill_pkg->setup > 0;
66     push @classes, 'recur' if ($cust_bill_pkg->recur - $usage) > 0;
67   }
68
69   # About $self->{cancel}: This protects against charging per-line or
70   # per-customer or other flat-rate surcharges on a package that's being
71   # billed on cancellation (which is an out-of-cycle bill and should only
72   # have usage charges).  See RT#29443.
73
74   # only calculate exemptions once for each tax rate, even if it's used for
75   # multiple classes.
76   my %tax_seen;
77
78   foreach my $class (@classes) {
79     my $err_or_ref = $self->_gather_taxes($part_item, $class, $location);
80     return $err_or_ref unless ref($err_or_ref);
81     my @taxes = @$err_or_ref;
82
83     next if !@taxes;
84
85     foreach my $tax (@taxes) {
86       my $taxnum = $tax->taxnum;
87       $self->{taxes}{$taxnum} ||= [];
88       $self->{taxclass}{$taxnum} ||= [];
89       push @{ $self->{taxes}{$taxnum} }, $cust_bill_pkg;
90       push @{ $self->{taxclass}{$taxnum} }, $class;
91
92       if ( !$tax_seen{$taxnum} ) {
93         $cust_bill_pkg->set_exemptions( $tax, 'custnum' => $custnum );
94         $tax_seen{$taxnum}++;
95       }
96     } #foreach $tax
97   } #foreach $class
98 }
99
100 sub _gather_taxes { # interface for this sucks
101   my $self = shift;
102   my $part_item = shift;
103   my $class = shift;
104   my $location = shift;
105
106   my $geocode = $location->geocode('cch');
107
108   my @taxes = $part_item->tax_rates('cch', $geocode, $class);
109
110   warn "Found taxes ".
111        join(',', map{ ref($_). " ". $_->get($_->primary_key) } @taxes). "\n"
112    if $DEBUG;
113
114   \@taxes;
115 }
116
117 # differs from stock make_taxlines because we need another pass to do
118 # tax on tax
119 sub make_taxlines {
120   my $self = shift;
121   my $cust_bill = shift;
122
123   my @raw_taxlines;
124   my %taxable_location; # taxable billpkgnum => cust_location
125   my %item_has_tax; # taxable billpkgnum => taxnum
126   foreach my $taxnum ( keys %{ $self->{taxes} } ) {
127     my $tax_rate = FS::tax_rate->by_key($taxnum);
128     my $taxables = $self->{taxes}{$taxnum};
129     my $charge_classes = $self->{taxclass}{$taxnum};
130     foreach (@$taxables) {
131       $taxable_location{ $_->billpkgnum } ||= $_->tax_location;
132     }
133
134     my @taxlines = $tax_rate->taxline_cch( $taxables, $charge_classes );
135
136     next if !@taxlines;
137     if (!ref $taxlines[0]) {
138       # it's an error string
139       warn "error evaluating tax#$taxnum\n";
140       return $taxlines[0];
141     }
142
143     my $billpkgnum = -1; # the current one
144     my $fragments; # $item_has_tax{$billpkgnum}{taxnum}
145
146     foreach my $taxline (@taxlines) {
147       next if $taxline->setup == 0;
148
149       my $link = $taxline->get('cust_bill_pkg_tax_rate_location')->[0];
150       # store this tax fragment, indexed by taxable item, then by taxnum
151       if ( $billpkgnum != $link->taxable_billpkgnum ) {
152         $billpkgnum = $link->taxable_billpkgnum;
153         $item_has_tax{$billpkgnum} ||= {};
154         $fragments = $item_has_tax{$billpkgnum}{$taxnum} ||= [];
155       }
156
157       $taxline->set('invnum', $cust_bill->invnum);
158       push @$fragments, $taxline; # so we can ToT it
159       push @raw_taxlines, $taxline; # so we actually bill it
160     }
161   } # foreach $taxnum
162
163   # all first-tier taxes are calculated. now for tax on tax
164   # (has to be done on a per-taxable-item basis)
165   foreach my $billpkgnum (keys %item_has_tax) {
166     # taxes that apply to this item
167     my $this_has_tax = $item_has_tax{$billpkgnum};
168     my $location = $taxable_location{$billpkgnum};
169     foreach my $taxnum (keys %$this_has_tax) {
170       my $tax_rate = FS::tax_rate->by_key($taxnum);
171       # find all taxes that apply to it in this location
172       my @tot = $tax_rate->tax_on_tax( $location );
173       next if !@tot;
174
175       warn "found possible taxed taxnum $taxnum\n"
176         if $DEBUG > 2;
177       # Calculate ToT separately for each taxable item, and only if _that 
178       # item_ is already taxed under the ToT.  This is counterintuitive.
179       # See RT#5243.
180       foreach my $tot (@tot) { 
181         my $totnum = $tot->taxnum;
182         warn "checking taxnum ".$tot->taxnum. 
183              " which we call ". $tot->taxname ."\n"
184           if $DEBUG > 2;
185         if ( exists $this_has_tax->{ $totnum } ) {
186           warn "calculating tax on tax: taxnum ".$tot->taxnum." on $taxnum\n"
187             if $DEBUG; 
188           my @taxlines = $tot->taxline_cch(
189             $this_has_tax->{ $taxnum }, # the first-stage tax (in an arrayref)
190           );
191           next if (!@taxlines); # it didn't apply after all
192           if (!ref($taxlines[0])) {
193             warn "error evaluating TOT ($totnum on $taxnum)\n";
194             return $taxlines[0];
195           }
196           # add these to the taxline queue
197           push @raw_taxlines, @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;