summaryrefslogtreecommitdiff
path: root/FS/FS/TaxEngine
diff options
context:
space:
mode:
authorMark Wells <mark@freeside.biz>2014-10-31 15:45:50 -0700
committerMark Wells <mark@freeside.biz>2014-10-31 15:45:50 -0700
commit7516e3da0f17eeecba27219ef96a8b5f46af2083 (patch)
tree772fe13627910a7d0774871633697f2a4d1c6faf /FS/FS/TaxEngine
parentf31a9212ab3835b815aa87a86cca3b19babcaaff (diff)
tax engine refactoring for Avalara and Billsoft tax vendors, #25718
Diffstat (limited to 'FS/FS/TaxEngine')
-rw-r--r--FS/FS/TaxEngine/avalara.pm302
-rw-r--r--FS/FS/TaxEngine/billsoft.pm627
-rw-r--r--FS/FS/TaxEngine/cch.pm202
-rw-r--r--FS/FS/TaxEngine/internal.pm318
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;