diff options
author | Mark Wells <mark@freeside.biz> | 2014-10-31 15:45:50 -0700 |
---|---|---|
committer | Mark Wells <mark@freeside.biz> | 2014-10-31 15:45:50 -0700 |
commit | 7516e3da0f17eeecba27219ef96a8b5f46af2083 (patch) | |
tree | 772fe13627910a7d0774871633697f2a4d1c6faf /FS/FS/TaxEngine | |
parent | f31a9212ab3835b815aa87a86cca3b19babcaaff (diff) |
tax engine refactoring for Avalara and Billsoft tax vendors, #25718
Diffstat (limited to 'FS/FS/TaxEngine')
-rw-r--r-- | FS/FS/TaxEngine/avalara.pm | 302 | ||||
-rw-r--r-- | FS/FS/TaxEngine/billsoft.pm | 627 | ||||
-rw-r--r-- | FS/FS/TaxEngine/cch.pm | 202 | ||||
-rw-r--r-- | FS/FS/TaxEngine/internal.pm | 318 |
4 files changed, 1449 insertions, 0 deletions
diff --git a/FS/FS/TaxEngine/avalara.pm b/FS/FS/TaxEngine/avalara.pm new file mode 100644 index 0000000..183555d --- /dev/null +++ b/FS/FS/TaxEngine/avalara.pm @@ -0,0 +1,302 @@ +package FS::TaxEngine::avalara; + +use strict; +use base 'FS::TaxEngine'; +use FS::Conf; +use FS::Record qw(qsearch qsearchs dbh); +use FS::cust_pkg; +use FS::cust_location; +use FS::cust_bill_pkg; +use FS::tax_rate; +use JSON; +use Geo::StreetAddress::US; + +our $DEBUG = 2; +our $json = JSON->new->pretty(1); + +our $conf; + +sub info { + { batch => 0, + override => 0 } +} + +FS::UID->install_callback( sub { + $conf = FS::Conf->new; +}); + +#sub cust_tax_locations { +#} +# Avalara address standardization would be nice but isn't necessary + +# XXX this is just here to avoid reworking the framework right now. By the +# 4.0 release, ALL tax calculations should be done after the invoice has +# been inserted into the database. + +# nothing to do here +sub add_sale {} + +sub build_request { + my ($self, %opt) = @_; + + my $oldAutoCommit = $FS::UID::AutoCommit; + local $FS::UID::AutoCommit = 0; + my $dbh = dbh; + + my $cust_bill = $self->{cust_bill}; + my $cust_main = $cust_bill->cust_main; + + # unfortunately we can't directly use the Business::Tax::Avalara get_tax() + # interface, because we have multiple customer addresses + my %address_seen; + + # assemble invoice line items + my @lines; + # conventions we are using here: + # P#### = part pkg# + # F#### = part_fee# + # L#### = cust_location# (address code) + # L0 = company address + foreach my $sale ( $cust_bill->cust_bill_pkg ) { + my $part = $sale->part_X; + my $item_code = ($part->isa('FS::part_pkg') ? 'P'.$part->pkgpart : + 'F'.$part->feepart + ); + my $addr_code = 'L'.$sale->tax_locationnum; + my $taxproductnum = $part->taxproductnum; + next unless $taxproductnum; + my $taxproduct = FS::part_pkg_taxproduct->by_key($taxproductnum); + my $itemdesc = $part->itemdesc || $part->pkg; + + $address_seen{$sale->tax_locationnum} = 1; + + my $line = { + 'LineNo' => $sale->billpkgnum, + 'DestinationCode' => $addr_code, + 'OriginCode' => 'L0', + 'ItemCode' => $item_code, + 'TaxCode' => $taxproduct->taxproduct, + 'Description' => $itemdesc, + 'Qty' => $sale->quantity, + 'Amount' => ($sale->setup + $sale->recur), + # also available: + # 'ExemptionNo', 'Discounted', 'TaxIncluded', 'Ref1', 'Ref2', 'Ref3', + # 'TaxOverride' + }; + push @lines, $line; + } + + # assemble address records for any cust_locations we used here, plus + # the company address + # XXX these should just be separate config opts + my $our_address = join(' ', + $conf->config('company_address', $cust_main->agentnum) + ); + my $company_address = Geo::StreetAddress::US->parse_address($our_address); + my $address1 = join(' ', grep $_, @{$company_address}{qw( + number prefix street type suffix + )}); + my $address2 = join(' ', grep $_, @{$company_address}{qw( + sec_unit_type sec_unit_num + )}); + my @addrs = ( + { + 'AddressCode' => 'L0', + 'Line1' => $address1, + 'Line2' => $address2, + 'City' => $company_address->{city}, + 'Region' => $company_address->{state}, + 'Country' => ($company_address->{country} + || $conf->config('countrydefault') + || 'US'), + 'PostalCode' => $company_address->{zip}, + 'Latitude' => ($conf->config('company_latitude') || ''), + 'Longitude' => ($conf->config('company_longitude') || ''), + } + ); + + foreach my $locationnum (keys %address_seen) { + my $cust_location = FS::cust_location->by_key($locationnum); + my $addr = { + 'AddressCode' => 'L'.$locationnum, + 'Line1' => $cust_location->address1, + 'Line2' => $cust_location->address2, + 'Line3' => '', + 'City' => $cust_location->city, + 'Region' => $cust_location->state, + 'Country' => $cust_location->country, + 'PostalCode' => $cust_location->zip, + 'Latitude' => $cust_location->latitude, + 'Longitude' => $cust_location->longitude, + #'TaxRegionId', probably not necessary + }; + push @addrs, $addr; + } + + my @avalara_conf = $conf->config('avalara-taxconfig'); + # 1. company code + # 2. user name (account number) + # 3. password (license) + # 4. test mode (1 to enable) + + # create the top level object + my $date = DateTime->from_epoch(epoch => $self->{invoice_time}); + return { + 'CustomerCode' => $cust_main->custnum, + 'DocDate' => $date->strftime('%Y-%m-%d'), + 'CompanyCode' => $avalara_conf[0], + 'Client' => "Freeside $FS::VERSION", + 'DocCode' => $cust_bill->invnum, + 'DetailLevel' => 'Tax', + 'Commit' => 'false', + 'DocType' => 'SalesInvoice', # ??? + 'CustomerUsageType' => $cust_main->taxstatus, + # ExemptionNo, Discount, TaxOverride, PurchaseOrderNo, + 'Addresses' => \@addrs, + 'Lines' => \@lines, + }; +} + +sub calculate_taxes { + $DB::single = 1; # XXX + my $self = shift; + + my $cust_bill = shift; + if (!$cust_bill->invnum) { + warn "FS::TaxEngine::avalara: can't calculate taxes on a non-inserted invoice"; + return; + } + $self->{cust_bill} = $cust_bill; + + my $invnum = $cust_bill->invnum; + if (FS::cust_bill_pkg->count("invnum = $invnum") == 0) { + # don't even bother making the request + return []; + } + + # instantiate gateway + eval "use Business::Tax::Avalara"; + die "error loading Business::Tax::Avalara:\n$@\n" if $@; + + my @avalara_conf = $conf->config('avalara-taxconfig'); + if (scalar @avalara_conf < 3) { + die "Your Avalara configuration is incomplete. +The 'avalara-taxconfig' parameter must have three rows: company code, +account number, and license key. +"; + } + + my $gateway = Business::Tax::Avalara->new( + customer_code => $self->{cust_main}->custnum, + company_code => $avalara_conf[0], + user_name => $avalara_conf[1], + password => $avalara_conf[2], + is_development => ($avalara_conf[3] ? 1 : 0), + ); + + # assemble the request hash + my $request = $self->build_request; + + warn "sending Avalara tax request\n" if $DEBUG; + my $request_json = $json->encode($request); + warn $request_json if $DEBUG > 1; + + my $response_json = $gateway->_make_request_json($request_json); + warn "received response\n" if $DEBUG; + warn $response_json if $DEBUG > 1; + my $response = $json->decode($response_json); + + my %tax_item_named; + + if ( $response->{ResultCode} ne 'Success' ) { + return "invoice#".$cust_bill->invnum.": ". + join("\n", @{ $response->{Messages} }); + } + warn "creating taxes for inv#$invnum\n" if $DEBUG > 1; + foreach my $TaxLine (@{ $response->{TaxLines} }) { + my $taxable_billpkgnum = $TaxLine->{LineNo}; + warn " item #$taxable_billpkgnum\n" if $DEBUG > 1; + foreach my $TaxDetail (@{ $TaxLine->{TaxDetails} }) { + # in this case the tax doesn't apply (just informational) + next unless $TaxDetail->{Taxable}; + + my $taxname = $TaxDetail->{TaxName}; + warn " $taxname\n" if $DEBUG > 1; + + # create a tax line item + my $tax_item = $tax_item_named{$taxname} ||= FS::cust_bill_pkg->new({ + invnum => $cust_bill->invnum, + pkgnum => 0, + setup => 0, + recur => 0, + itemdesc => $taxname, + cust_bill_pkg_tax_rate_location => [], + }); + # create a tax_rate record if there isn't one yet. + # we're not actually going to do anything with it, just tie related + # taxes together. + my $tax_rate = FS::tax_rate->new({ + data_vendor => 'avalara', + taxname => $taxname, + taxclassnum => '', + geocode => $TaxDetail->{JurisCode}, + location => $TaxDetail->{JurisName}, + tax => 0, + fee => 0, + }); + my $error = $tax_rate->find_or_insert; + return "error inserting tax_rate record for '$taxname': $error\n" + if $error; + + # create a tax_rate_location record + my $tax_rate_location = FS::tax_rate_location->new({ + data_vendor => 'avalara', + geocode => $TaxDetail->{JurisCode}, + state => $TaxDetail->{Region}, + city => ($TaxDetail->{JurisType} eq 'City' ? + $TaxDetail->{JurisName} : ''), + county => ($TaxDetail->{JurisType} eq 'County' ? + $TaxDetail->{JurisName} : ''), + # country? + }); + $error = $tax_rate_location->find_or_insert; + return "error inserting tax_rate_location record for ". + $TaxDetail->{JurisCode} .": $error\n" + if $error; + + # create a link record + my $tax_link = FS::cust_bill_pkg_tax_rate_location->new({ + cust_bill_pkg => $tax_item, + taxtype => 'FS::tax_rate', + taxnum => $tax_rate->taxnum, + taxratelocationnum => $tax_rate_location->taxratelocationnum, + amount => $TaxDetail->{Tax}, + taxable_billpkgnum => $taxable_billpkgnum, + }); + + # append the tax link and increment the amount + push @{ $tax_item->get('cust_bill_pkg_tax_rate_location') }, $tax_link; + $tax_item->set('setup', $tax_item->get('setup') + $TaxDetail->{Tax}); + } # foreach $TaxDetail + } # foreach $TaxLine + + return [ values(%tax_item_named) ]; +} + +sub add_taxproduct { + my $class = shift; + my $desc = shift; # tax code and description, separated by a space. + if ($desc =~ s/^(\w+) //) { + my $part_pkg_taxproduct = FS::part_pkg_taxproduct->new({ + 'data_vendor' => 'avalara', + 'taxproduct' => $1, + 'description' => $desc, + }); + # $obj_or_error + return $part_pkg_taxproduct->insert || $part_pkg_taxproduct; + } else { + return "illegal avalara tax code '$desc'"; + } +} + +1; diff --git a/FS/FS/TaxEngine/billsoft.pm b/FS/FS/TaxEngine/billsoft.pm new file mode 100644 index 0000000..d262aa4 --- /dev/null +++ b/FS/FS/TaxEngine/billsoft.pm @@ -0,0 +1,627 @@ +package FS::TaxEngine::billsoft; + +use strict; +use vars qw( $DEBUG $TIMEOUT %TAX_CLASSES ); +use base 'FS::TaxEngine'; +use FS::Conf; +use FS::Record qw(qsearch qsearchs dbh); +use FS::part_pkg; +use FS::cdr; +use FS::upload_target; +use Date::Format qw( time2str ); +use File::chdir; +use File::Copy qw(move); +use Parse::FixedLength; + +$DEBUG = 1; + +$TIMEOUT = 86400; # absolute time limit on waiting for a response file. + +FS::UID->install_callback(\&load_tax_classes); + +sub info { + { batch => 1, + override => 0, + manual_tax_location => 1, + }, +} + +sub add_sale { } #do nothing + +sub spooldir { + $FS::UID::cache_dir . "/Billsoft"; +} + +sub spoolname { + my $self = shift; + my $conf = FS::Conf->new;; + my $spooldir = $self->spooldir; + mkdir $spooldir, 0700 unless -d $spooldir; + my $basename = $conf->config('billsoft-company_code') . + time2str('%Y%m%d', time); # use the real clock time here + my $uniq = 'AA'; + while ( -e "$spooldir/$basename$uniq.CDF" ) { + $uniq++; + # these two letters must be unique within each day + } + "$basename$uniq.CDF"; +} + +my $format = + '%10s' . # Origination + '%1s' . # Origination Flag (NPA-NXX) + '%10s' . # Termination + '%1s' . # Termination Flag (NPA-NXX) + '%10s' . # Service Location + '%1s' . # Service Location Flag (Pcode) + '%1s' . # Customer Type ('B'usiness or 'R'esidential) + '%8s' . # Invoice Date + '+' . # Taxable Amount Sign + '%011d' . # Taxable Amount (5 decimal places) + '%6d' . # Lines + '%6d' . # Locations + '%12s' . # Transaction Type + Service Type + '%1s' . # Client Resale Flag ('S'ale or 'R'esale) + '%1s' . # Inc-Code ('I'n an incorporated city, or 'O'utside) + ' ' . # Fed/State/County/Local Exempt + '%1s' . # Primary Output Key, flag (our field) + '%019d' . # Primary Output Key, numeric (our field) + 'R' . # 'R'egulated (or 'U'nregulated) + '%011d' . # Call Duration (tenths of minutes) + 'C' . # Telecom Type ('C'alls, other things) + '%1s' . # Service Class ('L'ocal, Long 'D'istance) + ' NNC' . # non-lifeline, non-facilities based, + # non-franchise, CLEC + # (gross assumptions, may need a config option + "\r\n"; # at least that's what was in the samples + + +sub create_batch { + my ($self, %opt) = @_; + + $DB::single=1; # XXX + + my $spooldir = $self->spooldir; + my $spoolname = $self->spoolname; + my $fh = IO::File->new(); + $fh->open("$spooldir/$spoolname", '>>'); + $self->{fh} = $fh; + + # XXX limit based on freeside-daily custnum/agentnum options + # and maybe invoice date + my @invoices = qsearch('cust_bill', { pending => 'Y' }); + warn scalar(@invoices)." pending invoice(s) found.\n"; + foreach my $cust_bill (@invoices) { + + my $invnum = $cust_bill->invnum; + my $cust_main = $cust_bill->cust_main; + my $cust_type = $cust_main->company ? 'B' : 'R'; + my $invoice_date = time2str('%Y%m%d', $cust_bill->_date); + + # cache some things + my (%cust_pkg, %part_pkg, %cust_location, %classname); + # keys are transaction codes (the first part of the taxproduct string) + # and then locationnums; for per-location taxes + my %sales; + + foreach my $cust_bill_pkg ( $cust_bill->cust_bill_pkg ) { + my $cust_pkg = $cust_pkg{$cust_bill_pkg->pkgnum} + ||= $cust_bill_pkg->cust_pkg; + my $pkgpart = $cust_bill_pkg->pkgpart_override || $cust_pkg->pkgpart; + my $part_pkg = $part_pkg{$pkgpart} ||= FS::part_pkg->by_key($pkgpart); + my $resale_mode = ($part_pkg->option('wholesale',1) ? 'R' : 'S'); + my $locationnum = $cust_pkg->locationnum; + my $location = $cust_location{$locationnum} ||= $cust_pkg->cust_location; + my %taxproduct; # CDR rated_classnum => taxproduct + + my $usage_total = 0; + # go back to the original call details + my $detailnums = FS::Record->scalar_sql( + "SELECT array_to_string(array_agg(detailnum), ',') ". + "FROM cust_bill_pkg_detail WHERE billpkgnum = ". + $cust_bill_pkg->billpkgnum + ); + + # With summary details, even the number of CDRs returned from a single + # invoice detail could be scary large. Avoid running out of memory. + if (length $detailnums > 0) { + my $cdr_search = FS::Cursor->new({ + 'table' => 'cdr', + 'hashref' => { freesidestatus => 'done' }, + 'extra_sql' => "AND detailnum IN($detailnums)", + }); + + while (my $cdr = $cdr_search->fetch) { + my $classnum = $cdr->rated_classnum; + $classname{$classnum} ||= FS::usage_class->by_key($classnum)->classname + if $classnum; + $taxproduct{$classnum} ||= $part_pkg->taxproduct($classnum); + if (!$taxproduct{$classnum}) { + warn "part_pkg $pkgpart, class $classnum: ". + ($taxproduct{$classnum} ? + "using taxproduct ".$taxproduct{$classnum}->description."\n" : + "taxproduct not found\n") + if $DEBUG; + next; + } + + my $line = sprintf($format, + substr($cdr->src, 0, 6), 'N', + substr($cdr->dst, 0, 6), 'N', + $location->geocode, 'P', + $cust_type, + $invoice_date, + 100000 * $cdr->rated_price, # price (5 decimal places) + 0, # lines + 0, # locations + $taxproduct{$classnum}->taxproduct, + $resale_mode, + ($location->incorporated ? 'I' : 'O'), + 'C', # for Call + $cdr->acctid, + # Call duration (tenths of minutes) + $cdr->duration / 6, + # Service class indicator ('L'ocal, Long 'D'istance) + # stupid hack + (lc($classname{$classnum}) eq 'local' ? 'L' : 'D'), + ); + + print $fh $line; + + $usage_total += $cdr->rated_price; + + } # while $cdr = $cdr_search->fetch + } # if @$detailnums; otherwise there are no usage details for this line + + my $recur_tcode; + # now write lines for the non-CDR portion of the charges + foreach (qw(setup recur)) { + my $taxproduct = $part_pkg->taxproduct($_); + warn "part_pkg $pkgpart, class $_: ". + ($taxproduct ? + "using taxproduct ".$taxproduct->description."\n" : + "taxproduct not found\n") + if $DEBUG; + next unless $taxproduct; + + my ($tcode) = $taxproduct->taxproduct =~ /^(\d{6})/; + $sales{$tcode} ||= {}; + $sales{$tcode}{$location->locationnum} ||= 0; + $recur_tcode = $tcode if $_ eq 'recur'; + + my $price = $cust_bill_pkg->get($_); + $sales{$tcode}{$location->locationnum} += $price; + + $price -= $usage_total if $_ eq 'recur'; + + my $line = sprintf($format, + $location->geocode, 'P', # all 3 locations the same + $location->geocode, 'P', + $location->geocode, 'P', + $cust_type, + $invoice_date, + 100000 * $price, # price (5 decimal places) + 0, # lines + 0, # locations + $taxproduct->taxproduct, + $resale_mode, + ($location->incorporated ? 'I' : 'O'), + substr(uc($_), 0, 1), # 'S'etup or 'R'ecur + $cust_bill_pkg->billpkgnum, + 0, # call duration + 'D' # service class indicator + ); + + print $fh $line; + + } # foreach (setup, recur) + + # S-code 23: taxes based on number of lines (E911, mostly) + # voip_cdr and voip_inbound packages know how to report this. Not all + # T-codes are eligible for this; only report it if the /23 taxproduct + # exists. + # + # (note: the nomenclature of "service" and "transaction" codes is + # backward from the way most people would use the terms. you'd think + # that in "cellular activation", "cellular" would be the service and + # "activation" would be the transaction, but for Billsoft it's the + # reverse. I recommend calling them "S" and "T" codes internally just + # to avoid confusion.) + + my $lines_taxproduct = qsearchs('part_pkg_taxproduct', { + 'taxproduct' => sprintf('%06d%06d', $recur_tcode, 21) + }); + my $lines = $cust_bill_pkg->units; + + if ( $lines_taxproduct and $lines ) { + + my $line = sprintf($format, + $location->geocode, 'P', # all 3 locations the same + $location->geocode, 'P', + $location->geocode, 'P', + $cust_type, + $invoice_date, + 0, # price (5 decimal places) + $lines, # lines + 0, # locations + $lines_taxproduct->taxproduct, + $resale_mode, + ($location->incorporated ? 'I' : 'O'), + 'L', # 'L'ines + $cust_bill_pkg->billpkgnum, + 0, # call duration + 'D' # service class indicator + ); + + } + + } # foreach my $cust_bill_pkg + + # Implicit transactions + foreach my $tcode (keys %sales) { + + # S-code 23: number of locations (rare) + my $locations_taxproduct = + qsearchs('part_pkg_taxproduct', { + 'taxproduct' => sprintf('%06d%06d', $tcode, 23) + }); + + if ( $locations_taxproduct and keys %{ $sales{$tcode} } > 0 ) { + my $location = $cust_main->bill_location; + my $line = sprintf($format, + $location->geocode, 'P', # all 3 locations the same + $location->geocode, 'P', + $location->geocode, 'P', + $cust_type, + $invoice_date, + 0, # price (5 decimal places) + 0, # lines + keys(%{ $sales{$tcode} }),# locations + $locations_taxproduct->taxproduct, + 'S', + ($location->incorporated ? 'I' : 'O'), + 'O', # l'O'cations + sprintf('%07d%06d%06d', $invnum, $tcode, 0), + 0, # call duration + 'D' # service class indicator + ); + + print $fh $line; + } + + # S-code 43: per-invoice tax (apparently this is a thing) + my $invoice_taxproduct = + qsearchs('part_pkg_taxproduct', { + 'taxproduct' => sprintf('%06d%06d', $tcode, 43) + }); + if ( $invoice_taxproduct ) { + my $location = $cust_main->bill_location; + my $line = sprintf($format, + $location->geocode, 'P', # all 3 locations the same + $location->geocode, 'P', + $location->geocode, 'P', + $cust_type, + $invoice_date, + 0, # price (5 decimal places) + 0, # lines + 0, # locations + $invoice_taxproduct->taxproduct, + 'S', # resale mode + ($location->incorporated ? 'I' : 'O'), + 'I', # 'I'nvoice tax + sprintf('%07d%06d%06d', $invnum, $tcode, 0), + 0, # call duration + 'D' # service class indicator + ); + + print $fh $line; + } + } # foreach $tcode + } # foreach $cust_bill + + $fh->close; + return $spoolname; +} + +sub cust_tax_locations { + my $class = shift; + my $location = shift; + if (ref $location eq 'HASH') { + $location = FS::cust_location->new($location); + } + my $zip = $location->zip; + return () unless $location->country eq 'US'; + # currently the only one supported + if ( $zip =~ /^(\d{5})(-\d{4})?$/ ) { + $zip = $1; + } else { + die "bad zip code $zip"; + } + return qsearch({ + table => 'cust_tax_location', + hashref => { 'data_vendor' => 'billsoft' }, + extra_sql => " AND ziplo <= '$zip' and ziphi >= '$zip'", + order_by => ' ORDER BY default_location', + }); +} + +sub transfer_batch { + my ($self, %opt) = @_; + + my $oldAutoCommit = $FS::UID::AutoCommit; + local $FS::UID::AutoCommit = 0; + my $dbh = dbh; + + eval "use Net::FTP;"; + # set up directories if they're not already + mkdir $self->spooldir unless -d $self->spooldir; + local $CWD = $self->spooldir; + foreach (qw(upload download)) { + mkdir $_ unless -d $_; + } + my $target = qsearchs('upload_target', { hostname => 'ftp.billsoft.com' }) + or die "No Billsoft upload target defined.\n"; + + # create the batch + my $upload = $self->create_batch(%opt); + + # upload it + my $ftp = $target->connect; + if (!ref $ftp) { # it's an error message + die "Error connecting to Billsoft FTP server:\n$ftp\n"; + } + my $fh = IO::File->new(); + warn "Processing: $upload\n"; + my $error = system("zip -j -o FTP.ZIP $upload"); + die "Failed to compress tax batch\n$!\n" if $error; + warn "Uploading file...\n"; + $ftp->put('FTP.ZIP'); + + my $download = $upload; + # naming convention for these is: same as the CDF contained in the + # zip file, but with an "R" inserted after the company ID prefix + $download =~ s/^(...)(\d{8}..).CDF/$1R$2.ZIP/; + warn "Waiting for output file ($download)...\n"; + my $starttime = time; + my $downloaded = 0; + while ( time - $starttime < $TIMEOUT ) { + my @ls = $ftp->ls($download); + if ( @ls ) { + if ($ftp->get($download, "download/$download")) { + warn "Downloaded '$download'.\n"; + $downloaded = 1; + last; + } else { + warn "Failed to download '$download': ".$ftp->message."\n"; + # We know the file exists, so continue trying to download it. + # Maybe the problem will get fixed. + } + } + sleep 30; + } + if (!$downloaded) { + warn "No output file received.\n"; + next BATCH; + } + warn "Decompressing...\n"; + system("unzip -o download/$download"); + foreach my $csf (glob "*.CSF") { + warn "Processing '$csf'...\n"; + $fh->open($csf, '<') or die "failed to open downloaded file $csf"; + $self->batch_import($fh); # dies on error + $fh->close; + unlink $csf unless $DEBUG; + } + unlink 'FTP.ZIP'; + move($upload, "upload/$upload"); + warn "Finished.\n"; + $dbh->commit if $oldAutoCommit; + return; +} + +sub batch_import { + $DB::single = 1; # XXX + # the hard part + my ($self, $fh) = @_; + $self->{'custnums'} = {}; + $self->{'cust_bill'} = {}; + + # gather up pending invoices + foreach my $cust_bill (qsearch('cust_bill', { pending => 'Y' })) { + $self->{'cust_bill'}{ $cust_bill->invnum } = $cust_bill; + } + + my $href; + my $parser = Parse::FixedLength->new( + [ + # key => 20, # for our purposes we split it up + flag => 1, + pkey => 19, + taxtype => 6, + authority => 1, + sign => 1, + amount => 11, + pcode => 9, + ], + ); + + # start parsing the input file + my $errors = 0; + my $row = 1; + foreach my $line (<$fh>) { + warn $line if $DEBUG > 1; + %$href = (); + $href = $parser->parse($line); + # convert some of these to integers + $href->{$_} += 0 foreach(qw(pkey taxtype amount pcode)); + next if $href->{amount} == 0; # then nobody cares + + my $flag = $href->{flag}; + my $pkey = $href->{pkey}; + my $cust_bill_pkg; # the line item that this tax applies to + # resolve the taxable object + if ( $flag eq 'C' ) { + # this line represents a CDR. + my $cdr = FS::cdr->by_key($pkey); + if (!$cdr) { + warn "[$row]\tCDR #$pkey not found.\n"; + } elsif (!$cdr->detailnum) { + warn "[$row]\tCDR #$pkey has not been billed.\n"; + $errors++; + next; + } else { + my $detail = FS::cust_bill_pkg_detail->by_key($cdr->detailnum); + $cust_bill_pkg = $detail->cust_bill_pkg; + } + } elsif ( $flag =~ /S|R|L/ ) { + # this line represents a setup or recur fee, or a number of lines. + $cust_bill_pkg = FS::cust_bill_pkg->by_key($pkey); + if (!$cust_bill_pkg) { + warn "[$row]\tLine item #$pkey not found.\n"; + } + } elsif ( $flag =~ /O|I/ ) { + warn "Per-invoice taxes are not implemented.\n"; + } else { + warn "[$row]\tFlag '$flag' not recognized.\n"; + } + if (!$cust_bill_pkg) { + $errors++; # this will trigger a rollback of the transaction + next; + } + # resolve the tax definition + # base name of the tax type (like "Sales Tax" or "Universal Lifeline + # Telephone Service Charge"). + my $tax_class = $TAX_CLASSES{ $href->{taxtype} + 0 }; + if (!$tax_class) { + warn "[$row]\tUnknown tax type $href->{taxtype}.\n"; + $errors++; + next; + } + my $itemdesc = uc($tax_class->description); + my $location = qsearchs('tax_rate_location', + { geocode => $href->{pcode} } + ); + if (!$location) { + warn "Unknown tax authority location ".$href->{pcode}."\n"; + $errors++; + next; + } + # jurisdiction name + my $prefix = ''; + if ( $href->{authority} == 0 ) { # national-level tax + # do nothing + } elsif ( $href->{authority} == 1 ) { + $prefix = $location->state; + } elsif ( $href->{authority} == 2 ) { + $prefix = $location->county . ' COUNTY'; + } elsif ( $href->{authority} == 3 ) { + $prefix = $location->city; + } elsif ( $href->{authority} == 4 ) { # unincorporated area ta + # do nothing + } + # Some itemdescs start with the jurisdiction name; otherwise, prepend + # it. + if ( $itemdesc !~ /^(city of )?$prefix\b/i ) { + $itemdesc = "$prefix $itemdesc"; + } + # Create or locate a tax_rate record, because we need one to foreign-key + # the cust_bill_pkg_tax_rate_location record. + my $tax_rate = $self->find_or_insert_tax_rate( + geocode => $href->{pcode}, + taxclassnum => $tax_class->taxclassnum, + taxname => $itemdesc, + ); + # Convert amount from 10^-5 dollars to dollars/cents + my $amount = sprintf('%.2f', $href->{amount} / 100000); + # and add it to the tax under this name + my $tax_item = $self->add_tax_item( + invnum => $cust_bill_pkg->invnum, + itemdesc => $itemdesc, + amount => $amount, + ); + # and link that tax line item to the taxed sale + my $subitem = FS::cust_bill_pkg_tax_rate_location->new({ + billpkgnum => $tax_item->billpkgnum, + taxnum => $tax_rate->taxnum, + taxtype => 'FS::tax_rate', + taxratelocationnum => $location->taxratelocationnum, + amount => $amount, + taxable_billpkgnum => $cust_bill_pkg->billpkgnum, + }); + my $error = $subitem->insert; + die "Error linking tax to taxable item: $error\n" if $error; + + $row++; + } #foreach $line + if ( $errors > 0 ) { + die "Encountered $errors error(s); rolling back tax import.\n"; + } + + # remove pending flag from invoices and schedule collect jobs + foreach my $cust_bill (values %{ $self->{'cust_bill'} }) { + my $invnum = $cust_bill->invnum; + $cust_bill->set('pending' => ''); + my $error = $cust_bill->replace; + die "Error updating invoice #$invnum: $error\n" + if $error; + $self->{'custnums'}->{ $cust_bill->custnum } = 1; + } + + foreach my $custnum ( keys %{ $self->{'custnums'} } ) { + my $queue = FS::queue->new({ 'job' => 'FS::cust_main::queued_collect' }); + my $error = $queue->insert('custnum' => $custnum); + die "Error scheduling collection for customer #$custnum: $error\n" + if $error; + } + + ''; +} + + +sub find_or_insert_tax_rate { + my ($self, %hash) = @_; + $hash{'tax'} = 0; + $hash{'data_vendor'} = 'billsoft'; + my $tax_rate = qsearchs('tax_rate', \%hash); + if (!$tax_rate) { + $tax_rate = FS::tax_rate->new(\%hash); + my $error = $tax_rate->insert; + die "Error inserting tax definition: $error\n" if $error; + } + return $tax_rate; +} + + +sub add_tax_item { + my ($self, %hash) = @_; + $hash{'pkgnum'} = 0; + my $amount = delete $hash{'amount'}; + + my $tax_item = qsearchs('cust_bill_pkg', \%hash); + if (!$tax_item) { + $tax_item = FS::cust_bill_pkg->new(\%hash); + $tax_item->set('setup', $amount); + my $error = $tax_item->insert; + die "Error inserting tax: $error\n" if $error; + } else { + $tax_item->set('setup', $tax_item->get('setup') + $amount); + my $error = $tax_item->replace; + die "Error incrementing tax: $error\n" if $error; + } + + my $cust_bill = $self->{'cust_bill'}->{$tax_item->invnum} + or die "Invoice #".$tax_item->{invnum}." is not pending.\n"; + $cust_bill->set('charged' => + sprintf('%.2f', $cust_bill->get('charged') + $amount)); + # don't replace the record yet, we'll do that at the end + + $tax_item; +} + +sub load_tax_classes { + %TAX_CLASSES = map { $_->taxclass => $_ } + qsearch('tax_class', { data_vendor => 'billsoft' }); +} + + +1; diff --git a/FS/FS/TaxEngine/cch.pm b/FS/FS/TaxEngine/cch.pm new file mode 100644 index 0000000..6bad69e --- /dev/null +++ b/FS/FS/TaxEngine/cch.pm @@ -0,0 +1,202 @@ +package FS::TaxEngine::cch; + +use strict; +use vars qw( $DEBUG ); +use base 'FS::TaxEngine'; +use FS::Record qw(dbh qsearch qsearchs); +use FS::Conf; + +=head1 SUMMARY + +FS::TaxEngine::cch CCH published tax tables. Uses multiple tables: +- tax_rate: definition of specific taxes, based on tax class and geocode. +- cust_tax_location: definition of geocodes, using zip+4 codes. +- tax_class: definition of tax classes. +- part_pkg_taxproduct: definition of taxable products (foreign key in + part_pkg.taxproductnum and the "usage_taxproductnum_*" part_pkg options). + The 'taxproduct' string in this table can implicitly include other + taxproducts. +- part_pkg_taxrate: links (geocode, taxproductnum) of a sold product to a + tax class. Many records here have partial-length geocodes which act + as wildcards. +- part_pkg_taxoverride: manual link from a part_pkg to a specific tax class. + +=cut + +$DEBUG = 0; + +my %part_pkg_cache; + +sub add_sale { + my ($self, $cust_bill_pkg, %options) = @_; + + my $part_item = $options{part_item} || $cust_bill_pkg->part_X; + my $location = $options{location} || $cust_bill_pkg->tax_location; + + push @{ $self->{items} }, $cust_bill_pkg; + + my $conf = FS::Conf->new; + + my @classes; + push @classes, $cust_bill_pkg->usage_classes if $cust_bill_pkg->usage; + # debatable + push @classes, 'setup' if ($cust_bill_pkg->setup && !$self->{cancel}); + push @classes, 'recur' if ($cust_bill_pkg->recur && !$self->{cancel}); + + my %taxes_for_class; + + my $exempt = $conf->exists('cust_class-tax_exempt') + ? ( $self->cust_class ? $self->cust_class->tax : '' ) + : $self->{cust_main}->tax; + # standardize this just to be sure + $exempt = ($exempt eq 'Y') ? 'Y' : ''; + + if ( !$exempt ) { + + foreach my $class (@classes) { + my $err_or_ref = $self->_gather_taxes( $part_item, $class, $location ); + return $err_or_ref unless ref($err_or_ref); + $taxes_for_class{$class} = $err_or_ref; + } + unless (exists $taxes_for_class{''}) { + my $err_or_ref = $self->_gather_taxes( $part_item, '', $location ); + return $err_or_ref unless ref($err_or_ref); + $taxes_for_class{''} = $err_or_ref; + } + + } + + my %tax_cust_bill_pkg = $cust_bill_pkg->disintegrate; # grrr + foreach my $key (keys %tax_cust_bill_pkg) { + # $key is "setup", "recur", or a usage class name. ('' is a usage class.) + # $tax_cust_bill_pkg{$key} is a cust_bill_pkg for that component of + # the line item. + # $taxes_for_class{$key} is an arrayref of tax_rate objects that + # apply to $key-class charges. + my @taxes = @{ $taxes_for_class{$key} || [] }; + my $tax_cust_bill_pkg = $tax_cust_bill_pkg{$key}; + + my %localtaxlisthash = (); + foreach my $tax ( @taxes ) { + + my $taxnum = $tax->taxnum; + $self->{taxes}{$taxnum} ||= [ $tax ]; + push @{ $self->{taxes}{$taxnum} }, $tax_cust_bill_pkg; + + $localtaxlisthash{ $taxnum } ||= [ $tax ]; + push @{ $localtaxlisthash{$taxnum} }, $tax_cust_bill_pkg; + + } + + warn "finding taxed taxes...\n" if $DEBUG > 2; + foreach my $taxnum ( keys %localtaxlisthash ) { + my $tax_object = shift @{ $localtaxlisthash{$taxnum} }; + + foreach my $tot ( $tax_object->tax_on_tax( $location ) ) { + my $totnum = $tot->taxnum; + + # I'm not sure why, but for some reason we only add ToT if that + # tax_rate already applies to a non-tax item on the same invoice. + next unless exists( $localtaxlisthash{ $totnum } ); + warn "adding #$totnum to taxed taxes\n" if $DEBUG > 2; + # calculate the tax amount that the tax_on_tax will apply to + my $taxline = + $self->taxline( 'tax' => $tax_object, + 'sales' => $localtaxlisthash{$taxnum} + ); + return $taxline unless ref $taxline; + # and append it to the list of taxable items + $self->{taxes}->{$totnum} ||= [ $tot ]; + push @{ $self->{taxes}->{$totnum} }, $taxline->setup; + + } # foreach $tot (tax-on-tax) + } # foreach $tax + } # foreach $key (i.e. usage class) +} + +sub _gather_taxes { # interface for this sucks + my $self = shift; + my $part_item = shift; + my $class = shift; + my $location = shift; + + my $geocode = $location->geocode('cch'); + + my @taxes = $part_item->tax_rates('cch', $geocode, $class); + + warn "Found taxes ". + join(',', map{ ref($_). " ". $_->get($_->primary_key) } @taxes). "\n" + if $DEBUG; + + \@taxes; + +} + +sub taxline { + # FS::tax_rate::taxline() ridiculously returns a description and amount + # instead of a real line item. Fix that here. + # + # XXX eventually move the code from tax_rate to here + # but that's not necessary yet + my ($self, %opt) = @_; + my $tax_object = $opt{tax}; + my $taxables = $opt{sales}; + my $hashref = $tax_object->taxline_cch($taxables); + return $hashref unless ref $hashref; # it's an error message + + my $tax_amount = sprintf('%.2f', $hashref->{amount}); + my $tax_item = FS::cust_bill_pkg->new({ + 'itemdesc' => $hashref->{name}, + 'pkgnum' => 0, + 'recur' => 0, + 'sdate' => '', + 'edate' => '', + 'setup' => $tax_amount, + }); + my $tax_link = FS::cust_bill_pkg_tax_rate_location->new({ + 'taxnum' => $tax_object->taxnum, + 'taxtype' => ref($tax_object), #redundant + 'amount' => $tax_amount, + 'locationtaxid' => $tax_object->location, + 'taxratelocationnum' => + $tax_object->tax_rate_location->taxratelocationnum, + 'tax_cust_bill_pkg' => $tax_item, + # XXX still need to get taxable_cust_bill_pkg in here + # but that requires messing around in the taxline code + }); + $tax_item->set('cust_bill_pkg_tax_rate_location', [ $tax_link ]); + + return $tax_item; +} + +sub cust_tax_locations { + my $class = shift; + my $location = shift; + $location = FS::cust_location->new($location) if ref($location) eq 'HASH'; + + # limit to CCH zip code prefix records, not zip+4 range records + my $hashref = { 'data_vendor' => 'cch-zip' }; + if ( $location->country eq 'CA' ) { + # weird CCH convention: treat Canadian provinces as localities, using + # their one-letter postal codes. + $hashref->{zip} = substr($location->zip, 0, 1); + } elsif ( $location->country eq 'US' ) { + $hashref->{zip} = substr($location->zip, 0, 5); + } else { + return (); + } + + return qsearch('cust_tax_location', $hashref); +} + +sub info { + +{ + batch => 0, + override => 1, + manual_tax_location => 1, + rate_table => 'tax_rate', + link_table => 'cust_bill_pkg_tax_rate_location', + } +} + +1; diff --git a/FS/FS/TaxEngine/internal.pm b/FS/FS/TaxEngine/internal.pm new file mode 100644 index 0000000..60f7aad --- /dev/null +++ b/FS/FS/TaxEngine/internal.pm @@ -0,0 +1,318 @@ +package FS::TaxEngine::internal; + +use strict; +use base 'FS::TaxEngine'; +use FS::Record qw(dbh qsearch qsearchs); + +=head1 SUMMARY + +FS::TaxEngine::internal: the classic Freeside "internal tax engine". +Uses tax rates and localities defined in L<FS::cust_main_county>. + +=cut + +my %part_pkg_cache; + +sub add_sale { + my ($self, $cust_bill_pkg) = @_; + my $cust_pkg = $cust_bill_pkg->cust_pkg; + my $pkgpart = $cust_bill_pkg->pkgpart_override || $cust_pkg->pkgpart; + my $part_pkg = $part_pkg_cache{$pkgpart} ||= FS::part_pkg->by_key($pkgpart) + or die "pkgpart $pkgpart not found"; + push @{ $self->{items} }, $cust_bill_pkg; + + my $location = $cust_pkg->tax_location; # cacheable? + + my @loc_keys = qw( district city county state country ); + my %taxhash = map { $_ => $location->get($_) } @loc_keys; + + $taxhash{'taxclass'} = $part_pkg->taxclass; + + my @taxes = (); # entries are cust_main_county objects + my %taxhash_elim = %taxhash; + my @elim = qw( district city county state ); + do { + + #first try a match with taxclass + @taxes = qsearch( 'cust_main_county', \%taxhash_elim ); + + if ( !scalar(@taxes) && $taxhash_elim{'taxclass'} ) { + #then try a match without taxclass + my %no_taxclass = %taxhash_elim; + $no_taxclass{ 'taxclass' } = ''; + @taxes = qsearch( 'cust_main_county', \%no_taxclass ); + } + + $taxhash_elim{ shift(@elim) } = ''; + } while ( !scalar(@taxes) && scalar(@elim) ); + + foreach (@taxes) { + my $taxnum = $_->taxnum; + $self->{taxes}->{$taxnum} ||= [ $_ ]; + push @{ $self->{taxes}->{$taxnum} }, $cust_bill_pkg; + } +} + +sub taxline { + my ($self, %opt) = @_; + my $tax_object = $opt{tax}; + my $taxables = $opt{sales}; + my $taxnum = $tax_object->taxnum; + my $exemptions = $self->{exemptions}->{$taxnum} ||= []; + + my $name = $tax_object->taxname || 'Tax'; + my $taxable_cents = 0; + my $tax_cents = 0; + + my $cust_main = $self->{cust_main}; + my $custnum = $cust_main->custnum; + my $invoice_time = $self->{invoice_time}; + + # set a flag if the customer is tax-exempt + my $exempt_cust; + my $conf = FS::Conf->new; + if ( $conf->exists('cust_class-tax_exempt') ) { + my $cust_class = $cust_main->cust_class; + $exempt_cust = $cust_class->tax if $cust_class; + } else { + $exempt_cust = $cust_main->tax; + } + # set a flag if the customer is exempt from this tax here + my $exempt_cust_taxname = $cust_main->tax_exemption($tax_object->taxname) + if $tax_object->taxname; + + # Gather any exemptions that are already attached to these cust_bill_pkgs + # so that we can deduct them from the customer's monthly limit. + my @existing_exemptions = @{ $exemptions }; + push @existing_exemptions, @{ $_->cust_tax_exempt_pkg } + foreach @$taxables; + + my $tax_item = FS::cust_bill_pkg->new({ + 'pkgnum' => 0, + 'recur' => 0, + 'sdate' => '', + 'edate' => '', + 'itemdesc' => $name, + }); + my @tax_location; + + foreach my $cust_bill_pkg (@$taxables) { + + my $cust_pkg = $cust_bill_pkg->cust_pkg; + my $part_pkg = $cust_bill_pkg->part_pkg; + my @new_exemptions; + my $taxable_charged = $cust_bill_pkg->setup + $cust_bill_pkg->recur + or next; # don't create zero-amount exemptions + + # XXX the following procedure should probably be in cust_bill_pkg + + if ( $exempt_cust ) { + + push @new_exemptions, FS::cust_tax_exempt_pkg->new({ + amount => $taxable_charged, + exempt_cust => 'Y', + }); + $taxable_charged = 0; + + } elsif ( $exempt_cust_taxname ) { + + push @new_exemptions, FS::cust_tax_exempt_pkg->new({ + amount => $taxable_charged, + exempt_cust_taxname => 'Y', + }); + $taxable_charged = 0; + + } + + if ( ($part_pkg->setuptax eq 'Y' or $tax_object->setuptax eq 'Y') + and $cust_bill_pkg->setup > 0 and $taxable_charged > 0 ) { + + push @new_exemptions, FS::cust_tax_exempt_pkg->new({ + amount => $cust_bill_pkg->setup, + exempt_setup => 'Y' + }); + $taxable_charged -= $cust_bill_pkg->setup; + + } + if ( ($part_pkg->recurtax eq 'Y' or $tax_object->recurtax eq 'Y') + and $cust_bill_pkg->recur > 0 and $taxable_charged > 0 ) { + + push @new_exemptions, FS::cust_tax_exempt_pkg->new({ + amount => $cust_bill_pkg->recur, + exempt_recur => 'Y' + }); + $taxable_charged -= $cust_bill_pkg->recur; + + } + + if ( $tax_object->exempt_amount && $tax_object->exempt_amount > 0 + and $taxable_charged > 0 ) { + # If the billing period extends across multiple calendar months, + # there may be several months of exemption available. + my $sdate = $cust_bill_pkg->sdate || $invoice_time; + my $start_month = (localtime($sdate))[4] + 1; + my $start_year = (localtime($sdate))[5] + 1900; + my $edate = $cust_bill_pkg->edate || $invoice_time; + my $end_month = (localtime($edate))[4] + 1; + my $end_year = (localtime($edate))[5] + 1900; + + # If the partial last month + partial first month <= one month, + # don't use the exemption in the last month + # (unless the last month is also the first month, e.g. one-time + # charges) + if ( (localtime($sdate))[3] >= (localtime($edate))[3] + and ($start_month != $end_month or $start_year != $end_year) + ) { + $end_month--; + if ( $end_month == 0 ) { + $end_year--; + $end_month = 12; + } + } + + # number of months of exemption available + my $freq = ($end_month - $start_month) + + ($end_year - $start_year) * 12 + + 1; + + # divide equally among all of them + my $permonth = sprintf('%.2f', $taxable_charged / $freq); + + #call the whole thing off if this customer has any old + #exemption records... + my @cust_tax_exempt = + qsearch( 'cust_tax_exempt' => { custnum=> $custnum } ); + if ( @cust_tax_exempt ) { + return + 'this customer still has old-style tax exemption records; '. + 'run bin/fs-migrate-cust_tax_exempt?'; + } + + my ($mon, $year) = ($start_month, $start_year); + while ($taxable_charged > 0.005 and + ($year < $end_year or + ($year == $end_year and $mon <= $end_month) + ) + ) { + + # find the sum of the exemption used by this customer, for this tax, + # in this month + my $sql = " + SELECT SUM(amount) + FROM cust_tax_exempt_pkg + LEFT JOIN cust_bill_pkg USING ( billpkgnum ) + LEFT JOIN cust_bill USING ( invnum ) + WHERE custnum = ? + AND taxnum = ? + AND year = ? + AND month = ? + AND exempt_monthly = 'Y' + "; + my $sth = dbh->prepare($sql) or + return "fatal: can't lookup existing exemption: ". dbh->errstr; + $sth->execute( + $custnum, + $tax_object->taxnum, + $year, + $mon, + ) or + return "fatal: can't lookup existing exemption: ". dbh->errstr; + my $existing_exemption = $sth->fetchrow_arrayref->[0] || 0; + + # add any exemption we're already using for another line item + foreach ( grep { $_->taxnum == $tax_object->taxnum && + $_->exempt_monthly eq 'Y' && + $_->month == $mon && + $_->year == $year + } @existing_exemptions + ) + { + $existing_exemption += $_->amount; + } + + my $remaining_exemption = + $tax_object->exempt_amount - $existing_exemption; + if ( $remaining_exemption > 0 ) { + my $addl = $remaining_exemption > $permonth + ? $permonth + : $remaining_exemption; + $addl = $taxable_charged if $addl > $taxable_charged; + + push @new_exemptions, FS::cust_tax_exempt_pkg->new({ + amount => sprintf('%.2f', $addl), + exempt_monthly => 'Y', + year => $year, + month => $mon, + }); + $taxable_charged -= $addl; + } + # if they're using multiple months of exemption for a multi-month + # package, then record the exemptions in separate months + $mon++; + if ( $mon > 12 ) { + $mon -= 12; + $year++; + } + + } + } # if exempt_amount + + $_->taxnum($tax_object->taxnum) foreach @new_exemptions; + + # attach them to the line item + push @{ $cust_bill_pkg->cust_tax_exempt_pkg }, @new_exemptions; + push @existing_exemptions, @new_exemptions; + + $taxable_charged = sprintf( "%.2f", $taxable_charged); + next if $taxable_charged == 0; + + my $this_tax_cents = int($taxable_charged * $tax_object->tax); + my $location = FS::cust_bill_pkg_tax_location->new({ + 'taxnum' => $tax_object->taxnum, + 'taxtype' => ref($tax_object), + 'cents' => $this_tax_cents, + 'pkgnum' => $cust_bill_pkg->pkgnum, + 'locationnum' => $cust_bill_pkg->cust_pkg->tax_locationnum, + 'taxable_cust_bill_pkg' => $cust_bill_pkg, + 'tax_cust_bill_pkg' => $tax_item, + }); + push @tax_location, $location; + + $taxable_cents += $taxable_charged; + $tax_cents += $this_tax_cents; + } #foreach $cust_bill_pkg + + # now round and distribute + my $extra_cents = sprintf('%.2f', $taxable_cents * $tax_object->tax / 100) + * 100 - $tax_cents; + # make sure we have an integer + $extra_cents = sprintf('%.0f', $extra_cents); + if ( $extra_cents < 0 ) { + die "nonsense extra_cents value $extra_cents"; + } + $tax_cents += $extra_cents; + my $i = 0; + foreach (@tax_location) { # can never require more than a single pass, yes? + my $cents = $_->get('cents'); + if ( $extra_cents > 0 ) { + $cents++; + $extra_cents--; + } + $_->set('amount', sprintf('%.2f', $cents/100)); + } + $tax_item->set('setup' => sprintf('%.2f', $tax_cents / 100)); + $tax_item->set('cust_bill_pkg_tax_location', \@tax_location); + + return $tax_item; +} + +sub info { + +{ + batch => 0, + override => 0, + rate_table => 'cust_main_county', + link_table => 'cust_bill_pkg_tax_location', + } +} + +1; |