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;
my $job = $options{'job'};
$job->update_statustext('0,cleaning expired packages') if $job;
- $error = $self->cancel_expired_pkgs( $options{actual_time} );
+ $error = $self->cancel_expired_pkgs( day_end( $options{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} );
+ $error = $self->suspend_adjourned_pkgs( day_end( $options{actual_time} ) );
if ( $error ) {
$error = "Error adjourning custnum ". $self->custnum. ": $error";
if ( $options{fatal} && $options{fatal} eq 'return' ) { return $error; }
else { warn $error; }
}
+ $error = $self->unsuspend_resumed_pkgs( day_end( $options{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;
$error = $self->bill( %options );
if ( $error ) {
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 "
} );
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
+ '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
next if $options{'not_pkgpart'}->{$cust_pkg->pkgpart};
- warn " bill package ". $cust_pkg->pkgnum. "\n" if $DEBUG > 1;
+ warn " bill package ". $cust_pkg->pkgnum. "\n" if $DEBUG;
#? to avoid use of uninitialized value errors... ?
$cust_pkg->setfield('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 <= $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;
#create the new invoice
my $cust_bill = new FS::cust_bill ( {
'custnum' => $self->custnum,
- '_date' => ( $invoice_time ),
+ '_date' => $invoice_time,
'charged' => $charged,
'billing_balance' => $balance,
'previous_balance' => $previous_balance,
#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);
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 } );
+
+ my @cust_tax_exempt_pkg = splice( @{ $_->_cust_tax_exempt_pkg } );
+
+ next unless @cust_tax_exempt_pkg; #just avoiding the prob when irrelevant?
+ die "can't distribute tax exemptions: no line item for ". Dumper($_).
+ " in packagemap ". join(',', sort {$a<=>$b} keys %packagemap). "\n"
+ unless $packagemap{$_->pkgnum};
+
+ push @{ $packagemap{$_->pkgnum}->_cust_tax_exempt_pkg },
+ @cust_tax_exempt_pkg;
}
}
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 $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 );
+ if ( ! $options{recurring_only}
+ and ! $options{cancel}
+ and ( $options{'resetup'}
+ || ( ! $cust_pkg->setup
+ && ( ! $cust_pkg->start_date
+ || $cust_pkg->start_date <= day_end($time)
+ )
+ && ( ! $conf->exists('disable_setup_suspended_pkgs')
+ || ( $conf->exists('disable_setup_suspended_pkgs') &&
+ ! $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++;
- $unitsetup = $cust_pkg->part_pkg->unit_setup || $setup; #XXX uuh
+ $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
+ }
$cust_pkg->setfield('setup', $time)
unless $cust_pkg->setup;
#XXX unit stuff here too
my $recur = 0;
my $unitrecur = 0;
+ my @recur_discounts = ();
my $sdate;
if ( ! $cust_pkg->start_date
and ( ! $cust_pkg->susp || $part_pkg->option('suspend_bill', 1) )
and
- ( $part_pkg->freq ne '0' && ( $cust_pkg->bill || 0 ) <= $time )
+ ( $part_pkg->freq ne '0' && ( $cust_pkg->bill || 0 ) <= day_end($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 ) <= day_end($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,
);
my $method = $options{cancel} ? 'calc_cancel' : 'calc_recur';
# 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 ( $@ );
}
+ if ( $param{'setup_fee'} ) {
+ # Add an additional setup fee at the billing stage.
+ # Used for prorate_defer_bill.
+ $setup += $param{'setup_fee'};
+ $unitsetup += $param{'setup_fee'};
+ $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);
if $DEBUG >1;
my $error = $cust_pkg->replace( $old_cust_pkg,
+ 'depend_jobnum'=>$options{depend_jobnum},
'options' => { $cust_pkg->options },
)
unless $options{no_commit};
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"
'unitrecur' => $unitrecur,
'quantity' => $cust_pkg->quantity,
'details' => \@details,
- 'discounts' => \@discounts,
+ '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};
} else {
- my @loc_keys = qw( city county state country );
+ my @loc_keys = qw( district city county state country );
my %taxhash;
if ( $conf->exists('tax-pkg_address') && $cust_pkg->locationnum ) {
my $cust_location = $cust_pkg->cust_location;
my @taxes = ();
my %taxhash_elim = %taxhash;
- my @elim = qw( city county state );
+ my @elim = qw( district city county state );
do {
#first try a match with 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 };
- }
-
- }
- $cust_bill_pkg->set('display', \@display);
+ #what's this doing in the middle of _handle_taxes? probably should split
+ #this into three parts above in _make_lines
+ $cust_bill_pkg->set_display( part_pkg => $part_pkg,
+ real_pkgpart => $real_pkgpart,
+ );
my %tax_cust_bill_pkg = $cust_bill_pkg->disintegrate;
foreach my $key (keys %tax_cust_bill_pkg) {
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)
. ') ';
).
' ) ';
- my @cust_event = qsearchs({
+ my @cust_event = qsearch({
'table' => 'cust_event',
'select' => 'cust_event.*',
'addl_from' => "LEFT JOIN part_event USING ( eventpart ) $join",
#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;
@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 {
+ @objects = ( $self );
- 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" : '';
-
- 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);
+ 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';
" 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"
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)