Merge branch 'master' of git.freeside.biz:/home/git/freeside
[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 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') };
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      => '2015', #$date->year,
101     DataMonth     => '04', #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   my $location = $cust_bill_pkg->tax_location;
141   my ($svc_zip, $svc_plus4) = split('-', $location->zip);
142
143   my $startdate =
144     DateTime->from_epoch( epoch => $cust_bill->_date )->strftime('%m-%d-%Y');
145
146   my %base_item = (
147     'LineNumber'      => '',
148     'InvoiceNumber'   => $billpkgnum,
149     'CustomerNumber'  => $custnum,
150     'OrigNumber'      => '',
151     'TermNumber'      => '',
152     'BillToNumber'    => '',
153     'Zipcode'         => $self->{bill_zip},
154     'Plus4'           => ($self->{bill_plus4} ||= '0000'),
155     'P2PZipcode'      => $svc_zip,
156     'P2PPlus4'        => ($svc_plus4 ||= '0000'),
157     # we don't support Order Placement/Approval zip codes
158     'Geocode'         => '',
159     'TransDate'       => $startdate,
160     'Revenue'         => '',
161     'Units'           => 0,
162     'UnitType'        => '00', # "number of unique lines", the only choice
163     'Seconds'         => 0,
164     'TaxIncludedCode' => '0',
165     'TaxSitusRule'    => '',
166     'TransTypeCode'   => '',
167     'SalesTypeCode'   => $self->{taxstatus},
168     'RegulatoryCode'  => $self->{regcode},
169     'TaxExemptionCodeList' => [ ],
170     'AuxRevenue'      => 0, # we don't currently support freight and such
171     'AuxRevenueType'  => '',
172   );
173
174   # some naming conventions:
175   # 'C#####' is a call detail record (using the acctid)
176   # 'S#####' is a cust_bill_pkg setup element (using the billpkgnum)
177   # 'R#####' is a cust_bill_pkg recur element
178   # always set "InvoiceNumber" = the billpkgnum, so we can link it properly
179
180   # cursor all this stuff; data sets can be LARGE
181   # (if it gets really out of hand, we can also incrementally write JSON
182   # to a file)
183
184   my $details = FS::Cursor->new('cust_bill_pkg_detail', {
185       billpkgnum  => $cust_bill_pkg->billpkgnum,
186       amount      => { op => '>', value => 0 }
187   }, dbh() );
188   while ( my $cust_bill_pkg_detail = $details->fetch ) {
189
190     # look up the tax product for this class
191     my $classnum = $cust_bill_pkg_detail->classnum;
192     my $taxproduct = $taxproduct_of_class->{ $classnum } ||= do {
193       my $part_pkg_taxproduct = $part_item->taxproduct($classnum);
194       $part_pkg_taxproduct ? $part_pkg_taxproduct->taxproduct : '';
195     };
196     die "no taxproduct configured for pkgpart ".$part_item->pkgpart.
197         ", usage class $classnum\n"
198         if !$taxproduct;
199
200     my $cdrs = FS::Cursor->new('cdr', {
201         detailnum       => $cust_bill_pkg_detail->detailnum,
202         freesidestatus  => 'done',
203     }, dbh() );
204     while ( my $cdr = $cdrs->fetch ) {
205       my $calldate =
206         DateTime->from_epoch( epoch => $cdr->startdate )->strftime('%m-%d-%Y');
207       # determine the tax situs rule; it's different (probably more accurate) 
208       # if the call has PSTN phone numbers at both ends
209       my $tsr = $TSR_CALL_OTHER;
210       if ( $cdr->charged_party =~ /^\d{10}$/ and
211            $cdr->src           =~ /^\d{10}$/ and
212            $cdr->dst           =~ /^\d{10}$/ ) {
213         $tsr = $TSR_CALL_NPANXX;
214       }
215       my %hash = (
216         %base_item,
217         'LineNumber'      => 'C' . $cdr->acctid,
218         'OrigNumber'      => $cdr->src,
219         'TermNumber'      => $cdr->dst,
220         'BillToNumber'    => $cdr->charged_party,
221         'TransDate'       => $calldate,
222         'Revenue'         => $cdr->rated_price, # 4 decimal places
223         'Units'           => 0, # right?
224         'CallDuration'    => $cdr->duration,
225         'TaxSitusRule'    => $tsr,
226         'TransTypeCode'   => $taxproduct,
227       );
228       push @items, \%hash;
229
230     } # while ($cdrs->fetch)
231
232     # decrement the recurring charge
233     $recur_without_usage -= $cust_bill_pkg_detail->amount;
234
235   } # while ($details->fetch)
236
237   # recurring charge
238   if ( $recur_without_usage > 0 ) {
239     my $taxproduct = $taxproduct_of_class->{ 'recur' } ||= do {
240       my $part_pkg_taxproduct = $part_item->taxproduct('recur');
241       $part_pkg_taxproduct ? $part_pkg_taxproduct->taxproduct : '';
242     };
243     die "no taxproduct configured for pkgpart ".$part_item->pkgpart.
244         " recurring charge\n"
245         if !$taxproduct;
246
247     my $tsr = $TSR_GENERAL;
248     my %hash = (
249       %base_item,
250       'LineNumber'      => 'R' . $billpkgnum,
251       'Revenue'         => $recur_without_usage, # 4 decimal places
252       'Units'           => $cust_bill_pkg->units,
253       'TaxSitusRule'    => $tsr,
254       'TransTypeCode'   => $taxproduct,
255     );
256     # API expects all these fields to be _present_, even when they're not 
257     # required
258     $hash{$_} = '' foreach(qw(OrigNumber TermNumber BillToNumber));
259     push @items, \%hash;
260   }
261
262   if ( $cust_bill_pkg->setup > 0 ) {
263     my $startdate =
264       DateTime->from_epoch( epoch => $cust_bill->_date )->strftime('%m-%d-%Y');
265     my $taxproduct = $taxproduct_of_class->{ 'setup' } ||= do {
266       my $part_pkg_taxproduct = $part_item->taxproduct('setup');
267       $part_pkg_taxproduct ? $part_pkg_taxproduct->taxproduct : '';
268     };
269     die "no taxproduct configured for pkgpart ".$part_item->pkgpart.
270         " setup charge\n"
271         if !$taxproduct;
272
273     my $tsr = $TSR_GENERAL;
274     my %hash = (
275       %base_item,
276       'LineNumber'      => 'S' . $billpkgnum,
277       'Revenue'         => $cust_bill_pkg->setup, # 4 decimal places
278       'Units'           => $cust_bill_pkg->units,
279       'TaxSitusRule'    => $tsr,
280       'TransTypeCode'   => $taxproduct,
281     );
282     push @items, \%hash;
283   }
284
285   @items;
286 }
287
288 sub make_taxlines {
289   my $self = shift;
290
291   my @elements;
292
293   my $cust_bill = shift;
294   if (!$cust_bill->invnum) {
295     die "FS::TaxEngine::suretax can't calculate taxes on a non-inserted invoice\n";
296   }
297   $self->{cust_bill} = $cust_bill;
298   my $cust_main = $cust_bill->cust_main;
299   my $country = $cust_main->bill_location->country;
300
301   my $invnum = $cust_bill->invnum;
302   if (FS::cust_bill_pkg->count("invnum = $invnum") == 0) {
303     # don't even bother making the request
304     # (why are we even here, then? invoices with no line items
305     # should not be created)
306     return;
307   }
308
309   # assemble the request hash
310   my $request = $self->build_request;
311   if (!$request) {
312     warn "no taxable items in invoice; skipping SureTax request\n" if $DEBUG;
313     return;
314   }
315
316   warn "sending SureTax request\n" if $DEBUG;
317   my $request_json = $json->encode($request);
318   warn $request_json if $DEBUG > 1;
319
320   my $host = $conf->config('suretax-hostname');
321   $host ||= 'testapi.taxrating.net';
322
323   # We are targeting the "V05" interface:
324   # - accepts both telecom and general sales transactions
325   # - produces results broken down by "invoice" (Freeside line item)
326   my $ua = LWP::UserAgent->new;
327   my $http_response =  $ua->request(
328    POST "https://$host/Services/V05/SureTax.asmx/PostRequest",
329     [ request => $request_json ],
330     'Content-Type'  => 'application/x-www-form-urlencoded',
331     'Accept'        => 'application/json',
332   );
333
334   my $raw_response = $http_response->content;
335   warn "received response\n" if $DEBUG;
336   warn $raw_response if $DEBUG > 2;
337   my $response;
338   if ( $raw_response =~ /^<\?xml/ ) {
339     # an error message wrapped in a riddle inside an enigma inside an XML
340     # document...
341     $response = XMLin( $raw_response );
342     $raw_response = $response->{content};
343   }
344   $response = eval { $json->decode($raw_response) }
345     or die "$raw_response\n";
346
347   # documentation implies this might be necessary
348   $response = $response->{'d'} if exists $response->{'d'};
349
350   warn $json->encode($response) if $DEBUG > 1;
351  
352   if ( $response->{Successful} ne 'Y' ) {
353     die $response->{HeaderMessage}."\n";
354   } else {
355     my $error = join("\n",
356       map { $_->{"LineNumber"}.': '. $_->{Message} }
357       @{ $response->{ItemMessages} }
358     );
359     die "$error\n" if $error;
360   }
361
362   return if !$response->{GroupList};
363   foreach my $taxable ( @{ $response->{GroupList} } ) {
364     # each member of this array here corresponds to what SureTax calls an
365     # "invoice" and we call a "line item". The invoice number is 
366     # cust_bill_pkg.billpkgnum.
367
368     my ($state, $geocode) = split(/\|/, $taxable->{StateCode});
369     foreach my $tax_element ( @{ $taxable->{TaxList} } ) {
370       # create a tax rate location if there isn't one yet
371       my $taxname = $tax_element->{TaxTypeDesc};
372       my $taxauth = substr($tax_element->{TaxTypeCode}, 0, 1);
373       my $tax_rate = FS::tax_rate->new({
374           data_vendor   => 'suretax',
375           taxname       => $taxname,
376           taxclassnum   => '',
377           taxauth       => $taxauth, # federal / state / city / district
378           geocode       => $geocode, # this is going to disambiguate all
379                                      # the taxes named "STATE SALES TAX", etc.
380           tax           => 0,
381           fee           => 0,
382       });
383       my $error = $tax_rate->find_or_insert;
384       die "error inserting tax_rate record for '$taxname': $error\n"
385         if $error;
386       $tax_rate = $tax_rate->replace_old;
387
388       my $tax_rate_location = FS::tax_rate_location->new({
389           data_vendor => 'suretax',
390           geocode     => $geocode,
391           state       => $state,
392           country     => $country,
393       });
394       $error = $tax_rate_location->find_or_insert;
395       die "error inserting tax_rate_location record for '$geocode': $error\n"
396         if $error;
397       $tax_rate_location = $tax_rate_location->replace_old;
398
399       push @elements, FS::cust_bill_pkg_tax_rate_location->new({
400           taxable_billpkgnum  => $taxable->{InvoiceNumber},
401           taxnum              => $tax_rate->taxnum,
402           taxtype             => 'FS::tax_rate',
403           taxratelocationnum  => $tax_rate_location->taxratelocationnum,
404           amount              => sprintf('%.2f', $tax_element->{TaxAmount}),
405       });
406     }
407   }
408   return @elements;
409 }
410
411 sub add_taxproduct {
412   my $class = shift;
413   my $desc = shift; # tax code and description, separated by a space.
414   if ($desc =~ s/^(\d{6}+) //) {
415     my $part_pkg_taxproduct = FS::part_pkg_taxproduct->new({
416         'data_vendor' => 'suretax',
417         'taxproduct'  => $1,
418         'description' => $desc,
419     });
420     # $obj_or_error
421     return $part_pkg_taxproduct->insert || $part_pkg_taxproduct;
422   } else {
423     return "illegal suretax tax code '$desc'";
424   }
425 }
426
427 1;