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
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
) {
+ $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);
'';
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;
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,
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,
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 ( {
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 $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'
# 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 = $self->_handle_taxes( $taxlisthash, $cust_bill_pkg,
+ cancel => $options{cancel} );
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 ]
+=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 = @_;
- 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 $location = $cust_pkg->tax_location;
+ 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;
}
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},
- 'custnum' => $self->custnum,
- 'invoice_time' => $invoice_time,
- );
+ $tax_object->taxline( $localtaxlisthash{$tax} );
return $hashref_or_error
unless ref($hashref_or_error);
my @loc_keys = qw( district city county state country );
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 = $cust_pkg->tax_location->geocode('cch');
-
- my @taxes = ();
-
- my @taxclassnums = map { $_->taxclassnum }
- $part_pkg->part_pkg_taxoverride($class);
+ my $geocode = $location->geocode('cch');
- 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) ]
}
#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
_handle_taxes
(vendor-only) _gather_taxes
_omit_zero_value_bundles
+ _handle_taxes (for fees)
calculate_taxes
apply_payments_and_credits