summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--FS/FS/Conf.pm19
-rw-r--r--FS/FS/Cron/tax_batch.pm26
-rw-r--r--FS/FS/Mason.pm2
-rw-r--r--FS/FS/Record.pm5
-rw-r--r--FS/FS/Schema.pm35
-rw-r--r--FS/FS/TaxEngine.pm239
-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
-rw-r--r--FS/FS/UI/Web.pm9
-rw-r--r--FS/FS/Upgrade.pm11
-rw-r--r--FS/FS/bill_batch.pm4
-rw-r--r--FS/FS/cdr.pm13
-rw-r--r--FS/FS/cust_bill.pm8
-rw-r--r--FS/FS/cust_bill_event.pm4
-rw-r--r--FS/FS/cust_bill_pkg_detail.pm37
-rw-r--r--FS/FS/cust_class.pm2
-rw-r--r--FS/FS/cust_event.pm6
-rw-r--r--FS/FS/cust_location.pm6
-rw-r--r--FS/FS/cust_main.pm51
-rw-r--r--FS/FS/cust_main/Billing.pm331
-rw-r--r--FS/FS/cust_main/Import.pm5
-rw-r--r--FS/FS/cust_main_Mixin.pm2
-rw-r--r--FS/FS/cust_pkg.pm4
-rw-r--r--FS/FS/cust_pkg/Import.pm5
-rw-r--r--FS/FS/cust_tax_location.pm140
-rw-r--r--FS/FS/detail_format.pm2
-rw-r--r--FS/FS/detail_format/sum_count.pm4
-rw-r--r--FS/FS/detail_format/sum_duration.pm4
-rw-r--r--FS/FS/detail_format/sum_duration_prefix.pm4
-rw-r--r--FS/FS/part_export/huawei_hlr.pm4
-rw-r--r--FS/FS/part_pkg/voip_cdr.pm3
-rw-r--r--FS/FS/part_pkg_taxclass.pm12
-rw-r--r--FS/FS/part_pkg_taxproduct.pm52
-rw-r--r--FS/FS/part_svc.pm10
-rw-r--r--FS/FS/pay_batch.pm4
-rw-r--r--FS/FS/rate.pm5
-rw-r--r--FS/FS/rate_detail.pm4
-rw-r--r--FS/FS/tax_class.pm31
-rw-r--r--FS/FS/tax_rate.pm261
-rw-r--r--FS/FS/tax_rate_location.pm110
-rw-r--r--FS/FS/tax_status.pm163
-rw-r--r--FS/MANIFEST2
-rwxr-xr-xFS/bin/freeside-daily4
-rw-r--r--FS/bin/freeside-tax-location-update75
-rw-r--r--FS/t/tax_status.t5
-rwxr-xr-xhttemplate/browse/part_pkg_taxproduct/avalara.html84
-rwxr-xr-xhttemplate/browse/part_pkg_taxproduct/billsoft.html146
-rwxr-xr-xhttemplate/browse/part_pkg_taxproduct/cch.html (renamed from httemplate/browse/part_pkg_taxproduct.cgi)43
-rwxr-xr-xhttemplate/edit/cust_main.cgi2
-rw-r--r--httemplate/edit/cust_main/basics.html (renamed from httemplate/edit/cust_main/top_misc.html)7
-rwxr-xr-xhttemplate/edit/process/part_pkg.cgi28
-rw-r--r--httemplate/elements/select-table.html13
-rw-r--r--httemplate/elements/select-taxproduct.html5
-rw-r--r--httemplate/elements/standardize_locations.js18
-rw-r--r--httemplate/elements/tr-select-tax_status.html24
-rw-r--r--httemplate/elements/tr-select-taxoverride.html4
-rw-r--r--httemplate/elements/tr-td-label.html3
-rw-r--r--httemplate/misc/choose_tax_location.html28
-rw-r--r--httemplate/misc/tax-import.cgi68
61 files changed, 3062 insertions, 583 deletions
diff --git a/FS/FS/Conf.pm b/FS/FS/Conf.pm
index 9cac9e1..77267cf 100644
--- a/FS/FS/Conf.pm
+++ b/FS/FS/Conf.pm
@@ -2453,8 +2453,9 @@ and customer address. Include units.',
{
'key' => 'enable_taxproducts',
'section' => 'billing',
- 'description' => 'Enable per-package mapping to vendor tax data from CCH or elsewhere.',
- 'type' => 'checkbox',
+ 'description' => 'Tax data vendor you are using.',
+ 'type' => 'select',
+ 'select_enum' => [ 'cch', 'billsoft', 'avalara' ],
},
{
@@ -2472,6 +2473,20 @@ and customer address. Include units.',
},
{
+ 'key' => 'billsoft-company_code',
+ 'section' => 'billing',
+ 'description' => 'Billsoft tax service company code (3 letters)',
+ 'type' => 'text',
+ },
+
+ {
+ 'key' => 'avalara-taxconfig',
+ 'section' => 'billing',
+ 'description' => 'Avalara tax service configuration. Four lines: company code, account number, license key, test mode (1 to enable).',
+ 'type' => 'textarea',
+ },
+
+ {
'key' => 'welcome_msgnum',
'section' => 'notification',
'description' => 'Template to use for welcome messages when a svc_acct record is created.',
diff --git a/FS/FS/Cron/tax_batch.pm b/FS/FS/Cron/tax_batch.pm
new file mode 100644
index 0000000..59da135
--- /dev/null
+++ b/FS/FS/Cron/tax_batch.pm
@@ -0,0 +1,26 @@
+package FS::Cron::tax_batch;
+
+use FS::TaxEngine;
+use FS::queue;
+use base qw( Exporter );
+@EXPORT_OK = 'process_tax_batch';
+
+sub process_tax_batch {
+ my %opt = @_;
+ my $engine = FS::TaxEngine->new;
+ return unless $engine->info->{batch};
+ if ( $opt{'m'} ) {
+ # then there may be queued_bill jobs running; wait for them to finish
+ while(1) {
+ my $num_jobs =
+ FS::queue->count("job = 'FS::cust_main::queued_bill' AND ".
+ "status != 'failed'");
+ last if $num_jobs == 0;
+ warn "Waiting for billing jobs to finish ($num_jobs still active)...\n";
+ sleep(30);
+ }
+ }
+ $engine->transfer_batch(%opt);
+}
+
+1;
diff --git a/FS/FS/Mason.pm b/FS/FS/Mason.pm
index 93eca5e..900da10 100644
--- a/FS/FS/Mason.pm
+++ b/FS/FS/Mason.pm
@@ -390,6 +390,8 @@ if ( -e $addl_handler_use_file ) {
use FS::deploy_zone;
use FS::deploy_zone_block;
use FS::deploy_zone_vertex;
+ use FS::TaxEngine;
+ use FS::tax_status;
# Sammath Naur
if ( $FS::Mason::addl_handler_use ) {
diff --git a/FS/FS/Record.pm b/FS/FS/Record.pm
index 51cb6dc..a667f4e 100644
--- a/FS/FS/Record.pm
+++ b/FS/FS/Record.pm
@@ -1766,17 +1766,14 @@ files. Currently only supports a single file named "file".
=cut
-use Storable qw(thaw);
use Data::Dumper;
-use MIME::Base64;
sub process_batch_import {
- my($job, $opt) = ( shift, shift );
+ my($job, $opt, $param) = @_;
my $table = $opt->{table};
my @pass_params = $opt->{params} ? @{ $opt->{params} } : ();
my %formats = %{ $opt->{formats} };
- my $param = thaw(decode_base64(shift));
warn Dumper($param) if $DEBUG;
my $files = $param->{'uploaded_files'}
diff --git a/FS/FS/Schema.pm b/FS/FS/Schema.pm
index ae34bba..53e78de 100644
--- a/FS/FS/Schema.pm
+++ b/FS/FS/Schema.pm
@@ -691,6 +691,8 @@ sub tables_hashref {
'statementnum', 'int', 'NULL', '', '', '', #invoice aggregate statements
'agent_invid', 'int', 'NULL', '', '', '', #(varchar?) importing legacy
'promised_date', @date_type, '', '',
+
+ 'pending', 'char', 'NULL', 1, '', '',
],
'primary_key' => 'invnum',
'unique' => [ [ 'custnum', 'agent_invid' ] ], #agentnum? huh
@@ -1026,7 +1028,8 @@ sub tables_hashref {
'cust_bill_pkg_detail' => {
'columns' => [
- 'detailnum', 'serial', '', '', '', '',
+ 'detailnum', 'serial', '', '', '', '',
+ # bigserial? this table will eventually be as big as cdr...
'billpkgnum', 'int', 'NULL', '', '', '', # should not be nullable
'pkgnum', 'int', 'NULL', '', '', '', # deprecated
'invnum', 'int', 'NULL', '', '', '', # deprecated
@@ -1038,7 +1041,7 @@ sub tables_hashref {
'accountcode', 'varchar', 'NULL', 20, '', '',
'startdate', @date_type, '', '',
'regionname', 'varchar', 'NULL', $char_d, '', '',
- 'detail', 'varchar', '', 255, '', '',
+ 'detail', 'varchar', '', 255, '', '',
],
'primary_key' => 'detailnum',
'unique' => [],
@@ -1625,6 +1628,7 @@ sub tables_hashref {
'message_noemail', 'char', 'NULL', 1, '', '',
'bill_locationnum', 'int', 'NULL', '', '', '',
'ship_locationnum', 'int', 'NULL', '', '', '',
+ 'taxstatusnum', 'char', 'NULL', 32, '', '',
],
'primary_key' => 'custnum',
'unique' => [ [ 'agentnum', 'agent_custid' ] ],
@@ -1929,6 +1933,7 @@ sub tables_hashref {
'location_type', 'varchar', 'NULL', 20, '', '',
'location_number', 'varchar', 'NULL', 20, '', '',
'location_kind', 'char', 'NULL', 1, '', '',
+ 'incorporated', 'char', 'NULL', 1, '', '',
'disabled', 'char', 'NULL', 1, '', '',
],
'primary_key' => 'locationnum',
@@ -2158,7 +2163,7 @@ sub tables_hashref {
'geocode', 'varchar', 'NULL', $char_d, '', '',#cch provides 10 char
'data_vendor', 'varchar', 'NULL', $char_d, '', '',#auto update source
'location', 'varchar', 'NULL', $char_d, '', '',#provided by tax authority
- 'taxclassnum', 'int', '', '', '', '',
+ 'taxclassnum', 'int', 'NULL', '', '', '',
'effective_date', @date_type, '', '',
'tax', @taxrate_type, '', '', # tax %
'excessrate', @taxrate_typen, '', '', # second tax %
@@ -2202,6 +2207,7 @@ sub tables_hashref {
'city', 'varchar', 'NULL', $char_d, '', '',
'county', 'varchar', 'NULL', $char_d, '', '',
'state', 'char', 'NULL', 2, '', '',
+ 'country', 'char', 'NULL', 2, '', '',
'disabled', 'char', 'NULL', 1, '', '',
],
'primary_key' => 'taxratelocationnum',
@@ -2216,8 +2222,11 @@ sub tables_hashref {
'city', 'varchar', 'NULL', $char_d, '', '',
'postalcity', 'varchar', 'NULL', $char_d, '', '',
'county', 'varchar', 'NULL', $char_d, '', '',
- 'zip', 'char', '', 5, '', '',
+ 'zip', 'char', 'NULL', 5, '', '',
'state', 'char', '', 2, '', '',
+ 'country', 'char', 'NULL', 2, '', '', # should not actually be NULL, but cch...
+ 'ziphi', 'char', 'NULL', 10, '', '',
+ 'ziplo', 'char', 'NULL', 10, '', '',
'plus4hi', 'char', 'NULL', 4, '', '',
'plus4lo', 'char', 'NULL', 4, '', '',
'default_location','char', 'NULL', 1, '', '', # Y = default for zip
@@ -2226,7 +2235,7 @@ sub tables_hashref {
],
'primary_key' => 'custlocationnum',
'unique' => [],
- 'index' => [ [ 'zip', 'plus4lo', 'plus4hi' ] ],
+ 'index' => [ [ 'zip', 'plus4lo', 'plus4hi', 'ziphi', 'ziplo' ] ],
},
'tax_class' => {
@@ -2241,6 +2250,18 @@ sub tables_hashref {
'index' => [],
},
+ 'tax_status' => {
+ 'columns' => [
+ 'taxstatusnum', 'serial', '', '', '', '',
+ 'data_vendor', 'varchar', 'NULL', $char_d, '', '',
+ 'taxstatus', 'varchar', '', $char_d, '', '',
+ 'description', 'varchar', '', $char_d, '', '',
+ ],
+ 'primary_key' => 'taxstatusnum',
+ 'unique' => [ [ 'data_vendor', 'taxstatus' ] ],
+ 'index' => [],
+ },
+
'cust_pay_pending' => {
'columns' => [
'paypendingnum', 'serial', '', '', '', '',
@@ -3312,6 +3333,7 @@ sub tables_hashref {
'data_vendor', 'varchar', 'NULL', $char_d, '', '',
'taxproduct', 'varchar', '', $char_d, '', '',
'description', 'varchar', '', 3*$char_d, '', '',
+ 'note', 'text', 'NULL', '', '', '',
],
'primary_key' => 'taxproductnum',
'unique' => [ [ 'data_vendor', 'taxproduct' ] ],
@@ -5285,6 +5307,9 @@ sub tables_hashref {
#new
'cdrbatchnum', 'int', 'NULL', '', '', '',
+ # FK to cust_bill_pkg_detail; having a value here absolutely means
+ # that the CDR appears on an invoice
+ 'detailnum', 'bigint', 'NULL', '', '', '',
],
'primary_key' => 'acctid',
'unique' => [],
diff --git a/FS/FS/TaxEngine.pm b/FS/FS/TaxEngine.pm
new file mode 100644
index 0000000..a146c54
--- /dev/null
+++ b/FS/FS/TaxEngine.pm
@@ -0,0 +1,239 @@
+package FS::TaxEngine;
+
+use strict;
+use vars qw( $DEBUG );
+use FS::Conf;
+use FS::Record qw(qsearch qsearchs);
+
+$DEBUG = 0;
+
+=head1 NAME
+
+FS::TaxEngine - Base class for tax calculation engines.
+
+=head1 USAGE
+
+1. At the start of creating an invoice, create an FS::TaxEngine object.
+2. Each time a sale item is added to the invoice, call C<add_sale> on the
+ TaxEngine.
+
+- If the TaxEngine is "batch" style (Billsoft):
+3. Set the "pending" flag on the invoice.
+4. Insert the invoice and its line items.
+5. After creating all invoices for the day, call
+ FS::TaxEngine::process_tax_batch. This will create the tax items for
+ all of the pending invoices, clear the "pending" flag, and call
+ C<collect> on each of the billed customers.
+
+- If not (the internal tax system, CCH):
+3. After adding all sale items, call C<calculate_taxes> on the TaxEngine to
+ produce a list of tax line items.
+4. Append the tax line items to the invoice.
+5. Insert the invoice.
+
+=head1 CLASS METHODS
+
+=over 4
+
+=item new 'cust_main' => CUST_MAIN, 'invoice_time' => TIME, OPTIONS...
+
+Creates an L<FS::TaxEngine> object. The subclass will be chosen by the
+'enable_taxproducts' configuration setting.
+
+CUST_MAIN and TIME are required. OPTIONS can include "cancel" => 1 to
+indicate that the package is being billed on cancellation.
+
+=cut
+
+sub new {
+ my $class = shift;
+ my %opt = @_;
+ if ($class eq 'FS::TaxEngine') {
+ my $conf = FS::Conf->new;
+ my $subclass = $conf->config('enable_taxproducts') || 'internal';
+ $class .= "::$subclass";
+ local $@;
+ eval "use $class";
+ die "couldn't load $class: $@\n" if $@;
+ }
+ my $self = { items => [], taxes => {}, %opt };
+ bless $self, $class;
+}
+
+=item info
+
+Returns a hashref of metadata about this tax method, including:
+- batch: whether this is a batch-style engine (requires different usage)
+- override: whether this engine uses tax overrides
+- manual_tax_location: whether this engine requires the user to select a "tax
+ location" separate from the address/city/state/zip fields
+- rate_table: the table that stores the tax rates
+ (the 'taxline' method of that class will be used to calculate line-item
+ taxes)
+- link_table: the table that links L<FS::cust_bill_pkg> records for taxes
+ to the C<rate_table> entry that generated them, and to the item they
+ represent tax on.
+
+=back
+
+=head1 METHODS
+
+=over 4
+
+=item add_sale CUST_BILL_PKG
+
+Adds the CUST_BILL_PKG object as a taxable sale on this invoice.
+
+=item calculate_taxes CUST_BILL
+
+Calculates the taxes on the taxable sales and returns a list of
+L<FS::cust_bill_pkg> objects to add to the invoice. There is a base
+implementation of this, which calls the C<taxline> method to calculate
+each individual tax.
+
+=cut
+
+sub calculate_taxes {
+ my $self = shift;
+ my $conf = FS::Conf->new;
+
+ my $cust_bill = shift;
+
+ my @tax_line_items;
+ # keys are tax names (as printed on invoices / itemdesc )
+ # values are arrayrefs of taxlines
+ my %taxname;
+
+ # keys are taxnums
+ # values are (cumulative) amounts
+ my %tax_amount;
+
+ # keys are taxnums
+ # values are arrayrefs of cust_tax_exempt_pkg objects
+ my %tax_exemption;
+
+ # For each distinct tax rate definition, calculate the tax and exemptions.
+ foreach my $taxnum ( keys %{ $self->{taxes} } ) {
+
+ my $taxables = $self->{taxes}{$taxnum};
+ my $tax_object = shift @$taxables;
+ # $tax_object is a cust_main_county or tax_rate
+ # (with billpkgnum, pkgnum, locationnum set)
+ # the rest of @{ $taxlisthash->{$tax} } is cust_bill_pkg component objects
+ # (setup, recurring, usage classes)
+
+ my $taxline = $self->taxline('tax' => $tax_object, 'sales' => $taxables);
+ # taxline methods are now required to return real line items
+ # with their link records
+ die $taxline unless ref($taxline);
+
+ push @{ $taxname{ $taxline->itemdesc } }, $taxline;
+
+ } #foreach $taxnum
+
+ my $link_table = $self->info->{link_table};
+ # For each distinct tax name (the values set as $taxline->itemdesc),
+ # create a consolidated tax item with the total amount and all the links
+ # of all tax items that share that name.
+ foreach my $taxname ( keys %taxname ) {
+ my @tax_links;
+ my $tax_cust_bill_pkg = FS::cust_bill_pkg->new({
+ 'invnum' => $cust_bill->invnum,
+ 'pkgnum' => 0,
+ 'recur' => 0,
+ 'sdate' => '',
+ 'edate' => '',
+ 'itemdesc' => $taxname,
+ $link_table => \@tax_links,
+ });
+
+ my $tax_total = 0;
+ warn "adding $taxname\n" if $DEBUG > 1;
+
+ foreach my $taxitem ( @{ $taxname{$taxname} } ) {
+ # then we need to transfer the amount and the links from the
+ # line item to the new one we're creating.
+ $tax_total += $taxitem->setup;
+ foreach my $link ( @{ $taxitem->get($link_table) } ) {
+ $link->set('tax_cust_bill_pkg', $tax_cust_bill_pkg);
+ push @tax_links, $link;
+ }
+ } # foreach $taxitem
+ next unless $tax_total;
+
+ # we should really neverround this up...I guess it's okay if taxline
+ # already returns amounts with 2 decimal places
+ $tax_total = sprintf('%.2f', $tax_total );
+ $tax_cust_bill_pkg->set('setup', $tax_total);
+
+ my $pkg_category = qsearchs( 'pkg_category', { 'categoryname' => $taxname,
+ 'disabled' => '',
+ },
+ );
+
+ my @display = ();
+ if ( $pkg_category and
+ $conf->config('invoice_latexsummary') ||
+ $conf->config('invoice_htmlsummary')
+ )
+ {
+ my %hash = ( 'section' => $pkg_category->categoryname );
+ push @display, new FS::cust_bill_pkg_display { type => 'S', %hash };
+ }
+ $tax_cust_bill_pkg->set('display', \@display);
+
+ push @tax_line_items, $tax_cust_bill_pkg;
+ }
+
+ \@tax_line_items;
+}
+
+=head1 CLASS METHODS
+
+=item cust_tax_locations LOCATION
+
+Given an L<FS::cust_location> object (or a hash of location fields),
+returns a list of all tax jurisdiction locations that could possibly
+match it. This is meant for interactive use: the location editing UI
+displays the candidate locations to the user so they can choose the
+best match.
+
+=cut
+
+sub cust_tax_locations {
+ ();
+} # shouldn't even get called unless info->{manual_tax_location} is true
+
+=item add_taxproduct DESCRIPTION
+
+If the module allows manually adding tax products (categories of taxable
+items/services), this method will be called to do it. (If not, the UI in
+browse/part_pkg_taxproduct/* should prevent adding an unlisted tax product.
+That is the default behavior, so by default this method simply fails.)
+
+DESCRIPTION is the contents of the taxproduct_description form input, which
+will normally be filled in by browse/part_pkg_taxproduct/*.
+
+Must return the newly inserted part_pkg_taxproduct object on success, or
+a string on failure.
+
+=cut
+
+sub add_taxproduct {
+ my $class = shift;
+ "$class does not allow manually adding taxproducts";
+}
+
+=item transfer_batch (batch-style only)
+
+Submits the pending transaction batch for processing, receives the
+results, and appends the calculated taxes to all invoices that were
+included in the batch. Then clears their pending flags, and queues
+a job to run C<FS::cust_main::Billing::collect> on each affected
+customer.
+
+=back
+
+=cut
+
+1;
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;
diff --git a/FS/FS/UI/Web.pm b/FS/FS/UI/Web.pm
index 291cea7..bab7e30 100644
--- a/FS/FS/UI/Web.pm
+++ b/FS/FS/UI/Web.pm
@@ -694,14 +694,9 @@ sub start_job {
#too slow to insert all the cgi params as individual args..,?
#my $error = $queue->insert('_JOB', $cgi->Vars);
- #warn 'froze string of size '. length(nfreeze(\%param)). " for job args\n"
- # if $DEBUG;
- #
- # XXX FS::queue::insert knows how to do this.
- # not changing it here because that requires changing it everywhere else,
- # too, but we should eventually fix it
+ #rely on FS::queue smartness to freeze/encode the param hash
- my $error = $job->insert( '_JOB', encode_base64(nfreeze(\%param)) );
+ my $error = $job->insert( '_JOB', \%param );
if ( $error ) {
diff --git a/FS/FS/Upgrade.pm b/FS/FS/Upgrade.pm
index cb16ead..d76379f 100644
--- a/FS/FS/Upgrade.pm
+++ b/FS/FS/Upgrade.pm
@@ -125,6 +125,14 @@ If you need to continue using the old Form 477 report, turn on the
$conf->set($newname, 'location');
}
+ # boolean enable_taxproducts is now enable_taxproducts = 'cch'
+ if ( $conf->exists('enable_taxproducts') and
+ $conf->config('enable_taxproducts') eq '' ) {
+
+ $conf->set('enable_taxproducts', 'cch');
+
+ }
+
}
sub upgrade_overlimit_groups {
@@ -374,6 +382,9 @@ sub upgrade_data {
#populate state FIPS codes if not already done
'state' => [],
+
+ #populate tax statuses
+ 'tax_status' => [],
;
\%hash;
diff --git a/FS/FS/bill_batch.pm b/FS/FS/bill_batch.pm
index d4f2aec..da6420c 100644
--- a/FS/FS/bill_batch.pm
+++ b/FS/FS/bill_batch.pm
@@ -131,13 +131,11 @@ Returns the agent (see L<FS::agent>) for this invoice batch.
=cut
-use Storable 'thaw';
use Data::Dumper;
-use MIME::Base64;
sub process_print_pdf {
my $job = shift;
- my $param = thaw(decode_base64(shift));
+ my $param = shift;
warn Dumper($param) if $DEBUG;
die "no batchnum specified!\n" if ! exists($param->{batchnum});
my $batch = FS::bill_batch->by_key($param->{batchnum});
diff --git a/FS/FS/cdr.pm b/FS/FS/cdr.pm
index 4c02b06..306dbd0 100644
--- a/FS/FS/cdr.pm
+++ b/FS/FS/cdr.pm
@@ -161,6 +161,8 @@ following fields are currently supported:
=item cdrbatch
+=item detailnum - Link to invoice detail (L<FS::cust_bill_pkg_detail>)
+
=back
=head1 METHODS
@@ -226,6 +228,7 @@ sub table_info {
'freesiderewritestatus' => 'Freeside rewrite status',
'cdrbatch' => 'Legacy batch',
'cdrbatchnum' => 'Batch',
+ 'detailnum' => 'Freeside invoice detail line',
},
};
@@ -337,8 +340,12 @@ sub check {
#check the foreign keys even?
#do we want to outright *reject* the CDR?
- my $error =
- $self->ut_numbern('acctid');
+ my $error = $self->ut_numbern('acctid');
+ return $error if $error;
+
+ if ( $self->freesidestatus ne 'done' ) {
+ $self->set('detailnum', ''); # can't have this on an unbilled call
+ }
#add a config option to turn these back on if someone needs 'em
#
@@ -351,8 +358,6 @@ sub check {
# # Telstra =1, Optus = 2, RSL COM = 3
# || $self->ut_foreign_keyn('carrierid', 'cdr_carrier', 'carrierid' )
- return $error if $error;
-
$self->SUPER::check;
}
diff --git a/FS/FS/cust_bill.pm b/FS/FS/cust_bill.pm
index 1b9b347..e7c799f 100644
--- a/FS/FS/cust_bill.pm
+++ b/FS/FS/cust_bill.pm
@@ -125,6 +125,8 @@ Specific use cases
=item promised_date - customer promised payment date, for collection
+=item pending - invoice is still being generated, empty or 'Y'
+
=back
=head1 METHODS
@@ -334,6 +336,7 @@ sub replace_check {
#return "Can't change _date!" unless $old->_date eq $new->_date;
return "Can't change _date" unless $old->_date == $new->_date;
return "Can't change charged" unless $old->charged == $new->charged
+ || $old->pending eq 'Y'
|| $old->charged == 0
|| $new->{'Hash'}{'cc_surcharge_replace_hack'};
@@ -388,6 +391,7 @@ sub check {
|| $self->ut_enum('closed', [ '', 'Y' ])
|| $self->ut_foreign_keyn('statementnum', 'cust_statement', 'statementnum' )
|| $self->ut_numbern('agent_invid') #varchar?
+ || $self->ut_flag('pending')
;
return $error if $error;
@@ -3173,14 +3177,12 @@ sub process_respool {
process_re_X('spool', @_);
}
-use Storable qw(thaw);
use Data::Dumper;
-use MIME::Base64;
sub process_re_X {
my( $method, $job ) = ( shift, shift );
warn "$me process_re_X $method for job $job\n" if $DEBUG;
- my $param = thaw(decode_base64(shift));
+ my $param = shift;
warn Dumper($param) if $DEBUG;
re_X(
diff --git a/FS/FS/cust_bill_event.pm b/FS/FS/cust_bill_event.pm
index c91283b..adaa13e 100644
--- a/FS/FS/cust_bill_event.pm
+++ b/FS/FS/cust_bill_event.pm
@@ -304,13 +304,11 @@ sub process_refax {
process_re_X('fax', @_);
}
-use Storable qw(thaw);
use Data::Dumper;
-use MIME::Base64;
sub process_re_X {
my( $method, $job ) = ( shift, shift );
- my $param = thaw(decode_base64(shift));
+ my $param = shift;
warn Dumper($param) if $DEBUG;
re_X(
diff --git a/FS/FS/cust_bill_pkg_detail.pm b/FS/FS/cust_bill_pkg_detail.pm
index d0cbdbe..dd118c1 100644
--- a/FS/FS/cust_bill_pkg_detail.pm
+++ b/FS/FS/cust_bill_pkg_detail.pm
@@ -86,15 +86,52 @@ sub table { 'cust_bill_pkg_detail'; }
Adds this record to the database. If there is an error, returns the error,
otherwise returns false.
+=cut
+
+sub insert {
+ my $self = shift;
+ my $error = $self->SUPER::insert(@_);
+ return $error if $error;
+
+ # link CDRs
+ my $acctids = $self->get('acctid') or return '';
+ $acctids = [ $acctids ] unless ref $acctids;
+ foreach my $acctid ( @$acctids ) {
+ my $cdr = FS::cdr->by_key($acctid);
+ $cdr->set('detailnum', $self->detailnum);
+ $error = $cdr->replace;
+ # this should never happen
+ return "error linking CDR #$acctid: $error" if $error;
+ }
+ '';
+}
+
=item delete
Delete this record from the database.
+=cut
+
+sub delete {
+ my $self = shift;
+ my $error = $self->SUPER::delete;
+ return $error if $error;
+ foreach my $cdr (qsearch('cdr', { detailnum => $self->detailnum })) {
+ $cdr->set('detailnum', '');
+ $error = $cdr->replace;
+ return "error unlinking CDR #" . $cdr->acctid . ": $error" if $error;
+ }
+}
+
=item replace OLD_RECORD
Replaces the OLD_RECORD with this one in the database. If there is an error,
returns the error, otherwise returns false.
+=cut
+
+# the replace method can be inherited from FS::Record (doesn't touch CDRs)
+
=item check
Checks all fields to make sure this is a valid line item detail. If there is
diff --git a/FS/FS/cust_class.pm b/FS/FS/cust_class.pm
index 2536058..fdc92ba 100644
--- a/FS/FS/cust_class.pm
+++ b/FS/FS/cust_class.pm
@@ -102,8 +102,6 @@ sub check {
=item cust_category
-=item category
-
Returns the cust_category record associated with this class, or false if there
is none.
diff --git a/FS/FS/cust_event.pm b/FS/FS/cust_event.pm
index b29ab71..b5436d0 100644
--- a/FS/FS/cust_event.pm
+++ b/FS/FS/cust_event.pm
@@ -434,13 +434,9 @@ sub process_refax {
process_re_X('fax', @_);
}
-use Storable qw(thaw);
use Data::Dumper;
-use MIME::Base64;
sub process_re_X {
- my( $method, $job ) = ( shift, shift );
-
- my $param = thaw(decode_base64(shift));
+ my( $method, $job, $param ) = @_;
warn Dumper($param) if $DEBUG;
re_X(
diff --git a/FS/FS/cust_location.pm b/FS/FS/cust_location.pm
index 66fd7a0..e5d2e72 100644
--- a/FS/FS/cust_location.pm
+++ b/FS/FS/cust_location.pm
@@ -94,6 +94,11 @@ Geocode
Tax district code (optional)
+=item incorporated
+
+Incorporated city flag: set to 'Y' if the address is in the legal borders
+of an incorporated city.
+
=item disabled
Disabled flag; set to 'Y' to disable the location.
@@ -340,6 +345,7 @@ sub check {
|| $self->ut_alphan('geocode')
|| $self->ut_alphan('district')
|| $self->ut_numbern('censusyear')
+ || $self->ut_flag('incorporated')
;
return $error if $error;
if ( $self->censustract ne '' ) {
diff --git a/FS/FS/cust_main.pm b/FS/FS/cust_main.pm
index f3447cc..e5982a9 100644
--- a/FS/FS/cust_main.pm
+++ b/FS/FS/cust_main.pm
@@ -28,8 +28,6 @@ use vars qw( $DEBUG $me $conf
use Carp;
use Scalar::Util qw( blessed );
use Time::Local qw(timelocal);
-use Storable qw(thaw);
-use MIME::Base64;
use Data::Dumper;
use Tie::IxHash;
use Digest::MD5 qw(md5_base64);
@@ -61,6 +59,7 @@ use FS::part_referral;
use FS::cust_main_county;
use FS::cust_location;
use FS::cust_class;
+use FS::tax_status;
use FS::cust_main_exemption;
use FS::cust_tax_adjustment;
use FS::cust_tax_location;
@@ -1746,6 +1745,7 @@ sub check {
|| $self->ut_foreign_keyn('ship_locationnum', 'cust_location','locationnum')
|| $self->ut_foreign_keyn('classnum', 'cust_class', 'classnum')
|| $self->ut_foreign_keyn('salesnum', 'sales', 'salesnum')
+ || $self->ut_foreign_keyn('taxstatusnum', 'tax_status', 'taxstatusnum')
|| $self->ut_textn('custbatch')
|| $self->ut_name('last')
|| $self->ut_name('first')
@@ -2445,6 +2445,36 @@ sub classname {
: '';
}
+=item tax_status
+
+Returns the external tax status, as an FS::tax_status object, or the empty
+string if there is no tax status.
+
+=cut
+
+sub tax_status {
+ my $self = shift;
+ if ( $self->taxstatusnum ) {
+ qsearchs('tax_status', { 'taxstatusnum' => $self->taxstatusnum } );
+ } else {
+ return '';
+ }
+}
+
+=item taxstatus
+
+Returns the tax status code if there is one.
+
+=cut
+
+sub taxstatus {
+ my $self = shift;
+ my $tax_status = $self->tax_status;
+ $tax_status
+ ? $tax_status->taxstatus
+ : '';
+}
+
=item BILLING METHODS
Documentation on billing methods has been moved to
@@ -4998,9 +5028,24 @@ sub queued_bill {
$cust_main->bill_and_collect( %args );
}
+=item queued_collect 'custnum' => CUSTNUM [ , OPTION => VALUE ... ]
+
+Like queued_bill, but instead of C<bill_and_collect>, just runs the
+C<collect> part. This is used in batch tax calculation, where invoice
+generation and collection events have to be completely separated.
+
+=cut
+
+sub queued_collect {
+ my (%args) = @_;
+ my $cust_main = FS::cust_main->by_key($args{'custnum'});
+
+ $cust_main->collect(%args);
+}
+
sub process_bill_and_collect {
my $job = shift;
- my $param = thaw(decode_base64(shift));
+ my $param = shift;
my $cust_main = qsearchs( 'cust_main', { custnum => $param->{'custnum'} } )
or die "custnum '$param->{custnum}' not found!\n";
$param->{'job'} = $job;
diff --git a/FS/FS/cust_main/Billing.pm b/FS/FS/cust_main/Billing.pm
index 29f7e8e..f65d495 100644
--- a/FS/FS/cust_main/Billing.pm
+++ b/FS/FS/cust_main/Billing.pm
@@ -23,6 +23,7 @@ use FS::part_event_condition;
use FS::pkg_category;
use FS::cust_event_fee;
use FS::Log;
+use FS::TaxEngine;
# 1 is mostly method/subroutine entry and options
# 2 traces progress of some operations
@@ -171,9 +172,22 @@ sub bill_and_collect {
else { warn $error; }
}
- unless ( $conf->exists('cancelled_cust-noevents')
- && ! $self->num_ncancelled_pkgs
- ) {
+ # In a batch tax environment, do not run collection if any pending
+ # invoices were created. Collection will run after the next tax batch.
+ my $tax = FS::TaxEngine->new;
+ if ( $tax->info->{batch} and
+ qsearch('cust_bill', { custnum => $self->custnum, pending => 'Y' })
+ )
+ {
+ warn "skipped collection for custnum ".$self->custnum.
+ " due to pending invoices\n" if $DEBUG;
+ } elsif ( $conf->exists('cancelled_cust-noevents')
+ && ! $self->num_ncancelled_pkgs )
+ {
+ warn "skipped collection for custnum ".$self->custnum.
+ " because they have no active packages\n" if $DEBUG;
+ } else {
+ # run collection normally
$job->update_statustext('70,running collection events') if $job;
$log->debug('running collection events', %logopt);
$error = $self->collect( %options );
@@ -450,11 +464,19 @@ sub bill {
my %total_setup = map { my $z = 0; $_ => \$z; } @passes;
my %total_recur = map { my $z = 0; $_ => \$z; } @passes;
- my %taxlisthash = map { $_ => {} } @passes;
-
my @precommit_hooks = ();
$options{'pkg_list'} ||= [ $self->ncancelled_pkgs ]; #param checks?
+
+ my %tax_engines;
+ my $tax_is_batch = '';
+ foreach (@passes) {
+ $tax_engines{$_} = FS::TaxEngine->new(cust_main => $self,
+ invoice_time => $invoice_time,
+ cancel => $options{cancel}
+ );
+ $tax_is_batch ||= $tax_engines{$_}->info->{batch};
+ }
foreach my $cust_pkg ( @{ $options{'pkg_list'} } ) {
@@ -515,7 +537,7 @@ sub bill {
'line_items' => $cust_bill_pkg{$pass},
'setup' => $total_setup{$pass},
'recur' => $total_recur{$pass},
- 'tax_matrix' => $taxlisthash{$pass},
+ 'tax_engine' => $tax_engines{$pass},
'time' => $time,
'real_pkgpart' => $real_pkgpart,
'options' => \%options,
@@ -640,13 +662,9 @@ sub bill {
my $part_fee = $fee_item->part_fee;
my $fee_location = $self->ship_location; # I think?
+
+ my $error = $tax_engines{''}->add_sale($fee_item);
- my $error = $self->_handle_taxes(
- $taxlisthash{$pass},
- $fee_item,
- location => $fee_location
- # probably not right to pass cancel => 1 for fees
- );
return $error if $error;
}
@@ -683,7 +701,7 @@ sub bill {
'line_items' => \@cust_bill_pkg,
'setup' => $total_setup{$pass},
'recur' => $total_recur{$pass},
- 'tax_matrix' => $taxlisthash{$pass},
+ 'tax_engine' => $tax_engines{$pass},
'time' => $time,
'real_pkgpart' => $real_pkgpart,
'options' => \%postal_options,
@@ -701,21 +719,8 @@ sub bill {
}
- my $listref_or_error =
- $self->calculate_taxes( \@cust_bill_pkg, $taxlisthash{$pass}, $invoice_time);
-
- unless ( ref( $listref_or_error ) ) {
- $dbh->rollback if $oldAutoCommit && !$options{no_commit};
- return $listref_or_error;
- }
-
- foreach my $taxline ( @$listref_or_error ) {
- ${ $total_setup{$pass} } =
- sprintf('%.2f', ${ $total_setup{$pass} } + $taxline->setup );
- push @cust_bill_pkg, $taxline;
- }
-
#add tax adjustments
+ #XXX does this work with batch tax engines?
warn "adding tax adjustments...\n" if $DEBUG > 2;
foreach my $cust_tax_adjustment (
qsearch('cust_tax_adjustment', { 'custnum' => $self->custnum,
@@ -767,12 +772,63 @@ sub bill {
'previous_balance' => $previous_balance,
'invoice_terms' => $options{'invoice_terms'},
'cust_bill_pkg' => \@cust_bill_pkg,
+ 'pending' => 'Y', # clear this after doing taxes
} );
- $error = $cust_bill->insert unless $options{no_commit};
- if ( $error ) {
- $dbh->rollback if $oldAutoCommit && !$options{no_commit};
- return "can't create invoice for customer #". $self->custnum. ": $error";
+
+ if (!$options{no_commit}) {
+ # probably we ought to insert it as pending, and then rollback
+ # without ever un-pending it
+ $error = $cust_bill->insert;
+ if ( $error ) {
+ $dbh->rollback if $oldAutoCommit && !$options{no_commit};
+ return "can't create invoice for customer #". $self->custnum. ": $error";
+ }
+
}
+
+ # calculate and append taxes
+ if ( ! $tax_is_batch) {
+ my $arrayref_or_error = $tax_engines{$pass}->calculate_taxes($cust_bill);
+
+ unless ( ref( $arrayref_or_error ) ) {
+ $dbh->rollback if $oldAutoCommit && !$options{no_commit};
+ return $arrayref_or_error;
+ }
+
+ # or should this be in TaxEngine?
+ my $total_tax = 0;
+ foreach my $taxline ( @$arrayref_or_error ) {
+ $total_tax += $taxline->setup;
+ $taxline->set('invnum' => $cust_bill->invnum); # just to be sure
+ push @cust_bill_pkg, $taxline; # for return_bill
+
+ if (!$options{no_commit}) {
+ my $error = $taxline->insert;
+ if ( $error ) {
+ $dbh->rollback if $oldAutoCommit;
+ return $error;
+ }
+ }
+
+ }
+
+ # add tax to the invoice amount and finalize it
+ ${ $total_setup{$pass} } = sprintf('%.2f', ${ $total_setup{$pass} } + $total_tax);
+ $charged = sprintf('%.2f', $charged + $total_tax);
+ $cust_bill->set('charged', $charged);
+ $cust_bill->set('pending', '');
+
+ if (!$options{no_commit}) {
+ my $error = $cust_bill->replace;
+ if ( $error ) {
+ $dbh->rollback if $oldAutoCommit;
+ return $error;
+ }
+ }
+
+ } # if !$tax_is_batch
+ # if it IS batch, then we'll do all this in process_tax_batch
+
push @{$options{return_bill}}, $cust_bill if $options{return_bill};
} #foreach my $pass ( keys %cust_bill_pkg )
@@ -845,204 +901,6 @@ sub _omit_zero_value_bundles {
}
-=item calculate_taxes LINEITEMREF TAXHASHREF INVOICE_TIME
-
-Generates tax line items (see L<FS::cust_bill_pkg>) for this customer.
-Usually used internally by bill method B<bill>.
-
-If there is an error, returns the error, otherwise returns reference to a
-list of line items suitable for insertion.
-
-=over 4
-
-=item LINEITEMREF
-
-An array ref of the line items being billed.
-
-=item TAXHASHREF
-
-A strange beast. The keys to this hash are internal identifiers consisting
-of the name of the tax object type, a space, and its unique identifier ( e.g.
- 'cust_main_county 23' ). The values of the hash are listrefs. The first
-item in the list is the tax object. The remaining items are either line
-items or floating point values (currency amounts).
-
-The taxes are calculated on this entity. Calculated exemption records are
-transferred to the LINEITEMREF items on the assumption that they are related.
-
-Read the source.
-
-=item INVOICE_TIME
-
-This specifies the date appearing on the associated invoice. Some
-jurisdictions (i.e. Texas) have tax exemptions which are date sensitive.
-
-=back
-
-=cut
-
-sub calculate_taxes {
- my ($self, $cust_bill_pkg, $taxlisthash, $invoice_time) = @_;
-
- # $taxlisthash is a hashref
- # keys are identifiers, values are arrayrefs
- # each arrayref starts with a tax object (cust_main_county or tax_rate)
- # then any cust_bill_pkg objects the tax applies to
-
- local($DEBUG) = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG;
-
- warn "$me calculate_taxes\n"
- #.Dumper($self, $cust_bill_pkg, $taxlisthash, $invoice_time). "\n"
- if $DEBUG > 2;
-
- my @tax_line_items = ();
-
- # keys are tax names (as printed on invoices / itemdesc )
- # values are arrayrefs of taxlisthash keys (internal identifiers)
- my %taxname = ();
-
- # keys are taxlisthash keys (internal identifiers)
- # values are (cumulative) amounts
- my %tax_amount = ();
-
- # keys are taxlisthash keys (internal identifiers)
- # values are arrayrefs of cust_bill_pkg_tax_location hashrefs
- my %tax_location = ();
-
- # keys are taxlisthash keys (internal identifiers)
- # values are arrayrefs of cust_bill_pkg_tax_rate_location hashrefs
- my %tax_rate_location = ();
-
- # keys are taxlisthash keys (internal identifiers!)
- # values are arrayrefs of cust_tax_exempt_pkg objects
- my %tax_exemption;
-
- foreach my $tax ( keys %$taxlisthash ) {
- # $tax is a tax identifier (intersection of a tax definition record
- # and a cust_bill_pkg record)
- my $tax_object = shift @{ $taxlisthash->{$tax} };
- # $tax_object is a cust_main_county or tax_rate
- # (with billpkgnum, pkgnum, locationnum set)
- # the rest of @{ $taxlisthash->{$tax} } is cust_bill_pkg component objects
- # (setup, recurring, usage classes)
- warn "found ". $tax_object->taxname. " as $tax\n" if $DEBUG > 2;
- warn " ". join('/', @{ $taxlisthash->{$tax} } ). "\n" if $DEBUG > 2;
- # taxline calculates the tax on all cust_bill_pkgs in the
- # first (arrayref) argument, and returns a hashref of 'name'
- # (the line item description) and 'amount'.
- # It also calculates exemptions and attaches them to the cust_bill_pkgs
- # in the argument.
- my $taxables = $taxlisthash->{$tax};
- my $exemptions = $tax_exemption{$tax} ||= [];
- my $taxline = $tax_object->taxline(
- $taxables,
- 'custnum' => $self->custnum,
- 'invoice_time' => $invoice_time,
- 'exemptions' => $exemptions,
- );
- return $taxline unless ref($taxline);
-
- unshift @{ $taxlisthash->{$tax} }, $tax_object;
-
- if ( $tax_object->isa('FS::cust_main_county') ) {
- # then $taxline is a real line item
- push @{ $taxname{ $taxline->itemdesc } }, $taxline;
-
- } else {
- # leave this as is for now
-
- my $name = $taxline->{'name'};
- my $amount = $taxline->{'amount'};
-
- #warn "adding $amount as $name\n";
- $taxname{ $name } ||= [];
- push @{ $taxname{ $name } }, $tax;
-
- $tax_amount{ $tax } += $amount;
-
- # link records between cust_main_county/tax_rate and cust_location
- $tax_rate_location{ $tax } ||= [];
- my $taxratelocationnum =
- $tax_object->tax_rate_location->taxratelocationnum;
- push @{ $tax_rate_location{ $tax } },
- {
- 'taxnum' => $tax_object->taxnum,
- 'taxtype' => ref($tax_object),
- 'amount' => sprintf('%.2f', $amount ),
- 'locationtaxid' => $tax_object->location,
- 'taxratelocationnum' => $taxratelocationnum,
- };
- } #if ref($tax_object)...
- } #foreach keys %$taxlisthash
-
- #consolidate and create tax line items
- warn "consolidating and generating...\n" if $DEBUG > 2;
- foreach my $taxname ( keys %taxname ) {
- my @cust_bill_pkg_tax_location;
- my @cust_bill_pkg_tax_rate_location;
- my $tax_cust_bill_pkg = FS::cust_bill_pkg->new({
- 'pkgnum' => 0,
- 'recur' => 0,
- 'sdate' => '',
- 'edate' => '',
- 'itemdesc' => $taxname,
- 'cust_bill_pkg_tax_location' => \@cust_bill_pkg_tax_location,
- 'cust_bill_pkg_tax_rate_location' => \@cust_bill_pkg_tax_rate_location,
- });
-
- my $tax_total = 0;
- my %seen = ();
- warn "adding $taxname\n" if $DEBUG > 1;
- foreach my $taxitem ( @{ $taxname{$taxname} } ) {
- if ( ref($taxitem) eq 'FS::cust_bill_pkg' ) {
- # then we need to transfer the amount and the links from the
- # line item to the new one we're creating.
- $tax_total += $taxitem->setup;
- foreach my $link ( @{ $taxitem->get('cust_bill_pkg_tax_location') } ) {
- $link->set('tax_cust_bill_pkg', $tax_cust_bill_pkg);
- push @cust_bill_pkg_tax_location, $link;
- }
- } else {
- # the tax_rate way
- next if $seen{$taxitem}++;
- warn "adding $tax_amount{$taxitem}\n" if $DEBUG > 1;
- $tax_total += $tax_amount{$taxitem};
- push @cust_bill_pkg_tax_rate_location,
- map { new FS::cust_bill_pkg_tax_rate_location $_ }
- @{ $tax_rate_location{ $taxitem } };
- }
- }
- next unless $tax_total;
-
- # we should really neverround this up...I guess it's okay if taxline
- # already returns amounts with 2 decimal places
- $tax_total = sprintf('%.2f', $tax_total );
- $tax_cust_bill_pkg->set('setup', $tax_total);
-
- my $pkg_category = qsearchs( 'pkg_category', { 'categoryname' => $taxname,
- 'disabled' => '',
- },
- );
-
- my @display = ();
- if ( $pkg_category and
- $conf->config('invoice_latexsummary') ||
- $conf->config('invoice_htmlsummary')
- )
- {
-
- my %hash = ( 'section' => $pkg_category->categoryname );
- push @display, new FS::cust_bill_pkg_display { type => 'S', %hash };
-
- }
- $tax_cust_bill_pkg->set('display', \@display);
-
- push @tax_line_items, $tax_cust_bill_pkg;
- }
-
- \@tax_line_items;
-}
-
sub _make_lines {
my ($self, %params) = @_;
@@ -1055,10 +913,11 @@ sub _make_lines {
my $cust_bill_pkgs = $params{line_items} or die "no line buffer specified";
my $total_setup = $params{setup} or die "no setup accumulator specified";
my $total_recur = $params{recur} or die "no recur accumulator specified";
- my $taxlisthash = $params{tax_matrix} or die "no tax accumulator specified";
my $time = $params{'time'} or die "no time specified";
my (%options) = %{$params{options}};
+ my $tax_engine = $params{tax_engine};
+
if ( $part_pkg->freq ne '1' and ($options{'freq_override'} || 0) > 0 ) {
# this should never happen
die 'freq_override billing attempted on non-monthly package '.
@@ -1377,9 +1236,8 @@ sub _make_lines {
###
# handle taxes
###
-
- my $error = $self->_handle_taxes( $taxlisthash, $cust_bill_pkg,
- cancel => $options{cancel} );
+
+ my $error = $tax_engine->add_sale($cust_bill_pkg);
return $error if $error;
$cust_bill_pkg->set_display(
@@ -1476,6 +1334,8 @@ sub _transfer_balance {
return @transfers;
}
+#### vestigial code ####
+
=item handle_taxes TAXLISTHASH CUST_BILL_PKG [ OPTIONS ]
This is _handle_taxes. It's called once for each cust_bill_pkg generated
@@ -1685,6 +1545,8 @@ sub _gather_taxes {
}
+#### end vestigial code ####
+
=item collect [ HASHREF | OPTION => VALUE ... ]
(Attempt to) collect money for this customer's outstanding invoices (see
@@ -2526,10 +2388,7 @@ sub apply_payments {
bill
(do_cust_event pre-bill)
_make_lines
- _handle_taxes
- (vendor-only) _gather_taxes
_omit_zero_value_bundles
- _handle_taxes (for fees)
calculate_taxes
apply_payments_and_credits
diff --git a/FS/FS/cust_main/Import.pm b/FS/FS/cust_main/Import.pm
index 0fd34ee..a243b27 100644
--- a/FS/FS/cust_main/Import.pm
+++ b/FS/FS/cust_main/Import.pm
@@ -2,9 +2,7 @@ package FS::cust_main::Import;
use strict;
use vars qw( $DEBUG $conf );
-use Storable qw(thaw);
use Data::Dumper;
-use MIME::Base64;
use File::Slurp qw( slurp );
use FS::Misc::DateTime qw( parse_datetime );
use FS::UID qw( dbh );
@@ -67,8 +65,7 @@ Load a batch import as a queued JSRPC job
sub process_batch_import {
my $job = shift;
-
- my $param = thaw(decode_base64(shift));
+ my $param = shift;
warn Dumper($param) if $DEBUG;
my $files = $param->{'uploaded_files'}
diff --git a/FS/FS/cust_main_Mixin.pm b/FS/FS/cust_main_Mixin.pm
index 40c0ae9..4b151e7 100644
--- a/FS/FS/cust_main_Mixin.pm
+++ b/FS/FS/cust_main_Mixin.pm
@@ -526,7 +526,7 @@ sub process_email_search_result {
my $job = shift;
#warn "$me process_re_X $method for job $job\n" if $DEBUG;
- my $param = thaw(decode_base64(shift));
+ my $param = shift;
warn Dumper($param) if $DEBUG;
$param->{'job'} = $job;
diff --git a/FS/FS/cust_pkg.pm b/FS/FS/cust_pkg.pm
index 6d3ed2e..16cecdf 100644
--- a/FS/FS/cust_pkg.pm
+++ b/FS/FS/cust_pkg.pm
@@ -2458,12 +2458,10 @@ sub modify_charge {
-use Storable 'thaw';
-use MIME::Base64;
use Data::Dumper;
sub process_bulk_cust_pkg {
my $job = shift;
- my $param = thaw(decode_base64(shift));
+ my $param = shift;
warn Dumper($param) if $DEBUG;
my $old_part_pkg = qsearchs('part_pkg',
diff --git a/FS/FS/cust_pkg/Import.pm b/FS/FS/cust_pkg/Import.pm
index 694d814..6c8703c 100644
--- a/FS/FS/cust_pkg/Import.pm
+++ b/FS/FS/cust_pkg/Import.pm
@@ -2,9 +2,7 @@ package FS::cust_pkg::Import;
use strict;
use vars qw( $DEBUG ); #$conf );
-use Storable qw(thaw);
use Data::Dumper;
-use MIME::Base64;
use FS::Misc::DateTime qw( parse_datetime );
use FS::Record qw( qsearchs );
use FS::cust_pkg;
@@ -60,8 +58,7 @@ Load a batch import as a queued JSRPC job
sub process_batch_import {
my $job = shift;
-
- my $param = thaw(decode_base64(shift));
+ my $param = shift;
warn Dumper($param) if $DEBUG;
my $files = $param->{'uploaded_files'}
diff --git a/FS/FS/cust_tax_location.pm b/FS/FS/cust_tax_location.pm
index 4293b2c..cccebaf 100644
--- a/FS/FS/cust_tax_location.pm
+++ b/FS/FS/cust_tax_location.pm
@@ -28,38 +28,48 @@ FS::cust_tax_location - Object methods for cust_tax_location records
=head1 DESCRIPTION
-An FS::cust_tax_location object represents a mapping between a customer and
-a tax location. FS::cust_tax_location inherits from FS::Record. The
-following fields are currently supported:
+An FS::cust_tax_location object represents a classification rule for
+determining a tax region code ('geocode') for a service location. These
+records are used when editing customer locations to help the user choose the
+correct tax jurisdiction code. The jurisdiction codes are actually defined
+in L<FS::tax_rate_location>, and appear directly in records in
+L<FS::tax_rate>.
+
+FS::cust_tax_location is used in tax calculation (for CCH) to determine
+"implied" geocodes for customers and locations that have a complete U.S.
+ZIP+4 code and thus can be exactly placed in a jurisdiction. For those that
+don't, the user is expected to choose the geocode when entering the customer
+record.
+
+FS::cust_tax_location inherits from FS::Record. The following fields are
+currently supported:
=over 4
-=item custlocationnum
+=item custlocationnum - primary key
-primary key
+=item data_vendor - a tax data vendor and "style" of record
-=item data_vendor
+=item country - the two-letter country code
-a tax data vendor
+=item state - the two-letter state code (though CCH uses this differently;
+see QUIRKS)
-=item zip
+=item zip - an exact zip code (again, see QUIRKS)
-=item state
+=item ziplo - the lower bound of the zip code range (requires zip to be null)
-=item plus4hi
+=item ziphi - the upper bound of the zip code range (requires zip to be null)
-the upper bound of the last 4 zip code digits
+=item plus4lo - the lower bound of the last 4 zip code digits
-=item plus4lo
+=item plus4hi - the upper bound of the last 4 zip code digits
-the lower bound of the last 4 zip code digits
-
-=item default_location
-
-'Y' when this record represents the default for zip
-
-=item geocode - the foreign key into FS::part_pkg_tax_rate and FS::tax_rate
+=item default_location - 'Y' when this record represents the default. The UI
+will list default locations before non-default locations.
+=item geocode - the foreign key into L<FS::part_pkg_tax_rate>,
+L<FS::tax_rate>, L<FS::tax_rate_location>, etc.
=back
@@ -123,32 +133,28 @@ sub check {
|| $self->ut_enum('cityflag', [ '', 'I', 'O', 'B' ] )
|| $self->ut_alpha('geocode')
;
+ if ( $self->country ) {
+ $error ||= $self->ut_country('country')
+ || $self->ut_zip('ziphi', $self->country)
+ || $self->ut_zip('ziplo', $self->country);
+ }
return $error if $error;
- #ugh! cch canada weirdness and more
if ($self->state eq 'CN' && $self->data_vendor eq 'cch-zip' ) {
- $error = "Illegal cch canadian zip"
+ $error = "Illegal canadian zip"
unless $self->zip =~ /^[A-Z]$/;
} elsif ($self->state =~ /^E([B-DFGILNPR-UW])$/ && $self->data_vendor eq 'cch-zip' ) {
- $error = "Illegal cch european zip"
+ $error = "Illegal european zip"
unless $self->zip =~ /^E$1$/;
- } else {
- $error = $self->ut_number('zip', $self->state eq 'CN' ? 'CA' : 'US');
+ } elsif ($self->data_vendor =~ /^cch/) {
+ $error = $self->ut_numbern('zip', $self->state eq 'CN' ? 'CA' : 'US');
}
return $error if $error;
- #ugh! cch canada weirdness and more
- return "must specify either city/county or plus4lo/plus4hi"
- unless ( $self->plus4lo && $self->plus4hi ||
- ( $self->city ||
- $self->state eq 'CN' ||
- $self->state =~ /^E([B-DFGILNPR-UW])$/
- ) && $self->county
- );
-
$self->SUPER::check;
}
+# annoyingly incompatible with FS::Record::batch_import.
sub batch_import {
my ($param, $job) = @_;
@@ -255,6 +261,25 @@ sub batch_import {
};
+ } elsif ( $format eq 'billsoft' ) {
+
+ @fields = qw( geocode alt_location country state county city
+ ziplo ziphi );
+ $hook = sub {
+ my $hash = shift;
+ $hash->{data_vendor} = 'billsoft';
+ $hash->{default_location} = ($hash->{alt_location} ? '' : 'Y');
+ $hash->{city} =~ s/[^\w ]//g; # remove asterisks and other bad things
+ $hash->{country} = substr($hash->{country}, 0, 2);
+ if ( $hash->{state} =~ /^ *$/
+ or $hash->{county} =~ /^ *$/
+ or $hash->{country} !~ /^US|CA$/ ) {
+ # remove whole-country rows, whole-state rows, and non-CAN/USA rows
+ %$hash = ();
+ }
+ '';
+ };
+
} elsif ( $format eq 'extended' ) {
die "unimplemented\n";
@fields = qw( );
@@ -287,7 +312,7 @@ sub batch_import {
if ( $job ) { # progress bar
if ( time - $min_sec > $last ) {
my $error = $job->update_statustext(
- int( 100 * $imported / $count ). ",Importing locations"
+ int( 100 * $imported / $count ). ",Importing tax locations"
);
die $error if $error;
$last = time;
@@ -311,6 +336,8 @@ sub batch_import {
return $error;
}
+ # $hook can delete the contents of the hash to prevent the row from
+ # being inserted
next unless scalar(keys %cust_tax_location);
my $cust_tax_location = new FS::cust_tax_location( \%cust_tax_location );
@@ -334,9 +361,52 @@ sub batch_import {
=back
+=head1 SUBROUTINES
+
+=over 4
+
+=item process_batch_import JOB, PARAMS
+
+Starts a batch import given JOB (an L<FS::queue>) and PARAMS (a
+Base64-Storable hash). PARAMS should contain 'format' and 'uploaded_files'.
+
+Currently only usable for Billsoft imports; CCH's agglomeration of update
+files need to be imported through L<FS::tax_rate::process_batch_import>.
+
+=cut
+
+sub process_batch_import {
+ my $job = shift;
+ my $param = shift;
+
+ my $files = $param->{'uploaded_files'};
+
+ my ($file) = ($files =~ /^zipfile:(.*)$/)
+ or die "No files provided.\n";
+
+ my $dir = $FS::UID::cache_dir . '/cache.' . $FS::UID::datasrc;
+ open ( $param->{'filehandle'}, '<', "$dir/$file" )
+ or die "unable to open '$file': $!\n";
+
+ my $error = batch_import($param, $job);
+ die $error if $error;
+}
+
+=back
+
+=head1 QUIRKS
+
+CCH doesn't have a "country" field; for addresses in Canada it uses state
+= 'CN', and zip = the one-letter postal code prefix for the province. Or
+maybe that's just our CCH implementation. This doesn't apply to Billsoft,
+and shouldn't apply to any other tax vendor that may somehow be implemented.
+
+CCH also has two styles of records in this table: cch and cch-zip. cch
+records define a unique
+
=head1 BUGS
-The author should be informed of any you find.
+CCH clutter.
=head1 SEE ALSO
diff --git a/FS/FS/detail_format.pm b/FS/FS/detail_format.pm
index b072ff5..e49a9f9 100644
--- a/FS/FS/detail_format.pm
+++ b/FS/FS/detail_format.pm
@@ -178,6 +178,7 @@ Takes a single CDR and returns an invoice detail to describe it.
By default, this maps the following fields from the CDR:
+acctid => acctid
rated_price => amount
rated_classnum => classnum
rated_seconds => duration
@@ -208,6 +209,7 @@ sub single_detail {
$price = 0 if $cdr->freesidestatus eq 'no-charge';
FS::cust_bill_pkg_detail->new( {
+ 'acctid' => $cdr->acctid,
'amount' => $price,
'classnum' => $cdr->rated_classnum,
'duration' => $cdr->rated_seconds,
diff --git a/FS/FS/detail_format/sum_count.pm b/FS/FS/detail_format/sum_count.pm
index c40fcb8..253956f 100644
--- a/FS/FS/detail_format/sum_count.pm
+++ b/FS/FS/detail_format/sum_count.pm
@@ -24,6 +24,7 @@ sub header_detail {
sub append {
my $self = shift;
my $svcnums = ($self->{svcnums} ||= {});
+ my $acctids = $self->{acctids} ||= [];
foreach my $cdr (@_) {
my $object = $self->{inbound} ? $cdr->cdr_termination(1) : $cdr;
my $svcnum = $object->svcnum; # yes, $object->svcnum.
@@ -33,6 +34,8 @@ sub append {
$subtotal->{count}++;
$subtotal->{amount} += $object->rated_price
if $object->freesidestatus ne 'no-charge';
+
+ push @$acctids, $cdr->acctid;
}
}
@@ -68,6 +71,7 @@ sub finish {
startdate => '', #could use the earliest startdate in the bunch?
regionname => '', #no, we're using prefix instead
detail => $self->csv->string,
+ acctid => $self->{acctids},
});
} #foreach $svcnum
diff --git a/FS/FS/detail_format/sum_duration.pm b/FS/FS/detail_format/sum_duration.pm
index 1b967b4..c41bed3 100644
--- a/FS/FS/detail_format/sum_duration.pm
+++ b/FS/FS/detail_format/sum_duration.pm
@@ -24,6 +24,7 @@ sub header_detail {
sub append {
my $self = shift;
my $svcnums = ($self->{svcnums} ||= {});
+ my $acctids = ($self->{acctids} ||= []);
foreach my $cdr (@_) {
my $object = $self->{inbound} ? $cdr->cdr_termination(1) : $cdr;
my $svcnum = $object->svcnum; # yes, $object->svcnum.
@@ -34,6 +35,8 @@ sub append {
$subtotal->{duration} += $object->rated_seconds;
$subtotal->{amount} += $object->rated_price
if $object->freesidestatus ne 'no-charge';
+
+ push @$acctids, $cdr->acctid;
}
}
@@ -70,6 +73,7 @@ sub finish {
startdate => '', #could use the earliest startdate in the bunch?
regionname => '', #no, we're using prefix instead
detail => $self->csv->string,
+ acctid => $self->{acctids},
});
} #foreach $svcnum
diff --git a/FS/FS/detail_format/sum_duration_prefix.pm b/FS/FS/detail_format/sum_duration_prefix.pm
index cd7bbe3..3c33dc1 100644
--- a/FS/FS/detail_format/sum_duration_prefix.pm
+++ b/FS/FS/detail_format/sum_duration_prefix.pm
@@ -24,6 +24,7 @@ my $prefix_length = 6;
sub append {
my $self = shift;
my $prefixes = ($self->{prefixes} ||= {});
+ my $acctids = ($self->{acctids} ||= []);
foreach my $cdr (@_) {
my (undef, $phonenum) = $cdr->parse_number(
column => ( $self->{inbound} ? 'src' : 'dst' ),
@@ -52,6 +53,8 @@ sub append {
$subtotal->{duration} += $object->rated_seconds;
$subtotal->{amount} += $object->rated_price
if $object->freesidestatus ne 'no-charge';
+
+ push @$acctids, $cdr->acctid;
}
}
@@ -91,6 +94,7 @@ sub finish {
startdate => '', #could use the earliest startdate in the bunch?
regionname => '', #no, we're using prefix instead
detail => $self->csv->string,
+ acctid => $self->{acctids},
});
} #foreach $prefix
}
diff --git a/FS/FS/part_export/huawei_hlr.pm b/FS/FS/part_export/huawei_hlr.pm
index aa09a1c..9b405d5 100644
--- a/FS/FS/part_export/huawei_hlr.pm
+++ b/FS/FS/part_export/huawei_hlr.pm
@@ -9,8 +9,6 @@ use FS::inventory_class;
use FS::inventory_item;
use IO::Socket::INET;
use Data::Dumper;
-use MIME::Base64 qw(decode_base64);
-use Storable qw(thaw);
use strict;
@@ -251,7 +249,7 @@ sub command {
sub process_import_sim {
my $job = shift;
- my $param = thaw(decode_base64(shift));
+ my $param = shift;
$param->{'job'} = $job;
my $exportnum = delete $param->{'exportnum'};
my $export = __PACKAGE__->by_key($exportnum);
diff --git a/FS/FS/part_pkg/voip_cdr.pm b/FS/FS/part_pkg/voip_cdr.pm
index d3eff35..8278afe 100644
--- a/FS/FS/part_pkg/voip_cdr.pm
+++ b/FS/FS/part_pkg/voip_cdr.pm
@@ -10,9 +10,6 @@ use FS::Conf;
use FS::Record qw(qsearchs qsearch);
use FS::cdr;
use FS::detail_format;
-#use FS::rate;
-#use FS::rate_prefix;
-#use FS::rate_detail; #for ::granularities
$DEBUG = 0;
diff --git a/FS/FS/part_pkg_taxclass.pm b/FS/FS/part_pkg_taxclass.pm
index 824fd17..055c778 100644
--- a/FS/FS/part_pkg_taxclass.pm
+++ b/FS/FS/part_pkg_taxclass.pm
@@ -30,8 +30,16 @@ FS::part_pkg_taxclass - Object methods for part_pkg_taxclass records
=head1 DESCRIPTION
-An FS::part_pkg_taxclass object represents a tax class. FS::part_pkg_taxclass
-inherits from FS::Record. The following fields are currently supported:
+An FS::part_pkg_taxclass object declares the existence of a taxable sales
+class. FS::part_pkg_taxclass inherits from FS::Record.
+
+FS::part_pkg_taxclass is not used in tax calculation. It is only used to
+list a set of valid tax class names for use in the user interface. When
+using internal taxes, the actual matching of tax definitions to package
+tax class is a string match between tax class names. This is arguably
+a bug.
+
+The following fields are currently supported:
=over 4
diff --git a/FS/FS/part_pkg_taxproduct.pm b/FS/FS/part_pkg_taxproduct.pm
index ddea1da..c12a432 100644
--- a/FS/FS/part_pkg_taxproduct.pm
+++ b/FS/FS/part_pkg_taxproduct.pm
@@ -3,6 +3,7 @@ package FS::part_pkg_taxproduct;
use strict;
use vars qw( @ISA $delete_kludge );
use FS::Record qw( qsearch dbh );
+use Text::CSV_XS;
@ISA = qw(FS::Record);
$delete_kludge = 0;
@@ -28,7 +29,7 @@ FS::part_pkg_taxproduct - Object methods for part_pkg_taxproduct records
=head1 DESCRIPTION
-An FS::part_pkg_taxproduct object represents a tax product.
+An FS::part_pkg_taxproduct object represents a tax product.
FS::part_pkg_taxproduct inherits from FS::Record. The following fields are
currently supported:
@@ -199,6 +200,55 @@ sub expand_cch_taxproduct {
=cut
+sub batch_import {
+ my ($param, $job) = @_;
+
+ my $oldAutoCommit = $FS::UID::AutoCommit;
+ local $FS::UID::AutoCommit = 0;
+ my $dbh = dbh;
+
+ my $fh = $param->{filehandle};
+ my $format = $param->{format};
+ die "unsupported part_pkg_taxproduct format '$format'"
+ unless $format eq 'billsoft';
+
+ # this is slightly silly
+ my @lines = <$fh>;
+ my $lines = scalar @lines;
+ seek($fh, 0, 0);
+
+ my $imported = 0;
+ my $csv = Text::CSV_XS->new;
+ # fields: taxproduct, description
+ while ( my $row = $csv->getline($fh) ) {
+ if (!defined $row) {
+ $dbh->rollback if $oldAutoCommit;
+ return "can't parse: ". $csv->error_input();
+ }
+
+ if ( $job ) {
+ $job->update_statustext(
+ int( 100 * $imported / $lines ) . ',Inserting tax product records'
+ );
+ }
+
+ my $new = FS::part_pkg_taxproduct->new({
+ 'data_vendor' => 'billsoft',
+ 'taxproduct' => $row->[0],
+ 'description' => $row->[1],
+ });
+ my $error = $new->insert;
+ if ( $error ) {
+ $dbh->rollback if $oldAutoCommit;
+ return "error inserting part_pkg_taxproduct: $error\n";
+ }
+ $imported++;
+ }
+
+ $dbh->commit if $oldAutoCommit;
+ return '';
+}
+
=head1 BUGS
Confusingly named. It has nothing to do with part_pkg.
diff --git a/FS/FS/part_svc.pm b/FS/FS/part_svc.pm
index 01f3034..9ed56eb 100644
--- a/FS/FS/part_svc.pm
+++ b/FS/FS/part_svc.pm
@@ -722,13 +722,10 @@ Job-queue processor for web interface adds/edits
=cut
-use Storable qw(thaw);
use Data::Dumper;
-use MIME::Base64;
sub process {
my $job = shift;
-
- my $param = thaw(decode_base64(shift));
+ my $param = shift;
warn Dumper($param) if $DEBUG;
my $old = qsearchs('part_svc', { 'svcpart' => $param->{'svcpart'} })
@@ -802,13 +799,10 @@ Job-queue processor for web interface bulk customer service changes
=cut
-use Storable qw(thaw);
use Data::Dumper;
-use MIME::Base64;
sub process_bulk_cust_svc {
my $job = shift;
-
- my $param = thaw(decode_base64(shift));
+ my $param = shift;
warn Dumper($param) if $DEBUG;
local($FS::svc_Common::noexport_hack) = 1
diff --git a/FS/FS/pay_batch.pm b/FS/FS/pay_batch.pm
index 9ce6a83..b6b69f3 100644
--- a/FS/FS/pay_batch.pm
+++ b/FS/FS/pay_batch.pm
@@ -436,12 +436,10 @@ sub import_results {
}
-use MIME::Base64;
-use Storable 'thaw';
use Data::Dumper;
sub process_import_results {
my $job = shift;
- my $param = thaw(decode_base64(shift));
+ my $param = shift;
$param->{'job'} = $job;
warn Dumper($param) if $DEBUG;
my $gatewaynum = delete $param->{'gatewaynum'};
diff --git a/FS/FS/rate.pm b/FS/FS/rate.pm
index 9a5b905..a3826bf 100644
--- a/FS/FS/rate.pm
+++ b/FS/FS/rate.pm
@@ -422,13 +422,10 @@ Job-queue processor for web interface adds/edits
=cut
-use Storable qw(thaw);
use Data::Dumper;
-use MIME::Base64;
sub process {
my $job = shift;
-
- my $param = thaw(decode_base64(shift));
+ my $param = shift;
warn Dumper($param) if $DEBUG;
my $old = qsearchs('rate', { 'ratenum' => $param->{'ratenum'} } )
diff --git a/FS/FS/rate_detail.pm b/FS/FS/rate_detail.pm
index d50c89f..fcd9f58 100644
--- a/FS/FS/rate_detail.pm
+++ b/FS/FS/rate_detail.pm
@@ -310,9 +310,7 @@ sub conn_secs {
=cut
-use Storable qw(thaw);
use Data::Dumper;
-use MIME::Base64;
sub process_edit_import {
my $job = shift;
@@ -351,7 +349,7 @@ sub process_edit_import {
my @pass_params = @{ $opt->{params} };
my %formats = %{ $opt->{formats} };
- my $param = thaw(decode_base64(shift));
+ my $param = shift;
warn Dumper($param) if $DEBUG;
my $files = $param->{'uploaded_files'}
diff --git a/FS/FS/tax_class.pm b/FS/FS/tax_class.pm
index 04e9d37..904b575 100644
--- a/FS/FS/tax_class.pm
+++ b/FS/FS/tax_class.pm
@@ -31,26 +31,24 @@ FS::tax_class - Object methods for tax_class records
=head1 DESCRIPTION
-An FS::tax_class object represents a tax class. FS::tax_class
-inherits from FS::Record. The following fields are currently supported:
+An FS::tax_class object represents a class of tax definitions. FS::tax_class
+inherits from FS::Record.
-=over 4
-
-=item taxclassnum
-
-Primary key
+This should not be confused with L<FS::part_pkg_taxclass>, which defines tax
+classes for I<package> definitions. The two kinds of tax classes are
+completely unrelated.
-=item data_vendor
+The following fields are currently supported:
-Vendor of the tax data
+=over 4
-=item taxclass
+=item taxclassnum - Primary key
-Tax class
+=item data_vendor - Vendor of the tax data ('cch' or 'billsoft')
-=item description
+=item taxclass - The identifier used in the tax tables for this class.
-Human readable description of the tax class
+=item description - Human readable description of the tax class.
=back
@@ -321,6 +319,13 @@ sub batch_import {
'';
};
+ } elsif ( $format eq 'billsoft' ) {
+ # Billsoft doesn't actually have a format for this; it's just my own
+ # invention to have a way to load the list of tax classes from the
+ # documentation.
+ @fields = qw( taxclass description );
+ $endhook = $hook = sub {};
+
} elsif ( $format eq 'extended' ) {
die "unimplemented\n";
@fields = qw( );
diff --git a/FS/FS/tax_rate.pm b/FS/FS/tax_rate.pm
index d773ff5..a6da3d1 100644
--- a/FS/FS/tax_rate.pm
+++ b/FS/FS/tax_rate.pm
@@ -8,7 +8,6 @@ use vars qw( $DEBUG $me
use Date::Parse;
use DateTime;
use DateTime::Format::Strptime;
-use Storable qw( thaw nfreeze );
use IO::File;
use File::Temp;
use Text::CSV_XS;
@@ -16,7 +15,6 @@ use URI::Escape;
use LWP::UserAgent;
use HTTP::Request;
use HTTP::Response;
-use MIME::Base64;
use DBIx::DBSchema;
use DBIx::DBSchema::Table;
use DBIx::DBSchema::Column;
@@ -80,9 +78,10 @@ a location code provided by a tax authority
=item taxclassnum
-a foreign key into FS::tax_class - the type of tax
-referenced but FS::part_pkg_taxrate
-eitem effective_date
+a foreign key into FS::tax_class - the type of tax referenced by
+FS::part_pkg_taxrate
+
+=item effective_date
the time after which the tax applies
@@ -214,7 +213,7 @@ sub check {
|| $self->ut_text('geocode')
|| $self->ut_textn('data_vendor')
|| $self->ut_cch_textn('location')
- || $self->ut_foreign_key('taxclassnum', 'tax_class', 'taxclassnum')
+ || $self->ut_foreign_keyn('taxclassnum', 'tax_class', 'taxclassnum')
|| $self->ut_snumbern('effective_date')
|| $self->ut_float('tax')
|| $self->ut_floatn('excessrate')
@@ -380,7 +379,7 @@ sub passtype_name {
$tax_passtypes{$self->passtype};
}
-=item taxline TAXABLES
+=item taxline_cch TAXABLES, [ OPTIONSHASH ]
Returns a listref of a name and an amount of tax calculated for the list
of packages/amounts referenced by TAXABLES. If an error occurs, a message
@@ -388,7 +387,7 @@ is returned as a scalar.
=cut
-sub taxline {
+sub taxline_cch {
my $self = shift;
# this used to accept a hash of options but none of them did anything
# so it's been removed.
@@ -614,6 +613,36 @@ sub tax_rate_location {
}
+
+=item find_or_insert
+
+Finds an existing tax definition matching the data_vendor, taxname,
+taxclassnum, and geocode of this one, if one exists, and sets the contents of
+this tax rate equal to that one (including its taxnum). If an existing
+definition is not found, inserts this one. Returns an error string if
+inserting a record failed.
+
+=cut
+
+sub find_or_insert {
+ my $self = shift;
+ # this doesn't uniquely identify CCH taxes (kinda goofy, I know)
+ die "find_or_insert is not compatible with CCH taxes\n"
+ if $self->data_vendor eq 'cch';
+
+ my @keys = (qw(data_vendor taxname taxclassnum geocode));
+ my %hash = map { $_ => $self->get($_) } @keys;
+ my $existing = qsearchs('tax_rate', \%hash);
+ if ($existing) {
+ foreach ($self->fields) {
+ $self->set($_, $existing->get($_));
+ }
+ return;
+ } else {
+ return $self->insert;
+ }
+}
+
=back
=head1 SUBROUTINES
@@ -933,35 +962,25 @@ Load a batch import as a queued JSRPC job
=cut
sub process_batch_import {
- my $job = shift;
-
- my $oldAutoCommit = $FS::UID::AutoCommit;
- local $FS::UID::AutoCommit = 0;
- my $dbh = dbh;
-
- my $param = thaw(decode_base64(shift));
- my $args = '$job, encode_base64( nfreeze( $param ) )';
+ my ($job, $param) = @_;
- my $method = '_perform_batch_import';
if ( $param->{reload} ) {
- $method = 'process_batch_reload';
- }
-
- eval "$method($args);";
- if ($@) {
- $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
- die $@;
+ process_batch_reload($job, $param);
+ } else {
+ # '_perform', yuck
+ _perform_batch_import($job, $param);
}
- #success!
- $dbh->commit or die $dbh->errstr if $oldAutoCommit;
}
sub _perform_batch_import {
- my $job = shift;
+ my ($job, $param) = @_;
- my $param = thaw(decode_base64(shift));
- my $format = $param->{'format'}; #well... this is all cch specific
+ my $oldAutoCommit = $FS::UID::AutoCommit;
+ local $FS::UID::AutoCommit = 0;
+ my $dbh = dbh;
+
+ my $format = $param->{'format'};
my $files = $param->{'uploaded_files'}
or die "No files provided.";
@@ -969,20 +988,18 @@ sub _perform_batch_import {
my (%files) = map { /^(\w+):((taxdata\/\w+\.\w+\/)?[\.\w]+)$/ ? ($1,$2):() }
split /,/, $files;
+ my $dir = '%%%FREESIDE_CACHE%%%/cache.'. $FS::UID::datasrc;
+ my $error = '';
+
if ( $format eq 'cch' || $format eq 'cch-fixed'
|| $format eq 'cch-update' || $format eq 'cch-fixed-update' )
{
- my $oldAutoCommit = $FS::UID::AutoCommit;
- local $FS::UID::AutoCommit = 0;
- my $dbh = dbh;
- my $error = '';
my @insert_list = ();
my @delete_list = ();
my @predelete_list = ();
my $insertname = '';
my $deletename = '';
- my $dir = '%%%FREESIDE_CACHE%%%/cache.'. $FS::UID::datasrc;
my @list = ( 'GEOCODE', \&FS::tax_rate_location::batch_import,
'CODE', \&FS::tax_class::batch_import,
@@ -1051,19 +1068,45 @@ sub _perform_batch_import {
unlink $file or warn "Can't delete $file: $!";
}
- if ($error) {
- $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
- die $error;
- }else{
- $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+ } elsif ( $format =~ /^billsoft-(\w+)$/ ) {
+ my $mode = $1;
+ my $file = $dir.'/'.$files{'file'};
+ open my $fh, "< $file" or $error ||= "Can't open file $file: $!";
+ my @param = (
+ {
+ filehandle => $fh,
+ format => 'billsoft',
+ }, $job);
+ if ( $mode eq 'pcode' ) {
+ $error ||= FS::cust_tax_location::batch_import(@param);
+ seek $fh, 0, 0;
+ $error ||= FS::tax_rate_location::batch_import(@param);
+ } elsif ( $mode eq 'taxclass' ) {
+ $error ||= FS::tax_class::batch_import(@param);
+ } elsif ( $mode eq 'taxproduct' ) {
+ $error ||= FS::part_pkg_taxproduct::batch_import(@param);
+ } else {
+ die "unknown import mode 'billsoft-$mode'\n";
}
- }else{
+ } else {
die "Unknown format: $format";
}
+ if ($error) {
+ $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
+ die $error;
+ } else {
+ $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+ }
+
}
+#
+#
+# EVERYTHING THAT FOLLOWS IS CCH-SPECIFIC.
+#
+#
sub _perform_cch_tax_import {
my ( $job, $predelete_list, $insert_list, $delete_list, $addl_param ) = @_;
@@ -1549,15 +1592,20 @@ sub _copy_from_temp {
=item process_download_and_reload
Download and process a tax update as a queued JSRPC job after wiping the
-existing wipable tax data.
+existing wipeable tax data.
=cut
sub process_download_and_reload {
- _process_reload('process_download_and_update', @_);
+ _process_reload(\&process_download_and_update, @_);
}
-
+#
+#
+# END OF CCH STUFF
+#
+#
+
=item process_batch_reload
Load and process a tax update from the provided files as a queued JSRPC job
@@ -1566,15 +1614,12 @@ after wiping the existing wipable tax data.
=cut
sub process_batch_reload {
- _process_reload('_perform_batch_import', @_);
+ _process_reload(\&_perform_batch_import, @_);
}
-
sub _process_reload {
- my ( $method, $job ) = ( shift, shift );
-
- my $param = thaw(decode_base64($_[0]));
- my $format = $param->{'format'}; #well... this is all cch specific
+ my ( $continuation, $job, $param ) = @_;
+ my $format = $param->{'format'};
my ( $imported, $last, $min_sec ) = _progressbar_foo();
@@ -1588,47 +1633,79 @@ sub _process_reload {
my $dbh = dbh;
my $error = '';
- my $sql =
- "SELECT count(*) FROM part_pkg_taxoverride JOIN tax_class ".
- "USING (taxclassnum) WHERE data_vendor = '$format'";
- my $sth = $dbh->prepare($sql) or die $dbh->errstr;
- $sth->execute
- or die "Unexpected error executing statement $sql: ". $sth->errstr;
- die "Don't (yet) know how to handle part_pkg_taxoverride records."
- if $sth->fetchrow_arrayref->[0];
-
- # really should get a table EXCLUSIVE lock here
-
- #remember disabled taxes
- my %disabled_tax_rate = ();
- $error ||= _remember_disabled_taxes( $job, $format, \%disabled_tax_rate );
-
- #remember tax products
- my %taxproduct = ();
- $error ||= _remember_tax_products( $job, $format, \%taxproduct );
-
- #create temp tables
- $error ||= _create_temporary_tables( $job, $format );
-
- #import new data
- unless ($error) {
- my $args = '$job, @_';
- eval "$method($args);";
- $error = $@ if $@;
- }
+ if ( $format =~ /^cch/ ) {
+ # no, THIS part is CCH specific
- #restore taxproducts
- $error ||= _restore_remembered_tax_products( $job, $format, \%taxproduct );
+ my $sql =
+ "SELECT count(*) FROM part_pkg_taxoverride JOIN tax_class ".
+ "USING (taxclassnum) WHERE data_vendor = '$format'";
+ my $sth = $dbh->prepare($sql) or die $dbh->errstr;
+ $sth->execute
+ or die "Unexpected error executing statement $sql: ". $sth->errstr;
+ die "Don't (yet) know how to handle part_pkg_taxoverride records."
+ if $sth->fetchrow_arrayref->[0];
- #disable tax_rates
- $error ||=
- _restore_remembered_disabled_taxes( $job, $format, \%disabled_tax_rate );
+ # really should get a table EXCLUSIVE lock here
- #wipe out the old data
- $error ||= _remove_old_tax_data( $job, $format );
+ #remember disabled taxes
+ my %disabled_tax_rate = ();
+ $error ||= _remember_disabled_taxes( $job, $format, \%disabled_tax_rate );
- #untemporize
- $error ||= _copy_from_temp( $job, $format );
+ #remember tax products
+ my %taxproduct = ();
+ $error ||= _remember_tax_products( $job, $format, \%taxproduct );
+
+ #create temp tables
+ $error ||= _create_temporary_tables( $job, $format );
+
+ #import new data
+ unless ($error) {
+ eval { &{$continuation}( $job, $param ) };
+ $error = $@ if $@;
+ }
+
+ #restore taxproducts
+ $error ||= _restore_remembered_tax_products( $job, $format, \%taxproduct );
+
+ #disable tax_rates
+ $error ||=
+ _restore_remembered_disabled_taxes( $job, $format, \%disabled_tax_rate );
+
+ #wipe out the old data
+ $error ||= _remove_old_tax_data( $job, $format );
+
+ #untemporize
+ $error ||= _copy_from_temp( $job, $format );
+
+ } elsif ( $format =~ /^billsoft-(\w+)/ ) {
+
+ my $mode = $1;
+ my @sql;
+ if ( $mode eq 'pcode' ) {
+ push @sql,
+ "DELETE FROM cust_tax_location WHERE data_vendor = 'billsoft'",
+ "UPDATE tax_rate_location SET disabled = 'Y' WHERE data_vendor = 'billsoft'";
+ } elsif ( $mode eq 'taxclass' ) {
+ push @sql,
+ "DELETE FROM tax_class WHERE data_vendor = 'billsoft'";
+ } elsif ( $mode eq 'taxproduct' ) {
+ push @sql,
+ "DELETE FROM part_pkg_taxproduct WHERE data_vendor = 'billsoft'";
+ }
+
+ foreach (@sql) {
+ if (!$dbh->do($_)) {
+ $error = $dbh->errstr;
+ last;
+ }
+ }
+
+ unless ( $error ) {
+ local $@;
+ eval { &{ $continuation }($job, $param) };
+ $error = $@;
+ }
+ } # if ($format ...)
if ($error) {
$dbh->rollback or die $dbh->errstr if $oldAutoCommit;
@@ -1649,7 +1726,7 @@ Download and process a tax update as a queued JSRPC job
sub process_download_and_update {
my $job = shift;
- my $param = thaw(decode_base64(shift));
+ my $param = shift;
my $format = $param->{'format'}; #well... this is all cch specific
my ( $imported, $last, $min_sec ) = _progressbar_foo();
@@ -1752,7 +1829,7 @@ sub process_download_and_update {
$param->{uploaded_files} = join( ',', @list );
$param->{format} .= '-update' if $update;
$error ||=
- _perform_batch_import( $job, encode_base64( nfreeze( $param ) ) );
+ _perform_batch_import( $job, $param );
rename "$dir.new", "$dir"
or die "cch tax update processed, but can't rename $dir.new: $!\n";
@@ -1855,7 +1932,7 @@ PARAMS needs to be a base64-encoded Storable hash containing:
sub queue_liability_report {
my $job = shift;
- my $param = thaw(decode_base64(shift));
+ my $param = shift;
my $cgi = new CGI;
$cgi->param('beginning', $param->{beginning});
@@ -2160,11 +2237,17 @@ EOF
=head1 BUGS
+ Highly specific to CCH taxes. This should arguably go in some kind of
+ subclass (FS::tax_rate::CCH) with auto-reblessing, similar to part_pkg
+ subclasses. But currently there aren't any other options, so.
+
Mixing automatic and manual editing works poorly at present.
Tax liability calculations take too long and arguably don't belong here.
Tax liability report generation not entirely safe (escaped).
+ Sparse documentation.
+
=head1 SEE ALSO
L<FS::Record>, L<FS::cust_location>, L<FS::cust_bill>
diff --git a/FS/FS/tax_rate_location.pm b/FS/FS/tax_rate_location.pm
index b4be8b9..d9646e4 100644
--- a/FS/FS/tax_rate_location.pm
+++ b/FS/FS/tax_rate_location.pm
@@ -26,39 +26,32 @@ FS::tax_rate_location - Object methods for tax_rate_location records
=head1 DESCRIPTION
-An FS::tax_rate_location object represents an example. FS::tax_rate_location inherits from
-FS::Record. The following fields are currently supported:
+An FS::tax_rate_location object represents a tax jurisdiction. The only
+functional field is "geocode", a foreign key to tax rates (L<FS::tax_rate>)
+that apply in the jurisdiction. The city, county, state, and country fields
+are provided for description and reporting.
-=over 4
-
-=item taxratelocationnum
-
-Primary key (assigned automatically for new tax_rate_locations)
-
-=item data_vendor
+FS::tax_rate_location inherits from FS::Record. The following fields are
+currently supported:
-The tax data vendor
-
-=item geocode
-
-A unique geographic location code provided by the data vendor
-
-=item city
+=over 4
-City
+=item taxratelocationnum - Primary key (assigned automatically for new
+tax_rate_locations)
-=item county
+=item data_vendor - The tax data vendor ('cch' or 'billsoft').
-County
+=item geocode - A unique geographic location code provided by the data vendor
-=item state
+=item city - City
-State
+=item county - County
-=item disabled
+=item state - State (2-letter code)
-If 'Y' this record is no longer active.
+=item country - Country (2-letter code, optional)
+=item disabled - If 'Y' this record is no longer active.
=back
@@ -149,6 +142,40 @@ sub check {
$self->SUPER::check;
}
+=item find_or_insert
+
+Finds an existing, non-disabled tax jurisdiction matching the data_vendor
+and geocode fields. If there is one, updates its city, county, state, and
+country to match this record. If there is no existing record, inserts this
+record.
+
+=cut
+
+sub find_or_insert {
+ my $self = shift;
+ my $existing = qsearchs('tax_rate_location', {
+ disabled => '',
+ data_vendor => $self->data_vendor,
+ geocode => $self->geocode
+ });
+ if ($existing) {
+ my $update = 0;
+ foreach (qw(city county state country)) {
+ if ($self->get($_) ne $existing->get($_)) {
+ $update++;
+ }
+ }
+ $self->set(taxratelocationnum => $existing->taxratelocationnum);
+ if ($update) {
+ return $self->replace($existing);
+ } else {
+ return;
+ }
+ } else {
+ return $self->insert;
+ }
+}
+
=back
=head1 CLASS METHODS
@@ -186,10 +213,17 @@ sub location_sql {
=over 4
-=item batch_import
+=item batch_import HASHREF, JOB
+
+Starts importing tax_rate_location records from a file. HASHREF must contain
+'filehandle' (an open handle to the input file) and 'format' (one of 'cch',
+'cch-fixed', 'cch-update', 'cch-fixed-update', or 'billsoft'). JOB is an
+L<FS::queue> object to receive progress messages.
=cut
+# XXX move this into TaxEngine modules at some point
+
sub batch_import {
my ($param, $job) = @_;
@@ -214,7 +248,7 @@ sub batch_import {
my $line;
my ( $count, $last, $min_sec ) = (0, time, 5); #progressbar
- if ( $job || scalar(@column_callbacks) ) {
+ if ( $job || scalar(@column_callbacks) ) { # this makes zero sense
my $error =
csv_from_fixed(\$fh, \$count, \@column_lengths, \@column_callbacks);
return $error if $error;
@@ -251,6 +285,29 @@ sub batch_import {
};
+ } elsif ( $format eq 'billsoft' ) {
+ @fields = ( qw( geocode alt_location country state county city ), '', '' );
+
+ $hook = sub {
+ my $hash = shift;
+ if ($hash->{alt_location}) {
+ # don't import these; the jurisdiction should be named using its
+ # primary city
+ %$hash = ();
+ return;
+ }
+
+ $hash->{data_vendor} = 'billsoft';
+ # unlike cust_tax_location, keep the whole-country and whole-state
+ # rows, but strip the whitespace
+ $hash->{county} =~ s/^ //g;
+ $hash->{state} =~ s/^ //g;
+ $hash->{country} =~ s/^ //g;
+ $hash->{city} =~ s/[^\w ]//g; # remove asterisks and other bad things
+ $hash->{country} = substr($hash->{country}, 0, 2);
+ '';
+ }
+
} elsif ( $format eq 'extended' ) {
die "unimplemented\n";
@fields = qw( );
@@ -286,7 +343,8 @@ sub batch_import {
if ( $job ) { # progress bar
if ( time - $min_sec > $last ) {
my $error = $job->update_statustext(
- int( 100 * $imported / $count )
+ int( 100 * $imported / $count ) .
+ ',Creating tax jurisdiction records'
);
die $error if $error;
$last = time;
diff --git a/FS/FS/tax_status.pm b/FS/FS/tax_status.pm
new file mode 100644
index 0000000..f03eeca
--- /dev/null
+++ b/FS/FS/tax_status.pm
@@ -0,0 +1,163 @@
+package FS::tax_status;
+
+use strict;
+use base qw( FS::Record );
+use FS::Record qw( qsearch qsearchs );
+
+our %initial_data;
+
+=head1 NAME
+
+FS::tax_status - Object methods for tax_status records
+
+=head1 SYNOPSIS
+
+ use FS::tax_status;
+
+ $record = new FS::tax_status \%hash;
+ $record = new FS::tax_status { 'column' => 'value' };
+
+ $error = $record->insert;
+
+ $error = $new_record->replace($old_record);
+
+ $error = $record->delete;
+
+ $error = $record->check;
+
+=head1 DESCRIPTION
+
+An FS::tax_status object represents a customer tax status for use with
+an external tax table. FS::tax_status inherits from FS::Record. The
+following fields are currently supported:
+
+=over 4
+
+=item taxstatusnum
+
+primary key
+
+=item data_vendor
+
+Data vendor name (corresponds to the value of the C<taxproduct> config
+variable.)
+
+=item taxstatus
+
+The data vendor's name or code for the tax status.
+
+=item description
+
+Description for use in the Freeside UI.
+
+=back
+
+=head1 METHODS
+
+=over 4
+
+=item new HASHREF
+
+Creates a new tax status. To add the record to the database, see L<"insert">.
+
+=cut
+
+sub table { 'tax_status'; }
+
+=item insert
+
+Adds this record to the database. If there is an error, returns the error,
+otherwise returns false.
+
+=item delete
+
+Delete this record from the database.
+
+=item replace OLD_RECORD
+
+Replaces the OLD_RECORD with this one in the database. If there is an error,
+returns the error, otherwise returns false.
+
+=item check
+
+Checks all fields to make sure this is a valid example. If there is
+an error, returns the error, otherwise returns false. Called by the insert
+and replace methods.
+
+=cut
+
+sub check {
+ my $self = shift;
+
+ my $error =
+ $self->ut_numbern('taxstatusnum')
+ || $self->ut_textn('data_vendor')
+ || $self->ut_text('taxstatus')
+ || $self->ut_text('description')
+ ;
+ return $error if $error;
+
+ $self->SUPER::check;
+}
+
+sub _upgrade_data {
+ my $self = shift;
+ my $error;
+ foreach my $data_vendor ( keys %initial_data ) {
+ my $status_hash = $initial_data{$data_vendor};
+ foreach my $taxstatus (sort keys %$status_hash) {
+ my $description = $status_hash->{$taxstatus};
+ my $tax_status;
+ if ($tax_status = qsearchs('tax_status', {
+ data_vendor => $data_vendor,
+ taxstatus => $taxstatus
+ }))
+ {
+ if ($tax_status->description ne $description) {
+ $tax_status->set(description => $description);
+ $error = $tax_status->replace;
+ }
+ # else it's already correct
+ } else {
+ $tax_status = FS::tax_status->new({
+ data_vendor => $data_vendor,
+ taxstatus => $taxstatus,
+ description => $description
+ });
+ $error = $tax_status->insert;
+ }
+ die $error if $error;
+ }
+ }
+}
+
+%initial_data = (
+ 'avalara' => {
+ 'A' => 'Federal Government',
+ 'B' => 'State/Local Government',
+ 'C' => 'Tribal Government',
+ 'D' => 'Foreign Diplomat',
+ 'E' => 'Charitable Organization',
+ 'F' => 'Religious/Education',
+ 'G' => 'Resale',
+ 'H' => 'Agricultural Production',
+ 'I' => 'Industrial Production',
+ 'J' => 'Direct Pay Permit',
+ 'K' => 'Direct Mail',
+ 'L' => 'Other',
+ 'M' => 'Local Government',
+ # P, Q, R: Canada, not yet supported
+ # MED1/MED2: totally irrelevant to our users
+ },
+);
+
+=back
+
+=head1 SEE ALSO
+
+L<FS::Record>, schema.html from the base documentation.
+
+=cut
+
+1;
+
diff --git a/FS/MANIFEST b/FS/MANIFEST
index 9a9573d..4811e10 100644
--- a/FS/MANIFEST
+++ b/FS/MANIFEST
@@ -818,3 +818,5 @@ FS/deploy_zone_block.pm
t/deploy_zone_block.t
FS/deploy_zone_vertex.pm
t/deploy_zone_vertex.t
+FS/tax_status.pm
+t/tax_status.t
diff --git a/FS/bin/freeside-daily b/FS/bin/freeside-daily
index 294099a..f14e2b3 100755
--- a/FS/bin/freeside-daily
+++ b/FS/bin/freeside-daily
@@ -27,6 +27,10 @@ expire_banned_pay(%opt);
use FS::Cron::bill qw(bill);
bill(%opt);
+#you can skip this by not setting enable_taxproducts to 'billsoft'
+use FS::Cron::tax_batch qw(process_tax_batch);
+process_tax_batch(%opt);
+
#you can skip this just by not having the config
use FS::Cron::breakage qw(reconcile_breakage);
reconcile_breakage(%opt);
diff --git a/FS/bin/freeside-tax-location-update b/FS/bin/freeside-tax-location-update
new file mode 100644
index 0000000..f45e5e1
--- /dev/null
+++ b/FS/bin/freeside-tax-location-update
@@ -0,0 +1,75 @@
+#!/usr/bin/perl
+
+use strict;
+use Getopt::Std;
+use FS::UID qw(adminsuidsetup);
+use FS::Record qw(qsearch dbh);
+use FS::TaxEngine;
+use FS::cust_location;
+
+my %opt;
+getopts('d', \%opt);
+
+my $user = shift or die &usage;
+adminsuidsetup($user);
+$FS::UID::AutoCommit = 0;
+my $dbh = dbh;
+
+my $engine = FS::TaxEngine->new;
+my %hash = ( 'geocode' => '',
+ 'country' => 'US' );
+$hash{'disabled'} = '' unless $opt{d};
+my @locations = qsearch('cust_location', \%hash);
+foreach my $location (@locations) {
+ print $location->location_label . "...";
+ # only take the first one (the 'default')
+ my ($cust_tax_location) = $engine->cust_tax_locations($location);
+ if ($cust_tax_location) {
+ print $cust_tax_location->geocode;
+ $location->set('geocode', $cust_tax_location->geocode);
+ # geocode is not an immutable location field, so this is safe
+ my $error = $location->replace;
+ if ( $error ) {
+ print "$error\n";
+ }
+ } else {
+ print "not found.";
+ }
+ print "\n";
+}
+$dbh->commit;
+print "Finished!\n";
+
+sub usage {
+ "Usage:\n\n freeside-tax-location-update [ -d ] user\n\n"
+ }
+
+=head1 NAME
+
+freeside-tax-location-update - Update service locations with tax data vendor
+codes.
+
+=head1 SYNOPSIS
+
+ freeside-tax-location-update [ -d ] user
+
+=head1 DESCRIPTION
+
+When using tax tables from an external vendor, there's a table of tax
+jurisdiction codes that act as a foreign key to the tax rate definitions.
+The jurisdiction is usually chosen based on the customer's postal code.
+
+This script finds all non-disabled customer locations that don't have a
+value in the 'geocode' field, finds the most likely matching geocode in the
+cust_tax_location table, and stores that geocode in the cust_location record.
+This is not guaranteed to be accurate. There may be multiple correct
+geocodes for a given zip code; the script chooses the one that's marked
+as "default".
+
+The -d option tells the script to work on disabled location records as well,
+which is not likely to be necessary.
+
+Updating the geocode this way is not a location change and does not trigger
+a cancel/reorder of the customer's packages.
+
+=cut
diff --git a/FS/t/tax_status.t b/FS/t/tax_status.t
new file mode 100644
index 0000000..9c99492
--- /dev/null
+++ b/FS/t/tax_status.t
@@ -0,0 +1,5 @@
+BEGIN { $| = 1; print "1..1\n" }
+END {print "not ok 1\n" unless $loaded;}
+use FS::tax_status;
+$loaded=1;
+print "ok 1\n";
diff --git a/httemplate/browse/part_pkg_taxproduct/avalara.html b/httemplate/browse/part_pkg_taxproduct/avalara.html
new file mode 100755
index 0000000..e8da589
--- /dev/null
+++ b/httemplate/browse/part_pkg_taxproduct/avalara.html
@@ -0,0 +1,84 @@
+<& /elements/header-popup.html, { title => 'Select tax product' } &>
+<form NAME="myform">
+<table class="inv" width="100%">
+<& /elements/tr-select-table.html,
+ 'label' => 'Tax product',
+ 'field' => 'taxproductnum',
+ 'table' => 'part_pkg_taxproduct',
+ 'hashref' => { data_vendor => 'avalara' },
+ 'name_col' => 'taxproduct', # for sorting
+ 'label_callback' => $label_callback,
+ 'curr_value' => $taxproductnum,
+ 'empty_label' => 'none',
+ 'onchange' => 'select_onchange',
+&>
+</table>
+<table class="inv" width="100%">
+<tr>
+ <td style="border-top: 1px solid #7e0079; text-align: center" colspan=2>
+ Add a new tax product</td>
+</tr>
+<tr>
+ <td style="text-align: right">Avalara tax code</td>
+ <td><input name="taxproduct" size=8></td>
+</tr>
+<tr>
+ <td style="text-align: right">Description</td>
+ <td><input name="description" size=20></td>
+</tr>
+<tr>
+ <td colspan="2" style="text-align: center">
+ <input type="button" onclick="add_new()" value="Add" />
+ </td>
+</tr>
+</table>
+</form>
+<SCRIPT TYPE="text/javascript">
+function select_onchange() {
+ var select = document.forms['myform']['taxproductnum'];
+ parent.document.getElementById('<% $id %>').value = select.value;
+ parent.document.getElementById('<% $id %>_description').value =
+ select.options[select.selectedIndex].text;
+ parent.nd(1);
+}
+function add_new() {
+ parent.document.getElementById('<% $id %>').value = -1;
+ parent.document.getElementById('<% $id %>_description').value =
+ document.forms['myform']['taxproduct'].value + ' ' +
+ document.forms['myform']['description'].value;
+ parent.nd(1);
+}
+</SCRIPT>
+</BODY>
+</HTML>
+<%once>
+
+my $conf = new FS::Conf;
+
+</%once>
+<%init>
+
+die "access denied"
+ unless $FS::CurrentUser::CurrentUser->access_right('Edit package definitions');
+
+warn Dumper({ $cgi->Vars });
+
+# id: where to put the taxproductnum (in the parent document) after the user
+# selects it
+$cgi->param('id') =~ /^([ \w]+)$/
+ or die "id parameter required";
+my $id = $1;
+
+# current value of taxproductnum
+my $taxproductnum = '';
+if ($cgi->param('taxproductnum') =~ /^(\d+)$/) {
+ $taxproductnum = $1;
+}
+
+my $label_callback = sub {
+ my $part_pkg_taxproduct = shift;
+ join(' ', $part_pkg_taxproduct->taxproduct,
+ $part_pkg_taxproduct->description);
+};
+
+</%init>
diff --git a/httemplate/browse/part_pkg_taxproduct/billsoft.html b/httemplate/browse/part_pkg_taxproduct/billsoft.html
new file mode 100755
index 0000000..c58ac30
--- /dev/null
+++ b/httemplate/browse/part_pkg_taxproduct/billsoft.html
@@ -0,0 +1,146 @@
+<& /elements/header-popup.html, $title &>
+<& /browse/elements/browse.html,
+ 'name_singular' => 'tax product',
+ 'html_form' => include('.form', $service_code, $trans_code),
+ 'query' => {
+ 'table' => 'part_pkg_taxproduct',
+ 'hashref' => $hashref,
+ 'order_by' => 'ORDER BY taxproduct',
+ },
+ 'count_query' => $count_query,
+ 'header' => \@header,
+ 'fields' => \@fields,
+ 'align' => $align,
+ 'links' => [],
+ 'link_onclicks' => \@link_onclicks,
+ 'nohtmlheader' => 1,
+&>
+<%shared>
+# populate dropdowns
+
+# taxproduct is 12 digits. First half is the service type code, second
+# half is the transaction code. Description is also two parts, corresponding
+# to those codes, separated with a :.
+
+my (@service_codes, @trans_codes, %service_labels, %trans_labels);
+foreach my $row ( qsearch({
+ table => 'part_pkg_taxproduct',
+ select => 'DISTINCT substr(taxproduct, 1, 6) AS code, '.
+ "substring(description from '(.*):') AS label",
+ }))
+{
+ $service_labels{$row->get('code')} =
+ sprintf('%02d %s', $row->get('code'), $row->get('label'));
+}
+foreach my $row ( qsearch({
+ table => 'part_pkg_taxproduct',
+ select => 'DISTINCT substr(taxproduct, 7, 6) AS code, '.
+ "substring(description from ':(.*)') AS label",
+ }))
+{
+ $trans_labels{$row->get('code')} =
+ sprintf('%02d %s', $row->get('code'), $row->get('label'));
+}
+$service_labels{''} = $trans_labels{''} = '';
+
+@service_codes = sort {$a <=> $b} keys %service_labels;
+@trans_codes = sort {$a <=> $b} keys %trans_labels;
+
+</%shared>
+<%def .form>
+% my ($service_code, $trans_code) = @_;
+<FORM ACTION="<% $cgi->url %>" METHOD="GET">
+<& /elements/select.html,
+ field => 'service_code',
+ options => \@service_codes,
+ labels => \%service_labels,
+ curr_value => $service_code,
+ onchange => 'this.form.submit()',
+&>
+&nbsp;
+<& /elements/select.html,
+ field => 'trans_code',
+ options => \@trans_codes,
+ labels => \%trans_labels,
+ curr_value => $trans_code,
+ onchange => 'this.form.submit()',
+&>
+<& /elements/hidden.html,
+ field => 'id',
+ curr_value => $cgi->param('id'),
+&>
+</%def>
+<%init>
+
+die "access denied"
+ unless $FS::CurrentUser::CurrentUser->access_right('Configuration');
+
+$cgi->param('id') =~ /^\w+$/ or die "missing id parameter";
+my $id = $cgi->param('id');
+
+my $select_onclick = sub {
+ my $row = shift;
+ my $taxnum = $row->taxproductnum;
+ my $desc = $row->description;
+ "parent.document.getElementById('$id').value = $taxnum;".
+ "parent.document.getElementById('${id}_description').value = '$desc';".
+ "parent.cClick();";
+}
+ if $id;
+
+my @menubar;
+my $title = 'Tax Products';
+
+my $hashref = { data_vendor => 'billsoft' };
+
+my ($service_code, $trans_code, $taxproduct);
+if ( $cgi->param('service_code') =~ /^(\d+)$/ ) {
+ $service_code = $1;
+ $taxproduct = sprintf('%06d', $service_code);
+} else {
+ $taxproduct = '%';
+}
+
+if ( $cgi->param('trans_code') =~ /^(\d+)$/ ) {
+ $trans_code = $1;
+ $taxproduct .= sprintf('%06d', $trans_code);
+} elsif ( $service_code ) {
+ $taxproduct .= '%';
+}
+
+$hashref->{taxproduct} = { op => 'LIKE', value => $taxproduct };
+
+my $count_query = "SELECT COUNT(*) FROM part_pkg_taxproduct ".
+ "WHERE data_vendor = 'billsoft' AND ".
+ "taxproduct LIKE '$taxproduct'";
+
+my $sub_service_desc = sub {
+ my $ppt = shift; #part_pkg_taxproduct
+ my @codes = ($ppt->taxproduct =~ /(\d{6})(\d{6})/);
+ my @descs = split(':', $ppt->description);
+ $ppt->set('service_desc' => sprintf('%02d %s', $codes[0], $descs[0]));
+ $ppt->set('trans_desc' => sprintf('%02d %s', $codes[1], $descs[1]));
+ $ppt->service_desc;
+};
+
+my $sub_trans_desc = sub {
+ my $ppt = shift;
+ $ppt->trans_desc;
+};
+
+my @fields = (
+ $sub_service_desc,
+ $sub_trans_desc,
+ 'note'
+);
+
+my @header = (
+ 'Service Type',
+ 'Transaction',
+ '',
+);
+
+my $align = 'lll';
+my @link_onclicks = ( $select_onclick, $select_onclick );
+
+</%init>
diff --git a/httemplate/browse/part_pkg_taxproduct.cgi b/httemplate/browse/part_pkg_taxproduct/cch.html
index 7e0cb81..b901bad 100755
--- a/httemplate/browse/part_pkg_taxproduct.cgi
+++ b/httemplate/browse/part_pkg_taxproduct/cch.html
@@ -1,4 +1,4 @@
-<% include( 'elements/browse.html',
+<% include( '../elements/browse.html',
'title' => "Tax Products $title",
'name_singular' => 'tax product',
'menubar' => \@menubar,
@@ -33,12 +33,7 @@ my @menubar;
my $title = '';
my $onclick = 'cClick';
-my $data_vendor = '';
-if ( $cgi->param('data_vendor') =~ /^(\w+)$/ ) {
- $data_vendor = $1;
- $title = "$data_vendor";
-}
-$cgi->delete('data_vendor');
+my $data_vendor = 'cch';
$title = " for $title" if $title;
@@ -83,10 +78,7 @@ if ($taxproductnum) {
}
my $hashref = {};
-my $extra_sql = '';
-if ( $data_vendor ) {
- $extra_sql .= ' WHERE data_vendor = '. dbh->quote($data_vendor);
-}
+my $extra_sql .= ' WHERE data_vendor = '. dbh->quote($data_vendor);
if ($tax_group || $tax_item || $tax_customer || $tax_provider) {
my $compare = "LIKE '". ( $tax_group || "%" ). " : ". ( $tax_item || "%" ). " : ".
@@ -111,7 +103,6 @@ if ( $tax_group || $tax_item || $tax_provider || $tax_customer ) {
$cgi->param('dummy', 1);
#restore this so pagination works
-$cgi->param('data_vendor', $data_vendor) if $data_vendor;
$cgi->param('tax_group', $tax_group) if $tax_group;
$cgi->param('tax_item', $tax_item ) if $tax_item;
$cgi->param('tax_provider', $tax_provider ) if $tax_provider;
@@ -120,23 +111,20 @@ $cgi->param('onclick', $onclick ) if $onclick;
my $count_query = "SELECT COUNT(*) FROM part_pkg_taxproduct $extra_sql";
-my @header = ( 'Data Vendor', 'Group', 'Item', 'Provider', 'Customer' );
+my @header = ( 'Group', 'Item', 'Provider', 'Customer' );
my @links = ( $select_link,
$select_link,
$select_link,
$select_link,
- $select_link,
);
my @link_onclicks = ( $select_onclick,
$select_onclick,
$select_onclick,
$select_onclick,
- $select_onclick,
);
-my $align = 'lllll';
+my $align = 'llll';
my @fields = (
- 'data_vendor',
sub { shift->description =~ /^(.*):.*:.*:.*$/; $1;},
sub { shift->description =~ /^.*:(.*):.*:.*$/; $1;},
sub { shift->description =~ /^.*:.*:(.*):.*$/; $1;},
@@ -162,31 +150,16 @@ $html_init .= qq(
<INPUT NAME="id" TYPE="hidden" VALUE="$id">
<TABLE>
<TR>
- <TD><SELECT NAME="data_vendor" onChange="this.form.submit()">
-);
-
-my $sql = "SELECT DISTINCT data_vendor FROM part_pkg_taxproduct ORDER BY data_vendor";
-my $dbh = dbh;
-my $sth = $dbh->prepare($sql) or die $dbh->errstr;
-$sth->execute or die $sth->errstr;
-for (['(choose data vendor)'], @{$sth->fetchall_arrayref}) {
- $html_init .= '<OPTION VALUE="'. $_->[0]. '"'.
- ($_->[0] eq $data_vendor ? " SELECTED" : "").
- '">'. $_->[0];
-}
-$html_init .= qq(
- </SELECT>
-
-<!-- cch specific -->
<TD><SELECT NAME="tax_group" onChange="this.form.submit()">
);
-$sql = "SELECT DISTINCT ".
+my $sql = "SELECT DISTINCT ".
qq!substring(description from '#"%#" : % : % : %' for '#'),!.
qq!substring(description from '#"%#" : % : % : %' for '#')!.
"FROM part_pkg_taxproduct ORDER BY 1";
-$sth = $dbh->prepare($sql) or die $dbh->errstr;
+my $dbh = dbh;
+my $sth = $dbh->prepare($sql) or die $dbh->errstr;
$sth->execute or die $sth->errstr;
for (['', '(choose group)'], @{$sth->fetchall_arrayref}) {
$html_init .= '<OPTION VALUE="'. $_->[0]. '"'.
diff --git a/httemplate/edit/cust_main.cgi b/httemplate/edit/cust_main.cgi
index ddb61fa..ae5085e 100755
--- a/httemplate/edit/cust_main.cgi
+++ b/httemplate/edit/cust_main.cgi
@@ -25,7 +25,7 @@
%# agent, agent_custid, refnum (advertising source), referral_custnum
%# better section title for this?
<FONT CLASS="fsinnerbox-title"><% mt('Basics') |h %></FONT>
-<& cust_main/top_misc.html, $cust_main, 'custnum' => $custnum &>
+<& cust_main/basics.html, $cust_main, 'custnum' => $custnum &>
%# birthdate
% if ( $conf->config('national_id-country')
diff --git a/httemplate/edit/cust_main/top_misc.html b/httemplate/edit/cust_main/basics.html
index 41dd563..91868d4 100644
--- a/httemplate/edit/cust_main/top_misc.html
+++ b/httemplate/edit/cust_main/basics.html
@@ -145,6 +145,13 @@
'label' => emt("Class"),
&>
+%# tax status
+<& /elements/tr-select-tax_status.html,
+ 'curr_value' => $cust_main->taxstatusnum,
+ 'disable_empty' => 0,
+ 'empty_label' => ' ',
+&>
+
%#sales person
<& /elements/tr-select-sales.html,
'curr_value' => $cust_main->salesnum,
diff --git a/httemplate/edit/process/part_pkg.cgi b/httemplate/edit/process/part_pkg.cgi
index d27ddb0..0343cc0 100755
--- a/httemplate/edit/process/part_pkg.cgi
+++ b/httemplate/edit/process/part_pkg.cgi
@@ -96,11 +96,31 @@ my $args_callback = sub {
grep { $_ !~ /^report_option_/ }
@options;
- foreach ( split(',', $cgi->param('taxproductnums') ) ) {
- my $value = $cgi->param("taxproductnum_$_");
- $error ||= "Illegal taxproductnum_$_: $value"
+ foreach my $class ( '', split(',', $cgi->param('taxproductnums') ) ) {
+ my $param = 'taxproductnum';
+ $param .= "_$class" if length($class); # gah, "_$class"?
+ my $value = $cgi->param($param);
+
+ if ( $value == -1 ) {
+ my $desc = $cgi->param($param.'_description');
+ # insert a new part_pkg_taxproduct
+ my $engine = FS::TaxEngine->new;
+ my $obj_or_error = $engine->add_taxproduct($desc);
+ if (ref $obj_or_error) {
+ $value = $obj_or_error->taxproductnum;
+ $cgi->param($param, $value); # for error handling
+ } else {
+ die "$obj_or_error (adding tax product)";
+ }
+ }
+
+ $error ||= "Illegal $param: $value"
unless ( $value =~ /^\d*$/ );
- $options{"usage_taxproductnum_$_"} = $value;
+ if (length($class)) {
+ $options{"usage_taxproductnum_$_"} = $value;
+ } else {
+ $new->set('taxproductnum', $value);
+ }
}
foreach ( grep $_, $cgi->param('report_option') ) {
diff --git a/httemplate/elements/select-table.html b/httemplate/elements/select-table.html
index e736388..0b04fee 100644
--- a/httemplate/elements/select-table.html
+++ b/httemplate/elements/select-table.html
@@ -89,10 +89,12 @@ Example:
<OPTION VALUE=""><% $opt{'empty_label'} || 'all' %>
% }
-% foreach my $record ( sort { $a->$name_col() cmp $b->$name_col()
-% || $a->$key() <=> $b->$key()
-% }
-% @records
+% foreach my $record (
+% # we have the order_by parameter for this
+% #sort { $a->$name_col() cmp $b->$name_col()
+% # || $a->$key() <=> $b->$key()
+% # }
+% @records
% )
% {
% my $recvalue = $record->$key();
@@ -174,7 +176,8 @@ if ( $opt{'agent_virt'} ) {
my @records = ();
if ( $opt{'records'} ) {
- @records = @{ $opt{'records'} };
+ @records = sort { $a->get($name_col) cmp $b->get($name_col) }
+ @{ $opt{'records'} };
} else {
@records = qsearch( {
'table' => $opt{'table'},
diff --git a/httemplate/elements/select-taxproduct.html b/httemplate/elements/select-taxproduct.html
index 0f6ef55..07e5549 100644
--- a/httemplate/elements/select-taxproduct.html
+++ b/httemplate/elements/select-taxproduct.html
@@ -23,6 +23,9 @@ unless ( $description || ! $value ) {
if $part_pkg_taxproduct;
}
-my $onclick = $opt{onclick} || "overlib( OLiframeContent('${p}/browse/part_pkg_taxproduct.cgi?_type=select&id=${name}&taxproductnum='+document.getElementById('${name}').value, 1000, 400, 'tax_product_popup'), CAPTION, 'Select product', STICKY, AUTOSTATUSCAP, MIDX, 0, MIDY, 0, DRAGGABLE, CLOSECLICK); return false;";
+my $conf = FS::Conf->new;
+my $vendor = lc($conf->config('enable_taxproducts'));
+my $onclick = $opt{onclick} ||
+ "overlib( OLiframeContent('${p}/browse/part_pkg_taxproduct/$vendor.html?_type=select&id=${name}&taxproductnum='+document.getElementById('${name}').value, 1000, 400, 'tax_product_popup'), CAPTION, 'Select product', STICKY, AUTOSTATUSCAP, MIDX, 0, MIDY, 0, DRAGGABLE, CLOSECLICK); return false;";
</%init>
diff --git a/httemplate/elements/standardize_locations.js b/httemplate/elements/standardize_locations.js
index 2d1fc74..817a2e3 100644
--- a/httemplate/elements/standardize_locations.js
+++ b/httemplate/elements/standardize_locations.js
@@ -179,7 +179,7 @@ function confirm_manual_address() {
function post_standardization() {
-% if ( $conf->exists('enable_taxproducts') ) {
+% if ( $need_tax_location ) {
var cf = document.<% $formname %>;
@@ -204,9 +204,8 @@ function post_standardization() {
var state_el = cf.elements[prefix + 'state'];
var state = state_el.options[ state_el.selectedIndex ].value;
- var url = "<% $p %>/misc/choose_tax_location.html" +
- "?data_vendor=cch-zip" +
- ";city=" + cf.elements[prefix + 'city'].value +
+ var url = "<% $p %>/misc/choose_tax_location.html?" +
+ "city=" + cf.elements[prefix + 'city'].value +
";state=" + state +
";zip=" + cf.elements[prefix + 'zip'].value +
";country=" + country +
@@ -252,11 +251,9 @@ function update_geocode() {
prefix = 'bill_';
}
- //alert(what.options[what.selectedIndex].value);
- var argsHash = eval('(' + what.options[what.selectedIndex].value + ')');
- cf.elements[prefix + 'city'].value = argsHash['city'];
- setselect(cf.elements[prefix + 'state'], argsHash['state']);
- cf.elements[prefix + 'zip'].value = argsHash['zip'];
+%# this used to set the city/state/zip to the selected value; I think
+%# that's wrong.
+ var argsHash = JSON.parse(what.value);
cf.elements[prefix + 'geocode'].value = argsHash['geocode'];
<% $post_geocode %>;
@@ -343,4 +340,7 @@ if ( $census_functions ) {
$post_geocode = 'confirm_censustract()';
}
+my $tax_engine = FS::TaxEngine->new;
+my $need_tax_location = $tax_engine->info->{manual_tax_location} ? 1 : 0;
+
</%init>
diff --git a/httemplate/elements/tr-select-tax_status.html b/httemplate/elements/tr-select-tax_status.html
new file mode 100644
index 0000000..9c2de15
--- /dev/null
+++ b/httemplate/elements/tr-select-tax_status.html
@@ -0,0 +1,24 @@
+% if ( !$vendor ) {
+
+ <INPUT TYPE="hidden" NAME="<% $opt{'element_name'} || $opt{'field'} || 'taxstatusnum' %>" VALUE="">
+
+% } else {
+
+ <& tr-select-table.html,
+ label => 'Customer tax status',
+ table => 'tax_status',
+ name_col => 'description',
+ hashref => { data_vendor => $vendor },
+ order_by => 'order by taxstatus',
+ %opt
+ &>
+
+% }
+
+<%shared>
+my $conf = FS::Conf->new;
+my $vendor = $conf->config('enable_taxproducts');
+</%shared>
+<%init>
+my %opt = @_;
+</%init>
diff --git a/httemplate/elements/tr-select-taxoverride.html b/httemplate/elements/tr-select-taxoverride.html
index e20d37e..ee3ee41 100644
--- a/httemplate/elements/tr-select-taxoverride.html
+++ b/httemplate/elements/tr-select-taxoverride.html
@@ -1,4 +1,4 @@
-% if ( $conf->exists('enable_taxproducts') ) {
+% if ( FS::TaxEngine->new->info->{override} ) {
<%include('tr-td-label.html', @_) %>
<TD <% $cell_style %>><% include('select-taxoverride.html', @_) %></TD>
</TR>
@@ -9,8 +9,6 @@
<%init>
-my $conf = new FS::Conf;
-
my %opt = @_;
my $cell_style = $opt{'cell_style'}? 'STYLE="'. $opt{cell_style}. '"' : '';
my $name = $opt{element_name} || $opt{field} || 'tax_override';
diff --git a/httemplate/elements/tr-td-label.html b/httemplate/elements/tr-td-label.html
index 8125541..c318722 100644
--- a/httemplate/elements/tr-td-label.html
+++ b/httemplate/elements/tr-td-label.html
@@ -15,5 +15,8 @@ $style .= '; '. $opt{'cell_style'}
if $opt{'cell_style'};
my $required = $opt{'required'} ? '<font color="#ff0000">*</font>&nbsp;' : '';
+if ($required) {
+ $style .= ';font-weight: bold';
+}
</%init>
diff --git a/httemplate/misc/choose_tax_location.html b/httemplate/misc/choose_tax_location.html
index 23099c4..9c5881f 100644
--- a/httemplate/misc/choose_tax_location.html
+++ b/httemplate/misc/choose_tax_location.html
@@ -4,9 +4,8 @@
<SELECT NAME='geocodes' ID='geocodes' STYLE="<% $style %>">
% foreach my $location (@cust_tax_location) {
-% my %value = ( zip => $zip5,
-% map { $_ => $location->$_ }
-% qw ( city state geocode )
+% my %value = ( map { $_ => $location->$_ }
+% qw ( zip city state geocode )
% );
% map { $value{$_} = $location{$_} } qw ( city state )
% if $location{country} eq 'CA';
@@ -36,10 +35,10 @@
<%init>
my $conf = new FS::Conf;
+my $tax_engine = FS::TaxEngine->new;
-my %location = ();
-
-($location{data_vendor}) = $cgi->param('data_vendor') =~ /^([-\w]+)$/;
+my %location;
+($location{data_vendor}) = $conf->config('enable_taxproducts');
($location{city}) = $cgi->param('city') =~ /^([\w ]+)$/;
($location{state}) = $cgi->param('state') =~ /^(\w+)$/;
($location{zip}) = $cgi->param('zip') =~ /^([-\w ]+)$/;
@@ -50,22 +49,7 @@ my($geocode) = $cgi->param('geocode') =~ /^([\w]+)$/;
my($formname) = $cgi->param('formname') =~ /^([\w]*)$/;
$formname ||= 'CustomerForm';
-my($zip5, $zip4) = split('-', $location{zip});
-
-#only support US & CA
-my $hashref = { 'data_vendor' => $location{data_vendor} };
-$hashref->{zip} = $location{country} eq 'CA' ? substr($zip5,0,1) : $zip5,
-
-my @keys = keys(%$hashref);
-my @cust_tax_location = ();
-until ( @cust_tax_location ) {
- @cust_tax_location = qsearch({ table => 'cust_tax_location',
- hashref => $hashref,
- order_by => 'LIMIT 50',
- });
- last unless scalar(@keys);
- delete $hashref->{ shift @keys };
-}
+my @cust_tax_location = $tax_engine->cust_tax_locations(\%location);
my %max = ( city => 4, county => 6, state => 5);
foreach my $location (@cust_tax_location) {
diff --git a/httemplate/misc/tax-import.cgi b/httemplate/misc/tax-import.cgi
index 25414b2..7e72c74 100644
--- a/httemplate/misc/tax-import.cgi
+++ b/httemplate/misc/tax-import.cgi
@@ -1,4 +1,4 @@
-<% include("/elements/header.html",'Batch Tax Rate Import') %>
+<& /elements/header.html,'Batch Tax Rate Import' &>
Import a CSV file set containing tax rate records.
<BR><BR>
@@ -6,22 +6,22 @@ Import a CSV file set containing tax rate records.
<& /elements/form-file_upload.html,
'name' => 'TaxRateUpload',
'action' => 'process/tax-import.cgi',
- 'num_files' => 6,
'fields' => [ 'format', 'reload' ],
+ 'num_files' => $vendor_info{$data_vendor}->{num_files},
'message' => 'Tax rates imported',
'onsubmit' => "document.TaxRateUpload.submitButton.disabled=true;",
&>
-<% &ntable("#cccccc", 2) %>
+<& /elements/table-grid.html &>
<TR>
<TH ALIGN="right">Format</TH>
<TD>
<SELECT NAME="format">
- <!-- <OPTION VALUE="cch-update" SELECTED>CCH update (CSV) -->
- <OPTION VALUE="cch">CCH import (CSV)
- <!-- <OPTION VALUE="cch-fixed-update">CCH update (fixed length) -->
- <OPTION VALUE="cch-fixed">CCH import (fixed length)
+% my @formats = @{ $vendor_info{$data_vendor}->{formats} };
+% while (@formats) {
+ <OPTION VALUE="<% shift @formats %>"><% shift @formats %></OPTION>
+% }
</SELECT>
</TD>
</TR>
@@ -33,24 +33,11 @@ Import a CSV file set containing tax rate records.
</TD>
</TR>
- <% include( '/elements/file-upload.html',
- 'field' => [ 'geocodefile',
- 'codefile',
- 'plus4file',
- 'zipfile',
- 'txmatrixfile',
- 'detailfile',
- ],
- 'label' => [ 'geocode filename',
- 'code filename',
- 'plus4 filename',
- 'zip filename',
- 'txmatrix filename',
- 'detail filename',
- ],
+ <& /elements/file-upload.html,
+ 'field' => $vendor_info{$data_vendor}->{field},
+ 'label' => $vendor_info{$data_vendor}->{label},
'debug' => 0,
- )
- %>
+ &>
<TR>
<TD COLSPAN=2 ALIGN="center" STYLE="padding-top:6px">
@@ -72,4 +59,37 @@ Import a CSV file set containing tax rate records.
die "access denied"
unless $FS::CurrentUser::CurrentUser->access_right('Import');
+my $conf = FS::Conf->new;
+my $data_vendor = $conf->config('enable_taxproducts');
+
+my %vendor_info = (
+ CCH => {
+ 'num_files' => 6,
+ 'formats' => [ 'cch' => 'CCH import (CSV)',
+ 'cch-fixed' => 'CCH import (fixed length)' ],
+ 'field' => [ 'geocodefile',
+ 'codefile',
+ 'plus4file',
+ 'zipfile',
+ 'txmatrixfile',
+ 'detailfile',
+ ],
+ 'label' => [ 'geocode filename',
+ 'code filename',
+ 'plus4 filename',
+ 'zip filename',
+ 'txmatrix filename',
+ 'detail filename',
+ ],
+ },
+ Billsoft => {
+ 'num_files' => 1,
+ 'formats' => [ 'billsoft-pcode' => 'Billsoft PCodes',
+ 'billsoft-taxclass' => 'Tax classes',
+ 'billsoft-taxproduct' => 'Tax products' ],
+ 'field' => [ 'file' ],
+ 'label' => [ 'Filename' ],
+ },
+);
+
</%init>