tax engine refactoring for Avalara and Billsoft tax vendors, #25718
[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 sub add_sale {
31   my ($self, $cust_bill_pkg, %options) = @_;
32
33   my $part_item = $options{part_item} || $cust_bill_pkg->part_X;
34   my $location = $options{location} || $cust_bill_pkg->tax_location;
35
36   push @{ $self->{items} }, $cust_bill_pkg;
37
38   my $conf = FS::Conf->new;
39
40   my @classes;
41   push @classes, $cust_bill_pkg->usage_classes if $cust_bill_pkg->usage;
42   # debatable
43   push @classes, 'setup' if ($cust_bill_pkg->setup && !$self->{cancel});
44   push @classes, 'recur' if ($cust_bill_pkg->recur && !$self->{cancel});
45
46   my %taxes_for_class;
47
48   my $exempt = $conf->exists('cust_class-tax_exempt')
49                   ? ( $self->cust_class ? $self->cust_class->tax : '' )
50                   : $self->{cust_main}->tax;
51   # standardize this just to be sure
52   $exempt = ($exempt eq 'Y') ? 'Y' : '';
53
54   if ( !$exempt ) {
55
56     foreach my $class (@classes) {
57       my $err_or_ref = $self->_gather_taxes( $part_item, $class, $location );
58       return $err_or_ref unless ref($err_or_ref);
59       $taxes_for_class{$class} = $err_or_ref;
60     }
61     unless (exists $taxes_for_class{''}) {
62       my $err_or_ref = $self->_gather_taxes( $part_item, '', $location );
63       return $err_or_ref unless ref($err_or_ref);
64       $taxes_for_class{''} = $err_or_ref;
65     }
66
67   }
68
69   my %tax_cust_bill_pkg = $cust_bill_pkg->disintegrate; # grrr
70   foreach my $key (keys %tax_cust_bill_pkg) {
71     # $key is "setup", "recur", or a usage class name. ('' is a usage class.)
72     # $tax_cust_bill_pkg{$key} is a cust_bill_pkg for that component of 
73     # the line item.
74     # $taxes_for_class{$key} is an arrayref of tax_rate objects that
75     # apply to $key-class charges.
76     my @taxes = @{ $taxes_for_class{$key} || [] };
77     my $tax_cust_bill_pkg = $tax_cust_bill_pkg{$key};
78
79     my %localtaxlisthash = ();
80     foreach my $tax ( @taxes ) {
81
82       my $taxnum = $tax->taxnum;
83       $self->{taxes}{$taxnum} ||= [ $tax ];
84       push @{ $self->{taxes}{$taxnum} }, $tax_cust_bill_pkg;
85
86       $localtaxlisthash{ $taxnum } ||= [ $tax ];
87       push @{ $localtaxlisthash{$taxnum} }, $tax_cust_bill_pkg;
88
89     }
90
91     warn "finding taxed taxes...\n" if $DEBUG > 2;
92     foreach my $taxnum ( keys %localtaxlisthash ) {
93       my $tax_object = shift @{ $localtaxlisthash{$taxnum} };
94
95       foreach my $tot ( $tax_object->tax_on_tax( $location ) ) {
96         my $totnum = $tot->taxnum;
97
98         # I'm not sure why, but for some reason we only add ToT if that 
99         # tax_rate already applies to a non-tax item on the same invoice.
100         next unless exists( $localtaxlisthash{ $totnum } );
101         warn "adding #$totnum to taxed taxes\n" if $DEBUG > 2;
102         # calculate the tax amount that the tax_on_tax will apply to
103         my $taxline =
104           $self->taxline( 'tax' => $tax_object,
105                           'sales' => $localtaxlisthash{$taxnum}
106                         );
107         return $taxline unless ref $taxline;
108         # and append it to the list of taxable items
109         $self->{taxes}->{$totnum} ||= [ $tot ];
110         push @{ $self->{taxes}->{$totnum} }, $taxline->setup;
111
112       } # foreach $tot (tax-on-tax)
113     } # foreach $tax
114   } # foreach $key (i.e. usage class)
115 }
116
117 sub _gather_taxes { # interface for this sucks
118   my $self = shift;
119   my $part_item = shift;
120   my $class = shift;
121   my $location = shift;
122
123   my $geocode = $location->geocode('cch');
124
125   my @taxes = $part_item->tax_rates('cch', $geocode, $class);
126
127   warn "Found taxes ".
128        join(',', map{ ref($_). " ". $_->get($_->primary_key) } @taxes). "\n"
129    if $DEBUG;
130
131   \@taxes;
132
133 }
134
135 sub taxline {
136   # FS::tax_rate::taxline() ridiculously returns a description and amount 
137   # instead of a real line item.  Fix that here.
138   #
139   # XXX eventually move the code from tax_rate to here
140   # but that's not necessary yet
141   my ($self, %opt) = @_;
142   my $tax_object = $opt{tax};
143   my $taxables = $opt{sales};
144   my $hashref = $tax_object->taxline_cch($taxables);
145   return $hashref unless ref $hashref; # it's an error message
146
147   my $tax_amount = sprintf('%.2f', $hashref->{amount});
148   my $tax_item = FS::cust_bill_pkg->new({
149       'itemdesc'  => $hashref->{name},
150       'pkgnum'    => 0,
151       'recur'     => 0,
152       'sdate'     => '',
153       'edate'     => '',
154       'setup'     => $tax_amount,
155   });
156   my $tax_link = FS::cust_bill_pkg_tax_rate_location->new({
157       'taxnum'              => $tax_object->taxnum,
158       'taxtype'             => ref($tax_object), #redundant
159       'amount'              => $tax_amount,
160       'locationtaxid'       => $tax_object->location,
161       'taxratelocationnum'  =>
162           $tax_object->tax_rate_location->taxratelocationnum,
163       'tax_cust_bill_pkg'   => $tax_item,
164       # XXX still need to get taxable_cust_bill_pkg in here
165       # but that requires messing around in the taxline code
166   });
167   $tax_item->set('cust_bill_pkg_tax_rate_location', [ $tax_link ]);
168
169   return $tax_item;
170 }
171
172 sub cust_tax_locations {
173   my $class = shift;
174   my $location = shift;
175   $location = FS::cust_location->new($location) if ref($location) eq 'HASH';
176
177   # limit to CCH zip code prefix records, not zip+4 range records
178   my $hashref = { 'data_vendor' => 'cch-zip' };
179   if ( $location->country eq 'CA' ) {
180     # weird CCH convention: treat Canadian provinces as localities, using
181     # their one-letter postal codes.
182     $hashref->{zip} = substr($location->zip, 0, 1);
183   } elsif ( $location->country eq 'US' ) {
184     $hashref->{zip} = substr($location->zip, 0, 5);
185   } else {
186     return ();
187   }
188
189   return qsearch('cust_tax_location', $hashref);
190 }
191
192 sub info {
193  +{
194     batch               => 0,
195     override            => 1,
196     manual_tax_location => 1,
197     rate_table          => 'tax_rate',
198     link_table          => 'cust_bill_pkg_tax_rate_location',
199   }
200 }
201
202 1;