use FS::part_event;
use FS::part_event_condition;
use FS::pkg_category;
+use FS::FeeOrigin_Mixin;
use FS::Log;
+use FS::TaxEngine;
# 1 is mostly method/subroutine entry and options
# 2 traces progress of some operations
sub bill_and_collect {
my( $self, %options ) = @_;
- my $log = FS::Log->new('bill_and_collect');
- $log->debug('start', object => $self, agentnum => $self->agentnum);
+ my $log = FS::Log->new('FS::cust_main::Billing::bill_and_collect');
+ my %logopt = (object => $self);
+ $log->debug('start', %logopt);
my $error;
);
$job->update_statustext('0,cleaning expired packages') if $job;
+ $log->debug('canceling expired packages', %logopt);
$error = $self->cancel_expired_pkgs( $actual_time );
if ( $error ) {
$error = "Error expiring custnum ". $self->custnum. ": $error";
else { warn $error; }
}
+ $log->debug('suspending adjourned packages', %logopt);
$error = $self->suspend_adjourned_pkgs( $actual_time );
if ( $error ) {
$error = "Error adjourning custnum ". $self->custnum. ": $error";
else { warn $error; }
}
+ $log->debug('unsuspending resumed packages', %logopt);
$error = $self->unsuspend_resumed_pkgs( $actual_time );
if ( $error ) {
$error = "Error resuming custnum ".$self->custnum. ": $error";
}
$job->update_statustext('20,billing packages') if $job;
+ $log->debug('billing packages', %logopt);
$error = $self->bill( %options );
if ( $error ) {
$error = "Error billing custnum ". $self->custnum. ": $error";
}
$job->update_statustext('50,applying payments and credits') if $job;
+ $log->debug('applying payments and credits', %logopt);
$error = $self->apply_payments_and_credits;
if ( $error ) {
$error = "Error applying custnum ". $self->custnum. ": $error";
else { warn $error; }
}
- $job->update_statustext('70,running collection events') if $job;
- 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 );
if ( $error ) {
$error = "Error collecting custnum ". $self->custnum. ": $error";
else { warn $error; }
}
}
+
$job->update_statustext('100,finished') if $job;
- $log->debug('finish', object => $self, agentnum => $self->agentnum);
+ $log->debug('finish', %logopt);
'';
my @errors = ();
- foreach my $cust_pkg ( @cancel_pkgs ) {
+ CUST_PKG: foreach my $cust_pkg ( @cancel_pkgs ) {
my $cpr = $cust_pkg->last_cust_pkg_reason('expire');
- my $error = $cust_pkg->cancel($cpr ? ( 'reason' => $cpr->reasonnum,
+ my $error;
+
+ if ( $cust_pkg->change_to_pkgnum ) {
+
+ my $new_pkg = FS::cust_pkg->by_key($cust_pkg->change_to_pkgnum);
+ if ( !$new_pkg ) {
+ push @errors, 'can\'t change pkgnum '.$cust_pkg->pkgnum.' to pkgnum '.
+ $cust_pkg->change_to_pkgnum.'; not expiring';
+ next CUST_PKG;
+ }
+ $error = $cust_pkg->change( 'cust_pkg' => $new_pkg,
+ 'unprotect_svcs' => 1 );
+ $error = '' if ref $error eq 'FS::cust_pkg';
+
+ } else { # just cancel it
+ $error = $cust_pkg->cancel($cpr ? ( 'reason' => $cpr->reasonnum,
'reason_otaker' => $cpr->otaker,
'time' => $time,
)
: ()
);
+ }
push @errors, 'pkgnum '.$cust_pkg->pkgnum.": $error" if $error;
}
A hashref of pkgparts to exclude from this billing run (can also be specified as a comma-separated scalar).
+=item no_prepaid
+
+Do not bill prepaid packages. Used by freeside-daily.
+
=item invoice_time
Used in conjunction with the I<time> option, this option specifies the date of for the generated invoices. Other calculations, such as whether or not to generate the invoice in the first place, are not affected.
sub bill {
my( $self, %options ) = @_;
- return '' if $self->payby eq 'COMP';
+ return '' if $self->complimentary eq 'Y';
local($DEBUG) = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG;
+ my $log = FS::Log->new('FS::cust_main::Billing::bill');
+ my %logopt = (object => $self);
+ $log->debug('start', %logopt);
warn "$me bill customer ". $self->custnum. "\n"
if $DEBUG;
my $time = $options{'time'} || time;
my $invoice_time = $options{'invoice_time'} || $time;
+ my $cmp_time = ( $conf->exists('next-bill-ignore-time')
+ ? day_end( $time )
+ : $time
+ );
+
$options{'not_pkgpart'} ||= {};
$options{'not_pkgpart'} = { map { $_ => 1 }
split(/\s*,\s*/, $options{'not_pkgpart'})
local $FS::UID::AutoCommit = 0;
my $dbh = dbh;
+ $log->debug('acquiring lock', %logopt);
warn "$me acquiring lock on customer ". $self->custnum. "\n"
if $DEBUG;
$self->select_for_update; #mutex
+ $log->debug('running pre-bill events', %logopt);
warn "$me running pre-bill events for customer ". $self->custnum. "\n"
if $DEBUG;
return $error;
}
+ $log->debug('done running pre-bill events', %logopt);
warn "$me done running pre-bill events for customer ". $self->custnum. "\n"
if $DEBUG;
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'} } ) {
next if $options{'not_pkgpart'}->{$cust_pkg->pkgpart};
+ my $part_pkg = $cust_pkg->part_pkg;
+
+ next if $options{'no_prepaid'} && $part_pkg->is_prepaid;
+
+ $log->debug('bill package '. $cust_pkg->pkgnum, %logopt);
warn " bill package ". $cust_pkg->pkgnum. "\n" if $DEBUG;
#? to avoid use of uninitialized value errors... ?
$cust_pkg->setfield('bill', '')
unless defined($cust_pkg->bill);
- #my $part_pkg = $cust_pkg->part_pkg;
-
my $real_pkgpart = $cust_pkg->pkgpart;
my %hash = $cust_pkg->hash;
# we could implement this bit as FS::part_pkg::has_hidden, but we already
# suffer from performance issues
$options{has_hidden} = 0;
- my @part_pkg = $cust_pkg->part_pkg->self_and_bill_linked;
+ my @part_pkg = $part_pkg->self_and_bill_linked;
$options{has_hidden} = 1 if ($part_pkg[1] && $part_pkg[1]->hidden);
# if this package was changed from another package,
push @{ $cust_bill_pkg{$pass} }, @transfer_items;
# treating this as recur, just because most charges are recur...
${$total_recur{$pass}} += $_->recur foreach @transfer_items;
+
+ # currently not considering separate_bill here, as it's for
+ # one-time charges only
}
foreach my $part_pkg ( @part_pkg ) {
$cust_pkg->set($_, $hash{$_}) foreach qw ( setup last_bill bill );
- my $pass = ($cust_pkg->no_auto || $part_pkg->no_auto) ? 'no_auto' : '';
+ my $pass = '';
+ if ( $cust_pkg->separate_bill ) {
+ # if no_auto is also set, that's fine. we just need to not have
+ # invoices that are both auto and no_auto, and since the package
+ # gets an invoice all to itself, it will only be one or the other.
+ $pass = $cust_pkg->pkgnum;
+ if (!exists $cust_bill_pkg{$pass}) { # it may not exist yet
+ push @passes, $pass;
+ $total_setup{$pass} = do { my $z = 0; \$z };
+ $total_recur{$pass} = do { my $z = 0; \$z };
+ # it also needs its own tax context
+ $tax_engines{$pass} = FS::TaxEngine->new(
+ cust_main => $self,
+ invoice_time => $invoice_time,
+ cancel => $options{cancel}
+ );
+ $cust_bill_pkg{$pass} = [];
+ }
+ } elsif ( ($cust_pkg->no_auto || $part_pkg->no_auto) ) {
+ $pass = 'no_auto';
+ }
my $next_bill = $cust_pkg->getfield('bill') || 0;
my $error;
# let this run once if this is the last bill upon cancellation
- while ( $next_bill <= $time or $options{cancel} ) {
+ while ( $next_bill <= $cmp_time or $options{cancel} ) {
$error =
$self->_make_lines( 'part_pkg' => $part_pkg,
'cust_pkg' => $cust_pkg,
'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,
} #foreach my $cust_pkg
- #if the customer isn't on an automatic payby, everything can go on a single
- #invoice anyway?
- #if ( $cust_main->payby !~ /^(CARD|CHEK)$/ ) {
- #merge everything into one list
- #}
-
- foreach my $pass (@passes) { # keys %cust_bill_pkg ) {
+ foreach my $pass (@passes) { # keys %cust_bill_pkg )
my @cust_bill_pkg = _omit_zero_value_bundles(@{ $cust_bill_pkg{$pass} });
- next unless @cust_bill_pkg; #don't create an invoice w/o line items
-
warn "$me billing pass $pass\n"
#.Dumper(\@cust_bill_pkg)."\n"
if $DEBUG > 2;
+ ###
+ # process fees
+ ###
+
+ my @pending_fees = FS::FeeOrigin_Mixin->by_cust($self->custnum,
+ hashref => { 'billpkgnum' => '' }
+ );
+ warn "$me found pending fees:\n".Dumper(\@pending_fees)."\n"
+ if @pending_fees and $DEBUG > 1;
+
+ # determine whether to generate an invoice
+ my $generate_bill = scalar(@cust_bill_pkg) > 0;
+
+ foreach my $fee (@pending_fees) {
+ $generate_bill = 1 unless $fee->nextbill;
+ }
+
+ # don't create an invoice with no line items, or where the only line
+ # items are fees that are supposed to be held until the next invoice
+ next if !$generate_bill;
+
+ # calculate fees...
+ my @fee_items;
+ foreach my $fee_origin (@pending_fees) {
+ my $part_fee = $fee_origin->part_fee;
+
+ # check whether the fee is applicable before doing anything expensive:
+ #
+ # if the fee def belongs to a different agent, don't charge the fee.
+ # event conditions should prevent this, but just in case they don't,
+ # skip the fee.
+ if ( $part_fee->agentnum and $part_fee->agentnum != $self->agentnum ) {
+ warn "tried to charge fee#".$part_fee->feepart .
+ " on customer#".$self->custnum." from a different agent.\n";
+ next;
+ }
+ # also skip if it's disabled
+ next if $part_fee->disabled eq 'Y';
+
+ # Decide which invoice to base the fee on.
+ my $cust_bill = $fee_origin->cust_bill;
+ if (!$cust_bill) {
+ # Then link it to the current invoice. This isn't the real cust_bill
+ # object that will be inserted--in particular there are no taxes yet.
+ # If you want to charge a fee on the total invoice amount including
+ # taxes, you have to put the fee on the next invoice.
+ $cust_bill = FS::cust_bill->new({
+ 'custnum' => $self->custnum,
+ 'cust_bill_pkg' => \@cust_bill_pkg,
+ 'charged' => ${ $total_setup{$pass} } +
+ ${ $total_recur{$pass} },
+ });
+
+ # If the origin is for a specific package, then only apply the fee to
+ # line items from that package.
+ if ( my $cust_pkg = $fee_origin->cust_pkg ) {
+ my @charge_fee_on_item;
+ my $charge_fee_on_amount = 0;
+ foreach (@cust_bill_pkg) {
+ if ($_->pkgnum == $cust_pkg->pkgnum) {
+ push @charge_fee_on_item, $_;
+ $charge_fee_on_amount += $_->setup + $_->recur;
+ }
+ }
+ $cust_bill->set('cust_bill_pkg', \@charge_fee_on_item);
+ $cust_bill->set('charged', $charge_fee_on_amount);
+ }
+
+ } # $cust_bill is now set
+ # calculate the fee
+ my $fee_item = $part_fee->lineitem($cust_bill) or next;
+ # link this so that we can clear the marker on inserting the line item
+ $fee_item->set('fee_origin', $fee_origin);
+ push @fee_items, $fee_item;
+
+ }
+
+ # add fees to the invoice
+ foreach my $fee_item (@fee_items) {
+
+ push @cust_bill_pkg, $fee_item;
+ ${ $total_setup{$pass} } += $fee_item->setup;
+ ${ $total_recur{$pass} } += $fee_item->recur;
+
+ my $part_fee = $fee_item->part_fee;
+ my $fee_location = $self->ship_location; # I think?
+
+ my $error = $tax_engines{''}->add_sale($fee_item);
+
+ return $error if $error;
+
+ }
+
+ # XXX implementation of fees is supposed to make this go away...
if ( scalar( grep { $_->recur && $_->recur > 0 } @cust_bill_pkg) ||
!$conf->exists('postal_invoice-recurring_only')
)
'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,
}
- 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,
my $charged = sprintf('%.2f', ${ $total_setup{$pass} } + ${ $total_recur{$pass} } );
- my @cust_bill = $self->cust_bill;
my $balance = $self->balance;
- my $previous_balance = scalar(@cust_bill)
- ? ( $cust_bill[$#cust_bill]->billing_balance || 0 )
- : 0;
- $previous_balance += $cust_bill[$#cust_bill]->charged
- if scalar(@cust_bill);
- #my $balance_adjustments =
- # sprintf('%.2f', $balance - $prior_prior_balance - $prior_charged);
+ my $previous_bill = qsearchs({ 'table' => 'cust_bill',
+ 'hashref' => { custnum=>$self->custnum },
+ 'extra_sql' => 'ORDER BY _date DESC LIMIT 1',
+ });
+ my $previous_balance =
+ $previous_bill
+ ? ( $previous_bill->billing_balance + $previous_bill->charged )
+ : 0;
+ $log->debug('creating the new invoice', %logopt);
warn "creating the new invoice\n" if $DEBUG;
#create the new invoice
my $cust_bill = new FS::cust_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 )
}
-=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) = @_;
my $part_pkg = $params{part_pkg} or die "no part_pkg specified";
my $cust_pkg = $params{cust_pkg} or die "no cust_pkg specified";
+ my $cust_location = $cust_pkg->tax_location;
my $precommit_hooks = $params{precommit_hooks} or die "no precommit_hooks specified";
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 '.
my $setup = 0;
my $unitsetup = 0;
my @setup_discounts = ();
- my %setup_param = ( 'discounts' => \@setup_discounts );
+ my %setup_param = ( 'discounts' => \@setup_discounts,
+ 'real_pkgpart' => $params{real_pkgpart}
+ );
+ my $setup_billed_currency = '';
+ my $setup_billed_amount = 0;
+ # Conditions for setting setup date and charging the setup fee:
+ # - this is not a recurring-only billing run
+ # - and the package is not currently being canceled
+ # - and, unless we're specifically told otherwise via 'resetup':
+ # - it doesn't already HAVE a setup date
+ # - or a start date in the future
+ # - and it's not suspended
+ #
+ # The last condition used to check the "disable_setup_suspended" option but
+ # that's obsolete. We now never set the setup date on a suspended package.
if ( ! $options{recurring_only}
and ! $options{cancel}
and ( $options{'resetup'}
&& ( ! $cust_pkg->start_date
|| $cust_pkg->start_date <= $cmp_time
)
- && ( ! $conf->exists('disable_setup_suspended_pkgs')
- || ( $conf->exists('disable_setup_suspended_pkgs') &&
- ! $cust_pkg->getfield('susp')
- )
- )
+ && ( ! $cust_pkg->getfield('susp') )
)
)
)
return "$@ running calc_setup for $cust_pkg\n"
if $@;
- $unitsetup = $cust_pkg->part_pkg->unit_setup || $setup; #XXX uuh
+ $unitsetup = $cust_pkg->base_setup()
+ || $setup; #XXX uuh
+
+ if ( $setup_param{'billed_currency'} ) {
+ $setup_billed_currency = delete $setup_param{'billed_currency'};
+ $setup_billed_amount = delete $setup_param{'billed_amount'};
+ }
}
$cust_pkg->setfield('setup', $time)
my $recur = 0;
my $unitrecur = 0;
my @recur_discounts = ();
+ my $recur_billed_currency = '';
+ my $recur_billed_amount = 0;
my $sdate;
if ( ! $cust_pkg->start_date
- and ( ! $cust_pkg->susp || $cust_pkg->option('suspend_bill',1)
- || ( $part_pkg->option('suspend_bill', 1) )
- && ! $cust_pkg->option('no_suspend_bill',1)
- )
+ and
+ ( ! $cust_pkg->susp
+ || ( $cust_pkg->susp != $cust_pkg->order_date
+ && ( $cust_pkg->option('suspend_bill',1)
+ || ( $part_pkg->option('suspend_bill', 1)
+ && ! $cust_pkg->option('no_suspend_bill',1)
+ )
+ )
+ )
+ )
and
( $part_pkg->freq ne '0' && ( $cust_pkg->bill || 0 ) <= $cmp_time )
|| ( $part_pkg->plan eq 'voip_cdr'
#base_cancel???
$unitrecur = $cust_pkg->base_recur( \$sdate ) || $recur; #XXX uuh, better
+ if ( $param{'billed_currency'} ) {
+ $recur_billed_currency = delete $param{'billed_currency'};
+ $recur_billed_amount = delete $param{'billed_amount'};
+ }
+
if ( $increment_next_bill ) {
my $next_bill;
push @details, @cust_pkg_detail;
my $cust_bill_pkg = new FS::cust_bill_pkg {
- 'pkgnum' => $cust_pkg->pkgnum,
- 'setup' => $setup,
- 'unitsetup' => $unitsetup,
- 'recur' => $recur,
- 'unitrecur' => $unitrecur,
- 'quantity' => $cust_pkg->quantity,
- 'details' => \@details,
- 'discounts' => [ @setup_discounts, @recur_discounts ],
- 'hidden' => $part_pkg->hidden,
- 'freq' => $part_pkg->freq,
+ 'pkgnum' => $cust_pkg->pkgnum,
+ 'setup' => $setup,
+ 'unitsetup' => $unitsetup,
+ 'setup_billed_currency' => $setup_billed_currency,
+ 'setup_billed_amount' => $setup_billed_amount,
+ 'recur' => $recur,
+ 'unitrecur' => $unitrecur,
+ 'recur_billed_currency' => $recur_billed_currency,
+ 'recur_billed_amount' => $recur_billed_amount,
+ 'quantity' => $cust_pkg->quantity,
+ 'details' => \@details,
+ 'discounts' => [ @setup_discounts, @recur_discounts ],
+ 'hidden' => $part_pkg->hidden,
+ 'freq' => $part_pkg->freq,
};
if ( $part_pkg->option('prorate_defer_bill',1)
$cust_bill_pkg->sdate( $hash{last_bill} );
$cust_bill_pkg->edate( $sdate - 86399 ); #60s*60m*24h-1
$cust_bill_pkg->edate( $time ) if $options{cancel};
- } else { #if ( $part_pkg->recur_temporality eq 'upcoming' ) {
+ } else { #if ( $part_pkg->recur_temporality eq 'upcoming' )
$cust_bill_pkg->sdate( $sdate );
$cust_bill_pkg->edate( $cust_pkg->bill );
#$cust_bill_pkg->edate( $time ) if $options{cancel};
###
# handle taxes
###
-
- #unless ( $discount_show_always ) { # oh, for god's sake
- my $error = $self->_handle_taxes(
- $part_pkg,
- $taxlisthash,
- $cust_bill_pkg,
- $cust_pkg,
- $options{invoice_time},
- $real_pkgpart,
- \%options # I have serious objections to this
- );
+
+ my $error = $tax_engine->add_sale($cust_bill_pkg);
return $error if $error;
- #}
$cust_bill_pkg->set_display(
part_pkg => $part_pkg,
return @transfers;
}
-=item _handle_taxes PART_PKG TAXLISTHASH CUST_BILL_PKG CUST_PKG TIME PKGPART [ OPTIONS ]
+#### vestigial code ####
+
+=item handle_taxes TAXLISTHASH CUST_BILL_PKG [ OPTIONS ]
This is _handle_taxes. It's called once for each cust_bill_pkg generated
-from _make_lines, along with the part_pkg, cust_pkg, invoice time, the
-non-overridden pkgpart, a flag indicating whether the package is being
-canceled, and a partridge in a pear tree.
+from _make_lines.
-The most important argument is 'taxlisthash'. This is shared across the
-entire invoice. It looks like this:
+TAXLISTHASH is a hashref shared across the entire invoice. It looks like
+this:
{
'cust_main_county 1001' => [ [FS::cust_main_county], ... ],
'cust_main_county 1002' => [ [FS::cust_main_county], ... ],
the 'taxline' method to calculate the amount of the tax. This doesn't
happen until calculate_taxes, though.
+OPTIONS may include:
+- part_item: a part_pkg or part_fee object to be used as the package/fee
+ definition.
+- location: a cust_location to be used as the billing location.
+- cancel: true if this package is being billed on cancellation. This
+ allows tax to be calculated on usage charges only.
+
+If not supplied, part_item will be inferred from the pkgnum or feepart of the
+cust_bill_pkg, and location from the pkgnum (or, for fees, the invnum and
+the customer's default service location).
+
=cut
sub _handle_taxes {
my $self = shift;
- my $part_pkg = shift;
my $taxlisthash = shift;
my $cust_bill_pkg = shift;
- my $cust_pkg = shift;
- my $invoice_time = shift;
- my $real_pkgpart = shift;
- my $options = shift;
+ my %options = @_;
+
+ # at this point I realize that we have enough information to infer all this
+ # stuff, instead of passing around giant honking argument lists
+ my $location = $options{location} || $cust_bill_pkg->tax_location;
+ my $part_item = $options{part_item} || $cust_bill_pkg->part_X;
local($DEBUG) = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG;
return if ( $self->payby eq 'COMP' ); #dubious
if ( $conf->exists('enable_taxproducts')
- && ( scalar($part_pkg->part_pkg_taxoverride)
- || $part_pkg->has_taxproduct
+ && ( scalar($part_item->part_pkg_taxoverride)
+ || $part_item->has_taxproduct
)
)
{
my %taxes = ();
my @classes;
- #push @classes, $cust_bill_pkg->usage_classes if $cust_bill_pkg->type eq 'U';
push @classes, $cust_bill_pkg->usage_classes if $cust_bill_pkg->usage;
- # debatable
- push @classes, 'setup' if ($cust_bill_pkg->setup && !$options->{cancel});
- push @classes, 'recur' if ($cust_bill_pkg->recur && !$options->{cancel});
+ push @classes, 'setup' if $cust_bill_pkg->setup and !$options{cancel};
+ push @classes, 'recur' if $cust_bill_pkg->recur and !$options{cancel};
my $exempt = $conf->exists('cust_class-tax_exempt')
? ( $self->cust_class ? $self->cust_class->tax : '' )
if ( !$exempt ) {
foreach my $class (@classes) {
- my $err_or_ref = $self->_gather_taxes( $part_pkg, $class, $cust_pkg );
+ my $err_or_ref = $self->_gather_taxes($part_item, $class, $location);
return $err_or_ref unless ref($err_or_ref);
$taxes{$class} = $err_or_ref;
}
unless (exists $taxes{''}) {
- my $err_or_ref = $self->_gather_taxes( $part_pkg, '', $cust_pkg );
+ my $err_or_ref = $self->_gather_taxes($part_item, '', $location);
return $err_or_ref unless ref($err_or_ref);
$taxes{''} = $err_or_ref;
}
}
- my %tax_cust_bill_pkg = $cust_bill_pkg->disintegrate;
+ 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
# this is the tax identifier, not the taxname
my $taxname = ref( $tax ). ' '. $tax->taxnum;
- $taxname .= ' billpkgnum'. $cust_bill_pkg->billpkgnum;
- # We need to create a separate $taxlisthash entry for each billpkgnum
- # on the invoice, so that cust_bill_pkg_tax_location records will
- # be linked correctly.
-
# $taxlisthash: keys are "setup", "recur", and usage classes.
# Values are arrayrefs, first the tax object (cust_main_county
# or tax_rate) and then any cust_bill_pkg objects that the
if $DEBUG > 2;
next unless $tax_object->can('tax_on_tax');
- foreach my $tot ( $tax_object->tax_on_tax( $self ) ) {
+ foreach my $tot ( $tax_object->tax_on_tax( $location ) ) {
my $totname = ref( $tot ). ' '. $tot->taxnum;
warn "checking $totname which we call ". $tot->taxname. " as applicable\n"
next unless exists( $localtaxlisthash{ $totname } ); # only increase
# existing taxes
warn "adding $totname to taxed taxes\n" if $DEBUG > 2;
- # we're calling taxline() right here? wtf?
+ # calculate the tax amount that the tax_on_tax will apply to
my $hashref_or_error =
- $tax_object->taxline( $localtaxlisthash{$tax},
- 'custnum' => $self->custnum,
- 'invoice_time' => $invoice_time,
- );
+ $tax_object->taxline( $localtaxlisthash{$tax} );
return $hashref_or_error
unless ref($hashref_or_error);
+ # and append it to the list of taxable items
$taxlisthash->{ $totname } ||= [ $tot ];
push @{ $taxlisthash->{ $totname } }, $hashref_or_error->{amount};
# because we need to record that fact.
my @loc_keys = qw( district city county state country );
- my $location = $cust_pkg->tax_location;
my %taxhash = map { $_ => $location->$_ } @loc_keys;
- $taxhash{'taxclass'} = $part_pkg->taxclass;
+ $taxhash{'taxclass'} = $part_item->taxclass;
warn "taxhash:\n". Dumper(\%taxhash) if $DEBUG > 2;
'';
}
+=item _gather_taxes PART_ITEM CLASS CUST_LOCATION
+
+Internal method used with vendor-provided tax tables. PART_ITEM is a part_pkg
+or part_fee (which will define the tax eligibility of the product), CLASS is
+'setup', 'recur', null, or a C<usage_class> number, and CUST_LOCATION is the
+location where the service was provided (or billed, depending on
+configuration). Returns an arrayref of L<FS::tax_rate> objects that
+can apply to this line item.
+
+=cut
+
sub _gather_taxes {
my $self = shift;
- my $part_pkg = shift;
+ my $part_item = shift;
my $class = shift;
- my $cust_pkg = shift;
+ my $location = shift;
local($DEBUG) = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG;
- my $geocode;
- if ( $cust_pkg->locationnum && $conf->exists('tax-pkg_address') ) {
- $geocode = $cust_pkg->cust_location->geocode('cch');
- } else {
- $geocode = $self->geocode('cch');
- }
-
- my @taxes = ();
+ my $geocode = $location->geocode('cch');
- my @taxclassnums = map { $_->taxclassnum }
- $part_pkg->part_pkg_taxoverride($class);
-
- unless (@taxclassnums) {
- @taxclassnums = map { $_->taxclassnum }
- grep { $_->taxable eq 'Y' }
- $part_pkg->part_pkg_taxrate('cch', $geocode, $class);
- }
- warn "Found taxclassnum values of ". join(',', @taxclassnums)
- if $DEBUG;
-
- my $extra_sql =
- "AND (".
- join(' OR ', map { "taxclassnum = $_" } @taxclassnums ). ")";
-
- @taxes = qsearch({ 'table' => 'tax_rate',
- 'hashref' => { 'geocode' => $geocode, },
- 'extra_sql' => $extra_sql,
- })
- if scalar(@taxclassnums);
-
- warn "Found taxes ".
- join(',', map{ ref($_). " ". $_->get($_->primary_key) } @taxes). "\n"
- if $DEBUG;
-
- [ @taxes ];
+ [ $part_item->tax_rates('cch', $geocode, $class) ]
}
+#### end vestigial code ####
+
=item collect [ HASHREF | OPTION => VALUE ... ]
(Attempt to) collect money for this customer's outstanding invoices (see
#a little false laziness w/due_cust_event (not too bad, really)
- my $join = FS::part_event_condition->join_conditions_sql;
+ # I guess this is always as of now?
+ my $join = FS::part_event_condition->join_conditions_sql('', 'time' => time);
my $order = FS::part_event_condition->order_conditions_sql;
my $mine =
'( '
#some false laziness w/Cron::bill bill_where
- my $join = FS::part_event_condition->join_conditions_sql( $eventtable);
+ my $join = FS::part_event_condition->join_conditions_sql( $eventtable,
+ 'time' => $opt{'time'});
my $where = FS::part_event_condition->where_conditions_sql($eventtable,
'time'=>$opt{'time'},
);
my $pkey = $object->primary_key;
$cross_where = "$eventtable.$pkey = ". $object->$pkey();
- my $join = FS::part_event_condition->join_conditions_sql( $eventtable );
+ my $join = FS::part_event_condition->join_conditions_sql( $eventtable,
+ 'time' => $opt{'time'});
my $extra_sql =
FS::part_event_condition->where_conditions_sql( $eventtable,
'time'=>$opt{'time'}
#return 0 unless
- my @payments = sort { $b->_date <=> $a->_date }
- grep { $_->unapplied > 0 }
- $self->cust_pay;
+ my @payments = $self->unapplied_cust_pay;
- my @invoices = sort { $a->_date <=> $b->_date}
- grep { $_->owed > 0 }
- $self->cust_bill;
+ my @invoices = $self->open_cust_bill;
if ( $conf->exists('pkg-balances') ) {
# limit @payments to those w/ a pkgnum grepped from $self
bill
(do_cust_event pre-bill)
_make_lines
- _handle_taxes
- (vendor-only) _gather_taxes
_omit_zero_value_bundles
calculate_taxes