69717a22db27eee88699c628d32692f7c855079a
[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 Text::CSV_XS;
15 use Locale::Country qw(country_code2code);
16
17 # "use constant" this, for performance?
18 our @input_cols = qw(
19   RequestType
20   BillToCountryISO
21   BillToZipCode
22   BillToZipP4
23   BillToPCode
24   BillToNpaNxx
25   OriginationCountryISO
26   OriginationZipCode
27   OriginationZipP4
28   OriginationNpaNxx
29   TerminationCountryISO
30   TerminationZipCode
31   TerminationZipP4
32   TerminationPCode
33   TerminationNpaNxx
34   TransactionType
35   ServiceType
36   Date
37   Charge
38   CustomerType
39   Lines
40   Sale
41   Regulated
42   Minutes
43   Debit
44   ServiceClass
45   Lifeline
46   Facilities
47   Franchise
48   BusinessClass
49   CompanyIdentifier
50   CustomerNumber
51   InvoiceNumber
52   DiscountType
53   ExemptionType
54   AdjustmentMethod
55   Optional
56 );
57
58 $DEBUG = 2;
59
60 $TIMEOUT = 86400; # absolute time limit on waiting for a response file.
61
62 FS::UID->install_callback(\&load_tax_classes);
63
64 sub info {
65   { batch => 1,
66     override => 0,
67     manual_tax_location => 1,
68   },
69 }
70
71 sub add_sale { } #do nothing
72
73 sub spooldir {
74   $FS::UID::cache_dir . "/Billsoft";
75 }
76
77 sub spoolname {
78   my $self = shift;
79   my $spooldir = $self->spooldir;
80   mkdir $spooldir, 0700 unless -d $spooldir;
81   my $upload = $self->spooldir . '/upload';
82   mkdir $upload, 0700 unless -d $upload;
83   my $basename = $self->conf->config('billsoft-company_code') .
84                  time2str('%Y%m%d', time); # use the real clock time here
85   my $uniq = 'AA';
86   while ( -e "$upload/$basename$uniq.CSV" ) {
87     $uniq++;
88     # these two letters must be unique within each day
89   }
90   "$basename$uniq.CSV";
91 }
92
93 =item part_pkg_taxproduct PART_PKG, CLASSNUM
94
95 Returns the taxproduct string (T-code and S-code concatenated) for
96 PART_PKG with usage class CLASSNUM. CLASSNUM can be a numeric classnum,
97 an empty string (for the package's base taxproduct), 'setup', or 'recur'.
98
99 Returns undef if the package doesn't have a taxproduct.
100
101 =cut
102
103 sub part_pkg_taxproduct {
104   my ($self, $part_pkg, $classnum) = @_;
105   my $pkgpart = $part_pkg->get('pkgpart');
106   # all taxproducts
107   $self->{_taxproduct} ||= {};
108   # taxproduct(s) that are relevant to this package
109   my $pkg_taxproduct = $self->{_taxproduct}{$pkgpart} ||= {};
110   my $taxproduct; # return this
111   $classnum ||= '';
112   if (exists($pkg_taxproduct->{$classnum})) {
113     $taxproduct = $pkg_taxproduct->{$classnum};
114   } else {
115     my $part_pkg_taxproduct = $part_pkg->taxproduct($classnum);
116     $taxproduct = $pkg_taxproduct->{$classnum} = (
117       $part_pkg_taxproduct ? $part_pkg_taxproduct->taxproduct : undef
118     );
119     if (!$taxproduct) {
120       $self->log->error("part_pkg $pkgpart, class $_: taxproduct not found");
121       if ( !$self->conf->exists('ignore_incalculable_taxes') ) {
122         die "part_pkg $pkgpart, class $_: taxproduct not found\n";
123       }
124     }
125   }
126   warn "part_pkg $pkgpart, class $classnum: ".
127     ($taxproduct ?
128       "using taxproduct $taxproduct\n" :
129       "taxproduct not found\n")
130     if $DEBUG;
131   return $taxproduct;
132 }
133
134 sub log {
135   my $self = shift;
136   return $self->{_log} ||= FS::Log->new('FS::TaxEngine::billsoft');
137 }
138
139 sub conf {
140   my $self = shift;
141   return $self->{_conf} ||= FS::Conf->new;
142 }
143
144 sub create_batch {
145   my ($self, %opt) = @_;
146
147   my @invoices = qsearch('cust_bill', { pending => 'Y' });
148   $self->log->info(scalar(@invoices)." pending invoice(s) found.");
149   return if @invoices == 0;
150
151   $DB::single=1; # XXX
152
153   my $spooldir = $self->spooldir;
154   my $spoolname = $self->spoolname;
155   my $fh = IO::File->new();
156   $self->log->info("Starting batch in $spooldir/upload/$spoolname");
157   $fh->open("$spooldir/upload/$spoolname", '>');
158   $self->{fh} = $fh;
159
160   my $csv = Text::CSV_XS->new({ binary => 1, eol => "\r\n" });
161   $csv->print($fh, \@input_cols);
162   $csv->column_names(\@input_cols);
163
164   # XXX limit based on freeside-daily custnum/agentnum options
165   # and maybe invoice date
166   foreach my $cust_bill (@invoices) {
167
168     my $invnum = $cust_bill->invnum;
169     my $cust_main = $cust_bill->cust_main;
170     my $cust_type = $cust_main->taxstatus;
171     my $invoice_date = time2str('%Y%m%d', $cust_bill->_date);
172
173     my %bill_to = do {
174       my $location = $cust_main->bill_location;
175       my $zip = $location->zip;
176       my $plus4 = '';
177       if ($location->country eq 'US') {
178         ($zip, $plus4) = split(/-/, $zip);
179       }
180       ( BillToCountryISO  => uc(country_code2code($location->country,
181                                                   'alpha-2' => 'alpha-3')),
182         BillToPCode       => $location->geocode,
183         BillToZipCode     => $zip,
184         BillToZipP4       => $plus4,
185       )
186     };
187
188     # cache some things
189     my (%cust_pkg, %part_pkg, %cust_location, %classname);
190     # keys are transaction codes (the first part of the taxproduct string)
191     # and then locationnums; for per-location taxes
192     my %sales;
193
194     my @options = $self->conf->config('billsoft-taxconfig');
195     
196     my %bill_properties = (
197       %bill_to,
198       Date              => $invoice_date,
199       CustomerType      => $cust_type,
200       CustomerNumber    => $cust_bill->custnum,
201       InvoiceNumber     => $invnum,
202       Facilities        => ($options[0] || ''),
203       Franchise         => ($options[1] || ''),
204       Regulated         => ($options[2] || ''),
205       BusinessClass     => ($options[3] || ''),
206     );
207
208     foreach my $cust_bill_pkg ( $cust_bill->cust_bill_pkg ) {
209       my $cust_pkg = $cust_pkg{$cust_bill_pkg->pkgnum}
210                  ||= $cust_bill_pkg->cust_pkg;
211       my $pkgpart = $cust_bill_pkg->pkgpart_override || $cust_pkg->pkgpart;
212       my $part_pkg = $part_pkg{$pkgpart} ||= FS::part_pkg->by_key($pkgpart);
213       my $resale_mode = ($part_pkg->option('wholesale',1) ? 'Resale' : 'Sale');
214       my %pkg_properties = (
215         %bill_properties,
216         Sale              => $resale_mode,
217         Optional          => $cust_bill_pkg->billpkgnum, # will be echoed
218         # others at this level? Lifeline?
219         # DiscountType may be relevant...
220         # and Proration
221       );
222
223       my $usage_total = 0;
224
225       # cursorized joined search on the invoice details, for memory efficiency
226       my $cdr_search = FS::Cursor->new({
227         'table'     => 'cdr',
228         'hashref'   => { freesidestatus => 'done' },
229         'addl_from' => ' JOIN cust_bill_pkg_detail USING (detailnum)',
230         'extra_sql' => "AND cust_bill_pkg_detail.billpkgnum = ".
231                        $cust_bill_pkg->billpkgnum
232       });
233
234       while (my $cdr = $cdr_search->fetch) {
235         my $classnum = $cdr->rated_classnum;
236         if ( $classnum ) {
237           $classname{$classnum} ||= FS::usage_class->by_key($classnum)->classname;
238         }
239
240         my $taxproduct = $self->part_pkg_taxproduct($part_pkg, $classnum)
241           or next;
242         my $tcode = substr($taxproduct, 0, 6);
243         my $scode = substr($taxproduct, 6, 6);
244
245         # For CDRs, use the call termination site rather than setting
246         # Termination fields to the service address.
247         $csv->print_hr($fh, {
248           %pkg_properties,
249           RequestType       => 'CalcTaxes',
250           OriginationNpaNxx => substr($cdr->src_lrn || $cdr->src, 0, 6),
251           TerminationNpaNxx => substr($cdr->dst_lrn || $cdr->dst, 0, 6),
252           TransactionType   => $tcode,
253           ServiceType       => $scode,
254           Charge            => $cdr->rated_price,
255           Minutes           => ($cdr->duration / 60.0), # floating point
256         });
257
258         $usage_total += $cdr->rated_price;
259
260       } # while $cdr = $cdr_search->fetch
261       
262       my $recur_tcode;
263       # now write lines for the non-CDR portion of the charges
264
265       my $locationnum = $cust_pkg->locationnum;
266
267       # use termination address for the service location
268       my %termination = do {
269         my $location = $cust_location{$locationnum} ||= $cust_pkg->cust_location;
270         my $zip = $location->zip;
271         my $plus4 = '';
272         if ($location->country eq 'US') {
273           ($zip, $plus4) = split(/-/, $zip);
274         }
275         ( TerminationCountryISO  => uc(country_code2code($location->country,
276                                                         'alpha-2' => 'alpha-3')),
277           TerminationPCode       => $location->geocode,
278           TerminationZipCode     => $zip,
279           TerminationZipP4       => $plus4,
280         )
281       };
282
283       foreach (qw(setup recur)) {
284         my $taxproduct = $self->part_pkg_taxproduct($part_pkg, $_);
285         next unless $taxproduct;
286
287         my $tcode = substr($taxproduct, 0, 6);
288         my $scode = substr($taxproduct, 6, 6);
289         $sales{$tcode} ||= 0;
290         $recur_tcode = $tcode if $_ eq 'recur';
291
292         my $price = $cust_bill_pkg->get($_);
293         $sales{$tcode} += $price;
294
295         $price -= $usage_total if $_ eq 'recur';
296
297         $csv->print_hr($fh, {
298             %pkg_properties,
299             %termination,
300             RequestType       => 'CalcTaxes',
301             TransactionType   => $tcode,
302             ServiceType       => $scode,
303             Charge            => $price,
304         } );
305
306       } # foreach (setup, recur)
307
308       # S-code 21: taxes based on number of lines (E911, mostly)
309       # voip_cdr and voip_inbound packages know how to report this.  Not all 
310       # T-codes are eligible for this; only report it if the /21 taxproduct
311       # exists.
312       #
313       # (note: the nomenclature of "service" and "transaction" codes is 
314       # backward from the way most people would use the terms.  you'd think
315       # that in "cellular activation", "cellular" would be the service and 
316       # "activation" would be the transaction, but for Billsoft it's the 
317       # reverse.  I recommend calling them "S" and "T" codes internally just 
318       # to avoid confusion.)
319
320       # XXX cache me
321       # XXX this isn't precisely correct. Local exchange service on
322       # high-capacity trunks, Centrex, and PBX trunks are supposed to be
323       # reported as three separate implicit transactions: number of trunks,
324       # of outbound channels, of extensions.
325       # This is also true for VoIP PBX trunks. Come back to this.
326       if ( $recur_tcode ) {
327         my $lines_taxproduct = FS::part_pkg_taxproduct->count(
328           'data_vendor = \'billsoft\' and taxproduct = ?',
329           sprintf('%06d%06d', $recur_tcode, 21)
330         );
331         my $lines = $cust_bill_pkg->units;
332
333         if ( $lines_taxproduct and $lines ) {
334           $csv->print_hr($fh, {
335             %pkg_properties,
336             %termination,
337             RequestType       => 'CalcTaxes',
338             TransactionType   => $recur_tcode,
339             ServiceType       => 21,
340             Charge            => 0,
341             Lines             => $lines,
342           } );
343         }
344       }
345
346     } # foreach my $cust_bill_pkg
347
348     foreach my $tcode (keys %sales) {
349
350       # S-code 43: per-invoice tax (apparently this is a thing)
351       my $invoice_taxproduct = FS::part_pkg_taxproduct->count(
352         'data_vendor = \'billsoft\' and taxproduct = ?',
353         sprintf('%06d%06d', $tcode, 43)
354       );
355       if ( $invoice_taxproduct ) {
356         $csv->print_hr($fh, {
357           RequestType       => 'CalcTaxes',
358           %bill_properties,
359           TransactionType   => $tcode,
360           ServiceType       => 43,
361           Charge            => 0,
362         } );
363       }
364     } # foreach $tcode
365   } # foreach $cust_bill
366
367   $fh->close;
368   return $spoolname;
369 }
370
371 sub cust_tax_locations {
372   my $class = shift;
373   my $location = shift;
374   if (ref $location eq 'HASH') {
375     $location = FS::cust_location->new($location);
376   }
377   my $zip = $location->zip;
378   return () unless $location->country eq 'US';
379   return () unless $zip;
380   # currently the only one supported
381   if ( $zip =~ /^(\d{5})(-\d{4})?$/ ) {
382     $zip = $1;
383   } else {
384     die "bad zip code $zip";
385   }
386   return qsearch({
387       table     => 'cust_tax_location',
388       hashref   => { 'data_vendor' => 'billsoft' },
389       extra_sql => " AND ziplo <= '$zip' and ziphi >= '$zip'",
390       order_by  => ' ORDER BY default_location',
391   });
392 }
393
394 sub transfer_batch {
395   my ($self, %opt) = @_;
396
397   my $oldAutoCommit = $FS::UID::AutoCommit;
398   local $FS::UID::AutoCommit = 0;
399   my $dbh = dbh;
400
401   eval "use Net::FTP;";
402   # set up directories if they're not already
403   mkdir $self->spooldir unless -d $self->spooldir;
404   local $CWD = $self->spooldir;
405   foreach (qw(upload download)) {
406     mkdir $_ unless -d $_;
407   }
408   my $target = qsearchs('upload_target', { hostname => 'ftp.billsoft.com' })
409     or die "No Billsoft upload target defined.\n";
410
411   local $CWD = $self->spooldir . '/upload';
412   # create the batch
413   my $upload = $self->create_batch(%opt); # name of the CSV file
414   # returns undef if there were no pending invoices; in that case
415   # skip the rest of this procedure
416   return if !$upload;
417
418   # upload it
419   my $ftp = $target->connect;
420   if (!ref $ftp) { # it's an error message
421     die "Error connecting to Billsoft FTP server:\n$ftp\n";
422   }
423   my $fh = IO::File->new();
424   $self->log->info("Processing: $upload");
425   if ( stat('FTP.ZIP') ) {
426     unlink('FTP.ZIP') or die "Failed to remove old tax batch:\n$!\n";
427   }
428   my $error = system("zip -j -o FTP.ZIP $upload");
429   die "Failed to compress tax batch\n$!\n" if $error;
430   $self->log->debug("Uploading file");
431   $ftp->put('FTP.ZIP');
432   unlink('FTP.ZIP');
433
434   local $CWD = $self->spooldir;
435   my $download = $upload;
436   # naming convention for these is: same as the CSV contained in the 
437   # zip file, but with an "R" inserted after the company ID prefix
438   $download =~ s/^(...)(\d{8}..).CSV/$1R$2.ZIP/;
439   $self->log->debug("Waiting for output file ($download)");
440   my $starttime = time;
441   my $downloaded = 0;
442   while ( time - $starttime < $TIMEOUT ) {
443     my @ls = $ftp->ls($download);
444     if ( @ls ) {
445       if ($ftp->get($download, "download/$download")) {
446         $self->log->debug("Downloaded '$download'");
447         $downloaded = 1;
448         last;
449       } else {
450         $self->log->warn("Failed to download '$download': ".$ftp->message);
451         # We know the file exists, so continue trying to download it.
452         # Maybe the problem will get fixed.
453       }
454     }
455     sleep 30;
456   }
457   if (!$downloaded) {
458     $self->log->error("No output file received.");
459     next BATCH;
460   }
461   $self->log->debug("Decompressing...");
462   system("unzip -o download/$download");
463   my $output = $upload;
464   $output =~ s/.CSV$/_dtl.rpt.csv/i;
465   if ([ -f $output ]) {
466     $self->log->info("Processing '$output'");
467     $fh->open($output, '<') or die "failed to open downloaded file $output";
468     $self->batch_import($fh); # dies on error
469     $fh->close;
470     unlink $output unless $DEBUG;
471   }
472   unlink 'FTP.ZIP';
473   $dbh->commit if $oldAutoCommit;
474   return;
475 }
476
477 sub batch_import {
478   $DB::single = 1; # XXX
479   # the hard part
480   my ($self, $fh) = @_;
481   $self->{'custnums'} = {};
482   $self->{'cust_bill'} = {};
483
484   # gather up pending invoices
485   foreach my $cust_bill (qsearch('cust_bill', { pending => 'Y' })) {
486     $self->{'cust_bill'}{ $cust_bill->invnum } = $cust_bill;
487   }
488
489   my $href;
490   my $parser = Text::CSV_XS->new({binary => 1});
491   # set column names from header row
492   $parser->column_names($parser->getline($fh));
493
494   # start parsing the file
495   my $errors = 0;
496   my $row = 1;
497   # the file is functionally a left join of submitted line items with their
498   # taxes; if a line item has no taxes then it will produce an output row
499   # with all the tax fields empty.
500   while ($href = $parser->getline_hr($fh)) {
501     next if $href->{TaxTypeID} eq ''; # then this row has no taxes
502     next if $href->{TaxAmount} == 0; # then the calculated tax is zero
503
504     my $billpkgnum = $href->{Optional};
505     my $invnum = $href->{InvoiceNumber};
506     my $cust_bill_pkg; # the line item that this tax applies to
507     if ( !exists($self->{cust_bill}->{$invnum}) ) {
508       $self->log->error("invoice #$invnum invoice not in pending state");
509       $errors++;
510       next;
511     }
512     if ( $billpkgnum ) {
513       $cust_bill_pkg = FS::cust_bill_pkg->by_key($billpkgnum);
514       if ( $cust_bill_pkg->invnum != $invnum ) {
515         $self->log->error("invoice #$invnum invoice number mismatch");
516         $errors++;
517         next;
518       }
519     } else {
520       $cust_bill_pkg = ($self->{cust_bill}->{$invnum}->cust_bill_pkg)[0];
521       $billpkgnum = $cust_bill_pkg->billpkgnum;
522     }
523
524     # resolve the tax definition
525     # base name of the tax type (like "Sales Tax" or "Universal Lifeline 
526     # Telephone Service Charge").
527     my $tax_class = $TAX_CLASSES{ $href->{TaxTypeID} };
528     if (!$tax_class) {
529       $self->log->warn("Unknown tax type $href->{TaxTypeID}");
530       $tax_class = FS::tax_class->new({
531         'data_vendor' => 'billsoft',
532         'taxclass'    => $href->{TaxTypeID},
533         'description' => $href->{TaxType}
534       });
535       my $error = $tax_class->insert;
536       if ($error) {
537         $self->log->error("Failed to insert tax_class record: $error");
538         $errors++;
539         next;
540       }
541       $TAX_CLASSES{ $href->{TaxTypeID} } = $tax_class;
542     }
543     my $itemdesc = uc($tax_class->description);
544     my $location = qsearchs('tax_rate_location', {
545                              data_vendor  => 'billsoft',
546                              disabled     => '',
547                              geocode      => $href->{PCode}
548                            });
549     if (!$location) {
550       $location = FS::tax_rate_location->new({
551         'data_vendor' => 'billsoft',
552         'geocode'     => $href->{PCode},
553         'country'     => uc(country_code2code($href->{CountryISO},
554                                               'alpha-3' => 'alpha-2')),
555         'state'       => $href->{State},
556         'county'      => $href->{County},
557         'city'        => $href->{Locality},
558       });
559       my $error = $location->insert;
560       if ($error) {
561         $self->log->error("Failed to insert tax_class record: $error");
562         $errors++;
563         next;
564       }
565     }
566     # jurisdiction name
567     my $prefix = '';
568     if ( $href->{TaxLevelID} == 0 ) { # national-level tax
569       # do nothing
570     } elsif ( $href->{TaxLevelID} == 1 ) {
571       $prefix = $location->state;
572     } elsif ( $href->{TaxLevelID} == 2 ) {
573       $prefix = $location->county . ' COUNTY';
574     } elsif ( $href->{TaxLevelID} == 3 ) {
575       $prefix = $location->city;
576     } elsif ( $href->{TaxLevelID} == 4 ) { # unincorporated area ta
577       # do nothing
578     }
579     # Some itemdescs start with the jurisdiction name; otherwise, prepend 
580     # it.
581     if ( $itemdesc !~ /^(city of )?$prefix\b/i ) {
582       $itemdesc = "$prefix $itemdesc";
583     }
584     # Create or locate a tax_rate record, because we need one to foreign-key
585     # the cust_bill_pkg_tax_rate_location record.
586     my $tax_rate = $self->find_or_insert_tax_rate(
587       geocode     => $href->{PCode},
588       taxclassnum => $tax_class->taxclassnum,
589       taxname     => $itemdesc,
590     );
591     my $amount = sprintf('%.2f', $href->{TaxAmount});
592     # and add it to the tax under this name
593     my $tax_item = $self->add_tax_item(
594       invnum      => $invnum,
595       itemdesc    => $itemdesc,
596       amount      => $amount,
597     );
598     # and link that tax line item to the taxed sale
599     my $subitem = FS::cust_bill_pkg_tax_rate_location->new({
600         billpkgnum          => $tax_item->billpkgnum,
601         taxnum              => $tax_rate->taxnum,
602         taxtype             => 'FS::tax_rate',
603         taxratelocationnum  => $location->taxratelocationnum,
604         amount              => $amount,
605         taxable_billpkgnum  => $billpkgnum,
606     });
607     my $error = $subitem->insert;
608     die "Error linking tax to taxable item: $error\n" if $error;
609
610     $row++;
611   } #foreach $line
612   if ( $errors > 0 ) {
613     die "Encountered $errors error(s); rolling back tax import.\n";
614   }
615
616   # remove pending flag from invoices and schedule collect jobs
617   foreach my $cust_bill (values %{ $self->{'cust_bill'} }) {
618     my $invnum = $cust_bill->invnum;
619     $cust_bill->set('pending' => '');
620     my $error = $cust_bill->replace;
621     die "Error updating invoice #$invnum: $error\n"
622       if $error;
623     $self->{'custnums'}->{ $cust_bill->custnum } = 1;
624   }
625
626   foreach my $custnum ( keys %{ $self->{'custnums'} } ) {
627     my $queue = FS::queue->new({ 'job' => 'FS::cust_main::queued_collect' });
628     my $error = $queue->insert('custnum' => $custnum);
629     die "Error scheduling collection for customer #$custnum: $error\n" 
630       if $error;
631   }
632
633   '';
634 }
635
636
637 sub find_or_insert_tax_rate {
638   my ($self, %hash) = @_;
639   $hash{'tax'} = 0;
640   $hash{'data_vendor'} = 'billsoft';
641   my $tax_rate = qsearchs('tax_rate', \%hash);
642   if (!$tax_rate) {
643     $tax_rate = FS::tax_rate->new(\%hash);
644     my $error = $tax_rate->insert;
645     die "Error inserting tax definition: $error\n" if $error;
646   }
647   return $tax_rate;
648 }
649
650
651 sub add_tax_item {
652   my ($self, %hash) = @_;
653   $hash{'pkgnum'} = 0;
654   my $amount = delete $hash{'amount'};
655   
656   my $tax_item = qsearchs('cust_bill_pkg', \%hash);
657   if (!$tax_item) {
658     $tax_item = FS::cust_bill_pkg->new(\%hash);
659     $tax_item->set('setup', $amount);
660     my $error = $tax_item->insert;
661     die "Error inserting tax: $error\n" if $error;
662   } else {
663     $tax_item->set('setup', $tax_item->get('setup') + $amount);
664     my $error = $tax_item->replace;
665     die "Error incrementing tax: $error\n" if $error;
666   }
667
668   my $cust_bill = $self->{'cust_bill'}->{$tax_item->invnum}
669     or die "Invoice #".$tax_item->{invnum}." is not pending.\n";
670   $cust_bill->set('charged' => 
671                   sprintf('%.2f', $cust_bill->get('charged') + $amount));
672   # don't replace the record yet, we'll do that at the end
673
674   $tax_item;
675 }
676
677 sub load_tax_classes {
678   %TAX_CLASSES = map { $_->taxclass => $_ }
679                  qsearch('tax_class', { data_vendor => 'billsoft' });
680 }
681
682
683 1;