1 package FS::TaxEngine::suretax;
4 use base 'FS::TaxEngine';
6 use FS::Record qw(qsearch qsearchs dbh);
8 use XML::Simple qw(XMLin);
10 use HTTP::Request::Common;
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
17 our $json = Cpanel::JSON::XS->new->pretty(0)->shrink(1);
19 our %taxproduct_cache;
23 FS::UID->install_callback( sub {
24 $conf = FS::Conf->new;
25 # should we enable conf caching here?
28 # Tax Situs Rules, for determining tax jurisdiction.
29 # (may need to be configurable)
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';
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';
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';
44 # XXX incomplete; doesn't handle international taxes (Rule 14) or point
45 # to point private lines (Rule 07).
47 our %REGCODE = ( # can be selected per agent
63 sub add_sale { } # nothing to do here
66 my ($self, %opt) = @_;
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);
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";
77 ($self->{bill_zip}, $self->{bill_plus4}) =
78 split('-', $cust_main->bill_location->zip);
80 $self->{regcode} = $REGCODE{ $conf->config('suretax-regulatory_code', $agentnum) };
82 %taxproduct_cache = ();
84 # assemble invoice line items
85 my @lines = map { $self->build_item($_) }
86 $cust_bill->cust_bill_pkg;
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) || '';
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',
113 =item build_item CUST_BILL_PKG
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.
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;
129 # get the part_pkg/fee for this line item, and the relevant part of the
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} ||= {};
138 my $recur_without_usage = $cust_bill_pkg->recur;
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);
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;
150 my ($svc_zip, $svc_plus4) = split('-', $svc_location->zip);
153 DateTime->from_epoch( epoch => $cust_bill->_date )->strftime('%m-%d-%Y');
157 'InvoiceNumber' => $billpkgnum,
158 'CustomerNumber' => $custnum,
161 'BillToNumber' => '',
163 'Plus4' => ($plus4 ||= '0000'),
164 'P2PZipcode' => $svc_zip,
165 'P2PPlus4' => ($svc_plus4 ||= '0000'),
166 # we don't support Order Placement/Approval zip codes
168 'TransDate' => $startdate,
171 'UnitType' => '00', # "number of unique lines", the only choice
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' => '',
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
189 # cursor all this stuff; data sets can be LARGE
190 # (if it gets really out of hand, we can also incrementally write JSON
193 my $details = FS::Cursor->new('cust_bill_pkg_detail', {
194 billpkgnum => $cust_bill_pkg->billpkgnum,
195 amount => { op => '>', value => 0 }
197 while ( my $cust_bill_pkg_detail = $details->fetch ) {
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 : '';
205 die "no taxproduct configured for pkgpart ".$part_item->pkgpart.
206 ", usage class $classnum\n"
209 my $cdrs = FS::Cursor->new('cdr', {
210 detailnum => $cust_bill_pkg_detail->detailnum,
211 freesidestatus => 'done',
213 while ( my $cdr = $cdrs->fetch ) {
215 DateTime->from_epoch( epoch => $cdr->startdate )->strftime('%m-%d-%Y');
218 'LineNumber' => 'C' . $cdr->acctid,
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,
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;
243 } # while ($cdrs->fetch)
245 # decrement the recurring charge
246 $recur_without_usage -= $cust_bill_pkg_detail->amount;
248 } # while ($details->fetch)
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 : '';
256 die "no taxproduct configured for pkgpart ".$part_item->pkgpart.
257 " recurring charge\n"
260 my $tsr = $TSR_GENERAL;
261 # when billing on cancellation there are no units
262 my $units = $self->{cancel} ? 0 : $cust_bill_pkg->units;
265 'LineNumber' => 'R' . $billpkgnum,
266 'Revenue' => $recur_without_usage, # 4 decimal places
268 'TaxSitusRule' => $tsr,
269 'TransTypeCode' => $taxproduct,
271 # API expects all these fields to be _present_, even when they're not
273 $hash{$_} = '' foreach(qw(OrigNumber TermNumber BillToNumber));
277 if ( $cust_bill_pkg->setup > 0 ) {
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 : '';
284 die "no taxproduct configured for pkgpart ".$part_item->pkgpart.
288 my $tsr = $TSR_GENERAL;
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,
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";
312 $self->{cust_bill} = $cust_bill;
313 my $cust_main = $cust_bill->cust_main;
314 my $country = $cust_main->bill_location->country;
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)
324 # assemble the request hash
325 my $request = $self->build_request;
327 warn "no taxable items in invoice; skipping SureTax request\n" if $DEBUG;
331 warn "encoding SureTax request\n" if $DEBUG;
332 my $request_json = $json->encode($request);
333 warn $request_json if $DEBUG > 1;
335 my $host = $conf->config('suretax-hostname');
336 $host ||= 'testapi.taxrating.net';
338 warn "sending SureTax request\n" if $DEBUG;
339 # We are targeting the "V05" interface:
340 # - accepts both telecom and general sales transactions
341 # - produces results broken down by "invoice" (Freeside line item)
342 my $ua = LWP::UserAgent->new;
343 my $http_response = $ua->request(
344 POST "https://$host/Services/V05/SureTax.asmx/PostRequest",
345 [ request => $request_json ],
346 'Content-Type' => 'application/x-www-form-urlencoded',
347 'Accept' => 'application/json',
350 warn 'received SureTax response: '. $http_response->status_line. "\n"
352 die $http_response->status_line. "\n" unless $http_response->is_success;
354 my $raw_response = $http_response->content;
355 warn $raw_response if $DEBUG > 2;
357 if ( $raw_response =~ /^<\?xml/ ) {
358 # an error message wrapped in a riddle inside an enigma inside an XML
360 $response = XMLin( $raw_response );
361 $raw_response = $response->{content};
364 warn "decoding SureTax response\n" if $DEBUG;
365 $response = eval { $json->decode($raw_response) }
366 or die "Can't JSON-decode response: $raw_response\n";
368 # documentation implies this might be necessary
369 $response = $response->{'d'} if exists $response->{'d'};
371 warn $json->encode($response) if $DEBUG > 1;
373 if ( $response->{Successful} ne 'Y' ) {
374 die $response->{HeaderMessage}."\n";
376 my $error = join("\n",
377 map { $_->{"LineNumber"}.': '. $_->{Message} }
378 @{ $response->{ItemMessages} }
380 die "$error\n" if $error;
383 return if !$response->{GroupList};
384 warn "creating FS objects from SureTax data\n" if $DEBUG;
385 foreach my $taxable ( @{ $response->{GroupList} } ) {
386 # each member of this array here corresponds to what SureTax calls an
387 # "invoice" and we call a "line item". The invoice number is
388 # cust_bill_pkg.billpkgnum.
390 my ($state, $geocode) = split(/\|/, $taxable->{StateCode});
391 foreach my $tax_element ( @{ $taxable->{TaxList} } ) {
392 # create a tax rate location if there isn't one yet
393 my $taxname = $tax_element->{TaxTypeDesc};
394 my $taxauth = substr($tax_element->{TaxTypeCode}, 0, 1);
395 my $tax_rate = FS::tax_rate->new({
396 data_vendor => 'suretax',
399 taxauth => $taxauth, # federal / state / city / district
400 geocode => $geocode, # this is going to disambiguate all
401 # the taxes named "STATE SALES TAX", etc.
405 my $error = $tax_rate->find_or_insert;
406 die "error inserting tax_rate record for '$taxname': $error\n"
408 $tax_rate = $tax_rate->replace_old;
410 my $tax_rate_location = FS::tax_rate_location->new({
411 data_vendor => 'suretax',
416 $error = $tax_rate_location->find_or_insert;
417 die "error inserting tax_rate_location record for '$geocode': $error\n"
419 $tax_rate_location = $tax_rate_location->replace_old;
421 push @elements, FS::cust_bill_pkg_tax_rate_location->new({
422 taxable_billpkgnum => $taxable->{InvoiceNumber},
423 taxnum => $tax_rate->taxnum,
424 taxtype => 'FS::tax_rate',
425 taxratelocationnum => $tax_rate_location->taxratelocationnum,
426 amount => sprintf('%.2f', $tax_element->{TaxAmount}),
430 warn "TaxEngine/suretax.pm make_taxlines done; returning FS objects\n" if $DEBUG;
436 my $desc = shift; # tax code and description, separated by a space.
437 if ($desc =~ s/^(\d{6}+) //) {
438 my $part_pkg_taxproduct = FS::part_pkg_taxproduct->new({
439 'data_vendor' => 'suretax',
441 'description' => $desc,
444 return $part_pkg_taxproduct->insert || $part_pkg_taxproduct;
446 return "illegal suretax tax code '$desc'";