d262aa4d3d0764011031b9aa38e59ca5019104c9
[freeside.git] / FS / FS / TaxEngine / billsoft.pm
1 package FS::TaxEngine::billsoft;
2
3 use strict;
4 use vars qw( $DEBUG $TIMEOUT %TAX_CLASSES );
5 use base 'FS::TaxEngine';
6 use FS::Conf;
7 use FS::Record qw(qsearch qsearchs dbh);
8 use FS::part_pkg;
9 use FS::cdr;
10 use FS::upload_target;
11 use Date::Format qw( time2str );
12 use File::chdir;
13 use File::Copy qw(move);
14 use Parse::FixedLength;
15
16 $DEBUG = 1;
17
18 $TIMEOUT = 86400; # absolute time limit on waiting for a response file.
19
20 FS::UID->install_callback(\&load_tax_classes);
21
22 sub info {
23   { batch => 1,
24     override => 0,
25     manual_tax_location => 1,
26   },
27 }
28
29 sub add_sale { } #do nothing
30
31 sub spooldir {
32   $FS::UID::cache_dir . "/Billsoft";
33 }
34
35 sub spoolname {
36   my $self = shift;
37   my $conf = FS::Conf->new;;
38   my $spooldir = $self->spooldir;
39   mkdir $spooldir, 0700 unless -d $spooldir;
40   my $basename = $conf->config('billsoft-company_code') .
41                  time2str('%Y%m%d', time); # use the real clock time here
42   my $uniq = 'AA';
43   while ( -e "$spooldir/$basename$uniq.CDF" ) {
44     $uniq++;
45     # these two letters must be unique within each day
46   }
47   "$basename$uniq.CDF";
48 }
49
50 my $format =
51   '%10s' . # Origination
52   '%1s'   . # Origination Flag (NPA-NXX)
53   '%10s' . # Termination
54   '%1s'   . # Termination Flag (NPA-NXX)
55   '%10s' . # Service Location
56   '%1s'   . # Service Location Flag (Pcode)
57   '%1s'   . # Customer Type ('B'usiness or 'R'esidential)
58   '%8s'   . # Invoice Date
59   '+'     . # Taxable Amount Sign
60   '%011d' . # Taxable Amount (5 decimal places)
61   '%6d'  . # Lines
62   '%6d'  . # Locations
63   '%12s'  . # Transaction Type + Service Type
64   '%1s'   . # Client Resale Flag ('S'ale or 'R'esale)
65   '%1s'   . # Inc-Code ('I'n an incorporated city, or 'O'utside)
66   '    '  . # Fed/State/County/Local Exempt
67   '%1s'   . # Primary Output Key, flag (our field)
68   '%019d' . # Primary Output Key, numeric (our field)
69   'R'     . # 'R'egulated (or 'U'nregulated)
70   '%011d' . # Call Duration (tenths of minutes)
71   'C'     . # Telecom Type ('C'alls, other things)
72   '%1s'   . # Service Class ('L'ocal, Long 'D'istance)
73   ' NNC'  . # non-lifeline, non-facilities based,
74             # non-franchise, CLEC
75             # (gross assumptions, may need a config option
76   "\r\n";   # at least that's what was in the samples
77
78
79 sub create_batch {
80   my ($self, %opt) = @_;
81
82   $DB::single=1; # XXX
83
84   my $spooldir = $self->spooldir;
85   my $spoolname = $self->spoolname;
86   my $fh = IO::File->new();
87   $fh->open("$spooldir/$spoolname", '>>');
88   $self->{fh} = $fh;
89
90   # XXX limit based on freeside-daily custnum/agentnum options
91   # and maybe invoice date
92   my @invoices = qsearch('cust_bill', { pending => 'Y' });
93   warn scalar(@invoices)." pending invoice(s) found.\n";
94   foreach my $cust_bill (@invoices) {
95
96     my $invnum = $cust_bill->invnum;
97     my $cust_main = $cust_bill->cust_main;
98     my $cust_type = $cust_main->company ? 'B' : 'R';
99     my $invoice_date = time2str('%Y%m%d', $cust_bill->_date);
100
101     # cache some things
102     my (%cust_pkg, %part_pkg, %cust_location, %classname);
103     # keys are transaction codes (the first part of the taxproduct string)
104     # and then locationnums; for per-location taxes
105     my %sales;
106
107     foreach my $cust_bill_pkg ( $cust_bill->cust_bill_pkg ) {
108       my $cust_pkg = $cust_pkg{$cust_bill_pkg->pkgnum}
109                  ||= $cust_bill_pkg->cust_pkg;
110       my $pkgpart = $cust_bill_pkg->pkgpart_override || $cust_pkg->pkgpart;
111       my $part_pkg = $part_pkg{$pkgpart} ||= FS::part_pkg->by_key($pkgpart);
112       my $resale_mode = ($part_pkg->option('wholesale',1) ? 'R' : 'S');
113       my $locationnum = $cust_pkg->locationnum;
114       my $location = $cust_location{$locationnum} ||= $cust_pkg->cust_location;
115       my %taxproduct; # CDR rated_classnum => taxproduct
116
117       my $usage_total = 0;
118       # go back to the original call details
119       my $detailnums = FS::Record->scalar_sql(
120         "SELECT array_to_string(array_agg(detailnum), ',') ".
121         "FROM cust_bill_pkg_detail WHERE billpkgnum = ".
122         $cust_bill_pkg->billpkgnum
123       );
124
125       # With summary details, even the number of CDRs returned from a single
126       # invoice detail could be scary large.  Avoid running out of memory.
127       if (length $detailnums > 0) {
128         my $cdr_search = FS::Cursor->new({
129           'table'     => 'cdr',
130           'hashref'   => { freesidestatus => 'done' },
131           'extra_sql' => "AND detailnum IN($detailnums)",
132         });
133
134         while (my $cdr = $cdr_search->fetch) {
135           my $classnum = $cdr->rated_classnum;
136           $classname{$classnum} ||= FS::usage_class->by_key($classnum)->classname
137             if $classnum;
138           $taxproduct{$classnum} ||= $part_pkg->taxproduct($classnum);
139           if (!$taxproduct{$classnum}) {
140             warn "part_pkg $pkgpart, class $classnum: ".
141               ($taxproduct{$classnum} ?
142                   "using taxproduct ".$taxproduct{$classnum}->description."\n" :
143                   "taxproduct not found\n")
144               if $DEBUG;
145             next;
146           }
147
148           my $line = sprintf($format,
149               substr($cdr->src, 0, 6), 'N',
150               substr($cdr->dst, 0, 6), 'N',
151               $location->geocode, 'P',
152               $cust_type,
153               $invoice_date,
154               100000 * $cdr->rated_price, # price (5 decimal places)
155               0,                          # lines
156               0,                          # locations
157               $taxproduct{$classnum}->taxproduct,
158               $resale_mode,
159               ($location->incorporated ? 'I' : 'O'),
160               'C', # for Call
161               $cdr->acctid,
162               # Call duration (tenths of minutes)
163               $cdr->duration / 6,
164               # Service class indicator ('L'ocal, Long 'D'istance)
165               # stupid hack
166               (lc($classname{$classnum}) eq 'local' ? 'L' : 'D'),
167             );
168
169           print $fh $line;
170
171           $usage_total += $cdr->rated_price;
172
173         } # while $cdr = $cdr_search->fetch
174       } # if @$detailnums; otherwise there are no usage details for this line
175       
176       my $recur_tcode;
177       # now write lines for the non-CDR portion of the charges
178       foreach (qw(setup recur)) {
179         my $taxproduct = $part_pkg->taxproduct($_);
180         warn "part_pkg $pkgpart, class $_: ".
181           ($taxproduct ?
182             "using taxproduct ".$taxproduct->description."\n" :
183             "taxproduct not found\n")
184           if $DEBUG;
185         next unless $taxproduct;
186
187         my ($tcode) = $taxproduct->taxproduct =~ /^(\d{6})/;
188         $sales{$tcode} ||= {};
189         $sales{$tcode}{$location->locationnum} ||= 0;
190         $recur_tcode = $tcode if $_ eq 'recur';
191
192         my $price = $cust_bill_pkg->get($_);
193         $sales{$tcode}{$location->locationnum} += $price;
194
195         $price -= $usage_total if $_ eq 'recur';
196
197         my $line = sprintf($format,
198           $location->geocode, 'P', # all 3 locations the same
199           $location->geocode, 'P',
200           $location->geocode, 'P',
201           $cust_type,
202           $invoice_date,
203           100000 * $price,            # price (5 decimal places)
204           0,                          # lines
205           0,                          # locations
206           $taxproduct->taxproduct,
207           $resale_mode,
208           ($location->incorporated ? 'I' : 'O'),
209           substr(uc($_), 0, 1), # 'S'etup or 'R'ecur
210           $cust_bill_pkg->billpkgnum,
211           0, # call duration
212           'D' # service class indicator
213         );
214
215         print $fh $line;
216
217       } # foreach (setup, recur)
218
219       # S-code 23: taxes based on number of lines (E911, mostly)
220       # voip_cdr and voip_inbound packages know how to report this.  Not all 
221       # T-codes are eligible for this; only report it if the /23 taxproduct
222       # exists.
223       #
224       # (note: the nomenclature of "service" and "transaction" codes is 
225       # backward from the way most people would use the terms.  you'd think
226       # that in "cellular activation", "cellular" would be the service and 
227       # "activation" would be the transaction, but for Billsoft it's the 
228       # reverse.  I recommend calling them "S" and "T" codes internally just 
229       # to avoid confusion.)
230
231       my $lines_taxproduct = qsearchs('part_pkg_taxproduct', {
232         'taxproduct' => sprintf('%06d%06d', $recur_tcode, 21)
233       });
234       my $lines = $cust_bill_pkg->units;
235
236       if ( $lines_taxproduct and $lines ) {
237
238         my $line = sprintf($format,
239           $location->geocode, 'P', # all 3 locations the same
240           $location->geocode, 'P',
241           $location->geocode, 'P',
242           $cust_type,
243           $invoice_date,
244           0,                        # price (5 decimal places)
245           $lines,                   # lines
246           0,                        # locations
247           $lines_taxproduct->taxproduct,
248           $resale_mode,
249           ($location->incorporated ? 'I' : 'O'),
250           'L',                      # 'L'ines
251           $cust_bill_pkg->billpkgnum,
252           0, # call duration
253           'D' # service class indicator
254         );
255
256       }
257
258     } # foreach my $cust_bill_pkg
259
260     # Implicit transactions
261     foreach my $tcode (keys %sales) {
262
263       # S-code 23: number of locations (rare)
264       my $locations_taxproduct =
265         qsearchs('part_pkg_taxproduct', {
266           'taxproduct' => sprintf('%06d%06d', $tcode, 23)
267         });
268
269       if ( $locations_taxproduct and keys %{ $sales{$tcode} } > 0 ) {
270         my $location = $cust_main->bill_location;
271         my $line = sprintf($format,
272           $location->geocode, 'P', # all 3 locations the same
273           $location->geocode, 'P',
274           $location->geocode, 'P',
275           $cust_type,
276           $invoice_date,
277           0,                        # price (5 decimal places)
278           0,                        # lines
279           keys(%{ $sales{$tcode} }),# locations
280           $locations_taxproduct->taxproduct,
281           'S',
282           ($location->incorporated ? 'I' : 'O'),
283           'O',                      # l'O'cations
284           sprintf('%07d%06d%06d', $invnum, $tcode, 0),
285           0, # call duration
286           'D' # service class indicator
287         );
288
289         print $fh $line;
290       }
291
292       # S-code 43: per-invoice tax (apparently this is a thing)
293       my $invoice_taxproduct = 
294         qsearchs('part_pkg_taxproduct', {
295           'taxproduct' => sprintf('%06d%06d', $tcode, 43)
296         });
297       if ( $invoice_taxproduct ) {
298         my $location = $cust_main->bill_location;
299         my $line = sprintf($format,
300           $location->geocode, 'P', # all 3 locations the same
301           $location->geocode, 'P',
302           $location->geocode, 'P',
303           $cust_type,
304           $invoice_date,
305           0,                        # price (5 decimal places)
306           0,                        # lines
307           0,                        # locations
308           $invoice_taxproduct->taxproduct,
309           'S',                      # resale mode
310           ($location->incorporated ? 'I' : 'O'),
311           'I',                      # 'I'nvoice tax
312           sprintf('%07d%06d%06d', $invnum, $tcode, 0),
313           0, # call duration
314           'D' # service class indicator
315         );
316
317         print $fh $line;
318       }
319     } # foreach $tcode
320   } # foreach $cust_bill
321
322   $fh->close;
323   return $spoolname;
324 }
325
326 sub cust_tax_locations {
327   my $class = shift;
328   my $location = shift;
329   if (ref $location eq 'HASH') {
330     $location = FS::cust_location->new($location);
331   }
332   my $zip = $location->zip;
333   return () unless $location->country eq 'US';
334   # currently the only one supported
335   if ( $zip =~ /^(\d{5})(-\d{4})?$/ ) {
336     $zip = $1;
337   } else {
338     die "bad zip code $zip";
339   }
340   return qsearch({
341       table     => 'cust_tax_location',
342       hashref   => { 'data_vendor' => 'billsoft' },
343       extra_sql => " AND ziplo <= '$zip' and ziphi >= '$zip'",
344       order_by  => ' ORDER BY default_location',
345   });
346 }
347
348 sub transfer_batch {
349   my ($self, %opt) = @_;
350
351   my $oldAutoCommit = $FS::UID::AutoCommit;
352   local $FS::UID::AutoCommit = 0;
353   my $dbh = dbh;
354
355   eval "use Net::FTP;";
356   # set up directories if they're not already
357   mkdir $self->spooldir unless -d $self->spooldir;
358   local $CWD = $self->spooldir;
359   foreach (qw(upload download)) {
360     mkdir $_ unless -d $_;
361   }
362   my $target = qsearchs('upload_target', { hostname => 'ftp.billsoft.com' })
363     or die "No Billsoft upload target defined.\n";
364
365   # create the batch
366   my $upload = $self->create_batch(%opt);
367
368   # upload it
369   my $ftp = $target->connect;
370   if (!ref $ftp) { # it's an error message
371     die "Error connecting to Billsoft FTP server:\n$ftp\n";
372   }
373   my $fh = IO::File->new();
374   warn "Processing: $upload\n";
375   my $error = system("zip -j -o FTP.ZIP $upload");
376   die "Failed to compress tax batch\n$!\n" if $error;
377   warn "Uploading file...\n";
378   $ftp->put('FTP.ZIP');
379
380   my $download = $upload;
381   # naming convention for these is: same as the CDF contained in the 
382   # zip file, but with an "R" inserted after the company ID prefix
383   $download =~ s/^(...)(\d{8}..).CDF/$1R$2.ZIP/;
384   warn "Waiting for output file ($download)...\n";
385   my $starttime = time;
386   my $downloaded = 0;
387   while ( time - $starttime < $TIMEOUT ) {
388     my @ls = $ftp->ls($download);
389     if ( @ls ) {
390       if ($ftp->get($download, "download/$download")) {
391         warn "Downloaded '$download'.\n";
392         $downloaded = 1;
393         last;
394       } else {
395         warn "Failed to download '$download': ".$ftp->message."\n";
396         # We know the file exists, so continue trying to download it.
397         # Maybe the problem will get fixed.
398       }
399     }
400     sleep 30;
401   }
402   if (!$downloaded) {
403     warn "No output file received.\n";
404     next BATCH;
405   }
406   warn "Decompressing...\n";
407   system("unzip -o download/$download");
408   foreach my $csf (glob "*.CSF") {
409     warn "Processing '$csf'...\n";
410     $fh->open($csf, '<') or die "failed to open downloaded file $csf";
411     $self->batch_import($fh); # dies on error
412     $fh->close;
413     unlink $csf unless $DEBUG;
414   }
415   unlink 'FTP.ZIP';
416   move($upload, "upload/$upload");
417   warn "Finished.\n";
418   $dbh->commit if $oldAutoCommit;
419   return;
420 }
421
422 sub batch_import {
423   $DB::single = 1; # XXX
424   # the hard part
425   my ($self, $fh) = @_;
426   $self->{'custnums'} = {};
427   $self->{'cust_bill'} = {};
428
429   # gather up pending invoices
430   foreach my $cust_bill (qsearch('cust_bill', { pending => 'Y' })) {
431     $self->{'cust_bill'}{ $cust_bill->invnum } = $cust_bill;
432   }
433
434   my $href;
435   my $parser = Parse::FixedLength->new(
436     [
437       # key     => 20, # for our purposes we split it up
438       flag      => 1,
439       pkey      => 19,
440       taxtype   => 6,
441       authority => 1,
442       sign      => 1,
443       amount    => 11,
444       pcode     => 9,
445     ],
446   );
447
448   # start parsing the input file
449   my $errors = 0;
450   my $row = 1;
451   foreach my $line (<$fh>) {
452     warn $line if $DEBUG > 1;
453     %$href = ();
454     $href = $parser->parse($line);
455     # convert some of these to integers
456     $href->{$_} += 0 foreach(qw(pkey taxtype amount pcode));
457     next if $href->{amount} == 0; # then nobody cares
458
459     my $flag = $href->{flag};
460     my $pkey = $href->{pkey};
461     my $cust_bill_pkg; # the line item that this tax applies to
462     # resolve the taxable object
463     if ( $flag eq 'C' ) {
464       # this line represents a CDR.
465       my $cdr = FS::cdr->by_key($pkey);
466       if (!$cdr) {
467         warn "[$row]\tCDR #$pkey not found.\n";
468       } elsif (!$cdr->detailnum) {
469         warn "[$row]\tCDR #$pkey has not been billed.\n";
470         $errors++;
471         next;
472       } else {
473         my $detail = FS::cust_bill_pkg_detail->by_key($cdr->detailnum);
474         $cust_bill_pkg = $detail->cust_bill_pkg;
475       }
476     } elsif ( $flag =~ /S|R|L/ ) {
477       # this line represents a setup or recur fee, or a number of lines.
478       $cust_bill_pkg = FS::cust_bill_pkg->by_key($pkey);
479       if (!$cust_bill_pkg) {
480         warn "[$row]\tLine item #$pkey not found.\n";
481       }
482     } elsif ( $flag =~ /O|I/ ) {
483       warn "Per-invoice taxes are not implemented.\n";
484     } else {
485       warn "[$row]\tFlag '$flag' not recognized.\n";
486     }
487     if (!$cust_bill_pkg) {
488       $errors++; # this will trigger a rollback of the transaction
489       next;
490     }
491     # resolve the tax definition
492     # base name of the tax type (like "Sales Tax" or "Universal Lifeline 
493     # Telephone Service Charge").
494     my $tax_class = $TAX_CLASSES{ $href->{taxtype} + 0 };
495     if (!$tax_class) {
496       warn "[$row]\tUnknown tax type $href->{taxtype}.\n";
497       $errors++;
498       next;
499     }
500     my $itemdesc = uc($tax_class->description);
501     my $location = qsearchs('tax_rate_location',
502                             { geocode => $href->{pcode} }
503                            );
504     if (!$location) {
505       warn "Unknown tax authority location ".$href->{pcode}."\n";
506       $errors++;
507       next;
508     }
509     # jurisdiction name
510     my $prefix = '';
511     if ( $href->{authority} == 0 ) { # national-level tax
512       # do nothing
513     } elsif ( $href->{authority} == 1 ) {
514       $prefix = $location->state;
515     } elsif ( $href->{authority} == 2 ) {
516       $prefix = $location->county . ' COUNTY';
517     } elsif ( $href->{authority} == 3 ) {
518       $prefix = $location->city;
519     } elsif ( $href->{authority} == 4 ) { # unincorporated area ta
520       # do nothing
521     }
522     # Some itemdescs start with the jurisdiction name; otherwise, prepend 
523     # it.
524     if ( $itemdesc !~ /^(city of )?$prefix\b/i ) {
525       $itemdesc = "$prefix $itemdesc";
526     }
527     # Create or locate a tax_rate record, because we need one to foreign-key
528     # the cust_bill_pkg_tax_rate_location record.
529     my $tax_rate = $self->find_or_insert_tax_rate(
530       geocode     => $href->{pcode},
531       taxclassnum => $tax_class->taxclassnum,
532       taxname     => $itemdesc,
533     );
534     # Convert amount from 10^-5 dollars to dollars/cents
535     my $amount = sprintf('%.2f', $href->{amount} / 100000);
536     # and add it to the tax under this name
537     my $tax_item = $self->add_tax_item(
538       invnum      => $cust_bill_pkg->invnum,
539       itemdesc    => $itemdesc,
540       amount      => $amount,
541     );
542     # and link that tax line item to the taxed sale
543     my $subitem = FS::cust_bill_pkg_tax_rate_location->new({
544         billpkgnum          => $tax_item->billpkgnum,
545         taxnum              => $tax_rate->taxnum,
546         taxtype             => 'FS::tax_rate',
547         taxratelocationnum  => $location->taxratelocationnum,
548         amount              => $amount,
549         taxable_billpkgnum  => $cust_bill_pkg->billpkgnum,
550     });
551     my $error = $subitem->insert;
552     die "Error linking tax to taxable item: $error\n" if $error;
553
554     $row++;
555   } #foreach $line
556   if ( $errors > 0 ) {
557     die "Encountered $errors error(s); rolling back tax import.\n";
558   }
559
560   # remove pending flag from invoices and schedule collect jobs
561   foreach my $cust_bill (values %{ $self->{'cust_bill'} }) {
562     my $invnum = $cust_bill->invnum;
563     $cust_bill->set('pending' => '');
564     my $error = $cust_bill->replace;
565     die "Error updating invoice #$invnum: $error\n"
566       if $error;
567     $self->{'custnums'}->{ $cust_bill->custnum } = 1;
568   }
569
570   foreach my $custnum ( keys %{ $self->{'custnums'} } ) {
571     my $queue = FS::queue->new({ 'job' => 'FS::cust_main::queued_collect' });
572     my $error = $queue->insert('custnum' => $custnum);
573     die "Error scheduling collection for customer #$custnum: $error\n" 
574       if $error;
575   }
576
577   '';
578 }
579
580
581 sub find_or_insert_tax_rate {
582   my ($self, %hash) = @_;
583   $hash{'tax'} = 0;
584   $hash{'data_vendor'} = 'billsoft';
585   my $tax_rate = qsearchs('tax_rate', \%hash);
586   if (!$tax_rate) {
587     $tax_rate = FS::tax_rate->new(\%hash);
588     my $error = $tax_rate->insert;
589     die "Error inserting tax definition: $error\n" if $error;
590   }
591   return $tax_rate;
592 }
593
594
595 sub add_tax_item {
596   my ($self, %hash) = @_;
597   $hash{'pkgnum'} = 0;
598   my $amount = delete $hash{'amount'};
599   
600   my $tax_item = qsearchs('cust_bill_pkg', \%hash);
601   if (!$tax_item) {
602     $tax_item = FS::cust_bill_pkg->new(\%hash);
603     $tax_item->set('setup', $amount);
604     my $error = $tax_item->insert;
605     die "Error inserting tax: $error\n" if $error;
606   } else {
607     $tax_item->set('setup', $tax_item->get('setup') + $amount);
608     my $error = $tax_item->replace;
609     die "Error incrementing tax: $error\n" if $error;
610   }
611
612   my $cust_bill = $self->{'cust_bill'}->{$tax_item->invnum}
613     or die "Invoice #".$tax_item->{invnum}." is not pending.\n";
614   $cust_bill->set('charged' => 
615                   sprintf('%.2f', $cust_bill->get('charged') + $amount));
616   # don't replace the record yet, we'll do that at the end
617
618   $tax_item;
619 }
620
621 sub load_tax_classes {
622   %TAX_CLASSES = map { $_->taxclass => $_ }
623                  qsearch('tax_class', { data_vendor => 'billsoft' });
624 }
625
626
627 1;