use List::Util qw( min );
use FS::UID qw( dbh );
use FS::Record qw( qsearch qsearchs dbdef );
+use FS::Misc::DateTime qw( day_end );
use FS::cust_bill;
use FS::cust_bill_pkg;
use FS::cust_bill_pkg_display;
use FS::part_event;
use FS::part_event_condition;
use FS::pkg_category;
+use FS::cust_event_fee;
+use FS::Log;
# 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('FS::cust_main::Billing::bill_and_collect');
+ my %logopt = (object => $self);
+ $log->debug('start', %logopt);
+
my $error;
#$options{actual_time} not $options{time} because freeside-daily -d is for
$options{'actual_time'} ||= time;
my $job = $options{'job'};
+ my $actual_time = ( $conf->exists('next-bill-ignore-time')
+ ? day_end( $options{actual_time} )
+ : $options{actual_time}
+ );
+
$job->update_statustext('0,cleaning expired packages') if $job;
- $error = $self->cancel_expired_pkgs( $options{actual_time} );
+ $log->debug('canceling expired packages', %logopt);
+ $error = $self->cancel_expired_pkgs( $actual_time );
if ( $error ) {
$error = "Error expiring custnum ". $self->custnum. ": $error";
if ( $options{fatal} && $options{fatal} eq 'return' ) { return $error; }
else { warn $error; }
}
- $error = $self->suspend_adjourned_pkgs( $options{actual_time} );
+ $log->debug('suspending adjourned packages', %logopt);
+ $error = $self->suspend_adjourned_pkgs( $actual_time );
if ( $error ) {
$error = "Error adjourning custnum ". $self->custnum. ": $error";
if ( $options{fatal} && $options{fatal} eq 'return' ) { return $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";
+ if ( $options{fatal} && $options{fatal} eq 'return' ) { return $error; }
+ elsif ( $options{fatal} ) { die $error; }
+ else { warn $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
) {
+ $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', %logopt);
'';
sub cancel_expired_pkgs {
my ( $self, $time, %options ) = @_;
-
+
my @cancel_pkgs = $self->ncancelled_pkgs( {
'extra_sql' => " AND expire IS NOT NULL AND expire > 0 AND expire <= $time "
} );
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,
- 'reason_otaker' => $cpr->otaker
+ 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;
}
- scalar(@errors) ? join(' / ', @errors) : '';
+ join(' / ', @errors);
}
sub suspend_adjourned_pkgs {
my ( $self, $time, %options ) = @_;
-
+
my @susp_pkgs = $self->ncancelled_pkgs( {
'extra_sql' =>
" AND ( susp IS NULL OR susp = 0 )
push @errors, 'pkgnum '.$cust_pkg->pkgnum.": $error" if $error;
}
- scalar(@errors) ? join(' / ', @errors) : '';
+ join(' / ', @errors);
+
+}
+
+sub unsuspend_resumed_pkgs {
+ my ( $self, $time, %options ) = @_;
+
+ my @unsusp_pkgs = $self->ncancelled_pkgs( {
+ 'extra_sql' => " AND resume IS NOT NULL AND resume > 0 AND resume <= $time "
+ } );
+
+ my @errors = ();
+
+ foreach my $cust_pkg ( @unsusp_pkgs ) {
+ my $error = $cust_pkg->unsuspend( 'time' => $time );
+ push @errors, 'pkgnum '.$cust_pkg->pkgnum.": $error" if $error;
+ }
+
+ join(' / ', @errors);
}
=item freq_override
If set, then override the normal frequency and look for a part_pkg_discount
-to take at that frequency.
+to take at that frequency. This is appropriate only when the normal
+frequency for all packages is monthly, and is an error otherwise. Use
+C<pkg_list> to limit the set of packages included in billing.
=item time
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.
return '' if $self->payby eq 'COMP';
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 @precommit_hooks = ();
$options{'pkg_list'} ||= [ $self->ncancelled_pkgs ]; #param checks?
+
foreach my $cust_pkg ( @{ $options{'pkg_list'} } ) {
next if $options{'not_pkgpart'}->{$cust_pkg->pkgpart};
- warn " bill package ". $cust_pkg->pkgnum. "\n" if $DEBUG > 1;
+ 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,
+ # and it hasn't been billed since then,
+ # and package balances are enabled,
+ if ( $cust_pkg->change_pkgnum
+ and $cust_pkg->change_date >= ($cust_pkg->last_bill || 0)
+ and $cust_pkg->change_date < $invoice_time
+ and $conf->exists('pkg-balances') )
+ {
+ # _transfer_balance will also create the appropriate credit
+ my @transfer_items = $self->_transfer_balance($cust_pkg);
+ # $part_pkg[0] is the "real" part_pkg
+ my $pass = ($cust_pkg->no_auto || $part_pkg[0]->no_auto) ?
+ 'no_auto' : '';
+ push @{ $cust_bill_pkg{$pass} }, @transfer_items;
+ # treating this as recur, just because most charges are recur...
+ ${$total_recur{$pass}} += $_->recur foreach @transfer_items;
+ }
+
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 $error =
- $self->_make_lines( 'part_pkg' => $part_pkg,
- 'cust_pkg' => $cust_pkg,
- 'precommit_hooks' => \@precommit_hooks,
- 'line_items' => $cust_bill_pkg{$pass},
- 'setup' => $total_setup{$pass},
- 'recur' => $total_recur{$pass},
- 'tax_matrix' => $taxlisthash{$pass},
- 'time' => $time,
- 'real_pkgpart' => $real_pkgpart,
- 'options' => \%options,
- );
+ 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 <= $cmp_time or $options{cancel} ) {
+ $error =
+ $self->_make_lines( 'part_pkg' => $part_pkg,
+ 'cust_pkg' => $cust_pkg,
+ 'precommit_hooks' => \@precommit_hooks,
+ 'line_items' => $cust_bill_pkg{$pass},
+ 'setup' => $total_setup{$pass},
+ 'recur' => $total_recur{$pass},
+ 'tax_matrix' => $taxlisthash{$pass},
+ 'time' => $time,
+ 'real_pkgpart' => $real_pkgpart,
+ 'options' => \%options,
+ );
+
+ # Stop if anything goes wrong
+ last if $error;
+
+ # or if we're not incrementing the bill date.
+ last if ($cust_pkg->getfield('bill') || 0) == $next_bill;
+
+ # or if we're letting it run only once
+ last if $options{cancel};
+
+ $next_bill = $cust_pkg->getfield('bill') || 0;
+
+ #stop if -o was passed to freeside-daily
+ last if $options{'one_recur'};
+ }
if ($error) {
$dbh->rollback if $oldAutoCommit && !$options{no_commit};
return $error;
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_event_fees = FS::cust_event_fee->by_cust($self->custnum,
+ hashref => { 'billpkgnum' => '' }
+ );
+ warn "$me found pending fee events:\n".Dumper(\@pending_event_fees)."\n"
+ if @pending_event_fees and $DEBUG > 1;
+
+ # determine whether to generate an invoice
+ my $generate_bill = scalar(@cust_bill_pkg) > 0;
+
+ foreach my $event_fee (@pending_event_fees) {
+ $generate_bill = 1 unless $event_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 $event_fee (@pending_event_fees) {
+ my $object = $event_fee->cust_event->cust_X;
+ my $part_fee = $event_fee->part_fee;
+ my $cust_bill;
+ if ( $object->isa('FS::cust_main')
+ or $object->isa('FS::cust_pkg')
+ or $object->isa('FS::cust_pay_batch') )
+ {
+ # Not 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 this is a package event, only apply the fee to line items
+ # from that package.
+ if ($object->isa('FS::cust_pkg')) {
+ $cust_bill->set('cust_bill_pkg',
+ [ grep { $_->pkgnum == $object->pkgnum } @cust_bill_pkg ]
+ );
+ }
+
+ } elsif ( $object->isa('FS::cust_bill') ) {
+ # simple case: applying the fee to a previous invoice (late fee,
+ # etc.)
+ $cust_bill = $object;
+ }
+ # 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';
+ # 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('cust_event_fee', $event_fee);
+ 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 = $self->_handle_taxes(
+ $taxlisthash{$pass},
+ $fee_item,
+ location => $fee_location
+ # probably not right to pass cancel => 1 for fees
+ );
+ 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')
)
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 ( {
#discard bundled packages of 0 value
sub _omit_zero_value_bundles {
+ my @in = @_;
my @cust_bill_pkg = ();
my @cust_bill_pkg_bundle = ();
my $sum = 0;
my $discount_show_always = 0;
- foreach my $cust_bill_pkg ( @_ ) {
+ foreach my $cust_bill_pkg ( @in ) {
+
$discount_show_always = ($cust_bill_pkg->get('discounts')
&& scalar(@{$cust_bill_pkg->get('discounts')})
&& $conf->exists('discount-show-always'));
+
+ warn " pkgnum ". $cust_bill_pkg->pkgnum. " sum $sum, ".
+ "setup_show_zero ". $cust_bill_pkg->setup_show_zero.
+ "recur_show_zero ". $cust_bill_pkg->recur_show_zero. "\n"
+ if $DEBUG > 0;
+
if (scalar(@cust_bill_pkg_bundle) && !$cust_bill_pkg->pkgpart_override) {
push @cust_bill_pkg, @cust_bill_pkg_bundle
- if ($sum > 0 || ($sum == 0 && $discount_show_always));
+ if $sum > 0
+ || ($sum == 0 && ( $discount_show_always
+ || grep {$_->recur_show_zero || $_->setup_show_zero}
+ @cust_bill_pkg_bundle
+ )
+ );
@cust_bill_pkg_bundle = ();
$sum = 0;
}
+
$sum += $cust_bill_pkg->setup + $cust_bill_pkg->recur;
push @cust_bill_pkg_bundle, $cust_bill_pkg;
+
}
+
push @cust_bill_pkg, @cust_bill_pkg_bundle
- if ($sum > 0 || ($sum == 0 && $discount_show_always));
+ if $sum > 0
+ || ($sum == 0 && ( $discount_show_always
+ || grep {$_->recur_show_zero || $_->setup_show_zero}
+ @cust_bill_pkg_bundle
+ )
+ );
+
+ warn " _omit_zero_value_bundles: ". scalar(@in).
+ '->'. scalar(@cust_bill_pkg). "\n" #. Dumper(@cust_bill_pkg). "\n"
+ if $DEBUG > 2;
(@cust_bill_pkg);
=item calculate_taxes LINEITEMREF TAXHASHREF INVOICE_TIME
-This is a weird one. Perhaps it should not even be exposed.
-
Generates tax line items (see L<FS::cust_bill_pkg>) for this customer.
Usually used internally by bill method B<bill>.
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"
my @tax_line_items = ();
# keys are tax names (as printed on invoices / itemdesc )
- # values are listrefs of taxlisthash keys (internal identifiers)
+ # values are arrayrefs of taxlisthash keys (internal identifiers)
my %taxname = ();
# keys are taxlisthash keys (internal identifiers)
# values are (cumulative) amounts
- my %tax = ();
+ my %tax_amount = ();
# keys are taxlisthash keys (internal identifiers)
- # values are listrefs of cust_bill_pkg_tax_location hashrefs
+ # values are arrayrefs of cust_bill_pkg_tax_location hashrefs
my %tax_location = ();
# keys are taxlisthash keys (internal identifiers)
- # values are listrefs of cust_bill_pkg_tax_rate_location hashrefs
+ # 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;
- my $hashref_or_error =
- $tax_object->taxline( $taxlisthash->{$tax},
+ # 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
+ 'invoice_time' => $invoice_time,
+ 'exemptions' => $exemptions,
);
- return $hashref_or_error unless ref($hashref_or_error);
+ return $taxline unless ref($taxline);
unshift @{ $taxlisthash->{$tax} }, $tax_object;
- my $name = $hashref_or_error->{'name'};
- my $amount = $hashref_or_error->{'amount'};
+ if ( $tax_object->isa('FS::cust_main_county') ) {
+ # then $taxline is a real line item
+ push @{ $taxname{ $taxline->itemdesc } }, $taxline;
- #warn "adding $amount as $name\n";
- $taxname{ $name } ||= [];
- push @{ $taxname{ $name } }, $tax;
+ } else {
+ # leave this as is for now
- $tax{ $tax } += $amount;
+ my $name = $taxline->{'name'};
+ my $amount = $taxline->{'amount'};
- $tax_location{ $tax } ||= [];
- if ( $tax_object->get('pkgnum') || $tax_object->get('locationnum') ) {
- push @{ $tax_location{ $tax } },
- {
- 'taxnum' => $tax_object->taxnum,
- 'taxtype' => ref($tax_object),
- 'pkgnum' => $tax_object->get('pkgnum'),
- 'locationnum' => $tax_object->get('locationnum'),
- 'amount' => sprintf('%.2f', $amount ),
- };
- }
+ #warn "adding $amount as $name\n";
+ $taxname{ $name } ||= [];
+ push @{ $taxname{ $name } }, $tax;
+
+ $tax_amount{ $tax } += $amount;
- $tax_rate_location{ $tax } ||= [];
- if ( ref($tax_object) eq 'FS::tax_rate' ) {
+ # 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 } },
'locationtaxid' => $tax_object->location,
'taxratelocationnum' => $taxratelocationnum,
};
- }
-
- }
-
- #move the cust_tax_exempt_pkg records to the cust_bill_pkgs we will commit
- my %packagemap = map { $_->pkgnum => $_ } @$cust_bill_pkg;
- foreach my $tax ( keys %$taxlisthash ) {
- foreach ( @{ $taxlisthash->{$tax} }[1 ... scalar(@{ $taxlisthash->{$tax} })] ) {
- next unless ref($_) eq 'FS::cust_bill_pkg';
- push @{ $packagemap{$_->pkgnum}->_cust_tax_exempt_pkg },
- splice( @{ $_->_cust_tax_exempt_pkg } );
- }
- }
+ } #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 $tax = 0;
+ 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 = ();
- my @cust_bill_pkg_tax_location = ();
- my @cust_bill_pkg_tax_rate_location = ();
warn "adding $taxname\n" if $DEBUG > 1;
foreach my $taxitem ( @{ $taxname{$taxname} } ) {
- next if $seen{$taxitem}++;
- warn "adding $tax{$taxitem}\n" if $DEBUG > 1;
- $tax += $tax{$taxitem};
- push @cust_bill_pkg_tax_location,
- map { new FS::cust_bill_pkg_tax_location $_ }
- @{ $tax_location{ $taxitem } };
- push @cust_bill_pkg_tax_rate_location,
- map { new FS::cust_bill_pkg_tax_rate_location $_ }
- @{ $tax_rate_location{ $taxitem } };
+ 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;
+ next unless $tax_total;
- $tax = sprintf('%.2f', $tax );
+ # 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' => '',
push @display, new FS::cust_bill_pkg_display { type => 'S', %hash };
}
+ $tax_cust_bill_pkg->set('display', \@display);
- push @tax_line_items, new FS::cust_bill_pkg {
- 'pkgnum' => 0,
- 'setup' => $tax,
- 'recur' => 0,
- 'sdate' => '',
- 'edate' => '',
- 'itemdesc' => $taxname,
- 'display' => \@display,
- 'cust_bill_pkg_tax_location' => \@cust_bill_pkg_tax_location,
- 'cust_bill_pkg_tax_rate_location' => \@cust_bill_pkg_tax_rate_location,
- };
-
+ push @tax_line_items, $tax_cust_bill_pkg;
}
\@tax_line_items;
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 $precommit_hooks = $params{precommit_hooks} or die "no package 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 $time = $params{'time'} or die "no time specified";
my (%options) = %{$params{options}};
+ 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 '.
+ $cust_pkg->pkgnum;
+ }
+
my $dbh = dbh;
my $real_pkgpart = $params{real_pkgpart};
my %hash = $cust_pkg->hash;
my $old_cust_pkg = new FS::cust_pkg \%hash;
my @details = ();
- my @discounts = ();
my $lineitems = 0;
$cust_pkg->pkgpart($part_pkg->pkgpart);
+ my $cmp_time = ( $conf->exists('next-bill-ignore-time')
+ ? day_end( $time )
+ : $time
+ );
+
###
# bill setup
###
my $setup = 0;
my $unitsetup = 0;
- if ( $options{'resetup'}
- || ( ! $cust_pkg->setup
- && ( ! $cust_pkg->start_date
- || $cust_pkg->start_date <= $time
- )
- && ( ! $conf->exists('disable_setup_suspended_pkgs')
- || ( $conf->exists('disable_setup_suspended_pkgs') &&
- ! $cust_pkg->getfield('susp')
- )
- )
- )
- and !$options{recurring_only}
- )
+ my @setup_discounts = ();
+ my %setup_param = ( 'discounts' => \@setup_discounts );
+ 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->setup
+ && ( ! $cust_pkg->start_date
+ || $cust_pkg->start_date <= $cmp_time
+ )
+ && ( ! $cust_pkg->getfield('susp') )
+ )
+ )
+ )
{
warn " bill setup\n" if $DEBUG > 1;
- $lineitems++;
- $setup = eval { $cust_pkg->calc_setup( $time, \@details ) };
- return "$@ running calc_setup for $cust_pkg\n"
- if $@;
+ unless ( $cust_pkg->waive_setup ) {
+ $lineitems++;
+
+ $setup = eval { $cust_pkg->calc_setup( $time, \@details, \%setup_param ) };
+ 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)
unless $cust_pkg->setup;
# bill recurring fee
###
- #XXX unit stuff here too
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 || $part_pkg->option('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 ) <= $time )
+ ( $part_pkg->freq ne '0' && ( $cust_pkg->bill || 0 ) <= $cmp_time )
|| ( $part_pkg->plan eq 'voip_cdr'
&& $part_pkg->option('bill_every_call')
)
#over two params! lets at least switch to a hashref for the rest...
my $increment_next_bill = ( $part_pkg->freq ne '0'
- && ( $cust_pkg->getfield('bill') || 0 ) <= $time
+ && ( $cust_pkg->getfield('bill') || 0 ) <= $cmp_time
&& !$options{cancel}
);
- my %param = ( 'precommit_hooks' => $precommit_hooks,
+ my %param = ( %setup_param,
+ 'precommit_hooks' => $precommit_hooks,
'increment_next_bill' => $increment_next_bill,
- 'discounts' => \@discounts,
+ 'discounts' => \@recur_discounts,
'real_pkgpart' => $real_pkgpart,
'freq_override' => $options{freq_override} || '',
'setup_fee' => 0,
# which can_discount are supported.
# (the UI should prevent adding discounts to these at the moment)
+ warn "calling $method on cust_pkg ". $cust_pkg->pkgnum.
+ " for pkgpart ". $cust_pkg->pkgpart.
+ " with params ". join(' / ', map "$_=>$param{$_}", keys %param). "\n"
+ if $DEBUG > 2;
+
$recur = eval { $cust_pkg->$method( \$sdate, \@details, \%param ) };
return "$@ running $method for $cust_pkg\n"
if ( $@ );
+ #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 = $part_pkg->add_freq($sdate, $options{freq_override} || 0);
+ my $next_bill;
+
+ if ( my $main_pkg = $cust_pkg->main_pkg ) {
+ # supplemental package
+ # to keep in sync with the main package, simulate billing at
+ # its frequency
+ my $main_pkg_freq = $main_pkg->part_pkg->freq;
+ my $supp_pkg_freq = $part_pkg->freq;
+ my $ratio = $supp_pkg_freq / $main_pkg_freq;
+ if ( $ratio != int($ratio) ) {
+ # the UI should prevent setting up packages like this, but just
+ # in case
+ return "supplemental package period is not an integer multiple of main package period";
+ }
+ $next_bill = $sdate;
+ for (1..$ratio) {
+ $next_bill = $part_pkg->add_freq( $next_bill, $main_pkg_freq );
+ }
+
+ } else {
+ # the normal case
+ $next_bill = $part_pkg->add_freq($sdate, $options{freq_override} || 0);
return "unparsable frequency: ". $part_pkg->freq
if $next_bill == -1;
+ }
#pro-rating magic - if $recur_prog fiddled $sdate, want to use that
# only for figuring next bill date, nothing else, so, reset $sdate again
$lineitems++;
}
+ if ( defined $param{'discount_left_setup'} ) {
+ foreach my $discount_setup ( values %{$param{'discount_left_setup'}} ) {
+ $setup -= $discount_setup;
+ }
+ }
+
}
warn "\$setup is undefined" unless defined($setup);
return "negative recur $recur for pkgnum ". $cust_pkg->pkgnum;
}
- my $discount_show_always = ($recur == 0 && scalar(@discounts)
- && $conf->exists('discount-show-always'));
+ my $discount_show_always = $conf->exists('discount-show-always')
+ && ( ($setup == 0 && scalar(@setup_discounts))
+ || ($recur == 0 && scalar(@recur_discounts))
+ );
- if ( $setup != 0 ||
- $recur != 0 ||
- (!$part_pkg->hidden && $options{has_hidden}) || #include some $0 lines
- $discount_show_always )
+ if ( $setup != 0
+ || $recur != 0
+ || (!$part_pkg->hidden && $options{has_hidden}) #include some $0 lines
+ || $discount_show_always
+ || ($setup == 0 && $cust_pkg->_X_show_zero('setup'))
+ || ($recur == 0 && $cust_pkg->_X_show_zero('recur'))
+ )
{
warn " charges (setup=$setup, recur=$recur); adding line items\n"
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' => \@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('recur_temporality', 1) eq 'preceding' ) {
+ if ( $part_pkg->option('prorate_defer_bill',1)
+ and !$hash{last_bill} ) {
+ # both preceding and upcoming, technically
+ $cust_bill_pkg->sdate( $cust_pkg->setup );
+ $cust_bill_pkg->edate( $cust_pkg->bill );
+ } elsif ( $part_pkg->recur_temporality eq 'preceding' ) {
$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->option('recur_temporality', 1) 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 ) {
- my $error =
- $self->_handle_taxes($part_pkg, $taxlisthash, $cust_bill_pkg, $cust_pkg, $options{invoice_time}, $real_pkgpart, \%options);
- return $error if $error;
- }
+ my $error = $self->_handle_taxes( $taxlisthash, $cust_bill_pkg,
+ cancel => $options{cancel} );
+ return $error if $error;
+
+ $cust_bill_pkg->set_display(
+ part_pkg => $part_pkg,
+ real_pkgpart => $real_pkgpart,
+ );
push @$cust_bill_pkgs, $cust_bill_pkg;
}
+=item _transfer_balance TO_PKG [ FROM_PKGNUM ]
+
+Takes one argument, a cust_pkg object that is being billed. This will
+be called only if the package was created by a package change, and has
+not been billed since the package change, and package balance tracking
+is enabled. The second argument can be an alternate package number to
+transfer the balance from; this should not be used externally.
+
+Transfers the balance from the previous package (now canceled) to
+this package, by crediting one package and creating an invoice item for
+the other. Inserts the credit and returns the invoice item (so that it
+can be added to an invoice that's being built).
+
+If the previous package was never billed, and was also created by a package
+change, then this will also transfer the balance from I<its> previous
+package, and so on, until reaching a package that either has been billed
+or was not created by a package change.
+
+=cut
+
+my $balance_transfer_reason;
+
+sub _transfer_balance {
+ my $self = shift;
+ my $cust_pkg = shift;
+ my $from_pkgnum = shift || $cust_pkg->change_pkgnum;
+ my $from_pkg = FS::cust_pkg->by_key($from_pkgnum);
+
+ my @transfers;
+
+ # if $from_pkg is not the first package in the chain, and it was never
+ # billed, walk back
+ if ( $from_pkg->change_pkgnum and scalar($from_pkg->cust_bill_pkg) == 0 ) {
+ @transfers = $self->_transfer_balance($cust_pkg, $from_pkg->change_pkgnum);
+ }
+
+ my $prev_balance = $self->balance_pkgnum($from_pkgnum);
+ if ( $prev_balance != 0 ) {
+ $balance_transfer_reason ||= FS::reason->new_or_existing(
+ 'reason' => 'Package balance transfer',
+ 'type' => 'Internal adjustment',
+ 'class' => 'R'
+ );
+
+ my $credit = FS::cust_credit->new({
+ 'custnum' => $self->custnum,
+ 'amount' => abs($prev_balance),
+ 'reasonnum' => $balance_transfer_reason->reasonnum,
+ '_date' => $cust_pkg->change_date,
+ });
+
+ my $cust_bill_pkg = FS::cust_bill_pkg->new({
+ 'setup' => 0,
+ 'recur' => abs($prev_balance),
+ #'sdate' => $from_pkg->last_bill, # not sure about this
+ #'edate' => $cust_pkg->change_date,
+ 'itemdesc' => $self->mt('Previous Balance, [_1]',
+ $from_pkg->part_pkg->pkg),
+ });
+
+ if ( $prev_balance > 0 ) {
+ # credit the old package, charge the new one
+ $credit->set('pkgnum', $from_pkgnum);
+ $cust_bill_pkg->set('pkgnum', $cust_pkg->pkgnum);
+ } else {
+ # the reverse
+ $credit->set('pkgnum', $cust_pkg->pkgnum);
+ $cust_bill_pkg->set('pkgnum', $from_pkgnum);
+ }
+ my $error = $credit->insert;
+ die "error transferring package balance from #".$from_pkgnum.
+ " to #".$cust_pkg->pkgnum.": $error\n" if $error;
+
+ push @transfers, $cust_bill_pkg;
+ } # $prev_balance != 0
+
+ return @transfers;
+}
+
+=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.
+
+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], ... ],
+}
+
+'cust_main_county' can also be 'tax_rate'. The first object in the array
+is always the cust_main_county or tax_rate identified by the key.
+
+That "..." is a list of FS::cust_bill_pkg objects that will be fed to
+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 = @_;
- local($DEBUG) = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG;
+ # 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;
- my %cust_bill_pkg = ();
- 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;
- push @classes, 'setup' if ($cust_bill_pkg->setup && !$options->{cancel});
- push @classes, 'recur' if ($cust_bill_pkg->recur && !$options->{cancel});
+ local($DEBUG) = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG;
- if ( $self->tax !~ /Y/i && $self->payby ne 'COMP' ) {
+ return if ( $self->payby eq 'COMP' ); #dubious
- if ( $conf->exists('enable_taxproducts')
- && ( scalar($part_pkg->part_pkg_taxoverride)
- || $part_pkg->has_taxproduct
- )
- )
+ if ( $conf->exists('enable_taxproducts')
+ && ( scalar($part_item->part_pkg_taxoverride)
+ || $part_item->has_taxproduct
+ )
+ )
{
+ # EXTERNAL TAX RATES (via tax_rate)
+ my %cust_bill_pkg = ();
+ my %taxes = ();
+
+ my @classes;
+ push @classes, $cust_bill_pkg->usage_classes if $cust_bill_pkg->usage;
+ 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 : '' )
+ : $self->tax;
+ # standardize this just to be sure
+ $exempt = ($exempt eq 'Y') ? 'Y' : '';
+
+ if ( !$exempt ) {
+
foreach my $class (@classes) {
- my $err_or_ref = $self->_gather_taxes( $part_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;
}
- } else {
-
- my @loc_keys = qw( city county state country );
- my %taxhash;
- if ( $conf->exists('tax-pkg_address') && $cust_pkg->locationnum ) {
- my $cust_location = $cust_pkg->cust_location;
- %taxhash = map { $_ => $cust_location->$_() } @loc_keys;
- } else {
- my $prefix =
- ( $conf->exists('tax-ship_address') && length($self->ship_last) )
- ? 'ship_'
- : '';
- %taxhash = map { $_ => $self->get("$prefix$_") } @loc_keys;
- }
-
- $taxhash{'taxclass'} = $part_pkg->taxclass;
-
- my @taxes = ();
- my %taxhash_elim = %taxhash;
- my @elim = qw( city county state );
- do {
-
- #first try a match with taxclass
- @taxes = qsearch( 'cust_main_county', \%taxhash_elim );
-
- if ( !scalar(@taxes) && $taxhash_elim{'taxclass'} ) {
- #then try a match without taxclass
- my %no_taxclass = %taxhash_elim;
- $no_taxclass{ 'taxclass' } = '';
- @taxes = qsearch( 'cust_main_county', \%no_taxclass );
- }
+ }
- $taxhash_elim{ shift(@elim) } = '';
+ my %tax_cust_bill_pkg = $cust_bill_pkg->disintegrate; # grrr
+ foreach my $key (keys %tax_cust_bill_pkg) {
+ # $key is "setup", "recur", or a usage class name. ('' is a usage class.)
+ # $tax_cust_bill_pkg{$key} is a cust_bill_pkg for that component of
+ # the line item.
+ # $taxes{$key} is an arrayref of cust_main_county or tax_rate objects that
+ # apply to $key-class charges.
+ my @taxes = @{ $taxes{$key} || [] };
+ my $tax_cust_bill_pkg = $tax_cust_bill_pkg{$key};
+
+ my %localtaxlisthash = ();
+ foreach my $tax ( @taxes ) {
+
+ # this is the tax identifier, not the taxname
+ my $taxname = ref( $tax ). ' '. $tax->taxnum;
+ # $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
+ # tax applies to.
+ $taxlisthash->{ $taxname } ||= [ $tax ];
+ push @{ $taxlisthash->{ $taxname } }, $tax_cust_bill_pkg;
+
+ $localtaxlisthash{ $taxname } ||= [ $tax ];
+ push @{ $localtaxlisthash{ $taxname } }, $tax_cust_bill_pkg;
- } while ( !scalar(@taxes) && scalar(@elim) );
+ }
- @taxes = grep { ! $_->taxname or ! $self->tax_exemption($_->taxname) }
- @taxes
- if $self->cust_main_exemption; #just to be safe
+ warn "finding taxed taxes...\n" if $DEBUG > 2;
+ foreach my $tax ( keys %localtaxlisthash ) {
+ my $tax_object = shift @{ $localtaxlisthash{$tax} };
+ warn "found possible taxed tax ". $tax_object->taxname. " we call $tax\n"
+ if $DEBUG > 2;
+ next unless $tax_object->can('tax_on_tax');
+
+ 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"
+ if $DEBUG > 2;
+ next unless exists( $localtaxlisthash{ $totname } ); # only increase
+ # existing taxes
+ warn "adding $totname to taxed taxes\n" if $DEBUG > 2;
+ # calculate the tax amount that the tax_on_tax will apply to
+ my $hashref_or_error =
+ $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};
- if ( $conf->exists('tax-pkg_address') && $cust_pkg->locationnum ) {
- foreach (@taxes) {
- $_->set('pkgnum', $cust_pkg->pkgnum );
- $_->set('locationnum', $cust_pkg->locationnum );
}
}
-
- $taxes{''} = [ @taxes ];
- $taxes{'setup'} = [ @taxes ];
- $taxes{'recur'} = [ @taxes ];
- $taxes{$_} = [ @taxes ] foreach (@classes);
-
- # # maybe eliminate this entirely, along with all the 0% records
- # unless ( @taxes ) {
- # return
- # "fatal: can't find tax rate for state/county/country/taxclass ".
- # join('/', map $taxhash{$_}, qw(state county country taxclass) );
- # }
-
- } #if $conf->exists('enable_taxproducts') ...
-
- }
-
- my @display = ();
- my $separate = $conf->exists('separate_usage');
- my $temp_pkg = new FS::cust_pkg { pkgpart => $real_pkgpart };
- my $usage_mandate = $temp_pkg->part_pkg->option('usage_mandate', 'Hush!');
- my $section = $temp_pkg->part_pkg->categoryname;
- if ( $separate || $section || $usage_mandate ) {
-
- my %hash = ( 'section' => $section );
-
- $section = $temp_pkg->part_pkg->option('usage_section', 'Hush!');
- my $summary = $temp_pkg->part_pkg->option('summarize_usage', 'Hush!');
- if ( $separate ) {
- push @display, new FS::cust_bill_pkg_display { type => 'S', %hash };
- push @display, new FS::cust_bill_pkg_display { type => 'R', %hash };
- } else {
- push @display, new FS::cust_bill_pkg_display
- { type => '',
- %hash,
- ( ( $usage_mandate ) ? ( 'summary' => 'Y' ) : () ),
- };
- }
-
- if ($separate && $section && $summary) {
- push @display, new FS::cust_bill_pkg_display { type => 'U',
- summary => 'Y',
- %hash,
- };
- }
- if ($usage_mandate || $section && $summary) {
- $hash{post_total} = 'Y';
}
- if ($separate || $usage_mandate) {
- $hash{section} = $section if ($separate || $usage_mandate);
- push @display, new FS::cust_bill_pkg_display { type => 'U', %hash };
- }
+ } else {
- }
- $cust_bill_pkg->set('display', \@display);
+ # INTERNAL TAX RATES (cust_main_county)
- my %tax_cust_bill_pkg = $cust_bill_pkg->disintegrate;
- foreach my $key (keys %tax_cust_bill_pkg) {
- my @taxes = @{ $taxes{$key} || [] };
- my $tax_cust_bill_pkg = $tax_cust_bill_pkg{$key};
+ # We fetch taxes even if the customer is completely exempt,
+ # because we need to record that fact.
- my %localtaxlisthash = ();
- foreach my $tax ( @taxes ) {
+ my @loc_keys = qw( district city county state country );
+ my %taxhash = map { $_ => $location->$_ } @loc_keys;
- my $taxname = ref( $tax ). ' '. $tax->taxnum;
-# $taxname .= ' pkgnum'. $cust_pkg->pkgnum.
-# ' locationnum'. $cust_pkg->locationnum
-# if $conf->exists('tax-pkg_address') && $cust_pkg->locationnum;
+ $taxhash{'taxclass'} = $part_item->taxclass;
- $taxlisthash->{ $taxname } ||= [ $tax ];
- push @{ $taxlisthash->{ $taxname } }, $tax_cust_bill_pkg;
+ warn "taxhash:\n". Dumper(\%taxhash) if $DEBUG > 2;
- $localtaxlisthash{ $taxname } ||= [ $tax ];
- push @{ $localtaxlisthash{ $taxname } }, $tax_cust_bill_pkg;
+ my @taxes = (); # entries are cust_main_county objects
+ my %taxhash_elim = %taxhash;
+ my @elim = qw( district city county state );
+ do {
- }
+ #first try a match with taxclass
+ @taxes = qsearch( 'cust_main_county', \%taxhash_elim );
- warn "finding taxed taxes...\n" if $DEBUG > 2;
- foreach my $tax ( keys %localtaxlisthash ) {
- my $tax_object = shift @{ $localtaxlisthash{$tax} };
- warn "found possible taxed tax ". $tax_object->taxname. " we call $tax\n"
- if $DEBUG > 2;
- next unless $tax_object->can('tax_on_tax');
+ if ( !scalar(@taxes) && $taxhash_elim{'taxclass'} ) {
+ #then try a match without taxclass
+ my %no_taxclass = %taxhash_elim;
+ $no_taxclass{ 'taxclass' } = '';
+ @taxes = qsearch( 'cust_main_county', \%no_taxclass );
+ }
- foreach my $tot ( $tax_object->tax_on_tax( $self ) ) {
- my $totname = ref( $tot ). ' '. $tot->taxnum;
+ $taxhash_elim{ shift(@elim) } = '';
- warn "checking $totname which we call ". $tot->taxname. " as applicable\n"
- if $DEBUG > 2;
- next unless exists( $localtaxlisthash{ $totname } ); # only increase
- # existing taxes
- warn "adding $totname to taxed taxes\n" if $DEBUG > 2;
- my $hashref_or_error =
- $tax_object->taxline( $localtaxlisthash{$tax},
- 'custnum' => $self->custnum,
- 'invoice_time' => $invoice_time,
- );
- return $hashref_or_error
- unless ref($hashref_or_error);
-
- $taxlisthash->{ $totname } ||= [ $tot ];
- push @{ $taxlisthash->{ $totname } }, $hashref_or_error->{amount};
+ } while ( !scalar(@taxes) && scalar(@elim) );
- }
+ foreach (@taxes) {
+ my $tax_id = 'cust_main_county '.$_->taxnum;
+ $taxlisthash->{$tax_id} ||= [ $_ ];
+ push @{ $taxlisthash->{$tax_id} }, $cust_bill_pkg;
}
}
-
'';
}
+=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 @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;
+ my $geocode = $location->geocode('cch');
- [ @taxes ];
+ [ $part_item->tax_rates('cch', $geocode, $class) ]
}
#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 =
'( '
. join ( ' OR ' , map {
+ my $cust_join = FS::part_event->eventtables_cust_join->{$_} || '';
+ my $custnum = FS::part_event->eventtables_custnum->{$_};
"( part_event.eventtable = " . dbh->quote($_)
- . " AND tablenum IN( SELECT " . dbdef->table($_)->primary_key . " from $_ where custnum = " . dbh->quote( $self->custnum ) . "))" ;
+ . " AND tablenum IN( SELECT " . dbdef->table($_)->primary_key
+ . " from $_ $cust_join"
+ . " where $custnum = " . dbh->quote( $self->custnum ) . "))" ;
} FS::part_event->eventtables)
. ') ';
cust_bill_batch
);
- my $is_realtime_event = ' ( '. join(' OR ', map "part_event.action = '$_'",
- @realtime_events
- ).
- ' ) ';
+ my $is_realtime_event =
+ ' part_event.action IN ( '.
+ join(',', map "'$_'", @realtime_events ).
+ ' ) ';
- my @cust_event = qsearchs({
+ my $batch_or_statustext =
+ "( part_event.action = 'cust_bill_batch'
+ OR ( statustext IS NOT NULL AND statustext != '' )
+ )";
+
+
+ my @cust_event = qsearch({
'table' => 'cust_event',
'select' => 'cust_event.*',
'addl_from' => "LEFT JOIN part_event USING ( eventpart ) $join",
'hashref' => { 'status' => 'done' },
- 'extra_sql' => " AND statustext IS NOT NULL AND statustext != '' ".
+ 'extra_sql' => " AND $batch_or_statustext ".
" AND $mine AND $is_realtime_event AND $agent_virt $order" # LIMIT 1"
});
#XXX lock event
#re-eval event conditions (a previous event could have changed things)
- unless ( $cust_event->test_conditions( 'time' => $time ) ) {
+ unless ( $cust_event->test_conditions ) {
#don't leave stray "new/locked" records around
my $error = $cust_event->delete;
return $error if $error;
if $DEBUG > 1;
#if ( my $error = $cust_event->do_event(%options) ) { #XXX %options?
- if ( my $error = $cust_event->do_event() ) {
+ if ( my $error = $cust_event->do_event( 'time' => $time ) ) {
#XXX wtf is this? figure out a proper dealio with return value
#from do_event
return $error;
#???
#my $DEBUG = $opt{'debug'}
+ $opt{'debug'} ||= 0; # silence some warnings
local($DEBUG) = $opt{'debug'}
- if defined($opt{'debug'}) && $opt{'debug'} > $DEBUG;
+ if $opt{'debug'} > $DEBUG;
$DEBUG = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG;
warn "$me due_cust_event called with options ".
@objects = @{ $opt{'objects'} };
- } else {
+ } elsif ( $eventtable eq 'cust_main' ) {
- #my @objects = $self->$eventtable(); # sub cust_main { @{ [ $self ] }; }
- if ( $eventtable eq 'cust_main' ) {
- @objects = ( $self );
- } else {
-
- my $cm_join =
- "LEFT JOIN cust_main USING ( custnum )";
-
- #some false laziness w/Cron::bill bill_where
-
- my $join = FS::part_event_condition->join_conditions_sql( $eventtable);
- my $where = FS::part_event_condition->where_conditions_sql($eventtable,
- 'time'=>$opt{'time'},
- );
- $where = $where ? "AND $where" : '';
+ @objects = ( $self );
- my $are_part_event =
- "EXISTS ( SELECT 1 FROM part_event $join
- WHERE check_freq = '$check_freq'
- AND eventtable = '$eventtable'
- AND ( disabled = '' OR disabled IS NULL )
- $where
- )
- ";
- #eofalse
-
- @objects = $self->$eventtable(
- 'addl_from' => $cm_join,
- 'extra_sql' => " AND $are_part_event",
- );
- }
+ } else {
- }
+ my $cm_join = " LEFT JOIN cust_main USING ( custnum )";
+ # linkage not needed here because FS::cust_main->$eventtable will
+ # already supply it
+
+ #some false laziness w/Cron::bill bill_where
+
+ 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'},
+ );
+ $where = $where ? "AND $where" : '';
+
+ my $are_part_event =
+ "EXISTS ( SELECT 1 FROM part_event $join
+ WHERE check_freq = '$check_freq'
+ AND eventtable = '$eventtable'
+ AND ( disabled = '' OR disabled IS NULL )
+ $where
+ )
+ ";
+ #eofalse
+
+ @objects = $self->$eventtable(
+ 'addl_from' => $cm_join,
+ 'extra_sql' => " AND $are_part_event",
+ );
+ } # if ( !$opt{objects} and $eventtable ne 'cust_main' )
my @e_cust_event = ();
- my $cross = "CROSS JOIN $eventtable";
+ my $linkage = FS::part_event->eventtables_cust_join->{$eventtable} || '';
+
+ my $cross = "CROSS JOIN $eventtable $linkage";
$cross .= ' LEFT JOIN cust_main USING ( custnum )'
unless $eventtable eq 'cust_main';
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'}
" possible events found for $eventtable ". $object->$pkey(). "\n";
}
- push @e_cust_event, map { $_->new_cust_event($object) } @part_event;
+ push @e_cust_event, map {
+ $_->new_cust_event($object, 'time' => $opt{'time'})
+ } @part_event;
}
my %unsat = ();
- @cust_event = grep $_->test_conditions( 'time' => $opt{'time'},
- 'stats_hashref' => \%unsat ),
+ @cust_event = grep $_->test_conditions( 'stats_hashref' => \%unsat ),
@cust_event;
warn " ". scalar(@cust_event). " cust events left satisfying conditions\n"
#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
my $amount = min( $payment->unapplied, $owed );
- my $cust_bill_pay = new FS::cust_bill_pay ( {
+ my $cbp = {
'paynum' => $payment->paynum,
'invnum' => $cust_bill->invnum,
'amount' => $amount,
- } );
+ };
+ $cbp->{_date} = $payment->_date
+ if $options{'manual'} && $options{'backdate_application'};
+ my $cust_bill_pay = new FS::cust_bill_pay($cbp);
$cust_bill_pay->pkgnum( $payment->pkgnum )
if $conf->exists('pkg-balances') && $payment->pkgnum;
my $error = $cust_bill_pay->insert(%options);
cancel_expired_pkgs
suspend_adjourned_pkgs
+ unsuspend_resumed_pkgs
bill
(do_cust_event pre-bill)
_handle_taxes
(vendor-only) _gather_taxes
_omit_zero_value_bundles
+ _handle_taxes (for fees)
calculate_taxes
apply_payments_and_credits