summaryrefslogtreecommitdiff
path: root/FS/FS/TaxEngine/suretax.pm
blob: 356f5f318cacef91db2a45469b12dc7c1767c0dc (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
package FS::TaxEngine::suretax;

use strict;
use base 'FS::TaxEngine';
use FS::Conf;
use FS::Record qw(qsearch qsearchs dbh);
use Cpanel::JSON::XS;
use XML::Simple qw(XMLin);
use LWP::UserAgent;
use HTTP::Request::Common;
use DateTime;

our $DEBUG = 1; # prints progress messages
#   $DEBUG = 2; # prints decoded request and response (noisy, be careful)
#   $DEBUG = 3; # prints raw response from the API, ridiculously unreadable

our $json = Cpanel::JSON::XS->new->pretty(0)->shrink(1);

our %taxproduct_cache;

our $conf;

FS::UID->install_callback( sub {
    $conf = FS::Conf->new;
    # should we enable conf caching here?
});

# Tax Situs Rules, for determining tax jurisdiction.
# (may need to be configurable)

# For PSTN calls, use Rule 01, two-out-of-three using NPA-NXX. (The "three" 
# are source number, destination number, and charged party number.)
our $TSR_CALL_NPANXX = '01';

# For other types of calls (on-network hosted PBX, SIP-addressed calls, 
# other things that don't have an NPA-NXX number), use Rule 11. (See below.)
our $TSR_CALL_OTHER = '11';

# For regular recurring or one-time charges, use Rule 11. This uses the 
# service zip code for transaction types that are known to require it, and
# the billing zip code for all other transaction types.
our $TSR_GENERAL = '11';

# XXX incomplete; doesn't handle international taxes (Rule 14) or point
# to point private lines (Rule 07).

our %REGCODE = ( # can be selected per agent
  ''          => '99',
  'ILEC'      => '00',
  'IXC'       => '01',
  'CLEC'      => '02',
  'VOIP'      => '03',
  'ISP'       => '04',
  'Wireless'  => '05',
);

sub info {
  { batch => 0,
    override => 0,
  }
}

sub add_sale { } # nothing to do here

sub build_request {
  my ($self, %opt) = @_;

  my $cust_bill = $self->{cust_bill};
  my $cust_main = $cust_bill->cust_main;
  my $agentnum = $cust_main->agentnum;
  my $date = DateTime->from_epoch(epoch => $cust_bill->_date);

  # remember some things that are linked to the customer
  $self->{taxstatus} = $cust_main->taxstatus
    or die "Customer #".$cust_main->custnum." has no tax status defined.\n";

  ($self->{bill_zip}, $self->{bill_plus4}) =
    split('-', $cust_main->bill_location->zip);

  $self->{regcode} = $REGCODE{ $conf->config('suretax-regulatory_code', $agentnum) };

  %taxproduct_cache = ();

  # assemble invoice line items 
  my @lines = map { $self->build_item($_) }
              $cust_bill->cust_bill_pkg;

  return if !@lines;

  my $ClientNumber = $conf->config('suretax-client_number')
    or die "suretax-client_number config required.\n";
  my $ValidationKey = $conf->config('suretax-validation_key')
    or die "suretax-validation_key config required.\n";
  my $BusinessUnit = $conf->config('suretax-business_unit', $agentnum) || '';

  return {
    ClientNumber  => $ClientNumber,
    ValidationKey => $ValidationKey,
    BusinessUnit  => $BusinessUnit,
    DataYear      => $date->year,
    DataMonth     => sprintf('%02d', $date->month),
    TotalRevenue  => sprintf('%.4f', $cust_bill->charged),
    ReturnFileCode    => ($self->{estimate} ? 'Q' : '0'),
    ClientTracking  => $cust_bill->invnum,
    IndustryExemption => '',
    ResponseGroup => '13',
    ResponseType  => 'D2',
    STAN          => '',
    ItemList      => \@lines,
  };
}

=item build_item CUST_BILL_PKG

Takes a sale item and returns any number of request element hashrefs
corresponding to it. Yes, any number, because in a rated usage line item
we have to send each usage detail separately.

=cut

sub build_item {
  my $self = shift;
  my $cust_bill_pkg = shift;
  my $cust_bill = $cust_bill_pkg->cust_bill;
  my $billpkgnum = $cust_bill_pkg->billpkgnum;
  my $invnum = $cust_bill->invnum;
  my $custnum = $cust_bill->custnum;

  # get the part_pkg/fee for this line item, and the relevant part of the
  # taxproduct cache
  my $part_item = $cust_bill_pkg->part_X;
  my $taxproduct_of_class = do {
    my $part_id = $part_item->table . '#' . $part_item->get($part_item->primary_key);
    $taxproduct_cache{$part_id} ||= {};
  };

  my @items;
  my $recur_without_usage = $cust_bill_pkg->recur;

  # use the _configured_ tax location as 'Zipcode' (respecting 
  # tax-ship_address and tax-pkg_address configs)
  my $location = $cust_bill_pkg->tax_location;
  my ($zip, $plus4) = split('-', $location->zip);

  # and the _real_ location as 'P2PZipcode'
  my $svc_location = $location;
  if ( $cust_bill_pkg->pkgnum ) {
    $svc_location = $cust_bill_pkg->cust_pkg->cust_location;
  }
  my ($svc_zip, $svc_plus4) = split('-', $svc_location->zip);

  my $startdate =
    DateTime->from_epoch( epoch => $cust_bill->_date )->strftime('%m-%d-%Y');

  my %base_item = (
    'LineNumber'      => '',
    'InvoiceNumber'   => $billpkgnum,
    'CustomerNumber'  => $custnum,
    'OrigNumber'      => '',
    'TermNumber'      => '',
    'BillToNumber'    => '',
    'Zipcode'         => $zip,
    'Plus4'           => ($plus4 ||= '0000'),
    'P2PZipcode'      => $svc_zip,
    'P2PPlus4'        => ($svc_plus4 ||= '0000'),
    # we don't support Order Placement/Approval zip codes
    'Geocode'         => '',
    'TransDate'       => $startdate,
    'Revenue'         => '',
    'Units'           => 0,
    'UnitType'        => '00', # "number of unique lines", the only choice
    'Seconds'         => 0,
    'TaxIncludedCode' => '0',
    'TaxSitusRule'    => '',
    'TransTypeCode'   => '',
    'SalesTypeCode'   => $self->{taxstatus},
    'RegulatoryCode'  => $self->{regcode},
    'TaxExemptionCodeList' => [ ],
    'AuxRevenue'      => 0, # we don't currently support freight and such
    'AuxRevenueType'  => '',
  );

  # some naming conventions:
  # 'C#####' is a call detail record (using the acctid)
  # 'S#####' is a cust_bill_pkg setup element (using the billpkgnum)
  # 'R#####' is a cust_bill_pkg recur element
  # always set "InvoiceNumber" = the billpkgnum, so we can link it properly

  # cursor all this stuff; data sets can be LARGE
  # (if it gets really out of hand, we can also incrementally write JSON
  # to a file)

  my $details = FS::Cursor->new('cust_bill_pkg_detail', {
      billpkgnum  => $cust_bill_pkg->billpkgnum,
      amount      => { op => '>', value => 0 }
  }, dbh() );
  while ( my $cust_bill_pkg_detail = $details->fetch ) {

    # look up the tax product for this class
    my $classnum = $cust_bill_pkg_detail->classnum;
    my $taxproduct = $taxproduct_of_class->{ $classnum } ||= do {
      my $part_pkg_taxproduct = $part_item->taxproduct($classnum);
      $part_pkg_taxproduct ? $part_pkg_taxproduct->taxproduct : '';
    };
    die "no taxproduct configured for pkgpart ".$part_item->pkgpart.
        ", usage class $classnum\n"
        if !$taxproduct;

    my $cdrs = FS::Cursor->new('cdr', {
        detailnum       => $cust_bill_pkg_detail->detailnum,
        freesidestatus  => 'done',
    }, dbh() );
    while ( my $cdr = $cdrs->fetch ) {
      my $calldate =
        DateTime->from_epoch( epoch => $cdr->startdate )->strftime('%m-%d-%Y');
      my %hash = (
        %base_item,
        'LineNumber'      => 'C' . $cdr->acctid,
        'OrigNumber'      => '',
        'TermNumber'      => '',
        'BillToNumber'    => '',
        'TransDate'       => $calldate,
        'Revenue'         => $cdr->rated_price, # 4 decimal places
        'Units'           => 0, # right?
        'CallDuration'    => $cdr->duration,
        'TaxSitusRule'    => $TSR_CALL_OTHER,
        'TransTypeCode'   => $taxproduct,
      );
      # determine the tax situs rule; it's different (probably more accurate) 
      # if the call has PSTN phone numbers at both ends
      if ( $cdr->charged_party =~ /^\d{10}$/ and
           $cdr->src           =~ /^\d{10}$/ and
           $cdr->dst           =~ /^\d{10}$/ and
           !$cdr->is_tollfree ) {
        $hash{TaxSitusRule} = $TSR_CALL_NPANXX;
        $hash{OrigNumber}   = $cdr->src;
        $hash{TermNumber}   = $cdr->dst;
        $hash{BillToNumber} = $cdr->charged_party;
      }

      push @items, \%hash;

    } # while ($cdrs->fetch)

    # decrement the recurring charge
    $recur_without_usage -= $cust_bill_pkg_detail->amount;

  } # while ($details->fetch)

  # recurring charge
  if ( $recur_without_usage > 0 ) {
    my $taxproduct = $taxproduct_of_class->{ 'recur' } ||= do {
      my $part_pkg_taxproduct = $part_item->taxproduct('recur');
      $part_pkg_taxproduct ? $part_pkg_taxproduct->taxproduct : '';
    };
    die "no taxproduct configured for pkgpart ".$part_item->pkgpart.
        " recurring charge\n"
        if !$taxproduct;

    my $tsr = $TSR_GENERAL;
    # when billing on cancellation there are no units
    my $units = $self->{cancel} ? 0 : $cust_bill_pkg->units;
    my %hash = (
      %base_item,
      'LineNumber'      => 'R' . $billpkgnum,
      'Revenue'         => $recur_without_usage, # 4 decimal places
      'Units'           => $units,
      'TaxSitusRule'    => $tsr,
      'TransTypeCode'   => $taxproduct,
    );
    # API expects all these fields to be _present_, even when they're not 
    # required
    $hash{$_} = '' foreach(qw(OrigNumber TermNumber BillToNumber));
    push @items, \%hash;
  }

  if ( $cust_bill_pkg->setup > 0 ) {
    my $startdate =
      DateTime->from_epoch( epoch => $cust_bill->_date )->strftime('%m-%d-%Y');
    my $taxproduct = $taxproduct_of_class->{ 'setup' } ||= do {
      my $part_pkg_taxproduct = $part_item->taxproduct('setup');
      $part_pkg_taxproduct ? $part_pkg_taxproduct->taxproduct : '';
    };
    die "no taxproduct configured for pkgpart ".$part_item->pkgpart.
        " setup charge\n"
        if !$taxproduct;

    my $tsr = $TSR_GENERAL;
    my %hash = (
      %base_item,
      'LineNumber'      => 'S' . $billpkgnum,
      'Revenue'         => $cust_bill_pkg->setup, # 4 decimal places
      'Units'           => $cust_bill_pkg->units,
      'TaxSitusRule'    => $tsr,
      'TransTypeCode'   => $taxproduct,
    );
    push @items, \%hash;
  }

  @items;
}

sub make_taxlines {
  my $self = shift;

  my @elements;

  my $cust_bill = shift;
  if (!$cust_bill->invnum) {
    die "FS::TaxEngine::suretax can't calculate taxes on a non-inserted invoice\n";
  }
  $self->{cust_bill} = $cust_bill;
  my $cust_main = $cust_bill->cust_main;
  my $country = $cust_main->bill_location->country;

  my $invnum = $cust_bill->invnum;
  if (FS::cust_bill_pkg->count("invnum = $invnum") == 0) {
    # don't even bother making the request
    # (why are we even here, then? invoices with no line items
    # should not be created)
    return;
  }

  # assemble the request hash
  my $request = $self->build_request;
  if (!$request) {
    warn "no taxable items in invoice; skipping SureTax request\n" if $DEBUG;
    return;
  }

  warn "encoding SureTax request\n" if $DEBUG;
  my $request_json = $json->encode($request);
  warn $request_json if $DEBUG > 1;

  my $host = $conf->config('suretax-hostname');
  $host ||= 'testapi.taxrating.net';

  warn "sending SureTax request\n" if $DEBUG;
  # We are targeting the "V05" interface:
  # - accepts both telecom and general sales transactions
  # - produces results broken down by "invoice" (Freeside line item)
  my $ua = LWP::UserAgent->new;
  my $http_response =  $ua->request(
   POST "https://$host/Services/V05/SureTax.asmx/PostRequest",
    [ request => $request_json ],
    'Content-Type'  => 'application/x-www-form-urlencoded',
    'Accept'        => 'application/json',
  );

  warn 'received SureTax response: '. $http_response->status_line. "\n"
    if $DEBUG;
  die $http_response->status_line. "\n" unless $http_response->is_success;

  my $raw_response = $http_response->content;
  warn $raw_response if $DEBUG > 2;
  my $response;
  if ( $raw_response =~ /^<\?xml/ ) {
    # an error message wrapped in a riddle inside an enigma inside an XML
    # document...
    $response = XMLin( $raw_response );
    $raw_response = $response->{content};
  }

  warn "decoding SureTax response\n" if $DEBUG;
  $response = eval { $json->decode($raw_response) }
    or die "Can't JSON-decode response: $raw_response\n";

  # documentation implies this might be necessary
  $response = $response->{'d'} if exists $response->{'d'};

  warn $json->encode($response) if $DEBUG > 1;
 
  if ( $response->{Successful} ne 'Y' ) {
    die $response->{HeaderMessage}."\n";
  } else {
    my $error = join("\n",
      map { $_->{"LineNumber"}.': '. $_->{Message} }
      @{ $response->{ItemMessages} }
    );
    die "$error\n" if $error;
  }

  return if !$response->{GroupList};
  warn "creating FS objects from SureTax data\n" if $DEBUG;
  foreach my $taxable ( @{ $response->{GroupList} } ) {
    # each member of this array here corresponds to what SureTax calls an
    # "invoice" and we call a "line item". The invoice number is 
    # cust_bill_pkg.billpkgnum.

    my ($state, $geocode) = split(/\|/, $taxable->{StateCode});
    foreach my $tax_element ( @{ $taxable->{TaxList} } ) {
      # create a tax rate location if there isn't one yet
      my $taxname = $tax_element->{TaxTypeDesc};
      my $taxauth = substr($tax_element->{TaxTypeCode}, 0, 1);
      my $tax_rate = FS::tax_rate->new({
          data_vendor   => 'suretax',
          taxname       => $taxname,
          taxclassnum   => '',
          taxauth       => $taxauth, # federal / state / city / district
          geocode       => $geocode, # this is going to disambiguate all
                                     # the taxes named "STATE SALES TAX", etc.
          tax           => 0,
          fee           => 0,
      });
      my $error = $tax_rate->find_or_insert;
      die "error inserting tax_rate record for '$taxname': $error\n"
        if $error;
      $tax_rate = $tax_rate->replace_old;

      my $tax_rate_location = FS::tax_rate_location->new({
          data_vendor => 'suretax',
          geocode     => $geocode,
          state       => $state,
          country     => $country,
      });
      $error = $tax_rate_location->find_or_insert;
      die "error inserting tax_rate_location record for '$geocode': $error\n"
        if $error;
      $tax_rate_location = $tax_rate_location->replace_old;

      push @elements, FS::cust_bill_pkg_tax_rate_location->new({
          taxable_billpkgnum  => $taxable->{InvoiceNumber},
          taxnum              => $tax_rate->taxnum,
          taxtype             => 'FS::tax_rate',
          taxratelocationnum  => $tax_rate_location->taxratelocationnum,
          amount              => sprintf('%.2f', $tax_element->{TaxAmount}),
      });
    }
  }
  warn "TaxEngine/suretax.pm make_taxlines done; returning FS objects\n" if $DEBUG;
  return @elements;
}

sub add_taxproduct {
  my $class = shift;
  my $desc = shift; # tax code and description, separated by a space.
  if ($desc =~ s/^(\d{6}+) //) {
    my $part_pkg_taxproduct = FS::part_pkg_taxproduct->new({
        'data_vendor' => 'suretax',
        'taxproduct'  => $1,
        'description' => $desc,
    });
    # $obj_or_error
    return $part_pkg_taxproduct->insert || $part_pkg_taxproduct;
  } else {
    return "illegal suretax tax code '$desc'";
  }
}

1;