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