temporarily disabling torrus source build
[freeside.git] / FS / FS / TaxEngine / suretax.pm
1 package FS::TaxEngine::suretax;
2
3 use strict;
4 use base 'FS::TaxEngine';
5 use FS::Conf;
6 use FS::Record qw(qsearch qsearchs dbh);
7 use Cpanel::JSON::XS;
8 use XML::Simple qw(XMLin);
9 use LWP::UserAgent;
10 use HTTP::Request::Common;
11 use DateTime;
12
13 our $DEBUG = 1; # prints progress messages
14 #   $DEBUG = 2; # prints decoded request and response (noisy, be careful)
15 #   $DEBUG = 3; # prints raw response from the API, ridiculously unreadable
16
17 our $json = Cpanel::JSON::XS->new->pretty(1);
18
19 our %taxproduct_cache;
20
21 our $conf;
22
23 FS::UID->install_callback( sub {
24     $conf = FS::Conf->new;
25     # should we enable conf caching here?
26 });
27
28 # Tax Situs Rules, for determining tax jurisdiction.
29 # (may need to be configurable)
30
31 # For PSTN calls, use Rule 01, two-out-of-three using NPA-NXX. (The "three" 
32 # are source number, destination number, and charged party number.)
33 our $TSR_CALL_NPANXX = '01';
34
35 # For other types of calls (on-network hosted PBX, SIP-addressed calls, 
36 # other things that don't have an NPA-NXX number), use Rule 11. (See below.)
37 our $TSR_CALL_OTHER = '11';
38
39 # For regular recurring or one-time charges, use Rule 11. This uses the 
40 # service zip code for transaction types that are known to require it, and
41 # the billing zip code for all other transaction types.
42 our $TSR_GENERAL = '11';
43
44 # XXX incomplete; doesn't handle international taxes (Rule 14) or point
45 # to point private lines (Rule 07).
46
47 our %REGCODE = ( # can be selected per agent
48   ''          => '99',
49   'ILEC'      => '00',
50   'IXC'       => '01',
51   'CLEC'      => '02',
52   'VOIP'      => '03',
53   'ISP'       => '04',
54   'Wireless'  => '05',
55 );
56
57 sub info {
58   { batch => 0,
59     override => 0,
60   }
61 }
62
63 sub add_sale { } # nothing to do here
64
65 sub build_request {
66   my ($self, %opt) = @_;
67
68   my $cust_bill = $self->{cust_bill};
69   my $cust_main = $cust_bill->cust_main;
70   my $agentnum = $cust_main->agentnum;
71   my $date = DateTime->from_epoch(epoch => $cust_bill->_date);
72
73   # remember some things that are linked to the customer
74   $self->{taxstatus} = $cust_main->taxstatus
75     or die "Customer #".$cust_main->custnum." has no tax status defined.\n";
76
77   ($self->{bill_zip}, $self->{bill_plus4}) =
78     split('-', $cust_main->bill_location->zip);
79
80   $self->{regcode} = $REGCODE{ $conf->config('suretax-regulatory_code', $agentnum) };
81
82   %taxproduct_cache = ();
83
84   # assemble invoice line items 
85   my @lines = map { $self->build_item($_) }
86               $cust_bill->cust_bill_pkg;
87
88   return if !@lines;
89
90   my $ClientNumber = $conf->config('suretax-client_number')
91     or die "suretax-client_number config required.\n";
92   my $ValidationKey = $conf->config('suretax-validation_key')
93     or die "suretax-validation_key config required.\n";
94   my $BusinessUnit = $conf->config('suretax-business_unit', $agentnum) || '';
95
96   return {
97     ClientNumber  => $ClientNumber,
98     ValidationKey => $ValidationKey,
99     BusinessUnit  => $BusinessUnit,
100     DataYear      => $date->year,
101     DataMonth     => sprintf('%02d', $date->month),
102     TotalRevenue  => sprintf('%.4f', $cust_bill->charged),
103     ReturnFileCode    => ($self->{estimate} ? 'Q' : '0'),
104     ClientTracking  => $cust_bill->invnum,
105     IndustryExemption => '',
106     ResponseGroup => '13',
107     ResponseType  => 'D2',
108     STAN          => '',
109     ItemList      => \@lines,
110   };
111 }
112
113 =item build_item CUST_BILL_PKG
114
115 Takes a sale item and returns any number of request element hashrefs
116 corresponding to it. Yes, any number, because in a rated usage line item
117 we have to send each usage detail separately.
118
119 =cut
120
121 sub build_item {
122   my $self = shift;
123   my $cust_bill_pkg = shift;
124   my $cust_bill = $cust_bill_pkg->cust_bill;
125   my $billpkgnum = $cust_bill_pkg->billpkgnum;
126   my $invnum = $cust_bill->invnum;
127   my $custnum = $cust_bill->custnum;
128
129   # get the part_pkg/fee for this line item, and the relevant part of the
130   # taxproduct cache
131   my $part_item = $cust_bill_pkg->part_X;
132   my $taxproduct_of_class = do {
133     my $part_id = $part_item->table . '#' . $part_item->get($part_item->primary_key);
134     $taxproduct_cache{$part_id} ||= {};
135   };
136
137   my @items;
138   my $recur_without_usage = $cust_bill_pkg->recur;
139
140   # use the _configured_ tax location as 'Zipcode' (respecting 
141   # tax-ship_address and tax-pkg_address configs)
142   my $location = $cust_bill_pkg->tax_location;
143   my ($zip, $plus4) = split('-', $location->zip);
144
145   # and the _real_ location as 'P2PZipcode'
146   my $svc_location = $location;
147   if ( $cust_bill_pkg->pkgnum ) {
148     $svc_location = $cust_bill_pkg->cust_pkg->cust_location;
149   }
150   my ($svc_zip, $svc_plus4) = split('-', $svc_location->zip);
151
152   my $startdate =
153     DateTime->from_epoch( epoch => $cust_bill->_date )->strftime('%m-%d-%Y');
154
155   my %base_item = (
156     'LineNumber'      => '',
157     'InvoiceNumber'   => $billpkgnum,
158     'CustomerNumber'  => $custnum,
159     'OrigNumber'      => '',
160     'TermNumber'      => '',
161     'BillToNumber'    => '',
162     'Zipcode'         => $zip,
163     'Plus4'           => ($plus4 ||= '0000'),
164     'P2PZipcode'      => $svc_zip,
165     'P2PPlus4'        => ($svc_plus4 ||= '0000'),
166     # we don't support Order Placement/Approval zip codes
167     'Geocode'         => '',
168     'TransDate'       => $startdate,
169     'Revenue'         => '',
170     'Units'           => 0,
171     'UnitType'        => '00', # "number of unique lines", the only choice
172     'Seconds'         => 0,
173     'TaxIncludedCode' => '0',
174     'TaxSitusRule'    => '',
175     'TransTypeCode'   => '',
176     'SalesTypeCode'   => $self->{taxstatus},
177     'RegulatoryCode'  => $self->{regcode},
178     'TaxExemptionCodeList' => [ ],
179     'AuxRevenue'      => 0, # we don't currently support freight and such
180     'AuxRevenueType'  => '',
181   );
182
183   # some naming conventions:
184   # 'C#####' is a call detail record (using the acctid)
185   # 'S#####' is a cust_bill_pkg setup element (using the billpkgnum)
186   # 'R#####' is a cust_bill_pkg recur element
187   # always set "InvoiceNumber" = the billpkgnum, so we can link it properly
188
189   # cursor all this stuff; data sets can be LARGE
190   # (if it gets really out of hand, we can also incrementally write JSON
191   # to a file)
192
193   my $details = FS::Cursor->new('cust_bill_pkg_detail', {
194       billpkgnum  => $cust_bill_pkg->billpkgnum,
195       amount      => { op => '>', value => 0 }
196   }, dbh() );
197   while ( my $cust_bill_pkg_detail = $details->fetch ) {
198
199     # look up the tax product for this class
200     my $classnum = $cust_bill_pkg_detail->classnum;
201     my $taxproduct = $taxproduct_of_class->{ $classnum } ||= do {
202       my $part_pkg_taxproduct = $part_item->taxproduct($classnum);
203       $part_pkg_taxproduct ? $part_pkg_taxproduct->taxproduct : '';
204     };
205     die "no taxproduct configured for pkgpart ".$part_item->pkgpart.
206         ", usage class $classnum\n"
207         if !$taxproduct;
208
209     my $cdrs = FS::Cursor->new('cdr', {
210         detailnum       => $cust_bill_pkg_detail->detailnum,
211         freesidestatus  => 'done',
212     }, dbh() );
213     while ( my $cdr = $cdrs->fetch ) {
214       my $calldate =
215         DateTime->from_epoch( epoch => $cdr->startdate )->strftime('%m-%d-%Y');
216       my %hash = (
217         %base_item,
218         'LineNumber'      => 'C' . $cdr->acctid,
219         'OrigNumber'      => '',
220         'TermNumber'      => '',
221         'BillToNumber'    => '',
222         'TransDate'       => $calldate,
223         'Revenue'         => $cdr->rated_price, # 4 decimal places
224         'Units'           => 0, # right?
225         'CallDuration'    => $cdr->duration,
226         'TaxSitusRule'    => $TSR_CALL_OTHER,
227         'TransTypeCode'   => $taxproduct,
228       );
229       # determine the tax situs rule; it's different (probably more accurate) 
230       # if the call has PSTN phone numbers at both ends
231       if ( $cdr->charged_party =~ /^\d{10}$/ and
232            $cdr->src           =~ /^\d{10}$/ and
233            $cdr->dst           =~ /^\d{10}$/ and
234            !$cdr->is_tollfree ) {
235         $hash{TaxSitusRule} = $TSR_CALL_NPANXX;
236         $hash{OrigNumber}   = $cdr->src;
237         $hash{TermNumber}   = $cdr->dst;
238         $hash{BillToNumber} = $cdr->charged_party;
239       }
240
241       push @items, \%hash;
242
243     } # while ($cdrs->fetch)
244
245     # decrement the recurring charge
246     $recur_without_usage -= $cust_bill_pkg_detail->amount;
247
248   } # while ($details->fetch)
249
250   # recurring charge
251   if ( $recur_without_usage > 0 ) {
252     my $taxproduct = $taxproduct_of_class->{ 'recur' } ||= do {
253       my $part_pkg_taxproduct = $part_item->taxproduct('recur');
254       $part_pkg_taxproduct ? $part_pkg_taxproduct->taxproduct : '';
255     };
256     die "no taxproduct configured for pkgpart ".$part_item->pkgpart.
257         " recurring charge\n"
258         if !$taxproduct;
259
260     my $tsr = $TSR_GENERAL;
261     # when billing on cancellation there are no units
262     my $units = $self->{cancel} ? 0 : $cust_bill_pkg->units;
263     my %hash = (
264       %base_item,
265       'LineNumber'      => 'R' . $billpkgnum,
266       'Revenue'         => $recur_without_usage, # 4 decimal places
267       'Units'           => $units,
268       'TaxSitusRule'    => $tsr,
269       'TransTypeCode'   => $taxproduct,
270     );
271     # API expects all these fields to be _present_, even when they're not 
272     # required
273     $hash{$_} = '' foreach(qw(OrigNumber TermNumber BillToNumber));
274     push @items, \%hash;
275   }
276
277   if ( $cust_bill_pkg->setup > 0 ) {
278     my $startdate =
279       DateTime->from_epoch( epoch => $cust_bill->_date )->strftime('%m-%d-%Y');
280     my $taxproduct = $taxproduct_of_class->{ 'setup' } ||= do {
281       my $part_pkg_taxproduct = $part_item->taxproduct('setup');
282       $part_pkg_taxproduct ? $part_pkg_taxproduct->taxproduct : '';
283     };
284     die "no taxproduct configured for pkgpart ".$part_item->pkgpart.
285         " setup charge\n"
286         if !$taxproduct;
287
288     my $tsr = $TSR_GENERAL;
289     my %hash = (
290       %base_item,
291       'LineNumber'      => 'S' . $billpkgnum,
292       'Revenue'         => $cust_bill_pkg->setup, # 4 decimal places
293       'Units'           => $cust_bill_pkg->units,
294       'TaxSitusRule'    => $tsr,
295       'TransTypeCode'   => $taxproduct,
296     );
297     push @items, \%hash;
298   }
299
300   @items;
301 }
302
303 sub make_taxlines {
304   my $self = shift;
305
306   my @elements;
307
308   my $cust_bill = shift;
309   if (!$cust_bill->invnum) {
310     die "FS::TaxEngine::suretax can't calculate taxes on a non-inserted invoice\n";
311   }
312   $self->{cust_bill} = $cust_bill;
313   my $cust_main = $cust_bill->cust_main;
314   my $country = $cust_main->bill_location->country;
315
316   my $invnum = $cust_bill->invnum;
317   if (FS::cust_bill_pkg->count("invnum = $invnum") == 0) {
318     # don't even bother making the request
319     # (why are we even here, then? invoices with no line items
320     # should not be created)
321     return;
322   }
323
324   # assemble the request hash
325   my $request = $self->build_request;
326   if (!$request) {
327     warn "no taxable items in invoice; skipping SureTax request\n" if $DEBUG;
328     return;
329   }
330
331   warn "sending SureTax request\n" if $DEBUG;
332   my $request_json = $json->encode($request);
333   warn $request_json if $DEBUG > 1;
334
335   my $host = $conf->config('suretax-hostname');
336   $host ||= 'testapi.taxrating.net';
337
338   # We are targeting the "V05" interface:
339   # - accepts both telecom and general sales transactions
340   # - produces results broken down by "invoice" (Freeside line item)
341   my $ua = LWP::UserAgent->new;
342   my $http_response =  $ua->request(
343    POST "https://$host/Services/V05/SureTax.asmx/PostRequest",
344     [ request => $request_json ],
345     'Content-Type'  => 'application/x-www-form-urlencoded',
346     'Accept'        => 'application/json',
347   );
348
349   my $raw_response = $http_response->content;
350   warn "received response\n" if $DEBUG;
351   warn $raw_response if $DEBUG > 2;
352   my $response;
353   if ( $raw_response =~ /^<\?xml/ ) {
354     # an error message wrapped in a riddle inside an enigma inside an XML
355     # document...
356     $response = XMLin( $raw_response );
357     $raw_response = $response->{content};
358   }
359   $response = eval { $json->decode($raw_response) }
360     or die "$raw_response\n";
361
362   # documentation implies this might be necessary
363   $response = $response->{'d'} if exists $response->{'d'};
364
365   warn $json->encode($response) if $DEBUG > 1;
366  
367   if ( $response->{Successful} ne 'Y' ) {
368     die $response->{HeaderMessage}."\n";
369   } else {
370     my $error = join("\n",
371       map { $_->{"LineNumber"}.': '. $_->{Message} }
372       @{ $response->{ItemMessages} }
373     );
374     die "$error\n" if $error;
375   }
376
377   return if !$response->{GroupList};
378   foreach my $taxable ( @{ $response->{GroupList} } ) {
379     # each member of this array here corresponds to what SureTax calls an
380     # "invoice" and we call a "line item". The invoice number is 
381     # cust_bill_pkg.billpkgnum.
382
383     my ($state, $geocode) = split(/\|/, $taxable->{StateCode});
384     foreach my $tax_element ( @{ $taxable->{TaxList} } ) {
385       # create a tax rate location if there isn't one yet
386       my $taxname = $tax_element->{TaxTypeDesc};
387       my $taxauth = substr($tax_element->{TaxTypeCode}, 0, 1);
388       my $tax_rate = FS::tax_rate->new({
389           data_vendor   => 'suretax',
390           taxname       => $taxname,
391           taxclassnum   => '',
392           taxauth       => $taxauth, # federal / state / city / district
393           geocode       => $geocode, # this is going to disambiguate all
394                                      # the taxes named "STATE SALES TAX", etc.
395           tax           => 0,
396           fee           => 0,
397       });
398       my $error = $tax_rate->find_or_insert;
399       die "error inserting tax_rate record for '$taxname': $error\n"
400         if $error;
401       $tax_rate = $tax_rate->replace_old;
402
403       my $tax_rate_location = FS::tax_rate_location->new({
404           data_vendor => 'suretax',
405           geocode     => $geocode,
406           state       => $state,
407           country     => $country,
408       });
409       $error = $tax_rate_location->find_or_insert;
410       die "error inserting tax_rate_location record for '$geocode': $error\n"
411         if $error;
412       $tax_rate_location = $tax_rate_location->replace_old;
413
414       push @elements, FS::cust_bill_pkg_tax_rate_location->new({
415           taxable_billpkgnum  => $taxable->{InvoiceNumber},
416           taxnum              => $tax_rate->taxnum,
417           taxtype             => 'FS::tax_rate',
418           taxratelocationnum  => $tax_rate_location->taxratelocationnum,
419           amount              => sprintf('%.2f', $tax_element->{TaxAmount}),
420       });
421     }
422   }
423   return @elements;
424 }
425
426 sub add_taxproduct {
427   my $class = shift;
428   my $desc = shift; # tax code and description, separated by a space.
429   if ($desc =~ s/^(\d{6}+) //) {
430     my $part_pkg_taxproduct = FS::part_pkg_taxproduct->new({
431         'data_vendor' => 'suretax',
432         'taxproduct'  => $1,
433         'description' => $desc,
434     });
435     # $obj_or_error
436     return $part_pkg_taxproduct->insert || $part_pkg_taxproduct;
437   } else {
438     return "illegal suretax tax code '$desc'";
439   }
440 }
441
442 1;