summaryrefslogtreecommitdiff
path: root/FS
diff options
context:
space:
mode:
Diffstat (limited to 'FS')
-rw-r--r--FS/FS/AccessRight.pm1
-rw-r--r--FS/FS/Schema.pm1
-rw-r--r--FS/FS/TaxEngine/internal.pm16
-rw-r--r--FS/FS/TemplateItem_Mixin.pm23
-rw-r--r--FS/FS/access_right.pm27
-rw-r--r--FS/FS/cust_main.pm13
-rw-r--r--FS/FS/cust_main_Mixin.pm18
-rw-r--r--FS/FS/cust_pkg.pm175
-rw-r--r--FS/FS/detail_format.pm7
-rw-r--r--FS/FS/log.pm23
-rw-r--r--FS/FS/log_context.pm1
-rw-r--r--FS/FS/log_email.pm4
-rw-r--r--FS/FS/part_pkg/flat_introrate.pm34
-rwxr-xr-xFS/t/suite/08-sales_tax.t76
14 files changed, 330 insertions, 89 deletions
diff --git a/FS/FS/AccessRight.pm b/FS/FS/AccessRight.pm
index 89e50aa00..dac349eaf 100644
--- a/FS/FS/AccessRight.pm
+++ b/FS/FS/AccessRight.pm
@@ -143,6 +143,7 @@ tie my %rights, 'Tie::IxHash',
'Cancel customer package later',
'Un-cancel customer package',
'Delay suspension events',
+ 'Customize billing during suspension',
'Add on-the-fly cancel reason', #NEW
'Add on-the-fly suspend reason', #NEW
'Edit customer package invoice details', #NEW
diff --git a/FS/FS/Schema.pm b/FS/FS/Schema.pm
index 359354309..a50b551da 100644
--- a/FS/FS/Schema.pm
+++ b/FS/FS/Schema.pm
@@ -6635,6 +6635,7 @@ sub tables_hashref {
'min_level', 'int', 'NULL', '', '', '',
'msgnum', 'int', '', '', '', '',
'to_addr', 'varchar', 'NULL', 255, '', '',
+ 'context_height', 'int', 'NULL', '', '', '',
],
'primary_key' => 'logemailnum',
'unique' => [],
diff --git a/FS/FS/TaxEngine/internal.pm b/FS/FS/TaxEngine/internal.pm
index a9b32d133..db7010c18 100644
--- a/FS/FS/TaxEngine/internal.pm
+++ b/FS/FS/TaxEngine/internal.pm
@@ -66,7 +66,7 @@ sub taxline {
my $taxnum = $tax_object->taxnum;
my $exemptions = $self->{exemptions}->{$taxnum} ||= [];
- my $taxable_cents = 0;
+ my $taxable_total = 0;
my $tax_cents = 0;
my $round_per_line_item = $conf->exists('tax-round_per_line_item');
@@ -302,15 +302,17 @@ sub taxline {
});
push @tax_links, $location;
- $taxable_cents += $taxable_charged;
+ $taxable_total += $taxable_charged;
$tax_cents += $this_tax_cents;
} #foreach $cust_bill_pkg
- # calculate tax and rounding error for the whole group
- 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);
+ # calculate tax and rounding error for the whole group: total taxable
+ # amount times tax rate (as cents per dollar), minus the tax already
+ # charged
+ # and force 0.5 to round up
+ my $extra_cents = sprintf('%.0f',
+ ($taxable_total * $tax_object->tax) - $tax_cents + 0.00000001
+ );
# if we're rounding per item, then ignore that and don't distribute any
# extra cents.
diff --git a/FS/FS/TemplateItem_Mixin.pm b/FS/FS/TemplateItem_Mixin.pm
index 248da3cae..28fbd591d 100644
--- a/FS/FS/TemplateItem_Mixin.pm
+++ b/FS/FS/TemplateItem_Mixin.pm
@@ -258,14 +258,25 @@ sub details {
$sth->execute or die $sth->errstr;
#avoid the fetchall_arrayref and loop for less memory usage?
-
- map { (defined($_->[0]) && $_->[0] eq 'C')
- ? &{$format_sub}( $_->[1] )
- : &{$escape_function}( $_->[1] );
+ # probably should use a cursor...
+
+ my @return;
+ my $head = 1;
+ map {
+ my $row = $_;
+ if (defined($row->[0]) and $row->[0] eq 'C') {
+ if ($head) {
+ # first CSV row = the format header; localize it but not the others
+ $row->[1] = $self->mt($row->[1]);
+ $head = 0;
}
- @{ $sth->fetchall_arrayref };
+ &{$format_sub}($row->[1]);
+ } else {
+ &{$escape_function}($row->[1]);
+ }
+ } @{ $sth->fetchall_arrayref };
- }
+ } #!$opt{format_function}
}
diff --git a/FS/FS/access_right.pm b/FS/FS/access_right.pm
index 59defb72a..0ee0aa04a 100644
--- a/FS/FS/access_right.pm
+++ b/FS/FS/access_right.pm
@@ -292,6 +292,33 @@ sub _upgrade_data { # class method
}
+ # some false laziness with @onetime above,
+ # but for use when multiple old acls trigger a single new acl
+ # (keys/values reversed from @onetime, expects arrayref value)
+ my @onetime_bynew = (
+ 'Customize billing during suspension' => [ 'Suspend customer package', 'Suspend customer package later' ],
+ );
+ while ( @onetime_bynew ) {
+ my( $new_acl, $old_acl ) = splice(@onetime_bynew, 0, 2);
+ ( my $journal = 'ACL_'.lc($new_acl) ) =~ s/\W/_/g;
+ next if FS::upgrade_journal->is_done($journal);
+ # grant $new_acl to all groups who have one of @old_acl
+ for my $group (@all_groups) {
+ next unless grep { $group->access_right($_) } @$old_acl;
+ next if $group->access_right($new_acl);
+ my $access_right = FS::access_right->new( {
+ 'righttype' => 'FS::access_group',
+ 'rightobjnum' => $group->groupnum,
+ 'rightname' => $new_acl,
+ } );
+ my $error = $access_right->insert;
+ die $error if $error;
+ }
+
+ FS::upgrade_journal->set_done($journal);
+
+ }
+
### ACL_download_report_data
if ( !FS::upgrade_journal->is_done('ACL_download_report_data') ) {
diff --git a/FS/FS/cust_main.pm b/FS/FS/cust_main.pm
index ecd30702f..3fb0a87fb 100644
--- a/FS/FS/cust_main.pm
+++ b/FS/FS/cust_main.pm
@@ -4769,15 +4769,10 @@ Returns an SQL expression identifying un-cancelled cust_main records.
=cut
sub uncancelled_sql { uncancel_sql(@_); }
-sub uncancel_sql { "
- ( 0 < ( $select_count_pkgs
- AND ( cust_pkg.cancel IS NULL
- OR cust_pkg.cancel = 0
- )
- )
- OR 0 = ( $select_count_pkgs )
- )
-"; }
+sub uncancel_sql {
+ my $self = shift;
+ "( NOT (".$self->cancelled_sql.") )"; #sensitive to cust_main-status_module
+}
=item balance_sql
diff --git a/FS/FS/cust_main_Mixin.pm b/FS/FS/cust_main_Mixin.pm
index 9fc66e059..195574627 100644
--- a/FS/FS/cust_main_Mixin.pm
+++ b/FS/FS/cust_main_Mixin.pm
@@ -210,19 +210,9 @@ a customer.
sub cust_status {
my $self = shift;
return $self->cust_unlinked_msg unless $self->cust_linked;
-
- #FS::cust_main::status($self)
- #false laziness w/actual cust_main::status
- # (make sure FS::cust_main methods are called)
- for my $status (qw( prospect active inactive suspended cancelled )) {
- my $method = $status.'_sql';
- my $sql = FS::cust_main->$method();;
- my $numnum = ( $sql =~ s/cust_main\.custnum/?/g );
- my $sth = dbh->prepare("SELECT $sql") or die dbh->errstr;
- $sth->execute( ($self->custnum) x $numnum )
- or die "Error executing 'SELECT $sql': ". $sth->errstr;
- return $status if $sth->fetchrow_arrayref->[0];
- }
+ my $cust_main = $self->cust_main;
+ return $self->cust_unlinked_msg unless $cust_main;
+ return $cust_main->cust_status;
}
=item ucfirst_cust_status
@@ -673,7 +663,7 @@ sub unsuspend_balance {
my $self = shift;
my $cust_main = $self->cust_main;
my $conf = $self->conf;
- my $setting = $conf->config('unsuspend_balance');
+ my $setting = $conf->config('unsuspend_balance') or return;
my $maxbalance;
if ($setting eq 'Zero') {
$maxbalance = 0;
diff --git a/FS/FS/cust_pkg.pm b/FS/FS/cust_pkg.pm
index d15eb89ac..456847ea7 100644
--- a/FS/FS/cust_pkg.pm
+++ b/FS/FS/cust_pkg.pm
@@ -1129,6 +1129,135 @@ sub cancel_if_expired {
'';
}
+=item uncancel_svc_x
+
+For cancelled cust_pkg, returns a list of new, uninserted FS::svc_X records
+for services that would be inserted by L</uncancel>. Returned objects also
+include the field '_uncancel_svcnum' that contains the original svcnum.
+Set pkgnum before inserting.
+
+Accepts the following options:
+
+summarize_size - if true, returns empty list if number of potential services is
+equal to or greater than this
+
+only_svcnum - arrayref of svcnum, only returns objects for these svcnum
+(and only if they would otherwise be returned by this)
+
+=cut
+
+sub uncancel_svc_x {
+ my ($self, %opt) = @_;
+
+ die 'uncancel_svc_x called on a non-cancelled cust_pkg' unless $self->get('cancel');
+
+ #find historical services within this timeframe before the package cancel
+ # (incompatible with "time" option to cust_pkg->cancel?)
+ my $fuzz = 2 * 60; #2 minutes? too much? (might catch separate unprovision)
+ # too little? (unprovisioing export delay?)
+ my($end, $start) = ( $self->get('cancel'), $self->get('cancel') - $fuzz );
+ my @h_cust_svc = $self->h_cust_svc( $end, $start );
+
+ return () if $opt{'summarize_size'} and @h_cust_svc >= $opt{'summarize_size'};
+
+ my @svc_x;
+ foreach my $h_cust_svc (@h_cust_svc) {
+ next if $opt{'only_svcnum'} && !(grep { $_ == $h_cust_svc->svcnum } @{$opt{'only_svcnum'}});
+ my $h_svc_x = $h_cust_svc->h_svc_x( $end, $start );
+ #next unless $h_svc_x; #should this happen?
+ (my $table = $h_svc_x->table) =~ s/^h_//;
+ require "FS/$table.pm";
+ my $class = "FS::$table";
+ my $svc_x = $class->new( {
+ 'svcpart' => $h_cust_svc->svcpart,
+ '_uncancel_svcnum' => $h_cust_svc->svcnum,
+ map { $_ => $h_svc_x->get($_) } fields($table)
+ } );
+
+ # radius_usergroup
+ if ( $h_svc_x->isa('FS::h_svc_Radius_Mixin') ) {
+ $svc_x->usergroup( [ $h_svc_x->h_usergroup($end, $start) ] );
+ }
+
+ #these are pretty rare, but should handle them
+ # - dsl_device (mac addresses)
+ # - phone_device (mac addresses)
+ # - dsl_note (ikano notes)
+ # - domain_record (i.e. restore DNS information w/domains)
+ # - inventory_item(?) (inventory w/un-cancelling service?)
+ # - nas (svc_broaband nas stuff)
+ #this stuff is unused in the wild afaik
+ # - mailinglistmember
+ # - router.svcnum?
+ # - svc_domain.parent_svcnum?
+ # - acct_snarf (ancient mail fetching config)
+ # - cgp_rule (communigate)
+ # - cust_svc_option (used by our Tron stuff)
+ # - acct_rt_transaction (used by our time worked stuff)
+
+ push @svc_x, $svc_x;
+ }
+ return @svc_x;
+}
+
+=item uncancel_svc_summary
+
+Returns an array of hashrefs, one for each service that could
+potentially be reprovisioned by L</uncancel>, with the following keys:
+
+svcpart
+
+svc
+
+uncancel_svcnum
+
+label
+
+reprovisionable - 1 if test reprovision succeeded, otherwise 0
+
+Cannot be run from within a transaction. Performs inserts
+to test the results, and then rolls back the transaction.
+Does not perform exports, so does not catch if export would fail.
+
+Also accepts the following options:
+
+summarize_size - if true, returns empty list if number of potential services is
+equal to or greater than this
+
+=cut
+
+sub uncancel_svc_summary {
+ my ($self, %opt) = @_;
+
+ die 'uncancel_svc_summary called on a non-cancelled cust_pkg' unless $self->get('cancel');
+ die 'uncancel_svc_summary called from within a transaction' unless $FS::UID::AutoCommit;
+
+ local $FS::svc_Common::noexport_hack = 1; # very important not to run exports!!!
+ local $FS::UID::AutoCommit = 0;
+
+ my @out;
+ foreach my $svc_x ($self->uncancel_svc_x(%opt)) {
+ $svc_x->pkgnum($self->pkgnum); # provisioning services on a canceled package, will be rolled back
+ my $part_svc = $svc_x->part_svc;
+ my $out = {
+ 'svcpart' => $part_svc->svcpart,
+ 'svc' => $part_svc->svc,
+ 'uncancel_svcnum' => $svc_x->get('_uncancel_svcnum'),
+ };
+ if ($svc_x->insert) { # if error inserting
+ $out->{'label'} = "(cannot re-provision)";
+ $out->{'reprovisionable'} = 0;
+ } else {
+ $out->{'label'} = $svc_x->label;
+ $out->{'reprovisionable'} = 1;
+ }
+ push @out, $out;
+ }
+
+ dbh->rollback;
+ return @out;
+}
+
=item uncancel
"Un-cancels" this package: Orders a new package with the same custnum, pkgpart,
@@ -1141,6 +1270,8 @@ svc_fatal: service provisioning errors are fatal
svc_errors: pass an array reference, will be filled in with any provisioning errors
+only_svcnum: arrayref, only attempt to re-provision these cancelled services
+
main_pkgnum: link the package as a supplemental package of this one. For
internal use only.
@@ -1197,32 +1328,12 @@ sub uncancel {
# insert services
##
- #find historical services within this timeframe before the package cancel
- # (incompatible with "time" option to cust_pkg->cancel?)
- my $fuzz = 2 * 60; #2 minutes? too much? (might catch separate unprovision)
- # too little? (unprovisioing export delay?)
- my($end, $start) = ( $self->get('cancel'), $self->get('cancel') - $fuzz );
- my @h_cust_svc = $self->h_cust_svc( $end, $start );
-
my @svc_errors;
- foreach my $h_cust_svc (@h_cust_svc) {
- my $h_svc_x = $h_cust_svc->h_svc_x( $end, $start );
- #next unless $h_svc_x; #should this happen?
- (my $table = $h_svc_x->table) =~ s/^h_//;
- require "FS/$table.pm";
- my $class = "FS::$table";
- my $svc_x = $class->new( {
- 'pkgnum' => $cust_pkg->pkgnum,
- 'svcpart' => $h_cust_svc->svcpart,
- map { $_ => $h_svc_x->get($_) } fields($table)
- } );
-
- # radius_usergroup
- if ( $h_svc_x->isa('FS::h_svc_Radius_Mixin') ) {
- $svc_x->usergroup( [ $h_svc_x->h_usergroup($end, $start) ] );
- }
+ foreach my $svc_x ($self->uncancel_svc_x('only_svcnum' => $options{'only_svcnum'})) {
+ $svc_x->pkgnum($cust_pkg->pkgnum);
my $svc_error = $svc_x->insert;
+
if ( $svc_error ) {
if ( $options{svc_fatal} ) {
$dbh->rollback if $oldAutoCommit;
@@ -1246,23 +1357,7 @@ sub uncancel {
}
} # svc_fatal
} # svc_error
- } #foreach $h_cust_svc
-
- #these are pretty rare, but should handle them
- # - dsl_device (mac addresses)
- # - phone_device (mac addresses)
- # - dsl_note (ikano notes)
- # - domain_record (i.e. restore DNS information w/domains)
- # - inventory_item(?) (inventory w/un-cancelling service?)
- # - nas (svc_broaband nas stuff)
- #this stuff is unused in the wild afaik
- # - mailinglistmember
- # - router.svcnum?
- # - svc_domain.parent_svcnum?
- # - acct_snarf (ancient mail fetching config)
- # - cgp_rule (communigate)
- # - cust_svc_option (used by our Tron stuff)
- # - acct_rt_transaction (used by our time worked stuff)
+ } #foreach uncancel_svc_x
##
# also move over any services that didn't unprovision at cancellation
diff --git a/FS/FS/detail_format.pm b/FS/FS/detail_format.pm
index be84680f9..d032100b3 100644
--- a/FS/FS/detail_format.pm
+++ b/FS/FS/detail_format.pm
@@ -168,7 +168,7 @@ sub header {
my $self = shift;
FS::cust_bill_pkg_detail->new(
- { 'format' => 'C', 'detail' => $self->mt($self->header_detail) }
+ { 'format' => 'C', 'detail' => $self->header_detail }
)
}
@@ -270,10 +270,7 @@ sub time2str_local {
$self->{_dh}->time2str(@_);
}
-sub mt {
- my $self = shift;
- $self->{_lh}->maketext(@_);
-}
+# header strings are now localized in FS::TemplateItem_Mixin::detail
#imitate previous behavior for now
diff --git a/FS/FS/log.pm b/FS/FS/log.pm
index 95bc4c409..1d4df730a 100644
--- a/FS/FS/log.pm
+++ b/FS/FS/log.pm
@@ -81,15 +81,16 @@ sub insert {
my $self = shift;
my $error = $self->SUPER::insert;
return $error if $error;
- my $contexts = {}; #for quick checks when sending emails
- foreach ( @_ ) {
+ my $contexts = {}; # for quick checks when sending emails
+ my $context_height = @_; # also for email check
+ foreach ( @_ ) { # ordered from least to most specific
my $context = FS::log_context->new({
'lognum' => $self->lognum,
'context' => $_
});
$error = $context->insert;
return $error if $error;
- $contexts->{$_} = 1;
+ $contexts->{$_} = $context_height--;
}
foreach my $log_email (
qsearch('log_email',
@@ -102,8 +103,9 @@ sub insert {
}
)
) {
- # shouldn't be a lot of these, so not packing this into the qsearch
+ # shouldn't be a lot of log_email records, so not packing these checks into the qsearch
next if $log_email->context && !$contexts->{$log_email->context};
+ next if $log_email->context_height && ($contexts->{$log_email->context} > $log_email->context_height);
my $msg_template = qsearchs('msg_template',{ 'msgnum' => $log_email->msgnum });
unless ($msg_template) {
warn "Could not send email when logging, could not load message template for logemailnum " . $log_email->logemailnum;
@@ -346,9 +348,16 @@ sub search {
if ( $params->{'context'} ) {
my $quoted = dbh->quote($params->{'context'});
- push @where,
- "EXISTS(SELECT 1 FROM log_context WHERE log.lognum = log_context.lognum ".
- "AND log_context.context = $quoted)";
+ if ( $params->{'context_height'} =~ /^\d+$/ ) {
+ my $subq = 'SELECT context FROM log_context WHERE log.lognum = log_context.lognum'.
+ ' ORDER BY logcontextnum DESC LIMIT '.$params->{'context_height'};
+ push @where,
+ "EXISTS(SELECT 1 FROM ($subq) AS log_context_x WHERE log_context_x.context = $quoted)";
+ } else {
+ push @where,
+ "EXISTS(SELECT 1 FROM log_context WHERE log.lognum = log_context.lognum ".
+ "AND log_context.context = $quoted)";
+ }
}
# agent virtualization
diff --git a/FS/FS/log_context.pm b/FS/FS/log_context.pm
index ab1b0c388..83414a680 100644
--- a/FS/FS/log_context.pm
+++ b/FS/FS/log_context.pm
@@ -11,6 +11,7 @@ my @contexts = ( qw(
FS::cust_main::Billing::bill
FS::cust_main::Billing_Realtime::realtime_verify_bop
FS::pay_batch::import_from_gateway
+ FS::part_pkg
FS::Misc::Geo::standardize_uscensus
Cron::bill
Cron::backup
diff --git a/FS/FS/log_email.pm b/FS/FS/log_email.pm
index 9c53c230a..a055cb4c6 100644
--- a/FS/FS/log_email.pm
+++ b/FS/FS/log_email.pm
@@ -42,6 +42,9 @@ The following fields are currently supported:
=item to_addr - who the email will be sent to (in addition to any bcc on the template)
+=item context_height - number of context stack levels to match against
+(0 or null matches against full stack, 1 only matches lowest level context, 2 matches lowest two levels, etc.)
+
=back
=head1 METHODS
@@ -88,6 +91,7 @@ sub check {
|| $self->ut_number('min_level')
|| $self->ut_foreign_key('msgnum', 'msg_template', 'msgnum')
|| $self->ut_textn('to_addr')
+ || $self->ut_numbern('context_height')
;
return $error if $error;
diff --git a/FS/FS/part_pkg/flat_introrate.pm b/FS/FS/part_pkg/flat_introrate.pm
index 733760276..786841bff 100644
--- a/FS/FS/part_pkg/flat_introrate.pm
+++ b/FS/FS/part_pkg/flat_introrate.pm
@@ -4,6 +4,33 @@ use base qw( FS::part_pkg::flat );
use strict;
use vars qw( %info );
+use FS::Log;
+
+# mostly false laziness with FS::part_pkg::global_Mixin::validate_moneyn,
+# except for blank string handling...
+sub validate_money {
+ my ($option, $valref) = @_;
+ if ( $$valref eq '' ) {
+ $$valref = '0';
+ } elsif ( $$valref =~ /^\s*(\d*)(\.\d{1})\s*$/ ) {
+ #handle one decimal place without barfing out
+ $$valref = ( ($1||''). ($2.'0') ) || 0;
+ } elsif ( $$valref =~ /^\s*(\d*)(\.\d{2})?\s*$/ ) {
+ $$valref = ( ($1||''). ($2||'') ) || 0;
+ } else {
+ return "Illegal (money) $option: ". $$valref;
+ }
+ return '';
+}
+
+sub validate_number {
+ my ($option, $valref) = @_;
+ $$valref = 0 unless $$valref;
+ return "Invalid $option"
+ unless ($$valref) = ($$valref =~ /^\s*(\d+)\s*$/);
+ return '';
+}
+
%info = (
'name' => 'Introductory price for X months, then flat rate,'.
'relative to setup date (anniversary billing)',
@@ -12,10 +39,12 @@ use vars qw( %info );
'fields' => {
'intro_fee' => { 'name' => 'Introductory recurring fee for this package',
'default' => 0,
+ 'validate' => \&validate_money,
},
'intro_duration' =>
{ 'name' => 'Duration of the introductory period, in number of months',
'default' => 0,
+ 'validate' => \&validate_number,
},
},
'fieldorder' => [ qw(intro_duration intro_fee) ],
@@ -30,7 +59,10 @@ sub base_recur {
my ($duration) = ($self->option('intro_duration') =~ /^\s*(\d+)\s*$/);
unless (length($duration)) {
- die "Invalid intro_duration: " . $self->option('intro_duration');
+ my $log = FS::Log->new('FS::part_pkg');
+ $log->warning("Invalid intro_duration '".$self->option('intro_duration')."' on pkgpart ".$self->pkgpart
+ .", defaulting to 0, check package definition");
+ $duration = 0;
}
my $intro_end = $self->add_freq($cust_pkg->setup, $duration);
diff --git a/FS/t/suite/08-sales_tax.t b/FS/t/suite/08-sales_tax.t
new file mode 100755
index 000000000..bf1ae48c8
--- /dev/null
+++ b/FS/t/suite/08-sales_tax.t
@@ -0,0 +1,76 @@
+#!/usr/bin/perl
+
+=head2 DESCRIPTION
+
+Tests basic sales tax calculations, including consolidation and rounding.
+The invoice will have two charges that add up to $50 and two taxes:
+- Tax 1, 8.25%, for $4.125 in tax, which will round up.
+- Tax 2, 8.245%, for $4.1225 in tax, which will round down.
+
+Correct: The invoice will have one line item for each of those taxes, with
+the correct amount.
+
+=cut
+
+use strict;
+use Test::More tests => 2;
+use FS::Test;
+use Date::Parse 'str2time';
+use Date::Format 'time2str';
+use Test::MockTime qw(set_fixed_time);
+use FS::cust_main;
+use FS::cust_pkg;
+use FS::Conf;
+my $FS= FS::Test->new;
+
+# test configuration
+my @taxes = (
+ [ 'Tax 1', 8.250, 4.13 ],
+ [ 'Tax 2', 8.245, 4.12 ],
+);
+
+# Create the customer and charge them
+my $cust = $FS->new_customer('Basic taxes');
+$cust->bill_location->state('AZ'); # move it away from the default of CA
+my $error;
+$error = $cust->insert;
+BAIL_OUT("can't create test customer: $error") if $error;
+$error = $cust->charge( {
+ amount => 25.00,
+ pkg => 'Test charge 1',
+} ) ||
+$cust->charge({
+ amount => 25.00,
+ pkg => 'Test charge 2',
+});
+BAIL_OUT("can't create test charges: $error") if $error;
+
+# Create tax defs
+foreach my $tax (@taxes) {
+ my $cust_main_county = FS::cust_main_county->new({
+ 'country' => 'US',
+ 'state' => 'AZ',
+ 'exempt_amount' => 0.00,
+ 'taxname' => $tax->[0],
+ 'tax' => $tax->[1],
+ });
+ $error = $cust_main_county->insert;
+ BAIL_OUT("can't create tax definitions: $error") if $error;
+}
+
+# Bill the customer
+set_fixed_time(str2time('2016-03-10 08:00'));
+my @return;
+$error = $cust->bill( return_bill => \@return );
+BAIL_OUT("can't bill charges: $error") if $error;
+my $cust_bill = $return[0] or BAIL_OUT("no invoice generated");
+# Check amounts
+diag("Tax on 25.00 + 25.00");
+foreach my $cust_bill_pkg ($cust_bill->cust_bill_pkg) {
+ next if $cust_bill_pkg->pkgnum;
+ my ($tax) = grep { $_->[0] eq $cust_bill_pkg->itemdesc } @taxes;
+ if ( $tax ) {
+ ok ( $cust_bill_pkg->setup eq $tax->[2], "Tax at rate $tax->[1]% = $tax->[2]")
+ or diag("is ". $cust_bill_pkg->setup);
+ }
+}