Merge branch 'master' of git.freeside.biz:/home/git/freeside
[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 => 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;
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       my $fragments = $item_has_tax{$billpkgnum}{$link->taxclass}{$taxnum}
145                       ||= [];
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
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) {
164
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 );
168         next if !@tot;
169
170         warn "found possible taxed taxnum $taxnum\n"
171           if $DEBUG > 2;
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
174         # counterintuitive.
175         # See RT#5243 and RT#36380.
176         my $temp_lineitem;
177         foreach my $tot (@tot) { 
178           my $totnum = $tot->taxnum;
179           warn "checking taxnum ".$tot->taxnum. 
180                " which we call ". $tot->taxname ."\n"
181             if $DEBUG > 2;
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.
185          if (
186                exists $this_class_has_tax->{ $totnum }
187             or exists $this_has_tax->{''}{ $totnum }
188          ) {
189             warn "calculating tax on tax: taxnum ".$tot->taxnum." on $taxnum\n"
190               if $DEBUG; 
191             # construct a line item to calculate tax on
192             $temp_lineitem ||= FS::cust_bill_pkg->new({
193                 'pkgnum'    => 0,
194                 'invnum'    => $cust_bill->invnum,
195                 'setup'     => sum(map $_->amount, @{ $this_class_has_tax->{$taxnum} }),
196                 'recur'     => 0,
197                 'itemdesc'  => $tax_rate->taxname,
198                 'cust_bill_pkg_tax_rate_location' => $this_class_has_tax->{$taxnum},
199             });
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";
204             }
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
212
213   return @raw_taxlines;
214 }
215
216 sub cust_tax_locations {
217   my $class = shift;
218   my $location = shift;
219   $location = FS::cust_location->new($location) if ref($location) eq 'HASH';
220
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);
229   } else {
230     return ();
231   }
232
233   return qsearch('cust_tax_location', $hashref);
234 }
235
236 sub info {
237  +{
238     batch               => 0,
239     override            => 1,
240     manual_tax_location => 1,
241     rate_table          => 'tax_rate',
242     link_table          => 'cust_bill_pkg_tax_rate_location',
243   }
244 }
245
246 1;