specify Avalara tax product for per-line taxes, #73063
[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     my %all_tcodes;
192
193     my @options = $self->conf->config('billsoft-taxconfig');
194     
195     my %bill_properties = (
196       %bill_to,
197       Date              => $invoice_date,
198       CustomerType      => $cust_type,
199       CustomerNumber    => $cust_bill->custnum,
200       InvoiceNumber     => $invnum,
201       Facilities        => ($options[0] || ''),
202       Franchise         => ($options[1] || ''),
203       Regulated         => ($options[2] || ''),
204       BusinessClass     => ($options[3] || ''),
205     );
206
207     foreach my $cust_bill_pkg ( $cust_bill->cust_bill_pkg ) {
208       my $cust_pkg = $cust_pkg{$cust_bill_pkg->pkgnum}
209                  ||= $cust_bill_pkg->cust_pkg;
210       my $pkgpart = $cust_bill_pkg->pkgpart_override || $cust_pkg->pkgpart;
211       my $part_pkg = $part_pkg{$pkgpart} ||= FS::part_pkg->by_key($pkgpart);
212       my $resale_mode = ($part_pkg->option('wholesale',1) ? 'Resale' : 'Sale');
213       my %pkg_properties = (
214         %bill_properties,
215         Sale              => $resale_mode,
216         Optional          => $cust_bill_pkg->billpkgnum, # will be echoed
217         # others at this level? Lifeline?
218         # DiscountType may be relevant...
219         # and Proration
220       );
221
222       my $usage_total = 0;
223
224       # cursorized joined search on the invoice details, for memory efficiency
225       my $cdr_search = FS::Cursor->new({
226         'table'     => 'cdr',
227         'hashref'   => { freesidestatus => 'done' },
228         'addl_from' => ' JOIN cust_bill_pkg_detail USING (detailnum)',
229         'extra_sql' => "AND cust_bill_pkg_detail.billpkgnum = ".
230                        $cust_bill_pkg->billpkgnum
231       });
232
233       while (my $cdr = $cdr_search->fetch) {
234         my $classnum = $cdr->rated_classnum;
235         if ( $classnum ) {
236           $classname{$classnum} ||= FS::usage_class->by_key($classnum)->classname;
237         }
238
239         my $taxproduct = $self->part_pkg_taxproduct($part_pkg, $classnum)
240           or next;
241         my ($tcode, $scode) = split(':', $taxproduct);
242
243         # For CDRs, use the call termination site rather than setting
244         # Termination fields to the service address.
245         $csv->print_hr($fh, {
246           %pkg_properties,
247           RequestType       => 'CalcTaxes',
248           OriginationNpaNxx => substr($cdr->src_lrn || $cdr->src, 0, 6),
249           TerminationNpaNxx => substr($cdr->dst_lrn || $cdr->dst, 0, 6),
250           TransactionType   => $tcode,
251           ServiceType       => $scode,
252           Charge            => $cdr->rated_price,
253           Minutes           => ($cdr->duration / 60.0), # floating point
254         });
255
256         $usage_total += $cdr->rated_price;
257
258       } # while $cdr = $cdr_search->fetch
259       
260       my $locationnum = $cust_pkg->locationnum;
261
262       # use termination address for the service location
263       my %termination = do {
264         my $location = $cust_location{$locationnum} ||= $cust_pkg->cust_location;
265         my $zip = $location->zip;
266         my $plus4 = '';
267         if ($location->country eq 'US') {
268           ($zip, $plus4) = split(/-/, $zip);
269         }
270         ( TerminationCountryISO  => uc(country_code2code($location->country,
271                                                         'alpha-2' => 'alpha-3')),
272           TerminationPCode       => $location->geocode,
273           TerminationZipCode     => $zip,
274           TerminationZipP4       => $plus4,
275         )
276       };
277
278       foreach (qw(setup recur)) {
279         my $taxproduct = $self->part_pkg_taxproduct($part_pkg, $_);
280         next unless $taxproduct;
281
282         my ($tcode, $scode) = split(':', $taxproduct);
283         $all_tcodes{$tcode} ||= 1;
284
285         my $price = $cust_bill_pkg->get($_);
286
287         $price -= $usage_total if $_ eq 'recur';
288
289         $csv->print_hr($fh, {
290             %pkg_properties,
291             %termination,
292             RequestType       => 'CalcTaxes',
293             TransactionType   => $tcode,
294             ServiceType       => $scode,
295             Charge            => $price,
296         } );
297
298       } # foreach (setup, recur)
299
300       # taxes based on number of lines (E911, mostly)
301       # mostly S-code 21 but can be others, as they want to know about
302       # Centrex trunks, PBX extensions, etc.
303       #
304       # (note: the nomenclature of "service" and "transaction" codes is 
305       # backward from the way most people would use the terms.  you'd think
306       # that in "cellular activation", "cellular" would be the service and 
307       # "activation" would be the transaction, but for Billsoft it's the 
308       # reverse.  I recommend calling them "S" and "T" codes internally just 
309       # to avoid confusion.)
310
311       # XXX cache me
312       if ( my $lines_taxproduct = $part_pkg->units_taxproduct ) {
313         my $lines = $cust_bill_pkg->units;
314         my $taxproduct = $lines_taxproduct->taxproduct;
315         my ($tcode, $scode) = split(':', $taxproduct);
316         $all_tcodes{$tcode} ||= 1;
317         if ( $lines ) {
318           $csv->print_hr($fh, {
319             %pkg_properties,
320             %termination,
321             RequestType       => 'CalcTaxes',
322             TransactionType   => $tcode,
323             ServiceType       => $scode,
324             Charge            => 0,
325             Lines             => $lines,
326           } );
327         }
328       }
329
330     } # foreach my $cust_bill_pkg
331
332     foreach my $tcode (keys %all_tcodes) {
333
334       # S-code 43: per-invoice tax
335       # XXX not exactly correct; there's "Invoice Bundle" (7:94) and
336       # "Centrex Invoice" (7:623). Local Exchange service would benefit from
337       # more high-level selection of the tax properties. (Infer from the FCC
338       # reporting options?)
339       my $invoice_taxproduct = FS::part_pkg_taxproduct->count(
340         'data_vendor = \'billsoft\' and taxproduct = ?',
341         $tcode . ':43'
342       );
343       if ( $invoice_taxproduct ) {
344         $csv->print_hr($fh, {
345           RequestType       => 'CalcTaxes',
346           %bill_properties,
347           TransactionType   => $tcode,
348           ServiceType       => 43,
349           Charge            => 0,
350         } );
351       }
352     } # foreach $tcode
353   } # foreach $cust_bill
354
355   $fh->close;
356   return $spoolname;
357 }
358
359 sub cust_tax_locations {
360   my $class = shift;
361   my $location = shift;
362   if (ref $location eq 'HASH') {
363     $location = FS::cust_location->new($location);
364   }
365   my $zip = $location->zip;
366   return () unless $location->country eq 'US';
367   return () unless $zip;
368   # currently the only one supported
369   if ( $zip =~ /^(\d{5})(-\d{4})?$/ ) {
370     $zip = $1;
371   } else {
372     die "bad zip code $zip";
373   }
374   return qsearch({
375       table     => 'cust_tax_location',
376       hashref   => { 'data_vendor' => 'billsoft' },
377       extra_sql => " AND ziplo <= '$zip' and ziphi >= '$zip'",
378       order_by  => ' ORDER BY default_location',
379   });
380 }
381
382 sub transfer_batch {
383   my ($self, %opt) = @_;
384
385   my $oldAutoCommit = $FS::UID::AutoCommit;
386   local $FS::UID::AutoCommit = 0;
387   my $dbh = dbh;
388
389   eval "use Net::FTP;";
390   # set up directories if they're not already
391   mkdir $self->spooldir unless -d $self->spooldir;
392   local $CWD = $self->spooldir;
393   foreach (qw(upload download)) {
394     mkdir $_ unless -d $_;
395   }
396   my $target = qsearchs('upload_target', { hostname => 'ftp.billsoft.com' })
397     or die "No Billsoft upload target defined.\n";
398
399   local $CWD = $self->spooldir . '/upload';
400   # create the batch
401   my $upload = $self->create_batch(%opt); # name of the CSV file
402   # returns undef if there were no pending invoices; in that case
403   # skip the rest of this procedure
404   return if !$upload;
405
406   # upload it
407   my $ftp = $target->connect;
408   if (!ref $ftp) { # it's an error message
409     die "Error connecting to Billsoft FTP server:\n$ftp\n";
410   }
411   my $fh = IO::File->new();
412   $self->log->info("Processing: $upload");
413   if ( stat('FTP.ZIP') ) {
414     unlink('FTP.ZIP') or die "Failed to remove old tax batch:\n$!\n";
415   }
416   my $error = system("zip -j -o FTP.ZIP $upload");
417   die "Failed to compress tax batch\n$!\n" if $error;
418   $self->log->debug("Uploading file");
419   $ftp->put('FTP.ZIP');
420   unlink('FTP.ZIP');
421
422   local $CWD = $self->spooldir;
423   my $download = $upload;
424   # naming convention for these is: same as the CSV contained in the 
425   # zip file, but with an "R" inserted after the company ID prefix
426   $download =~ s/^(...)(\d{8}..).CSV/$1R$2.ZIP/;
427   $self->log->debug("Waiting for output file ($download)");
428   my $starttime = time;
429   my $downloaded = 0;
430   while ( time - $starttime < $TIMEOUT ) {
431     my @ls = $ftp->ls($download);
432     if ( @ls ) {
433       if ($ftp->get($download, "download/$download")) {
434         $self->log->debug("Downloaded '$download'");
435         $downloaded = 1;
436         last;
437       } else {
438         $self->log->warn("Failed to download '$download': ".$ftp->message);
439         # We know the file exists, so continue trying to download it.
440         # Maybe the problem will get fixed.
441       }
442     }
443     sleep 30;
444   }
445   if (!$downloaded) {
446     $self->log->error("No output file received.");
447     next BATCH;
448   }
449   $self->log->debug("Decompressing...");
450   system("unzip -o download/$download");
451   my $output = $upload;
452   $output =~ s/.CSV$/_dtl.rpt.csv/i;
453   if ([ -f $output ]) {
454     $self->log->info("Processing '$output'");
455     $fh->open($output, '<') or die "failed to open downloaded file $output";
456     $self->batch_import($fh); # dies on error
457     $fh->close;
458     unlink $output unless $DEBUG;
459   }
460   unlink 'FTP.ZIP';
461   $dbh->commit if $oldAutoCommit;
462   return;
463 }
464
465 sub batch_import {
466   $DB::single = 1; # XXX
467   # the hard part
468   my ($self, $fh) = @_;
469   $self->{'custnums'} = {};
470   $self->{'cust_bill'} = {};
471
472   # gather up pending invoices
473   foreach my $cust_bill (qsearch('cust_bill', { pending => 'Y' })) {
474     $self->{'cust_bill'}{ $cust_bill->invnum } = $cust_bill;
475   }
476
477   my $href;
478   my $parser = Text::CSV_XS->new({binary => 1});
479   # set column names from header row
480   $parser->column_names($parser->getline($fh));
481
482   # start parsing the file
483   my $errors = 0;
484   my $row = 1;
485   # the file is functionally a left join of submitted line items with their
486   # taxes; if a line item has no taxes then it will produce an output row
487   # with all the tax fields empty.
488   while ($href = $parser->getline_hr($fh)) {
489     next if $href->{TaxTypeID} eq ''; # then this row has no taxes
490     next if $href->{TaxAmount} == 0; # then the calculated tax is zero
491
492     my $billpkgnum = $href->{Optional};
493     my $invnum = $href->{InvoiceNumber};
494     my $cust_bill_pkg; # the line item that this tax applies to
495     if ( !exists($self->{cust_bill}->{$invnum}) ) {
496       $self->log->error("invoice #$invnum invoice not in pending state");
497       $errors++;
498       next;
499     }
500     if ( $billpkgnum ) {
501       $cust_bill_pkg = FS::cust_bill_pkg->by_key($billpkgnum);
502       if ( $cust_bill_pkg->invnum != $invnum ) {
503         $self->log->error("invoice #$invnum invoice number mismatch");
504         $errors++;
505         next;
506       }
507     } else {
508       $cust_bill_pkg = ($self->{cust_bill}->{$invnum}->cust_bill_pkg)[0];
509       $billpkgnum = $cust_bill_pkg->billpkgnum;
510     }
511
512     # resolve the tax definition
513     # base name of the tax type (like "Sales Tax" or "Universal Lifeline 
514     # Telephone Service Charge").
515     my $tax_class = $TAX_CLASSES{ $href->{TaxTypeID} };
516     if (!$tax_class) {
517       $self->log->warn("Unknown tax type $href->{TaxTypeID}");
518       $tax_class = FS::tax_class->new({
519         'data_vendor' => 'billsoft',
520         'taxclass'    => $href->{TaxTypeID},
521         'description' => $href->{TaxType}
522       });
523       my $error = $tax_class->insert;
524       if ($error) {
525         $self->log->error("Failed to insert tax_class record: $error");
526         $errors++;
527         next;
528       }
529       $TAX_CLASSES{ $href->{TaxTypeID} } = $tax_class;
530     }
531     my $itemdesc = uc($tax_class->description);
532     my $location = qsearchs('tax_rate_location', {
533                              data_vendor  => 'billsoft',
534                              disabled     => '',
535                              geocode      => $href->{PCode}
536                            });
537     if (!$location) {
538       $location = FS::tax_rate_location->new({
539         'data_vendor' => 'billsoft',
540         'geocode'     => $href->{PCode},
541         'country'     => uc(country_code2code($href->{CountryISO},
542                                               'alpha-3' => 'alpha-2')),
543         'state'       => $href->{State},
544         'county'      => $href->{County},
545         'city'        => $href->{Locality},
546       });
547       my $error = $location->insert;
548       if ($error) {
549         $self->log->error("Failed to insert tax_class record: $error");
550         $errors++;
551         next;
552       }
553     }
554     # jurisdiction name
555     my $prefix = '';
556     if ( $href->{TaxLevelID} == 0 ) { # national-level tax
557       # do nothing
558     } elsif ( $href->{TaxLevelID} == 1 ) {
559       $prefix = $location->state;
560     } elsif ( $href->{TaxLevelID} == 2 ) {
561       $prefix = $location->county . ' COUNTY';
562     } elsif ( $href->{TaxLevelID} == 3 ) {
563       $prefix = $location->city;
564     } elsif ( $href->{TaxLevelID} == 4 ) { # unincorporated area ta
565       # do nothing
566     }
567     # Some itemdescs start with the jurisdiction name; otherwise, prepend 
568     # it.
569     if ( $itemdesc !~ /^(city of )?$prefix\b/i ) {
570       $itemdesc = "$prefix $itemdesc";
571     }
572     # Create or locate a tax_rate record, because we need one to foreign-key
573     # the cust_bill_pkg_tax_rate_location record.
574     my $tax_rate = $self->find_or_insert_tax_rate(
575       geocode     => $href->{PCode},
576       taxclassnum => $tax_class->taxclassnum,
577       taxname     => $itemdesc,
578     );
579     my $amount = sprintf('%.2f', $href->{TaxAmount});
580     # and add it to the tax under this name
581     my $tax_item = $self->add_tax_item(
582       invnum      => $invnum,
583       itemdesc    => $itemdesc,
584       amount      => $amount,
585     );
586     # and link that tax line item to the taxed sale
587     my $subitem = FS::cust_bill_pkg_tax_rate_location->new({
588         billpkgnum          => $tax_item->billpkgnum,
589         taxnum              => $tax_rate->taxnum,
590         taxtype             => 'FS::tax_rate',
591         taxratelocationnum  => $location->taxratelocationnum,
592         amount              => $amount,
593         taxable_billpkgnum  => $billpkgnum,
594     });
595     my $error = $subitem->insert;
596     die "Error linking tax to taxable item: $error\n" if $error;
597
598     $row++;
599   } #foreach $line
600   if ( $errors > 0 ) {
601     die "Encountered $errors error(s); rolling back tax import.\n";
602   }
603
604   # remove pending flag from invoices and schedule collect jobs
605   foreach my $cust_bill (values %{ $self->{'cust_bill'} }) {
606     my $invnum = $cust_bill->invnum;
607     $cust_bill->set('pending' => '');
608     my $error = $cust_bill->replace;
609     die "Error updating invoice #$invnum: $error\n"
610       if $error;
611     $self->{'custnums'}->{ $cust_bill->custnum } = 1;
612   }
613
614   foreach my $custnum ( keys %{ $self->{'custnums'} } ) {
615     my $queue = FS::queue->new({ 'job' => 'FS::cust_main::queued_collect' });
616     my $error = $queue->insert('custnum' => $custnum);
617     die "Error scheduling collection for customer #$custnum: $error\n" 
618       if $error;
619   }
620
621   '';
622 }
623
624
625 sub find_or_insert_tax_rate {
626   my ($self, %hash) = @_;
627   $hash{'tax'} = 0;
628   $hash{'data_vendor'} = 'billsoft';
629   my $tax_rate = qsearchs('tax_rate', \%hash);
630   if (!$tax_rate) {
631     $tax_rate = FS::tax_rate->new(\%hash);
632     my $error = $tax_rate->insert;
633     die "Error inserting tax definition: $error\n" if $error;
634   }
635   return $tax_rate;
636 }
637
638
639 sub add_tax_item {
640   my ($self, %hash) = @_;
641   $hash{'pkgnum'} = 0;
642   my $amount = delete $hash{'amount'};
643   
644   my $tax_item = qsearchs('cust_bill_pkg', \%hash);
645   if (!$tax_item) {
646     $tax_item = FS::cust_bill_pkg->new(\%hash);
647     $tax_item->set('setup', $amount);
648     my $error = $tax_item->insert;
649     die "Error inserting tax: $error\n" if $error;
650   } else {
651     $tax_item->set('setup', $tax_item->get('setup') + $amount);
652     my $error = $tax_item->replace;
653     die "Error incrementing tax: $error\n" if $error;
654   }
655
656   my $cust_bill = $self->{'cust_bill'}->{$tax_item->invnum}
657     or die "Invoice #".$tax_item->{invnum}." is not pending.\n";
658   $cust_bill->set('charged' => 
659                   sprintf('%.2f', $cust_bill->get('charged') + $amount));
660   # don't replace the record yet, we'll do that at the end
661
662   $tax_item;
663 }
664
665 sub load_tax_classes {
666   %TAX_CLASSES = map { $_->taxclass => $_ }
667                  qsearch('tax_class', { data_vendor => 'billsoft' });
668 }
669
670
671 1;