1 package FS::TaxEngine::cch;
5 use base 'FS::TaxEngine';
6 use FS::Record qw(dbh qsearch qsearchs);
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
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
22 - part_pkg_taxoverride: manual link from a part_pkg to a specific tax class.
30 =item add_sale LINEITEM
32 Takes LINEITEM (a L<FS::cust_bill_pkg> object) and adds it to three internal
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.
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
45 C<add_sale> will also determine any exemptions that apply to the item
46 and attach them to LINEITEM.
51 my ($self, $cust_bill_pkg) = @_;
53 my $part_item = $cust_bill_pkg->part_X;
54 my $location = $cust_bill_pkg->tax_location;
55 my $custnum = $self->{cust_main}->custnum;
57 push @{ $self->{items} }, $cust_bill_pkg;
59 my $conf = FS::Conf->new;
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;
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.
74 # only calculate exemptions once for each tax rate, even if it's used for
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;
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;
92 if ( !$tax_seen{$taxnum} ) {
93 $cust_bill_pkg->set_exemptions( $tax, 'custnum' => $custnum );
100 sub _gather_taxes { # interface for this sucks
102 my $part_item = shift;
104 my $location = shift;
106 my $geocode = $location->geocode('cch');
108 my @taxes = $part_item->tax_rates('cch', $geocode, $class);
111 join(',', map{ ref($_). " ". $_->get($_->primary_key) } @taxes). "\n"
117 # differs from stock make_taxlines because we need another pass to do
121 my $cust_bill = shift;
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;
134 my @taxlines = $tax_rate->taxline_cch( $taxables, $charge_classes );
137 if (!ref $taxlines[0]) {
138 # it's an error string
139 warn "error evaluating tax#$taxnum\n";
143 my $billpkgnum = -1; # the current one
144 my $fragments; # $item_has_tax{$billpkgnum}{taxnum}
146 foreach my $taxline (@taxlines) {
147 next if $taxline->setup == 0;
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} ||= [];
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
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 );
175 warn "found possible taxed taxnum $taxnum\n"
177 # Calculate ToT separately for each taxable item, and only if _that
178 # item_ is already taxed under the ToT. This is counterintuitive.
180 foreach my $tot (@tot) {
181 my $totnum = $tot->taxnum;
182 warn "checking taxnum ".$tot->taxnum.
183 " which we call ". $tot->taxname ."\n"
185 if ( exists $this_has_tax->{ $totnum } ) {
186 warn "calculating tax on tax: taxnum ".$tot->taxnum." on $taxnum\n"
188 my @taxlines = $tot->taxline_cch(
189 $this_has_tax->{ $taxnum }, # the first-stage tax (in an arrayref)
191 next if (!@taxlines); # it didn't apply after all
192 if (!ref($taxlines[0])) {
193 warn "error evaluating TOT ($totnum on $taxnum)\n";
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
203 return @raw_taxlines;
206 sub cust_tax_locations {
208 my $location = shift;
209 $location = FS::cust_location->new($location) if ref($location) eq 'HASH';
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);
223 return qsearch('cust_tax_location', $hashref);
230 manual_tax_location => 1,
231 rate_table => 'tax_rate',
232 link_table => 'cust_bill_pkg_tax_rate_location',