From 817c1ce0e1cbcfd1f684222c66f46dd13b2d6dd7 Mon Sep 17 00:00:00 2001 From: Mark Wells Date: Sat, 30 May 2015 15:12:07 -0700 Subject: [PATCH] SureTax, #31639, #33015, #34598 --- FS/FS/Conf.pm | 63 ++- FS/FS/Cursor.pm | 32 +- FS/FS/TaxEngine.pm | 89 +++-- FS/FS/TaxEngine/cch.pm | 60 +-- FS/FS/TaxEngine/internal.pm | 19 +- FS/FS/TaxEngine/suretax.pm | 421 +++++++++++++++++++++ FS/FS/Upgrade.pm | 8 +- FS/FS/cust_bill_pkg.pm | 2 +- FS/FS/cust_bill_pkg_tax_location.pm | 13 + FS/FS/cust_bill_pkg_tax_rate_location.pm | 14 + FS/FS/cust_credit.pm | 2 +- FS/FS/cust_main/Billing.pm | 2 +- FS/FS/cust_main/Billing_Discount.pm | 3 + FS/FS/part_event/Action/fee.pm | 2 +- FS/FS/part_fee.pm | 5 + FS/FS/tax_rate.pm | 38 +- FS/FS/tax_status.pm | 6 + httemplate/browse/part_pkg_taxproduct/avalara.html | 2 - httemplate/browse/part_pkg_taxproduct/suretax.html | 172 +++++++++ httemplate/config/config-view.cgi | 5 +- httemplate/edit/part_fee.html | 2 +- httemplate/edit/part_pkg.cgi | 44 ++- httemplate/edit/process/part_pkg.cgi | 2 +- httemplate/edit/process/quick-charge.cgi | 4 +- httemplate/elements/menu.html | 8 +- httemplate/elements/select-taxproduct.html | 2 +- httemplate/elements/tr-part_pkg-taxproducts.html | 34 ++ httemplate/elements/tr-select-tax_status.html | 2 +- httemplate/elements/tr-select-taxproduct.html | 2 +- httemplate/misc/choose_tax_location.html | 2 +- httemplate/misc/tax-import.cgi | 6 +- httemplate/search/report_cust_pkg.html | 2 +- httemplate/view/cust_main/billing.html | 2 +- 33 files changed, 884 insertions(+), 186 deletions(-) create mode 100644 FS/FS/TaxEngine/suretax.pm create mode 100755 httemplate/browse/part_pkg_taxproduct/suretax.html create mode 100644 httemplate/elements/tr-part_pkg-taxproducts.html diff --git a/FS/FS/Conf.pm b/FS/FS/Conf.pm index f80f2d55f..17a7c23ec 100644 --- a/FS/FS/Conf.pm +++ b/FS/FS/Conf.pm @@ -2451,55 +2451,84 @@ and customer address. Include units.', { 'key' => 'enable_taxclasses', - 'section' => 'billing', + 'section' => 'taxation', 'description' => 'Enable per-package tax classes', 'type' => 'checkbox', }, { 'key' => 'require_taxclasses', - 'section' => 'billing', + 'section' => 'taxation', 'description' => 'Require a taxclass to be entered for every package', 'type' => 'checkbox', }, { - 'key' => 'enable_taxproducts', - 'section' => 'billing', + 'key' => 'tax_data_vendor', + 'section' => 'taxation', 'description' => 'Tax data vendor you are using.', 'type' => 'select', - 'select_enum' => [ 'cch', 'billsoft', 'avalara' ], + 'select_enum' => [ '', 'cch', 'billsoft', 'avalara', 'suretax' ], }, { 'key' => 'taxdatadirectdownload', - 'section' => 'billing', #well - 'description' => 'Enable downloading tax data directly from the vendor site. at least three lines: URL, username, and password.j', + 'section' => 'taxation', + 'description' => 'Enable downloading tax data directly from CCH. at least three lines: URL, username, and password.j', 'type' => 'textarea', }, { 'key' => 'ignore_incalculable_taxes', - 'section' => 'billing', + 'section' => 'taxation', 'description' => 'Prefer to invoice without tax over not billing at all', 'type' => 'checkbox', }, { 'key' => 'billsoft-company_code', - 'section' => 'billing', + 'section' => 'taxation', 'description' => 'Billsoft tax service company code (3 letters)', 'type' => 'text', }, { 'key' => 'avalara-taxconfig', - 'section' => 'billing', + 'section' => 'taxation', 'description' => 'Avalara tax service configuration. Four lines: company code, account number, license key, test mode (1 to enable).', 'type' => 'textarea', }, { + 'key' => 'suretax-client_number', + 'section' => 'taxation', + 'description' => 'SureTax tax service client ID.', + 'type' => 'text', + }, + { + 'key' => 'suretax-validation_key', + 'section' => 'taxation', + 'description' => 'SureTax validation key (UUID).', + 'type' => 'text', + }, + { + 'key' => 'suretax-business_unit', + 'section' => 'taxation', + 'description' => 'SureTax client business unit name; optional.', + 'type' => 'text', + 'per_agent' => 1, + }, + { + 'key' => 'suretax-regulatory_code', + 'section' => 'taxation', + 'description' => 'SureTax client regulatory status.', + 'type' => 'select', + 'select_enum' => [ '', 'ILEC', 'IXC', 'CLEC', 'VOIP', 'ISP', 'Wireless' ], + 'per_agent' => 1, + }, + + + { 'key' => 'welcome_msgnum', 'section' => 'notification', 'description' => 'Template to use for welcome messages when a svc_acct record is created.', @@ -3678,14 +3707,14 @@ and customer address. Include units.', { 'key' => 'tax-ship_address', - 'section' => 'billing', + 'section' => 'taxation', 'description' => 'By default, tax calculations are done based on the billing address. Enable this switch to calculate tax based on the shipping address instead.', 'type' => 'checkbox', } , { 'key' => 'tax-pkg_address', - 'section' => 'billing', + 'section' => 'taxation', 'description' => 'By default, tax calculations are done based on the billing address. Enable this switch to calculate tax based on the package address instead (when present).', 'type' => 'checkbox', }, @@ -4467,7 +4496,7 @@ and customer address. Include units.', { 'key' => 'tax_district_method', - 'section' => 'UI', + 'section' => 'taxation', 'description' => 'The method to use to look up tax district codes.', 'type' => 'select', #'select_hash' => [ FS::Misc::Geo::get_district_methods() ], @@ -5228,7 +5257,7 @@ and customer address. Include units.', { 'key' => 'tax-cust_exempt-groups', - 'section' => 'billing', + 'section' => 'taxation', 'description' => 'List of grouping possibilities for tax names, for per-customer exemption purposes, one tax name per line. For example, "GST" would indicate the ability to exempt customers individually from taxes named "GST" (but not other taxes).', 'type' => 'textarea', }, @@ -5242,7 +5271,7 @@ and customer address. Include units.', { 'key' => 'tax-cust_exempt-groups-num_req', - 'section' => 'billing', + 'section' => 'taxation', 'description' => 'When using tax-cust_exempt-groups, control whether individual tax exemption numbers are required for exemption from different taxes.', 'type' => 'select', 'select_hash' => [ '' => 'Not required', @@ -5270,7 +5299,7 @@ and customer address. Include units.', { 'key' => 'enable_tax_adjustments', - 'section' => 'billing', + 'section' => 'taxation', 'description' => 'Enable the ability to add manual tax adjustments.', 'type' => 'checkbox', }, @@ -5723,7 +5752,7 @@ and customer address. Include units.', { 'key' => 'cust_class-tax_exempt', - 'section' => 'billing', + 'section' => 'taxation', 'description' => 'Control the tax exemption flag per customer class rather than per indivual customer.', 'type' => 'checkbox', }, diff --git a/FS/FS/Cursor.pm b/FS/FS/Cursor.pm index 67a98eab4..faa15f9f6 100644 --- a/FS/FS/Cursor.pm +++ b/FS/FS/Cursor.pm @@ -4,7 +4,7 @@ use strict; use vars qw($DEBUG $buffer); use FS::Record; use FS::UID qw(myconnect driver_name); -use Scalar::Util qw(refaddr); +use Scalar::Util qw(refaddr blessed); $DEBUG = 2; @@ -29,17 +29,24 @@ while ( my $row = $search->fetch ) { =over 4 -=item new ARGUMENTS +=item new ARGUMENTS [, DBH ] Constructs a cursored search. Accepts all the same arguments as qsearch, and returns an FS::Cursor object to fetch the rows one at a time. +DBH may be a database handle; if so, the cursor will be created on that +connection and have all of its transaction state. Otherwise a new connection +will be opened for the cursor. + =cut sub new { my $class = shift; - my $q = FS::Record::_query(@_); # builds the statement and parameter list my $dbh; + if ( blessed($_[-1]) and $_[-1]->isa('DBI::db') ) { + $dbh = pop; + } + my $q = FS::Record::_query(@_); # builds the statement and parameter list my $self = { query => $q, @@ -59,7 +66,11 @@ sub new { my $statement; if ( driver_name() eq 'Pg' ) { - $self->{dbh} = $dbh = myconnect(); + if (!$dbh) { + $dbh = myconnect(); + $self->{autoclean} = 1; + } + $self->{dbh} = $dbh; $statement = "DECLARE ".$self->{id}." CURSOR FOR ".$q->{statement}; } elsif ( driver_name() eq 'mysql' ) { # build a cursor from scratch @@ -144,8 +155,11 @@ sub DESTROY { return unless $self->{pid} eq $$; $self->{dbh}->do('CLOSE '. $self->{id}) or die $self->{dbh}->errstr; # clean-up the cursor in Pg - $self->{dbh}->rollback; - $self->{dbh}->disconnect; + if ($self->{autoclean}) { + # the dbh was created just for this cursor, so it has no transaction + # state that we care about + $self->{dbh}->rollback; + } } =back @@ -159,12 +173,6 @@ Replace all uses of qsearch with this. Still doesn't really support MySQL, but it pretends it does, by simply running the query and returning records one at a time. -The cursor will close prematurely if any code issues a rollback/commit. If -you need protection against this use qsearch or fork and get a new dbh -handle. -Normally this issue will represent itself this message. -ERROR: cursor "cursorXXXXXXX" does not exist. - =head1 SEE ALSO L diff --git a/FS/FS/TaxEngine.pm b/FS/FS/TaxEngine.pm index ac30eb1fc..45601429a 100644 --- a/FS/FS/TaxEngine.pm +++ b/FS/FS/TaxEngine.pm @@ -35,10 +35,28 @@ FS::TaxEngine - Base class for tax calculation engines. =over 4 +=item class + +Returns the class name for tax engines, according to the 'tax_data_vendor' +configuration setting. + +=cut + +sub class { + my $conf = FS::Conf->new; + my $subclass = $conf->config('tax_data_vendor') || 'internal'; + my $class = "FS::TaxEngine::$subclass"; + local $@; + eval "use $class"; + die "couldn't load $class: $@\n" if $@; + + $class; +} + =item new 'cust_main' => CUST_MAIN, 'invoice_time' => TIME, OPTIONS... Creates an L object. The subclass will be chosen by the -'enable_taxproducts' configuration setting. +'tax_data_vendor' configuration setting. CUST_MAIN and TIME are required. OPTIONS can include: @@ -54,11 +72,7 @@ sub new { my %opt = @_; my $conf = FS::Conf->new; if ($class eq 'FS::TaxEngine') { - my $subclass = $conf->config('enable_taxproducts') || 'internal'; - $class .= "::$subclass"; - local $@; - eval "use $class"; - die "couldn't load $class: $@\n" if $@; + $class = $class->class; } my $self = { items => [], taxes => {}, conf => $conf, %opt }; bless $self, $class; @@ -107,6 +121,11 @@ sub calculate_taxes { my $cust_bill = shift; my @raw_taxlines = $self->make_taxlines($cust_bill); + if ( !@raw_taxlines ) { + return; + } elsif ( !ref $raw_taxlines[0] ) { # error message + return $raw_taxlines[0]; + } my @real_taxlines = $self->consolidate_taxlines(@raw_taxlines); @@ -117,12 +136,13 @@ sub calculate_taxes { } sub make_taxlines { + # only used by FS::TaxEngine::internal; should just move there my $self = shift; my $conf = $self->{conf}; my $cust_bill = shift; - my @taxlines; + my @raw_taxlines; # For each distinct tax rate definition, calculate the tax and exemptions. foreach my $taxnum ( keys %{ $self->{taxes} } ) { @@ -134,16 +154,17 @@ sub make_taxlines { # 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); + my @taxlines = $self->taxline('tax' => $tax_object, 'sales' => $taxables); + # taxline methods are now required to return the link records alone. + # Consolidation will take care of the rest. + next if !@taxlines; + die $taxlines[0] unless ref($taxlines[0]); - push @taxlines, $taxline; + push @raw_taxlines, @taxlines; } #foreach $taxnum - return @taxlines; + return @raw_taxlines; } sub consolidate_taxlines { @@ -152,14 +173,16 @@ sub consolidate_taxlines { my $conf = $self->{conf}; my @raw_taxlines = @_; + return if !@raw_taxlines; # shouldn't even be here + my @tax_line_items; # keys are tax names (as printed on invoices / itemdesc ) - # values are arrayrefs of taxlines + # values are arrayrefs of tax links ("raw taxlines") my %taxname; # collate these by itemdesc foreach my $taxline (@raw_taxlines) { - my $taxname = $taxline->itemdesc; + my $taxname = $taxline->taxname; $taxname{$taxname} ||= []; push @{ $taxname{$taxname} }, $taxline; } @@ -168,7 +191,7 @@ sub consolidate_taxlines { # values are (cumulative) amounts my %tax_amount; - my $link_table = $self->info->{link_table}; + my $link_table = $raw_taxlines[0]->table; # Preconstruct cust_bill_pkg objects that will become the "final" # taxlines for each name, so that we can reference them. @@ -187,32 +210,30 @@ sub consolidate_taxlines { # 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_links = $taxname{$taxname}; my $tax_cust_bill_pkg = $real_taxline_named{$taxname}; - $tax_cust_bill_pkg->set( $link_table => \@tax_links ); + $tax_cust_bill_pkg->set( $link_table => $tax_links ); my $tax_total = 0; warn "adding $taxname\n" if $DEBUG > 1; - foreach my $taxitem ( @{ $taxname{$taxname} } ) { + foreach my $link ( @$tax_links ) { # 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); - - # if the link represents tax on tax, also fix its taxable pointer - # to point to the "final" taxline - my $taxable_cust_bill_pkg = $link->get('taxable_cust_bill_pkg'); - if (my $other_taxname = $taxable_cust_bill_pkg->itemdesc) { - $link->set('taxable_cust_bill_pkg', - $real_taxline_named{$other_taxname} - ); - } - - push @tax_links, $link; + $tax_total += $link->amount; + $link->set('tax_cust_bill_pkg', $tax_cust_bill_pkg); + + # if the link represents tax on tax, also fix its taxable pointer + # to point to the "final" taxline + my $taxable_cust_bill_pkg = $link->get('taxable_cust_bill_pkg'); + if ( $taxable_cust_bill_pkg and + my $other_taxname = $taxable_cust_bill_pkg->itemdesc) { + $link->set('taxable_cust_bill_pkg', + $real_taxline_named{$other_taxname} + ); } - } # foreach $taxitem + + } # foreach $link next unless $tax_total; # we should really neverround this up...I guess it's okay if taxline diff --git a/FS/FS/TaxEngine/cch.pm b/FS/FS/TaxEngine/cch.pm index 4e6dbaf7e..fb3410365 100644 --- a/FS/FS/TaxEngine/cch.pm +++ b/FS/FS/TaxEngine/cch.pm @@ -5,6 +5,7 @@ use vars qw( $DEBUG ); use base 'FS::TaxEngine'; use FS::Record qw(dbh qsearch qsearchs); use FS::Conf; +use List::Util qw(sum); =head1 SUMMARY @@ -131,32 +132,21 @@ sub make_taxlines { $taxable_location{ $_->billpkgnum } ||= $_->tax_location; } - my @taxlines = $tax_rate->taxline_cch( $taxables, $charge_classes ); - - next if !@taxlines; - if (!ref $taxlines[0]) { + foreach my $link ( $tax_rate->taxline_cch( $taxables, $charge_classes ) ) { + if (!ref $link) { # it's an error string - warn "error evaluating tax#$taxnum\n"; - return $taxlines[0]; - } - - my $billpkgnum = -1; # the current one - my $fragments; # $item_has_tax{$billpkgnum}{taxnum} - - foreach my $taxline (@taxlines) { - next if $taxline->setup == 0; + die "error evaluating tax#$taxnum: $link\n"; + } + next if $link->amount == 0; - my $link = $taxline->get('cust_bill_pkg_tax_rate_location')->[0]; # store this tax fragment, indexed by taxable item, then by taxnum - if ( $billpkgnum != $link->taxable_billpkgnum ) { - $billpkgnum = $link->taxable_billpkgnum; - $item_has_tax{$billpkgnum} ||= {}; - $fragments = $item_has_tax{$billpkgnum}{$taxnum} ||= []; - } + my $billpkgnum = $link->taxable_billpkgnum; + $item_has_tax{$billpkgnum} ||= {}; + my $fragments = $item_has_tax{$billpkgnum}{$taxnum} ||= []; - $taxline->set('invnum', $cust_bill->invnum); - push @$fragments, $taxline; # so we can ToT it - push @raw_taxlines, $taxline; # so we actually bill it + push @raw_taxlines, $link; # this will go into final consolidation + push @$fragments, $link; # this will go into a temporary cust_bill_pkg + # for ToT calculation } } # foreach $taxnum @@ -167,6 +157,9 @@ sub make_taxlines { my $this_has_tax = $item_has_tax{$billpkgnum}; my $location = $taxable_location{$billpkgnum}; foreach my $taxnum (keys %$this_has_tax) { + # $this_has_tax->{$taxnum} = an arrayref of the tax links for taxdef + # $taxnum on taxable item $billpkgnum + my $tax_rate = FS::tax_rate->by_key($taxnum); # find all taxes that apply to it in this location my @tot = $tax_rate->tax_on_tax( $location ); @@ -177,6 +170,7 @@ sub make_taxlines { # Calculate ToT separately for each taxable item, and only if _that # item_ is already taxed under the ToT. This is counterintuitive. # See RT#5243. + my $temp_lineitem; foreach my $tot (@tot) { my $totnum = $tot->taxnum; warn "checking taxnum ".$tot->taxnum. @@ -185,16 +179,22 @@ sub make_taxlines { if ( exists $this_has_tax->{ $totnum } ) { warn "calculating tax on tax: taxnum ".$tot->taxnum." on $taxnum\n" if $DEBUG; - my @taxlines = $tot->taxline_cch( - $this_has_tax->{ $taxnum }, # the first-stage tax (in an arrayref) - ); - next if (!@taxlines); # it didn't apply after all - if (!ref($taxlines[0])) { - warn "error evaluating TOT ($totnum on $taxnum)\n"; - return $taxlines[0]; + # construct a line item to calculate tax on + $temp_lineitem ||= FS::cust_bill_pkg->new({ + 'pkgnum' => 0, + 'invnum' => $cust_bill->invnum, + 'setup' => sum(map $_->amount, @{ $this_has_tax->{$taxnum} }), + 'recur' => 0, + 'itemdesc' => $tax_rate->taxname, + 'cust_bill_pkg_tax_rate_location' => $this_has_tax->{$taxnum}, + }); + my @new_taxlines = $tot->taxline_cch( [ $temp_lineitem ] ); + next if (!@new_taxlines); # it didn't apply after all + if (!ref($new_taxlines[0])) { + die "error evaluating TOT ($totnum on $taxnum): $new_taxlines[0]\n"; } # add these to the taxline queue - push @raw_taxlines, @taxlines; + push @raw_taxlines, @new_taxlines; } # if $this_has_tax->{$totnum} } # foreach my $tot (tax-on-tax rate definition) } # foreach $taxnum (first-tier rate definition) diff --git a/FS/FS/TaxEngine/internal.pm b/FS/FS/TaxEngine/internal.pm index 99535ad38..f45bc0801 100644 --- a/FS/FS/TaxEngine/internal.pm +++ b/FS/FS/TaxEngine/internal.pm @@ -60,7 +60,6 @@ sub taxline { my $taxnum = $tax_object->taxnum; my $exemptions = $self->{exemptions}->{$taxnum} ||= []; - my $name = $tax_object->taxname || 'Tax'; my $taxable_cents = 0; my $tax_cents = 0; @@ -87,14 +86,7 @@ sub taxline { 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; + my @tax_links; foreach my $cust_bill_pkg (@$taxables) { @@ -274,9 +266,8 @@ sub taxline { '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; + push @tax_links, $location; $taxable_cents += $taxable_charged; $tax_cents += $this_tax_cents; @@ -292,7 +283,7 @@ sub taxline { } $tax_cents += $extra_cents; my $i = 0; - foreach (@tax_location) { # can never require more than a single pass, yes? + foreach (@tax_links) { # can never require more than a single pass, yes? my $cents = $_->get('cents'); if ( $extra_cents > 0 ) { $cents++; @@ -300,10 +291,8 @@ sub taxline { } $_->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; + return @tax_links; } sub info { diff --git a/FS/FS/TaxEngine/suretax.pm b/FS/FS/TaxEngine/suretax.pm new file mode 100644 index 000000000..327a72843 --- /dev/null +++ b/FS/FS/TaxEngine/suretax.pm @@ -0,0 +1,421 @@ +package FS::TaxEngine::suretax; + +use strict; +use base 'FS::TaxEngine'; +use FS::Conf; +use FS::Record qw(qsearch qsearchs dbh); +use JSON; +use XML::Simple qw(XMLin); +use LWP::UserAgent; +use HTTP::Request::Common; +use DateTime; + +our $DEBUG = 1; # prints progress messages +# $DEBUG = 2; # prints decoded request and response (noisy, be careful) +# $DEBUG = 3; # prints raw response from the API, ridiculously unreadable + +our $json = JSON->new->pretty(1); + +our %taxproduct_cache; + +our $conf; + +our $host = 'testapi.taxrating.net'; +# production: 'api.taxrating.net' + +FS::UID->install_callback( sub { + $conf = FS::Conf->new; + # should we enable conf caching here? +}); + +# Tax Situs Rules, for determining tax jurisdiction. +# (may need to be configurable) + +# For PSTN calls, use Rule 01, two-out-of-three using NPA-NXX. (The "three" +# are source number, destination number, and charged party number.) +our $TSR_CALL_NPANXX = '01'; + +# For other types of calls (on-network hosted PBX, SIP-addressed calls, +# other things that don't have an NPA-NXX number), use Rule 11. (See below.) +our $TSR_CALL_OTHER = '11'; + +# For regular recurring or one-time charges, use Rule 11. This uses the +# service zip code for transaction types that are known to require it, and +# the billing zip code for all other transaction types. +our $TSR_GENERAL = '11'; + +# XXX incomplete; doesn't handle international taxes (Rule 14) or point +# to point private lines (Rule 07). + +our %REGCODE = ( # can be selected per agent + '' => '99', + 'ILEC' => '00', + 'IXC' => '01', + 'CLEC' => '02', + 'VOIP' => '03', + 'ISP' => '04', + 'Wireless' => '05', +); + +sub info { + { batch => 0, + override => 0, + } +} + +sub add_sale { } # nothing to do here + +sub build_request { + my ($self, %opt) = @_; + + my $cust_bill = $self->{cust_bill}; + my $cust_main = $cust_bill->cust_main; + my $agentnum = $cust_main->agentnum; + my $date = DateTime->from_epoch(epoch => $cust_bill->_date); + + # remember some things that are linked to the customer + $self->{taxstatus} = $cust_main->taxstatus + or die "Customer #".$cust_main->custnum." has no tax status defined.\n"; + + ($self->{bill_zip}, $self->{bill_plus4}) = + split('-', $cust_main->bill_location->zip); + + $self->{regcode} = $REGCODE{ $conf->config('suretax-regulatory_code') }; + + %taxproduct_cache = (); + + # assemble invoice line items + my @lines = map { $self->build_item($_) } + $cust_bill->cust_bill_pkg; + + my $ClientNumber = $conf->config('suretax-client_number') + or die "suretax-client_number config required.\n"; + my $ValidationKey = $conf->config('suretax-validation_key') + or die "suretax-validation_key config required.\n"; + my $BusinessUnit = $conf->config('suretax-business_unit', $agentnum) || ''; + + return { + ClientNumber => $ClientNumber, + ValidationKey => $ValidationKey, + BusinessUnit => $BusinessUnit, + DataYear => '2015', #$date->year, + DataMonth => '04', #sprintf('%02d', $date->month), + TotalRevenue => sprintf('%.4f', $cust_bill->charged), + ReturnFileCode => ($self->{estimate} ? 'Q' : '0'), + ClientTracking => $cust_bill->invnum, + IndustryExemption => '', + ResponseGroup => '13', + ResponseType => 'D2', + STAN => '', + ItemList => \@lines, + }; +} + +=item build_item CUST_BILL_PKG + +Takes a sale item and returns any number of request element hashrefs +corresponding to it. Yes, any number, because in a rated usage line item +we have to send each usage detail separately. + +=cut + +sub build_item { + my $self = shift; + my $cust_bill_pkg = shift; + my $cust_bill = $cust_bill_pkg->cust_bill; + my $billpkgnum = $cust_bill_pkg->billpkgnum; + my $invnum = $cust_bill->invnum; + my $custnum = $cust_bill->custnum; + + # get the part_pkg/fee for this line item, and the relevant part of the + # taxproduct cache + my $part_item = $cust_bill_pkg->part_X; + my $taxproduct_of_class = do { + my $part_id = $part_item->table . '#' . $part_item->get($part_item->primary_key); + $taxproduct_cache{$part_id} ||= {}; + }; + + my @items; + my $recur_without_usage = $cust_bill_pkg->recur; + + my $location = $cust_bill_pkg->tax_location; + my ($svc_zip, $svc_plus4) = split('-', $location->zip); + + my $startdate = + DateTime->from_epoch( epoch => $cust_bill->_date )->strftime('%m-%d-%Y'); + + my %base_item = ( + 'LineNumber' => '', + 'InvoiceNumber' => $billpkgnum, + 'CustomerNumber' => $custnum, + 'OrigNumber' => '', + 'TermNumber' => '', + 'BillToNumber' => '', + 'Zipcode' => $self->{bill_zip}, + 'Plus4' => ($self->{bill_plus4} ||= '0000'), + 'P2PZipcode' => $svc_zip, + 'P2PPlus4' => ($svc_plus4 ||= '0000'), + # we don't support Order Placement/Approval zip codes + 'Geocode' => '', + 'TransDate' => $startdate, + 'Revenue' => '', + 'Units' => 0, + 'UnitType' => '00', # "number of unique lines", the only choice + 'Seconds' => 0, + 'TaxIncludedCode' => '0', + 'TaxSitusRule' => '', + 'TransTypeCode' => '', + 'SalesTypeCode' => $self->{taxstatus}, + 'RegulatoryCode' => $self->{regcode}, + 'TaxExemptionCodeList' => [ ], + 'AuxRevenue' => 0, # we don't currently support freight and such + 'AuxRevenueType' => '', + ); + + # some naming conventions: + # 'C#####' is a call detail record (using the acctid) + # 'S#####' is a cust_bill_pkg setup element (using the billpkgnum) + # 'R#####' is a cust_bill_pkg recur element + # always set "InvoiceNumber" = the billpkgnum, so we can link it properly + + # cursor all this stuff; data sets can be LARGE + # (if it gets really out of hand, we can also incrementally write JSON + # to a file) + + my $details = FS::Cursor->new('cust_bill_pkg_detail', { + billpkgnum => $cust_bill_pkg->billpkgnum, + amount => { op => '>', value => 0 } + }, dbh() ); + while ( my $cust_bill_pkg_detail = $details->fetch ) { + + # look up the tax product for this class + my $classnum = $cust_bill_pkg_detail->classnum; + my $taxproduct = $taxproduct_of_class->{ $classnum } ||= do { + my $part_pkg_taxproduct = $part_item->taxproduct($classnum); + $part_pkg_taxproduct ? $part_pkg_taxproduct->taxproduct : ''; + }; + die "no taxproduct configured for pkgpart ".$part_item->pkgpart. + ", usage class $classnum\n" + if !$taxproduct; + + my $cdrs = FS::Cursor->new('cdr', { + detailnum => $cust_bill_pkg_detail->detailnum, + freesidestatus => 'done', + }, dbh() ); + while ( my $cdr = $cdrs->fetch ) { + my $calldate = + DateTime->from_epoch( epoch => $cdr->startdate )->strftime('%m-%d-%Y'); + # determine the tax situs rule; it's different (probably more accurate) + # if the call has PSTN phone numbers at both ends + my $tsr = $TSR_CALL_OTHER; + if ( $cdr->charged_party =~ /^\d{10}$/ and + $cdr->src =~ /^\d{10}$/ and + $cdr->dst =~ /^\d{10}$/ ) { + $tsr = $TSR_CALL_NPANXX; + } + my %hash = ( + %base_item, + 'LineNumber' => 'C' . $cdr->acctid, + 'OrigNumber' => $cdr->src, + 'TermNumber' => $cdr->dst, + 'BillToNumber' => $cdr->charged_party, + 'TransDate' => $calldate, + 'Revenue' => $cdr->rated_price, # 4 decimal places + 'Units' => 0, # right? + 'CallDuration' => $cdr->duration, + 'TaxSitusRule' => $tsr, + 'TransTypeCode' => $taxproduct, + ); + push @items, \%hash; + + } # while ($cdrs->fetch) + + # decrement the recurring charge + $recur_without_usage -= $cust_bill_pkg_detail->amount; + + } # while ($details->fetch) + + # recurring charge + if ( $recur_without_usage > 0 ) { + my $taxproduct = $taxproduct_of_class->{ 'recur' } ||= do { + my $part_pkg_taxproduct = $part_item->taxproduct('recur'); + $part_pkg_taxproduct ? $part_pkg_taxproduct->taxproduct : ''; + }; + die "no taxproduct configured for pkgpart ".$part_item->pkgpart. + " recurring charge\n" + if !$taxproduct; + + my $tsr = $TSR_GENERAL; + my %hash = ( + %base_item, + 'LineNumber' => 'R' . $billpkgnum, + 'Revenue' => $recur_without_usage, # 4 decimal places + 'Units' => $cust_bill_pkg->units, + 'TaxSitusRule' => $tsr, + 'TransTypeCode' => $taxproduct, + ); + # API expects all these fields to be _present_, even when they're not + # required + $hash{$_} = '' foreach(qw(OrigNumber TermNumber BillToNumber)); + push @items, \%hash; + } + + if ( $cust_bill_pkg->setup > 0 ) { + my $startdate = + DateTime->from_epoch( epoch => $cust_bill->_date )->strftime('%m-%d-%Y'); + my $taxproduct = $taxproduct_of_class->{ 'setup' } ||= do { + my $part_pkg_taxproduct = $part_item->taxproduct('setup'); + $part_pkg_taxproduct ? $part_pkg_taxproduct->taxproduct : ''; + }; + die "no taxproduct configured for pkgpart ".$part_item->pkgpart. + " setup charge\n" + if !$taxproduct; + + my $tsr = $TSR_GENERAL; + my %hash = ( + %base_item, + 'LineNumber' => 'S' . $billpkgnum, + 'Revenue' => $cust_bill_pkg->setup, # 4 decimal places + 'Units' => $cust_bill_pkg->units, + 'TaxSitusRule' => $tsr, + 'TransTypeCode' => $taxproduct, + ); + push @items, \%hash; + } + + @items; +} + +sub make_taxlines { + my $self = shift; + + my @elements; + + my $cust_bill = shift; + if (!$cust_bill->invnum) { + die "FS::TaxEngine::suretax can't calculate taxes on a non-inserted invoice\n"; + } + $self->{cust_bill} = $cust_bill; + my $cust_main = $cust_bill->cust_main; + my $country = $cust_main->bill_location->country; + + my $invnum = $cust_bill->invnum; + if (FS::cust_bill_pkg->count("invnum = $invnum") == 0) { + # don't even bother making the request + # (why are we even here, then? invoices with no line items + # should not be created) + return; + } + + # assemble the request hash + my $request = $self->build_request; + + warn "sending SureTax request\n" if $DEBUG; + my $request_json = $json->encode($request); + warn $request_json if $DEBUG > 1; + + # We are targeting the "V05" interface: + # - accepts both telecom and general sales transactions + # - produces results broken down by "invoice" (Freeside line item) + my $ua = LWP::UserAgent->new; + my $http_response = $ua->request( + POST "https://$host/Services/V05/SureTax.asmx/PostRequest", + [ request => $request_json ], + 'Content-Type' => 'application/x-www-form-urlencoded', + 'Accept' => 'application/json', + ); + + my $raw_response = $http_response->content; + warn "received response\n" if $DEBUG; + warn $raw_response if $DEBUG > 2; + my $response; + if ( $raw_response =~ /^<\?xml/ ) { + # an error message wrapped in a riddle inside an enigma inside an XML + # document... + $response = XMLin( $raw_response ); + $raw_response = $response->{content}; + } + $response = eval { $json->decode($raw_response) } + or die "$raw_response\n"; + + # documentation implies this might be necessary + $response = $response->{'d'} if exists $response->{'d'}; + + warn $json->encode($response) if $DEBUG > 1; + + if ( $response->{Successful} ne 'Y' ) { + die $response->{HeaderMessage}."\n"; + } else { + my $error = join("\n", + map { $_->{"LineNumber"}.': '. $_->{Message} } + @{ $response->{ItemMessages} } + ); + die "$error\n" if $error; + } + + return if !$response->{GroupList}; + foreach my $taxable ( @{ $response->{GroupList} } ) { + # each member of this array here corresponds to what SureTax calls an + # "invoice" and we call a "line item". The invoice number is + # cust_bill_pkg.billpkgnum. + + my ($state, $geocode) = split(/\|/, $taxable->{StateCode}); + foreach my $tax_element ( @{ $taxable->{TaxList} } ) { + # create a tax rate location if there isn't one yet + my $taxname = $tax_element->{TaxTypeDesc}; + my $taxauth = substr($tax_element->{TaxTypeCode}, 0, 1); + my $tax_rate = FS::tax_rate->new({ + data_vendor => 'suretax', + taxname => $taxname, + taxclassnum => '', + taxauth => $taxauth, # federal / state / city / district + geocode => $geocode, # this is going to disambiguate all + # the taxes named "STATE SALES TAX", etc. + tax => 0, + fee => 0, + }); + my $error = $tax_rate->find_or_insert; + die "error inserting tax_rate record for '$taxname': $error\n" + if $error; + $tax_rate = $tax_rate->replace_old; + + my $tax_rate_location = FS::tax_rate_location->new({ + data_vendor => 'suretax', + geocode => $geocode, + state => $state, + country => $country, + }); + $error = $tax_rate_location->find_or_insert; + die "error inserting tax_rate_location record for '$geocode': $error\n" + if $error; + $tax_rate_location = $tax_rate_location->replace_old; + + push @elements, FS::cust_bill_pkg_tax_rate_location->new({ + taxable_billpkgnum => $taxable->{InvoiceNumber}, + taxnum => $tax_rate->taxnum, + taxtype => 'FS::tax_rate', + taxratelocationnum => $tax_rate_location->taxratelocationnum, + amount => sprintf('%.2f', $tax_element->{TaxAmount}), + }); + } + } + return @elements; +} + +sub add_taxproduct { + my $class = shift; + my $desc = shift; # tax code and description, separated by a space. + if ($desc =~ s/^(\d{6}+) //) { + my $part_pkg_taxproduct = FS::part_pkg_taxproduct->new({ + 'data_vendor' => 'suretax', + 'taxproduct' => $1, + 'description' => $desc, + }); + # $obj_or_error + return $part_pkg_taxproduct->insert || $part_pkg_taxproduct; + } else { + return "illegal suretax tax code '$desc'"; + } +} + +1; diff --git a/FS/FS/Upgrade.pm b/FS/FS/Upgrade.pm index b4340d075..ffc04bab7 100644 --- a/FS/FS/Upgrade.pm +++ b/FS/FS/Upgrade.pm @@ -133,11 +133,11 @@ 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 '' ) { + # boolean enable_taxproducts is now tax_data_vendor = 'cch' + if ( $conf->exists('enable_taxproducts') ) { - $conf->set('enable_taxproducts', 'cch'); + $conf->delete('enable_taxproducts'); + $conf->set('tax_data_vendor', 'cch'); } diff --git a/FS/FS/cust_bill_pkg.pm b/FS/FS/cust_bill_pkg.pm index b6e439552..a5c441008 100644 --- a/FS/FS/cust_bill_pkg.pm +++ b/FS/FS/cust_bill_pkg.pm @@ -1275,7 +1275,7 @@ sub upgrade_tax_location { local $FS::cust_location::import = 1; my $conf = FS::Conf->new; # h_conf? - return if $conf->exists('enable_taxproducts'); #don't touch this case + return if $conf->config('tax_data_vendor'); #don't touch this case my $use_ship = $conf->exists('tax-ship_address'); my $use_pkgloc = $conf->exists('tax-pkg_address'); diff --git a/FS/FS/cust_bill_pkg_tax_location.pm b/FS/FS/cust_bill_pkg_tax_location.pm index 2ffc27357..9a1f22a02 100644 --- a/FS/FS/cust_bill_pkg_tax_location.pm +++ b/FS/FS/cust_bill_pkg_tax_location.pm @@ -144,6 +144,19 @@ Returns the cust_bill_pkg object for the I charge. Returns the associated cust_location object +=item taxname + +Returns the tax name (for populating the itemdesc field). + +=cut + +sub taxname { + my $self = shift; + my $cust_main_county = FS::cust_main_county->by_key($self->taxnum) + or return ''; + $cust_main_county->taxname || 'Tax'; +} + =item desc Returns a description for this tax line item constituent. Currently this diff --git a/FS/FS/cust_bill_pkg_tax_rate_location.pm b/FS/FS/cust_bill_pkg_tax_rate_location.pm index 3e8098c3a..7ae5250e9 100644 --- a/FS/FS/cust_bill_pkg_tax_rate_location.pm +++ b/FS/FS/cust_bill_pkg_tax_rate_location.pm @@ -6,6 +6,7 @@ use FS::Record qw( qsearch qsearchs ); use FS::cust_pkg; use FS::cust_bill_pay_pkg; use FS::cust_credit_bill_pkg; +use FS::tax_rate; =head1 NAME @@ -130,6 +131,19 @@ Returns the associated cust_bill_pkg object Returns the associated tax_rate_location object +=item taxname + +Returns the tax name (the itemdesc). + +=cut + +sub taxname { + my $self = shift; + my $tax_rate = FS::tax_rate->by_key($self->taxnum) + or return ''; + $tax_rate->taxname; +} + =item desc Returns a description for this tax line item constituent. Currently this diff --git a/FS/FS/cust_credit.pm b/FS/FS/cust_credit.pm index 91bbf790b..f63d86f99 100644 --- a/FS/FS/cust_credit.pm +++ b/FS/FS/cust_credit.pm @@ -558,7 +558,7 @@ sub _upgrade_data { # class method $class->_upgrade_otaker(%opts); if ( !FS::upgrade_journal->is_done('cust_credit__tax_link') - and !$conf->exists('enable_taxproducts') ) { + and !$conf->config('tax_data_vendor') ) { # RT#25458: fix credit line item applications that should refer to a # specific tax allocation my @cust_credit_bill_pkg = qsearch({ diff --git a/FS/FS/cust_main/Billing.pm b/FS/FS/cust_main/Billing.pm index 75dca3426..f4c804568 100644 --- a/FS/FS/cust_main/Billing.pm +++ b/FS/FS/cust_main/Billing.pm @@ -1422,7 +1422,7 @@ sub _handle_taxes { return if ( $self->payby eq 'COMP' ); #dubious - if ( $conf->exists('enable_taxproducts') + if ( $conf->config('enable_taxproducts') && ( scalar($part_item->part_pkg_taxoverride) || $part_item->has_taxproduct ) diff --git a/FS/FS/cust_main/Billing_Discount.pm b/FS/FS/cust_main/Billing_Discount.pm index d437740e3..b2852f6c1 100644 --- a/FS/FS/cust_main/Billing_Discount.pm +++ b/FS/FS/cust_main/Billing_Discount.pm @@ -110,6 +110,9 @@ by prepaying the most recent invoice for MONTHS. =cut +# XXX this should work by creating a quotation; then we can finally retire +# the "no_commit" option, which doesn't work with modern tax calculation + sub discount_term_values { my $self = shift; my $term = shift; diff --git a/FS/FS/part_event/Action/fee.pm b/FS/FS/part_event/Action/fee.pm index f1d5891ac..a18cc33d1 100644 --- a/FS/FS/part_event/Action/fee.pm +++ b/FS/FS/part_event/Action/fee.pm @@ -40,7 +40,7 @@ sub _calc_fee { # they're definitely NOT linear and we haven't yet had a reason to # make that case work. return $total if $self->option('setuptax') eq 'Y' - or FS::Conf->new->exists('enable_taxproducts'); + or FS::Conf->new->config('tax_data_vendor'); # estimate tax rate # false laziness with xmlhttp-calculate_taxes, cust_main::Billing, etc. diff --git a/FS/FS/part_fee.pm b/FS/FS/part_fee.pm index ef14b4f08..0ca52a096 100644 --- a/FS/FS/part_fee.pm +++ b/FS/FS/part_fee.pm @@ -523,6 +523,11 @@ sub has_taxproduct { return ($self->taxproductnum ? 1 : 0); } +sub taxproduct { # compat w/ part_pkg + my $self = shift; + $self->part_pkg_taxproduct; +} + =back =head1 BUGS diff --git a/FS/FS/tax_rate.pm b/FS/FS/tax_rate.pm index 8579020e1..67dd40e83 100644 --- a/FS/FS/tax_rate.pm +++ b/FS/FS/tax_rate.pm @@ -386,10 +386,7 @@ Takes an arrayref of L objects representing taxable line items, and an arrayref of charge classes ('setup', 'recur', '' for unclassified usage, or an L number). Calculates the tax on each item under this tax definition and returns a list of new -L objects for the taxes charged. Each returned object -will have a pseudo-field, "cust_bill_pkg_tax_rate_location", containing a -single L object linking the tax rate -back to this tax, and to its originating sale. +L objects for the taxes charged. If the taxable objects are linked to an invoice, this will also calculate per-customer exemptions (cust_exempt and cust_taxname_exempt) and attach them @@ -461,7 +458,7 @@ sub taxline_cch { $self->_fatal_or_null( 'tax with "'. $self->basetype_name. '" basis' ); } - my @tax_locations; + my @tax_links; # for output my %seen; # locationnum or pkgnum => 1 my $taxable_cents = 0; @@ -514,7 +511,7 @@ sub taxline_cch { # yeah, some false laziness with cust_main_county my $this_tax_cents = int(100 * $taxable_charged * $self->tax); - my $tax_location = FS::cust_bill_pkg_tax_rate_location->new({ + my $tax_link = FS::cust_bill_pkg_tax_rate_location->new({ 'taxnum' => $self->taxnum, 'taxtype' => ref($self), 'cents' => $this_tax_cents, # not a real field @@ -524,7 +521,7 @@ sub taxline_cch { 'taxratelocationnum' => $taxratelocationnum, 'taxclass' => $class, }); - push @tax_locations, $tax_location; + push @tax_links, $tax_link; $taxable_cents += 100 * $taxable_charged; $tax_cents += $this_tax_cents; @@ -579,7 +576,7 @@ sub taxline_cch { return $self->_fatal_or_null( 'unknown unit type in tax'. $self->taxnum ); } my $this_tax_cents = int($units * $self->fee * 100); - my $tax_location = FS::cust_bill_pkg_tax_rate_location->new({ + my $tax_link = FS::cust_bill_pkg_tax_rate_location->new({ 'taxnum' => $self->taxnum, 'taxtype' => ref($self), 'cents' => $this_tax_cents, @@ -587,7 +584,7 @@ sub taxline_cch { 'taxable_cust_bill_pkg' => $cust_bill_pkg, 'taxratelocationnum' => $taxratelocationnum, }); - push @tax_locations, $tax_location; + push @tax_links, $tax_link; $taxable_units += $units; $tax_cents += $this_tax_cents; @@ -614,7 +611,7 @@ sub taxline_cch { my $extra_cents = sprintf('%.0f', $total_tax_cents - $tax_cents); $tax_cents += $extra_cents; my $i = 0; - foreach (@tax_locations) { # can never require more than a single pass, yes? + foreach (@tax_links) { # can never require more than a single pass, yes? my $cents = $_->get('cents'); if ( $extra_cents > 0 ) { $cents++; @@ -623,26 +620,7 @@ sub taxline_cch { $_->set('amount', sprintf('%.2f', $cents/100)); } - # just transform each CBPTRL record into a tax line item. - # calculate_taxes will consolidate them, but before that happens we have - # to do tax on tax calculation. - my @tax_items; - foreach (@tax_locations) { - next if $_->amount == 0; - my $tax_item = FS::cust_bill_pkg->new({ - 'pkgnum' => 0, - 'recur' => 0, - 'setup' => $_->amount, - 'sdate' => '', # $_->sdate? - 'edate' => '', - 'itemdesc' => $name, - 'cust_bill_pkg_tax_rate_location' => [ $_ ], - }); - $_->set('tax_cust_bill_pkg' => $tax_item); - push @tax_items, $tax_item; - } - - return @tax_items; + return @tax_links; } sub _fatal_or_null { diff --git a/FS/FS/tax_status.pm b/FS/FS/tax_status.pm index f03eeca6a..5f7b50fde 100644 --- a/FS/FS/tax_status.pm +++ b/FS/FS/tax_status.pm @@ -149,6 +149,12 @@ sub _upgrade_data { # P, Q, R: Canada, not yet supported # MED1/MED2: totally irrelevant to our users }, + suretax => { + 'R' => 'Residential', + 'B' => 'Business', + 'I' => 'Industrial', + 'L' => 'Lifeline', + }, ); =back diff --git a/httemplate/browse/part_pkg_taxproduct/avalara.html b/httemplate/browse/part_pkg_taxproduct/avalara.html index e8da58962..d7d8a6076 100755 --- a/httemplate/browse/part_pkg_taxproduct/avalara.html +++ b/httemplate/browse/part_pkg_taxproduct/avalara.html @@ -61,8 +61,6 @@ my $conf = new FS::Conf; 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]+)$/ diff --git a/httemplate/browse/part_pkg_taxproduct/suretax.html b/httemplate/browse/part_pkg_taxproduct/suretax.html new file mode 100755 index 000000000..667c07ee9 --- /dev/null +++ b/httemplate/browse/part_pkg_taxproduct/suretax.html @@ -0,0 +1,172 @@ +<& /elements/header-popup.html, $title &> +<& /browse/elements/browse.html, + 'name_singular' => 'tax product', + 'html_form' => include('.form', $category_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, + 'disable_total' => 1, +&> + + + +
+
+ + +
+ + +
+ + +
+ +
+
+<%shared> +# populate dropdown + +# taxproduct is 6 digits: 2-digit category code + 4-digit detail code. +# Description is also two parts, corresponding to those codes, separated with +# a :. + +my (@category_codes, @taxproduct_codes, %category_labels, %taxproduct_labels); +foreach my $row ( qsearch({ + table => 'part_pkg_taxproduct', + select => 'DISTINCT substr(taxproduct, 1, 2) AS code, '. + "substring(description from '(.*):') AS label", + hashref => { data_vendor => 'suretax' }, + })) +{ + $category_labels{$row->get('code')} = $row->get('label'); +} + +@category_codes = sort {$a <=> $b} keys %category_labels; + + +<%def .form> +% my ($category_code) = @_; +
+<& /elements/select.html, + field => 'category_code', + options => \@category_codes, + labels => \%category_labels, + curr_value => $category_code, + onchange => 'this.form.submit()', +&> +<& /elements/hidden.html, + field => 'id', + curr_value => $cgi->param('id'), +&> + +<%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 $code = $row->taxproduct; + my $desc = $row->description; + "select_taxproduct('$taxnum', '$desc')"; +}; + +my @menubar; +my $title = 'Tax Products'; + +my $hashref = { data_vendor => 'suretax' }; + +my ($category_code, $taxproduct); +if ( $cgi->param('category_code') =~ /^(\d+)$/ ) { + $category_code = $1; + $taxproduct = $category_code . '%'; +} else { + $taxproduct = '%'; +} + +$hashref->{taxproduct} = { op => 'LIKE', value => $taxproduct }; + +my $count_query = "SELECT COUNT(*) FROM part_pkg_taxproduct ". + "WHERE data_vendor = 'suretax' AND ". + "taxproduct LIKE '$taxproduct'"; + +my @fields = ( + 'taxproduct', + 'description', + 'note' +); + +my @header = ( + 'Code', + 'Description', + '', +); + +my $align = 'lll'; +my @link_onclicks = ( $select_onclick, $select_onclick ); + + diff --git a/httemplate/config/config-view.cgi b/httemplate/config/config-view.cgi index 0d16c5d2f..a2e908847 100644 --- a/httemplate/config/config-view.cgi +++ b/httemplate/config/config-view.cgi @@ -416,8 +416,9 @@ my @deleteable = qw( invoice_latexreturnaddress invoice_htmlreturnaddress ); my %deleteable = map { $_ => 1 } @deleteable; my @sections = (qw( - required billing invoicing notification UI API self-service ticketing - network_monitoring username password session shell BIND telephony + required billing taxation invoicing notification UI API self-service + ticketing network_monitoring username password session shell BIND + telephony ), '', 'deprecated' ); diff --git a/httemplate/edit/part_fee.html b/httemplate/edit/part_fee.html index 339941015..5f6dc3818 100644 --- a/httemplate/edit/part_fee.html +++ b/httemplate/edit/part_fee.html @@ -35,7 +35,7 @@ die "access denied" my $conf = FS::Conf->new; my @tax_fields; -if ( $conf->exists('enable_taxproducts') ) { +if ( $conf->config('tax_data_vendor') ) { @tax_fields = ( { field => 'taxproductnum', type => 'select-taxproduct' } ); diff --git a/httemplate/edit/part_pkg.cgi b/httemplate/edit/part_pkg.cgi index fbc19c3f5..bfa5d50ea 100755 --- a/httemplate/edit/part_pkg.cgi +++ b/httemplate/edit/part_pkg.cgi @@ -179,22 +179,28 @@ type => 'hidden', value => join(',', @taxproductnums), }, - { field => 'taxproduct_select', - type => 'selectlayers', - options => [ '(default)', @taxproductnums ], - curr_value => '(default)', - labels => { ( '(default)' => '(default)' ), - map {($_=>$usage_class{$_})} - @taxproductnums - }, - layer_fields => \%taxproduct_fields, - layer_values_callback => $taxproduct_values, - layers_only => !$taxproducts, - cell_style => ( !$taxproducts - ? 'display:none' - : '' - ), + #{ field => 'taxproduct_select', + # type => 'selectlayers', + # options => [ '(default)', @taxproductnums ], + # curr_value => '(default)', + # labels => { ( '(default)' => '(default)' ), + # map {($_=>$usage_class{$_})} + # @taxproductnums + # }, + # layer_fields => \%taxproduct_fields, + # layer_values_callback => $taxproduct_values, + # layers_only => !$taxproducts, + # cell_style => ( !$taxproducts + # ? 'display:none' + # : '' + # ), + #}, + { field => 'taxproductnum', + type => 'part_pkg-taxproducts', + include_opt_callback => + sub { pkgpart => $_[0]->pkgpart }, }, + { type => 'tablebreak-tr-title', value => 'Promotions', #better name? @@ -414,7 +420,7 @@ my $agent_clone_extra_sql = ' ) '; my $conf = new FS::Conf; -my $taxproducts = $conf->exists('enable_taxproducts'); +my $taxproducts = $conf->config('tax_data_vendor') ne ''; my $fcc_opts = $conf->exists('part_pkg-show_fcc_options'); @@ -1120,9 +1126,9 @@ my $html_bottom = sub { ''; diff --git a/httemplate/edit/process/part_pkg.cgi b/httemplate/edit/process/part_pkg.cgi index eda3f33d4..f3ee06157 100755 --- a/httemplate/edit/process/part_pkg.cgi +++ b/httemplate/edit/process/part_pkg.cgi @@ -117,7 +117,7 @@ my $args_callback = sub { $error ||= "Illegal $param: $value" unless ( $value =~ /^\d*$/ ); if (length($class)) { - $options{"usage_taxproductnum_$_"} = $value; + $options{"usage_taxproductnum_$class"} = $value; } else { $new->set('taxproductnum', $value); } diff --git a/httemplate/edit/process/quick-charge.cgi b/httemplate/edit/process/quick-charge.cgi index c1e7fc159..23eead451 100644 --- a/httemplate/edit/process/quick-charge.cgi +++ b/httemplate/edit/process/quick-charge.cgi @@ -74,7 +74,7 @@ if ( $param->{'pkgnum'} =~ /^(\d+)$/ ) { #modifying an existing one-time charge if ( $param->{'taxclass'} eq '(select)' ) { $error .= "Must select a tax class. " - unless ($conf->exists('enable_taxproducts') && + unless ($conf->config('tax_data_vendor') && ( $override || $param->{taxproductnum} ) ); $cgi->param('taxclass', ''); @@ -122,7 +122,7 @@ if ( $param->{'pkgnum'} =~ /^(\d+)$/ ) { #modifying an existing one-time charge if ( $param->{'taxclass'} eq '(select)' ) { $error .= "Must select a tax class. " - unless ($conf->exists('enable_taxproducts') && + unless ($conf->config('tax_data_vendor')) ( $override || $param->{taxproductnum} ) ); $cgi->param('taxclass', ''); diff --git a/httemplate/elements/menu.html b/httemplate/elements/menu.html index 9c9b2de64..7d34d427e 100644 --- a/httemplate/elements/menu.html +++ b/httemplate/elements/menu.html @@ -375,7 +375,7 @@ if( $curuser->access_right('Financial reports') ) { $report_financial{'A/R Aging'} = [ $fsurl.'search/report_receivables.html', 'Accounts Receivable Aging report' ]; $report_financial{'Prepaid Income'} = [ $fsurl.'search/report_prepaid_income.html', 'Prepaid income (unearned revenue) report' ]; - my $taxproducts = $conf->exists('enable_taxproducts'); + my $taxproducts = $conf->config('tax_data_vendor'); $report_financial{'Tax Liability'. ($taxproducts ? ' (internal tax data)' : '')} = [ $fsurl.'search/report_tax.html', 'Tax liability report (internal tax data)' ]; $report_financial{'Tax Liability (vendor tax data)'} = [ $fsurl.'search/report_newtax.html', 'Tax liability report (vendor tax data)' ] if $taxproducts; @@ -458,7 +458,7 @@ tie my %tools_importing, 'Tie::IxHash', 'Phone numbers (DIDs)' => [ $fsurl.'misc/phone_avail-import.html', '' ], 'Call Detail Records (CDRs)' => [ $fsurl.'misc/cdr-import.html', '' ], ; -if ( $conf->exists('enable_taxproducts') ) { +if ( $conf->config('tax_data_vendor') eq 'cch' ) { if ( $conf->exists('taxdatadirectdownload') ) { $tools_importing{'Tax rates from vendor site'} = [ $fsurl.'misc/tax-fetch_and_import.cgi', '' ]; @@ -680,13 +680,13 @@ if ( $curuser->access_right('Configuration') ) { $config_billing{'separator2'} = ''; #its a separator! my $config_taxes_name = 'Locales and tax rates'. - ( $conf->exists('enable_taxproducts') + ( $conf->config('tax_data_vendor') ? ' (internal tax class system)' : '' ); $config_billing{$config_taxes_name} = [ $fsurl.'browse/cust_main_county.cgi', 'Change tax rates, or break down a country into states, or a state into counties and assign different tax rates to each' ]; $config_billing{'Tax rates (vendor data tax products system)'} = [ $fsurl.'browse/tax_rate.cgi', 'Edit tax rates for the vendor data tax products system' ] - if $conf->exists('enable_taxproducts'); + if $conf->config('tax_data_vendor'); $config_billing{'Tax classes'} = [ $fsurl. 'browse/part_pkg_taxclass.html', 'Tax classes' ]; if ( $conf->config('currencies') ) { diff --git a/httemplate/elements/select-taxproduct.html b/httemplate/elements/select-taxproduct.html index 07e554927..5feb71d80 100644 --- a/httemplate/elements/select-taxproduct.html +++ b/httemplate/elements/select-taxproduct.html @@ -24,7 +24,7 @@ unless ( $description || ! $value ) { } my $conf = FS::Conf->new; -my $vendor = lc($conf->config('enable_taxproducts')); +my $vendor = lc($conf->config('tax_data_vendor')); 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;"; diff --git a/httemplate/elements/tr-part_pkg-taxproducts.html b/httemplate/elements/tr-part_pkg-taxproducts.html new file mode 100644 index 000000000..274dc3b48 --- /dev/null +++ b/httemplate/elements/tr-part_pkg-taxproducts.html @@ -0,0 +1,34 @@ + + Tax products + +% foreach my $usage_class (@classes) { +% my $classnum = $usage_class->classnum; +% my $curr_value = +% $cgi->param("usage_taxproductnum_$classnum") +% || $pkg_options{"usage_taxproductnum_$classnum"} +% || ''; + + <% $usage_class->classname %> + <& select-taxproduct.html, + %opt, + 'field' => $field.'_'.$classnum, + 'curr_value' => $curr_value + &> + + +% } +<%init> +my %opt = @_; +my $field = delete($opt{field}) || 'taxproductnum'; +my $pkgpart = delete($opt{pkgpart}); +my $part_pkg = FS::part_pkg->by_key($pkgpart); +my %pkg_options = $part_pkg->options; +$pkg_options{'usage_taxproductnum_'} = $part_pkg->taxproductnum; + +my @classes = qsearch('usage_class', { 'disabled' => '' }); +unshift @classes, + FS::usage_class->new({ 'classnum' => '', 'classname' => '(default)', }), + FS::usage_class->new({ 'classnum' => 'setup', 'classname' => 'Setup', }), + FS::usage_class->new({ 'classnum' => 'recur', 'classname' => 'Recur', }), +; + diff --git a/httemplate/elements/tr-select-tax_status.html b/httemplate/elements/tr-select-tax_status.html index 9c2de154f..1e0ea8a98 100644 --- a/httemplate/elements/tr-select-tax_status.html +++ b/httemplate/elements/tr-select-tax_status.html @@ -17,7 +17,7 @@ <%shared> my $conf = FS::Conf->new; -my $vendor = $conf->config('enable_taxproducts'); +my $vendor = $conf->config('tax_data_vendor'); <%init> my %opt = @_; diff --git a/httemplate/elements/tr-select-taxproduct.html b/httemplate/elements/tr-select-taxproduct.html index 759d0c01c..547f06626 100644 --- a/httemplate/elements/tr-select-taxproduct.html +++ b/httemplate/elements/tr-select-taxproduct.html @@ -1,4 +1,4 @@ -% if ( $conf->exists('enable_taxproducts') ) { +% if ( $conf->config('tax_data_vendor') ) { # still not quite right <%include('tr-td-label.html', @_) %> ><% include('select-taxproduct.html', @_) %> diff --git a/httemplate/misc/choose_tax_location.html b/httemplate/misc/choose_tax_location.html index 9c5881fd4..2eb5ab98e 100644 --- a/httemplate/misc/choose_tax_location.html +++ b/httemplate/misc/choose_tax_location.html @@ -38,7 +38,7 @@ my $conf = new FS::Conf; my $tax_engine = FS::TaxEngine->new; my %location; -($location{data_vendor}) = $conf->config('enable_taxproducts'); +($location{data_vendor}) = $conf->config('tax_data_vendor'); ($location{city}) = $cgi->param('city') =~ /^([\w ]+)$/; ($location{state}) = $cgi->param('state') =~ /^(\w+)$/; ($location{zip}) = $cgi->param('zip') =~ /^([-\w ]+)$/; diff --git a/httemplate/misc/tax-import.cgi b/httemplate/misc/tax-import.cgi index 7e72c74e3..9581a7975 100644 --- a/httemplate/misc/tax-import.cgi +++ b/httemplate/misc/tax-import.cgi @@ -60,10 +60,10 @@ die "access denied" unless $FS::CurrentUser::CurrentUser->access_right('Import'); my $conf = FS::Conf->new; -my $data_vendor = $conf->config('enable_taxproducts'); +my $data_vendor = $conf->config('tax_data_vendor'); my %vendor_info = ( - CCH => { + cch => { 'num_files' => 6, 'formats' => [ 'cch' => 'CCH import (CSV)', 'cch-fixed' => 'CCH import (fixed length)' ], @@ -82,7 +82,7 @@ my %vendor_info = ( 'detail filename', ], }, - Billsoft => { + billsoft => { 'num_files' => 1, 'formats' => [ 'billsoft-pcode' => 'Billsoft PCodes', 'billsoft-taxclass' => 'Tax classes', diff --git a/httemplate/search/report_cust_pkg.html b/httemplate/search/report_cust_pkg.html index f124f0f87..dd1f97d0d 100755 --- a/httemplate/search/report_cust_pkg.html +++ b/httemplate/search/report_cust_pkg.html @@ -190,7 +190,7 @@ <& /elements/tr-title.html, value => mt('Location search options') &> % my @location_options = qw(cust nocust census nocensus); -% if ( $conf->exists('enable_taxproducts') ) { +% if ( $conf->config('tax_data_vendor') eq 'cch' ) { % push @location_options, 'geocode', 'nogeocode'; % } <& /elements/tr-checkbox-multiple.html, diff --git a/httemplate/view/cust_main/billing.html b/httemplate/view/cust_main/billing.html index a16e8a564..0f794e334 100644 --- a/httemplate/view/cust_main/billing.html +++ b/httemplate/view/cust_main/billing.html @@ -71,7 +71,7 @@ % } -% if ( $conf->exists('enable_taxproducts') ) { +% if ( $conf->config('tax_data_vendor') eq 'cch' ) { <% mt('Tax location') |h %> % my $tax_location = $conf->exists('tax-ship_address') -- 2.11.0