1 package FS::cust_main::Billing;
4 use vars qw( $conf $DEBUG $me );
7 use FS::Record qw( qsearch qsearchs dbdef );
10 use FS::cust_bill_pkg_display;
11 use FS::cust_bill_pay;
12 use FS::cust_credit_bill;
14 use FS::cust_tax_adjustment;
16 use FS::tax_rate_location;
17 use FS::cust_bill_pkg_tax_location;
18 use FS::cust_bill_pkg_tax_rate_location;
20 use FS::part_event_condition;
22 # 1 is mostly method/subroutine entry and options
23 # 2 traces progress of some operations
24 # 3 is even more information including possibly sensitive data
26 $me = '[FS::cust_main::Billing]';
28 install_callback FS::UID sub {
30 #yes, need it for stuff below (prolly should be cached)
35 FS::cust_main::Billing - Billing mixin for cust_main
41 These methods are available on FS::cust_main objects.
47 =item bill_and_collect
49 Cancels and suspends any packages due, generates bills, applies payments and
50 credits, and applies collection events to run cards, send bills and notices,
53 By default, warns on errors and continues with the next operation (but see the
56 Options are passed as name-value pairs. Currently available options are:
62 Bills the customer as if it were that time. Specified as a UNIX timestamp; see L<perlfunc/"time">). Also see L<Time::Local> and L<Date::Parse> for conversion functions. For example:
66 $cust_main->bill( 'time' => str2time('April 20th, 2001') );
70 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.
74 "1d" for the traditional, daily events (the default), or "1m" for the new monthly events (part_event.check_freq)
78 If set true, re-charges setup fees.
82 If set any errors prevent subsequent operations from continusing. If set
83 specifically to "return", returns the error (or false, if there is no error).
84 Any other true value causes errors to die.
88 Debugging level. Default is 0 (no debugging), or can be set to 1 (passed-in options), 2 (traces progress), 3 (more information), or 4 (include full search queries)
92 Optional FS::queue entry to receive status updates.
96 Options are passed to the B<bill> and B<collect> methods verbatim, so all
97 options of those methods are also available.
101 sub bill_and_collect {
102 my( $self, %options ) = @_;
106 #$options{actual_time} not $options{time} because freeside-daily -d is for
107 #pre-printing invoices
109 $options{'actual_time'} ||= time;
110 my $job = $options{'job'};
112 $job->update_statustext('0,cleaning expired packages') if $job;
113 $error = $self->cancel_expired_pkgs( $options{actual_time} );
115 $error = "Error expiring custnum ". $self->custnum. ": $error";
116 if ( $options{fatal} && $options{fatal} eq 'return' ) { return $error; }
117 elsif ( $options{fatal} ) { die $error; }
118 else { warn $error; }
121 $error = $self->suspend_adjourned_pkgs( $options{actual_time} );
123 $error = "Error adjourning custnum ". $self->custnum. ": $error";
124 if ( $options{fatal} && $options{fatal} eq 'return' ) { return $error; }
125 elsif ( $options{fatal} ) { die $error; }
126 else { warn $error; }
129 $job->update_statustext('20,billing packages') if $job;
130 $error = $self->bill( %options );
132 $error = "Error billing custnum ". $self->custnum. ": $error";
133 if ( $options{fatal} && $options{fatal} eq 'return' ) { return $error; }
134 elsif ( $options{fatal} ) { die $error; }
135 else { warn $error; }
138 $job->update_statustext('50,applying payments and credits') if $job;
139 $error = $self->apply_payments_and_credits;
141 $error = "Error applying custnum ". $self->custnum. ": $error";
142 if ( $options{fatal} && $options{fatal} eq 'return' ) { return $error; }
143 elsif ( $options{fatal} ) { die $error; }
144 else { warn $error; }
147 $job->update_statustext('70,running collection events') if $job;
148 unless ( $conf->exists('cancelled_cust-noevents')
149 && ! $self->num_ncancelled_pkgs
151 $error = $self->collect( %options );
153 $error = "Error collecting custnum ". $self->custnum. ": $error";
154 if ($options{fatal} && $options{fatal} eq 'return') { return $error; }
155 elsif ($options{fatal} ) { die $error; }
156 else { warn $error; }
159 $job->update_statustext('100,finished') if $job;
165 sub cancel_expired_pkgs {
166 my ( $self, $time, %options ) = @_;
168 my @cancel_pkgs = $self->ncancelled_pkgs( {
169 'extra_sql' => " AND expire IS NOT NULL AND expire > 0 AND expire <= $time "
174 foreach my $cust_pkg ( @cancel_pkgs ) {
175 my $cpr = $cust_pkg->last_cust_pkg_reason('expire');
176 my $error = $cust_pkg->cancel($cpr ? ( 'reason' => $cpr->reasonnum,
177 'reason_otaker' => $cpr->otaker
181 push @errors, 'pkgnum '.$cust_pkg->pkgnum.": $error" if $error;
184 scalar(@errors) ? join(' / ', @errors) : '';
188 sub suspend_adjourned_pkgs {
189 my ( $self, $time, %options ) = @_;
191 my @susp_pkgs = $self->ncancelled_pkgs( {
193 " AND ( susp IS NULL OR susp = 0 )
194 AND ( ( bill IS NOT NULL AND bill != 0 AND bill < $time )
195 OR ( adjourn IS NOT NULL AND adjourn != 0 AND adjourn <= $time )
200 #only because there's no SQL test for is_prepaid :/
202 grep { ( $_->part_pkg->is_prepaid
207 && $_->adjourn <= $time
215 foreach my $cust_pkg ( @susp_pkgs ) {
216 my $cpr = $cust_pkg->last_cust_pkg_reason('adjourn')
217 if ($cust_pkg->adjourn && $cust_pkg->adjourn < $^T);
218 my $error = $cust_pkg->suspend($cpr ? ( 'reason' => $cpr->reasonnum,
219 'reason_otaker' => $cpr->otaker
223 push @errors, 'pkgnum '.$cust_pkg->pkgnum.": $error" if $error;
226 scalar(@errors) ? join(' / ', @errors) : '';
232 Generates invoices (see L<FS::cust_bill>) for this customer. Usually used in
233 conjunction with the collect method by calling B<bill_and_collect>.
235 If there is an error, returns the error, otherwise returns false.
237 Options are passed as name-value pairs. Currently available options are:
243 If set true, re-charges setup fees.
247 Bills the customer as if it were that time. Specified as a UNIX timestamp; see L<perlfunc/"time">). Also see L<Time::Local> and L<Date::Parse> for conversion functions. For example:
251 $cust_main->bill( 'time' => str2time('April 20th, 2001') );
255 An array ref of specific packages (objects) to attempt billing, instead trying all of them.
257 $cust_main->bill( pkg_list => [$pkg1, $pkg2] );
261 A hashref of pkgparts to exclude from this billing run (can also be specified as a comma-separated scalar).
265 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.
269 This boolean value informs the us that the package is being cancelled. This
270 typically might mean not charging the normal recurring fee but only usage
271 fees since the last billing. Setup charges may be charged. Not all package
272 plans support this feature (they tend to charge 0).
276 Optional terms to be printed on this invoice. Otherwise, customer-specific
277 terms or the default terms are used.
284 my( $self, %options ) = @_;
285 return '' if $self->payby eq 'COMP';
286 warn "$me bill customer ". $self->custnum. "\n"
289 my $time = $options{'time'} || time;
290 my $invoice_time = $options{'invoice_time'} || $time;
292 $options{'not_pkgpart'} ||= {};
293 $options{'not_pkgpart'} = { map { $_ => 1 }
294 split(/\s*,\s*/, $options{'not_pkgpart'})
296 unless ref($options{'not_pkgpart'});
298 local $SIG{HUP} = 'IGNORE';
299 local $SIG{INT} = 'IGNORE';
300 local $SIG{QUIT} = 'IGNORE';
301 local $SIG{TERM} = 'IGNORE';
302 local $SIG{TSTP} = 'IGNORE';
303 local $SIG{PIPE} = 'IGNORE';
305 my $oldAutoCommit = $FS::UID::AutoCommit;
306 local $FS::UID::AutoCommit = 0;
309 warn "$me acquiring lock on customer ". $self->custnum. "\n"
312 $self->select_for_update; #mutex
314 warn "$me running pre-bill events for customer ". $self->custnum. "\n"
317 my $error = $self->do_cust_event(
318 'debug' => ( $options{'debug'} || 0 ),
319 'time' => $invoice_time,
320 'check_freq' => $options{'check_freq'},
321 'stage' => 'pre-bill',
324 $dbh->rollback if $oldAutoCommit;
328 warn "$me done running pre-bill events for customer ". $self->custnum. "\n"
331 #keep auto-charge and non-auto-charge line items separate
332 my @passes = ( '', 'no_auto' );
334 my %cust_bill_pkg = map { $_ => [] } @passes;
337 # find the packages which are due for billing, find out how much they are
338 # & generate invoice database.
341 my %total_setup = map { my $z = 0; $_ => \$z; } @passes;
342 my %total_recur = map { my $z = 0; $_ => \$z; } @passes;
344 my %taxlisthash = map { $_ => {} } @passes;
346 my @precommit_hooks = ();
348 $options{'pkg_list'} ||= [ $self->ncancelled_pkgs ]; #param checks?
349 foreach my $cust_pkg ( @{ $options{'pkg_list'} } ) {
351 next if $options{'not_pkgpart'}->{$cust_pkg->pkgpart};
353 warn " bill package ". $cust_pkg->pkgnum. "\n" if $DEBUG > 1;
355 #? to avoid use of uninitialized value errors... ?
356 $cust_pkg->setfield('bill', '')
357 unless defined($cust_pkg->bill);
359 #my $part_pkg = $cust_pkg->part_pkg;
361 my $real_pkgpart = $cust_pkg->pkgpart;
362 my %hash = $cust_pkg->hash;
364 # we could implement this bit as FS::part_pkg::has_hidden, but we already
365 # suffer from performance issues
366 $options{has_hidden} = 0;
367 my @part_pkg = $cust_pkg->part_pkg->self_and_bill_linked;
368 $options{has_hidden} = 1 if ($part_pkg[1] && $part_pkg[1]->hidden);
370 foreach my $part_pkg ( @part_pkg ) {
372 $cust_pkg->set($_, $hash{$_}) foreach qw ( setup last_bill bill );
374 my $pass = ($cust_pkg->no_auto || $part_pkg->no_auto) ? 'no_auto' : '';
377 $self->_make_lines( 'part_pkg' => $part_pkg,
378 'cust_pkg' => $cust_pkg,
379 'precommit_hooks' => \@precommit_hooks,
380 'line_items' => $cust_bill_pkg{$pass},
381 'setup' => $total_setup{$pass},
382 'recur' => $total_recur{$pass},
383 'tax_matrix' => $taxlisthash{$pass},
385 'real_pkgpart' => $real_pkgpart,
386 'options' => \%options,
389 $dbh->rollback if $oldAutoCommit;
393 } #foreach my $part_pkg
395 } #foreach my $cust_pkg
397 #if the customer isn't on an automatic payby, everything can go on a single
399 #if ( $cust_main->payby !~ /^(CARD|CHEK)$/ ) {
400 #merge everything into one list
403 foreach my $pass (@passes) { # keys %cust_bill_pkg ) {
405 my @cust_bill_pkg = _omit_zero_value_bundles(@{ $cust_bill_pkg{$pass} });
407 next unless @cust_bill_pkg; #don't create an invoice w/o line items
409 if ( scalar( grep { $_->recur && $_->recur > 0 } @cust_bill_pkg) ||
410 !$conf->exists('postal_invoice-recurring_only')
414 my $postal_pkg = $self->charge_postal_fee();
415 if ( $postal_pkg && !ref( $postal_pkg ) ) {
417 $dbh->rollback if $oldAutoCommit;
418 return "can't charge postal invoice fee for customer ".
419 $self->custnum. ": $postal_pkg";
421 } elsif ( $postal_pkg ) {
423 my $real_pkgpart = $postal_pkg->pkgpart;
424 # we could implement this bit as FS::part_pkg::has_hidden, but we already
425 # suffer from performance issues
426 $options{has_hidden} = 0;
427 my @part_pkg = $postal_pkg->part_pkg->self_and_bill_linked;
428 $options{has_hidden} = 1 if ($part_pkg[1] && $part_pkg[1]->hidden);
430 foreach my $part_pkg ( @part_pkg ) {
431 my %postal_options = %options;
432 delete $postal_options{cancel};
434 $self->_make_lines( 'part_pkg' => $part_pkg,
435 'cust_pkg' => $postal_pkg,
436 'precommit_hooks' => \@precommit_hooks,
437 'line_items' => \@cust_bill_pkg,
438 'setup' => $total_setup{$pass},
439 'recur' => $total_recur{$pass},
440 'tax_matrix' => $taxlisthash{$pass},
442 'real_pkgpart' => $real_pkgpart,
443 'options' => \%postal_options,
446 $dbh->rollback if $oldAutoCommit;
451 # it's silly to have a zero value postal_pkg, but....
452 @cust_bill_pkg = _omit_zero_value_bundles(@cust_bill_pkg);
458 my $listref_or_error =
459 $self->calculate_taxes( \@cust_bill_pkg, $taxlisthash{$pass}, $invoice_time);
461 unless ( ref( $listref_or_error ) ) {
462 $dbh->rollback if $oldAutoCommit;
463 return $listref_or_error;
466 foreach my $taxline ( @$listref_or_error ) {
467 ${ $total_setup{$pass} } =
468 sprintf('%.2f', ${ $total_setup{$pass} } + $taxline->setup );
469 push @cust_bill_pkg, $taxline;
473 warn "adding tax adjustments...\n" if $DEBUG > 2;
474 foreach my $cust_tax_adjustment (
475 qsearch('cust_tax_adjustment', { 'custnum' => $self->custnum,
481 my $tax = sprintf('%.2f', $cust_tax_adjustment->amount );
483 my $itemdesc = $cust_tax_adjustment->taxname;
484 $itemdesc = '' if $itemdesc eq 'Tax';
486 push @cust_bill_pkg, new FS::cust_bill_pkg {
492 'itemdesc' => $itemdesc,
493 'itemcomment' => $cust_tax_adjustment->comment,
494 'cust_tax_adjustment' => $cust_tax_adjustment,
495 #'cust_bill_pkg_tax_location' => \@cust_bill_pkg_tax_location,
500 my $charged = sprintf('%.2f', ${ $total_setup{$pass} } + ${ $total_recur{$pass} } );
502 my @cust_bill = $self->cust_bill;
503 my $balance = $self->balance;
504 my $previous_balance = scalar(@cust_bill)
505 ? ( $cust_bill[$#cust_bill]->billing_balance || 0 )
508 $previous_balance += $cust_bill[$#cust_bill]->charged
509 if scalar(@cust_bill);
510 #my $balance_adjustments =
511 # sprintf('%.2f', $balance - $prior_prior_balance - $prior_charged);
513 #create the new invoice
514 my $cust_bill = new FS::cust_bill ( {
515 'custnum' => $self->custnum,
516 '_date' => ( $invoice_time ),
517 'charged' => $charged,
518 'billing_balance' => $balance,
519 'previous_balance' => $previous_balance,
520 'invoice_terms' => $options{'invoice_terms'},
522 $error = $cust_bill->insert;
524 $dbh->rollback if $oldAutoCommit;
525 return "can't create invoice for customer #". $self->custnum. ": $error";
528 foreach my $cust_bill_pkg ( @cust_bill_pkg ) {
529 $cust_bill_pkg->invnum($cust_bill->invnum);
530 my $error = $cust_bill_pkg->insert;
532 $dbh->rollback if $oldAutoCommit;
533 return "can't create invoice line item: $error";
537 } #foreach my $pass ( keys %cust_bill_pkg )
539 foreach my $hook ( @precommit_hooks ) {
544 $dbh->rollback if $oldAutoCommit;
545 return "$@ running precommit hook $hook\n";
549 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
553 #discard bundled packages of 0 value
554 sub _omit_zero_value_bundles {
556 my @cust_bill_pkg = ();
557 my @cust_bill_pkg_bundle = ();
560 foreach my $cust_bill_pkg ( @_ ) {
561 if (scalar(@cust_bill_pkg_bundle) && !$cust_bill_pkg->pkgpart_override) {
562 push @cust_bill_pkg, @cust_bill_pkg_bundle if $sum > 0;
563 @cust_bill_pkg_bundle = ();
566 $sum += $cust_bill_pkg->setup + $cust_bill_pkg->recur;
567 push @cust_bill_pkg_bundle, $cust_bill_pkg;
569 push @cust_bill_pkg, @cust_bill_pkg_bundle if $sum > 0;
575 =item calculate_taxes LINEITEMREF TAXHASHREF INVOICE_TIME
577 This is a weird one. Perhaps it should not even be exposed.
579 Generates tax line items (see L<FS::cust_bill_pkg>) for this customer.
580 Usually used internally by bill method B<bill>.
582 If there is an error, returns the error, otherwise returns reference to a
583 list of line items suitable for insertion.
589 An array ref of the line items being billed.
593 A strange beast. The keys to this hash are internal identifiers consisting
594 of the name of the tax object type, a space, and its unique identifier ( e.g.
595 'cust_main_county 23' ). The values of the hash are listrefs. The first
596 item in the list is the tax object. The remaining items are either line
597 items or floating point values (currency amounts).
599 The taxes are calculated on this entity. Calculated exemption records are
600 transferred to the LINEITEMREF items on the assumption that they are related.
606 This specifies the date appearing on the associated invoice. Some
607 jurisdictions (i.e. Texas) have tax exemptions which are date sensitive.
612 sub calculate_taxes {
613 my ($self, $cust_bill_pkg, $taxlisthash, $invoice_time) = @_;
615 my @tax_line_items = ();
617 warn "having a look at the taxes we found...\n" if $DEBUG > 2;
619 # keys are tax names (as printed on invoices / itemdesc )
620 # values are listrefs of taxlisthash keys (internal identifiers)
623 # keys are taxlisthash keys (internal identifiers)
624 # values are (cumulative) amounts
627 # keys are taxlisthash keys (internal identifiers)
628 # values are listrefs of cust_bill_pkg_tax_location hashrefs
629 my %tax_location = ();
631 # keys are taxlisthash keys (internal identifiers)
632 # values are listrefs of cust_bill_pkg_tax_rate_location hashrefs
633 my %tax_rate_location = ();
635 foreach my $tax ( keys %$taxlisthash ) {
636 my $tax_object = shift @{ $taxlisthash->{$tax} };
637 warn "found ". $tax_object->taxname. " as $tax\n" if $DEBUG > 2;
638 warn " ". join('/', @{ $taxlisthash->{$tax} } ). "\n" if $DEBUG > 2;
639 my $hashref_or_error =
640 $tax_object->taxline( $taxlisthash->{$tax},
641 'custnum' => $self->custnum,
642 'invoice_time' => $invoice_time
644 return $hashref_or_error unless ref($hashref_or_error);
646 unshift @{ $taxlisthash->{$tax} }, $tax_object;
648 my $name = $hashref_or_error->{'name'};
649 my $amount = $hashref_or_error->{'amount'};
651 #warn "adding $amount as $name\n";
652 $taxname{ $name } ||= [];
653 push @{ $taxname{ $name } }, $tax;
655 $tax{ $tax } += $amount;
657 $tax_location{ $tax } ||= [];
658 if ( $tax_object->get('pkgnum') || $tax_object->get('locationnum') ) {
659 push @{ $tax_location{ $tax } },
661 'taxnum' => $tax_object->taxnum,
662 'taxtype' => ref($tax_object),
663 'pkgnum' => $tax_object->get('pkgnum'),
664 'locationnum' => $tax_object->get('locationnum'),
665 'amount' => sprintf('%.2f', $amount ),
669 $tax_rate_location{ $tax } ||= [];
670 if ( ref($tax_object) eq 'FS::tax_rate' ) {
671 my $taxratelocationnum =
672 $tax_object->tax_rate_location->taxratelocationnum;
673 push @{ $tax_rate_location{ $tax } },
675 'taxnum' => $tax_object->taxnum,
676 'taxtype' => ref($tax_object),
677 'amount' => sprintf('%.2f', $amount ),
678 'locationtaxid' => $tax_object->location,
679 'taxratelocationnum' => $taxratelocationnum,
685 #move the cust_tax_exempt_pkg records to the cust_bill_pkgs we will commit
686 my %packagemap = map { $_->pkgnum => $_ } @$cust_bill_pkg;
687 foreach my $tax ( keys %$taxlisthash ) {
688 foreach ( @{ $taxlisthash->{$tax} }[1 ... scalar(@{ $taxlisthash->{$tax} })] ) {
689 next unless ref($_) eq 'FS::cust_bill_pkg';
691 push @{ $packagemap{$_->pkgnum}->_cust_tax_exempt_pkg },
692 splice( @{ $_->_cust_tax_exempt_pkg } );
696 #consolidate and create tax line items
697 warn "consolidating and generating...\n" if $DEBUG > 2;
698 foreach my $taxname ( keys %taxname ) {
701 my @cust_bill_pkg_tax_location = ();
702 my @cust_bill_pkg_tax_rate_location = ();
703 warn "adding $taxname\n" if $DEBUG > 1;
704 foreach my $taxitem ( @{ $taxname{$taxname} } ) {
705 next if $seen{$taxitem}++;
706 warn "adding $tax{$taxitem}\n" if $DEBUG > 1;
707 $tax += $tax{$taxitem};
708 push @cust_bill_pkg_tax_location,
709 map { new FS::cust_bill_pkg_tax_location $_ }
710 @{ $tax_location{ $taxitem } };
711 push @cust_bill_pkg_tax_rate_location,
712 map { new FS::cust_bill_pkg_tax_rate_location $_ }
713 @{ $tax_rate_location{ $taxitem } };
717 $tax = sprintf('%.2f', $tax );
719 my $pkg_category = qsearchs( 'pkg_category', { 'categoryname' => $taxname,
725 if ( $pkg_category and
726 $conf->config('invoice_latexsummary') ||
727 $conf->config('invoice_htmlsummary')
731 my %hash = ( 'section' => $pkg_category->categoryname );
732 push @display, new FS::cust_bill_pkg_display { type => 'S', %hash };
736 push @tax_line_items, new FS::cust_bill_pkg {
742 'itemdesc' => $taxname,
743 'display' => \@display,
744 'cust_bill_pkg_tax_location' => \@cust_bill_pkg_tax_location,
745 'cust_bill_pkg_tax_rate_location' => \@cust_bill_pkg_tax_rate_location,
754 my ($self, %params) = @_;
756 my $part_pkg = $params{part_pkg} or die "no part_pkg specified";
757 my $cust_pkg = $params{cust_pkg} or die "no cust_pkg specified";
758 my $precommit_hooks = $params{precommit_hooks} or die "no package specified";
759 my $cust_bill_pkgs = $params{line_items} or die "no line buffer specified";
760 my $total_setup = $params{setup} or die "no setup accumulator specified";
761 my $total_recur = $params{recur} or die "no recur accumulator specified";
762 my $taxlisthash = $params{tax_matrix} or die "no tax accumulator specified";
763 my $time = $params{'time'} or die "no time specified";
764 my (%options) = %{$params{options}};
767 my $real_pkgpart = $params{real_pkgpart};
768 my %hash = $cust_pkg->hash;
769 my $old_cust_pkg = new FS::cust_pkg \%hash;
775 $cust_pkg->pkgpart($part_pkg->pkgpart);
783 if ( $options{'resetup'}
784 || ( ! $cust_pkg->setup
785 && ( ! $cust_pkg->start_date
786 || $cust_pkg->start_date <= $time
788 && ( ! $conf->exists('disable_setup_suspended_pkgs')
789 || ( $conf->exists('disable_setup_suspended_pkgs') &&
790 ! $cust_pkg->getfield('susp')
797 warn " bill setup\n" if $DEBUG > 1;
800 $setup = eval { $cust_pkg->calc_setup( $time, \@details ) };
801 return "$@ running calc_setup for $cust_pkg\n"
804 $unitsetup = $cust_pkg->part_pkg->unit_setup || $setup; #XXX uuh
806 $cust_pkg->setfield('setup', $time)
807 unless $cust_pkg->setup;
808 #do need it, but it won't get written to the db
809 #|| $cust_pkg->pkgpart != $real_pkgpart;
811 $cust_pkg->setfield('start_date', '')
812 if $cust_pkg->start_date;
820 #XXX unit stuff here too
824 if ( ! $cust_pkg->get('susp')
825 and ! $cust_pkg->get('start_date')
826 and ( $part_pkg->getfield('freq') ne '0'
827 && ( $cust_pkg->getfield('bill') || 0 ) <= $time
829 || ( $part_pkg->plan eq 'voip_cdr'
830 && $part_pkg->option('bill_every_call')
832 || ( $options{cancel} )
835 # XXX should this be a package event? probably. events are called
836 # at collection time at the moment, though...
837 $part_pkg->reset_usage($cust_pkg, 'debug'=>$DEBUG)
838 if $part_pkg->can('reset_usage');
839 #don't want to reset usage just cause we want a line item??
840 #&& $part_pkg->pkgpart == $real_pkgpart;
842 warn " bill recur\n" if $DEBUG > 1;
845 # XXX shared with $recur_prog
846 $sdate = ( $options{cancel} ? $cust_pkg->last_bill : $cust_pkg->bill )
850 #over two params! lets at least switch to a hashref for the rest...
851 my $increment_next_bill = ( $part_pkg->freq ne '0'
852 && ( $cust_pkg->getfield('bill') || 0 ) <= $time
855 my %param = ( 'precommit_hooks' => $precommit_hooks,
856 'increment_next_bill' => $increment_next_bill,
857 'discounts' => \@discounts,
858 'real_pkgpart' => $real_pkgpart,
861 my $method = $options{cancel} ? 'calc_cancel' : 'calc_recur';
862 $recur = eval { $cust_pkg->$method( \$sdate, \@details, \%param ) };
863 return "$@ running $method for $cust_pkg\n"
866 if ( $increment_next_bill ) {
868 my $next_bill = $part_pkg->add_freq($sdate);
869 return "unparsable frequency: ". $part_pkg->freq
872 #pro-rating magic - if $recur_prog fiddled $sdate, want to use that
873 # only for figuring next bill date, nothing else, so, reset $sdate again
875 $sdate = $cust_pkg->bill || $cust_pkg->setup || $time;
876 #no need, its in $hash{last_bill}# my $last_bill = $cust_pkg->last_bill;
877 $cust_pkg->last_bill($sdate);
879 $cust_pkg->setfield('bill', $next_bill );
885 warn "\$setup is undefined" unless defined($setup);
886 warn "\$recur is undefined" unless defined($recur);
887 warn "\$cust_pkg->bill is undefined" unless defined($cust_pkg->bill);
890 # If there's line items, create em cust_bill_pkg records
891 # If $cust_pkg has been modified, update it (if we're a real pkgpart)
894 if ( $lineitems || $options{has_hidden} ) {
896 if ( $cust_pkg->modified && $cust_pkg->pkgpart == $real_pkgpart ) {
897 # hmm.. and if just the options are modified in some weird price plan?
899 warn " package ". $cust_pkg->pkgnum. " modified; updating\n"
902 my $error = $cust_pkg->replace( $old_cust_pkg,
903 'options' => { $cust_pkg->options },
905 return "Error modifying pkgnum ". $cust_pkg->pkgnum. ": $error"
906 if $error; #just in case
909 $setup = sprintf( "%.2f", $setup );
910 $recur = sprintf( "%.2f", $recur );
911 if ( $setup < 0 && ! $conf->exists('allow_negative_charges') ) {
912 return "negative setup $setup for pkgnum ". $cust_pkg->pkgnum;
914 if ( $recur < 0 && ! $conf->exists('allow_negative_charges') ) {
915 return "negative recur $recur for pkgnum ". $cust_pkg->pkgnum;
920 !$part_pkg->hidden && $options{has_hidden} ) #include some $0 lines
923 warn " charges (setup=$setup, recur=$recur); adding line items\n"
926 my @cust_pkg_detail = map { $_->detail } $cust_pkg->cust_pkg_detail('I');
928 warn " adding customer package invoice detail: $_\n"
929 foreach @cust_pkg_detail;
931 push @details, @cust_pkg_detail;
933 my $cust_bill_pkg = new FS::cust_bill_pkg {
934 'pkgnum' => $cust_pkg->pkgnum,
936 'unitsetup' => $unitsetup,
938 'unitrecur' => $unitrecur,
939 'quantity' => $cust_pkg->quantity,
940 'details' => \@details,
941 'discounts' => \@discounts,
942 'hidden' => $part_pkg->hidden,
945 if ( $part_pkg->option('recur_temporality', 1) eq 'preceding' ) {
946 $cust_bill_pkg->sdate( $hash{last_bill} );
947 $cust_bill_pkg->edate( $sdate - 86399 ); #60s*60m*24h-1
948 $cust_bill_pkg->edate( $time ) if $options{cancel};
949 } else { #if ( $part_pkg->option('recur_temporality', 1) eq 'upcoming' ) {
950 $cust_bill_pkg->sdate( $sdate );
951 $cust_bill_pkg->edate( $cust_pkg->bill );
952 #$cust_bill_pkg->edate( $time ) if $options{cancel};
955 $cust_bill_pkg->pkgpart_override($part_pkg->pkgpart)
956 unless $part_pkg->pkgpart == $real_pkgpart;
958 $$total_setup += $setup;
959 $$total_recur += $recur;
966 $self->_handle_taxes($part_pkg, $taxlisthash, $cust_bill_pkg, $cust_pkg, $options{invoice_time}, $real_pkgpart, \%options);
967 return $error if $error;
969 push @$cust_bill_pkgs, $cust_bill_pkg;
971 } #if $setup != 0 || $recur != 0
981 my $part_pkg = shift;
982 my $taxlisthash = shift;
983 my $cust_bill_pkg = shift;
984 my $cust_pkg = shift;
985 my $invoice_time = shift;
986 my $real_pkgpart = shift;
989 my %cust_bill_pkg = ();
993 #push @classes, $cust_bill_pkg->usage_classes if $cust_bill_pkg->type eq 'U';
994 push @classes, $cust_bill_pkg->usage_classes if $cust_bill_pkg->usage;
995 push @classes, 'setup' if ($cust_bill_pkg->setup && !$options->{cancel});
996 push @classes, 'recur' if ($cust_bill_pkg->recur && !$options->{cancel});
998 if ( $self->tax !~ /Y/i && $self->payby ne 'COMP' ) {
1000 if ( $conf->exists('enable_taxproducts')
1001 && ( scalar($part_pkg->part_pkg_taxoverride)
1002 || $part_pkg->has_taxproduct
1007 if ( $conf->exists('tax-pkg_address') && $cust_pkg->locationnum ) {
1008 return "fatal: Can't (yet) use tax-pkg_address with taxproducts";
1011 foreach my $class (@classes) {
1012 my $err_or_ref = $self->_gather_taxes( $part_pkg, $class );
1013 return $err_or_ref unless ref($err_or_ref);
1014 $taxes{$class} = $err_or_ref;
1017 unless (exists $taxes{''}) {
1018 my $err_or_ref = $self->_gather_taxes( $part_pkg, '' );
1019 return $err_or_ref unless ref($err_or_ref);
1020 $taxes{''} = $err_or_ref;
1025 my @loc_keys = qw( city county state country );
1027 if ( $conf->exists('tax-pkg_address') && $cust_pkg->locationnum ) {
1028 my $cust_location = $cust_pkg->cust_location;
1029 %taxhash = map { $_ => $cust_location->$_() } @loc_keys;
1032 ( $conf->exists('tax-ship_address') && length($self->ship_last) )
1035 %taxhash = map { $_ => $self->get("$prefix$_") } @loc_keys;
1038 $taxhash{'taxclass'} = $part_pkg->taxclass;
1041 my %taxhash_elim = %taxhash;
1042 my @elim = qw( city county state );
1045 #first try a match with taxclass
1046 @taxes = qsearch( 'cust_main_county', \%taxhash_elim );
1048 if ( !scalar(@taxes) && $taxhash_elim{'taxclass'} ) {
1049 #then try a match without taxclass
1050 my %no_taxclass = %taxhash_elim;
1051 $no_taxclass{ 'taxclass' } = '';
1052 @taxes = qsearch( 'cust_main_county', \%no_taxclass );
1055 $taxhash_elim{ shift(@elim) } = '';
1057 } while ( !scalar(@taxes) && scalar(@elim) );
1059 @taxes = grep { ! $_->taxname or ! $self->tax_exemption($_->taxname) }
1061 if $self->cust_main_exemption; #just to be safe
1063 if ( $conf->exists('tax-pkg_address') && $cust_pkg->locationnum ) {
1065 $_->set('pkgnum', $cust_pkg->pkgnum );
1066 $_->set('locationnum', $cust_pkg->locationnum );
1070 $taxes{''} = [ @taxes ];
1071 $taxes{'setup'} = [ @taxes ];
1072 $taxes{'recur'} = [ @taxes ];
1073 $taxes{$_} = [ @taxes ] foreach (@classes);
1075 # # maybe eliminate this entirely, along with all the 0% records
1076 # unless ( @taxes ) {
1078 # "fatal: can't find tax rate for state/county/country/taxclass ".
1079 # join('/', map $taxhash{$_}, qw(state county country taxclass) );
1082 } #if $conf->exists('enable_taxproducts') ...
1087 my $separate = $conf->exists('separate_usage');
1088 my $temp_pkg = new FS::cust_pkg { pkgpart => $real_pkgpart };
1089 my $usage_mandate = $temp_pkg->part_pkg->option('usage_mandate', 'Hush!');
1090 my $section = $temp_pkg->part_pkg->categoryname;
1091 if ( $separate || $section || $usage_mandate ) {
1093 my %hash = ( 'section' => $section );
1095 $section = $temp_pkg->part_pkg->option('usage_section', 'Hush!');
1096 my $summary = $temp_pkg->part_pkg->option('summarize_usage', 'Hush!');
1098 push @display, new FS::cust_bill_pkg_display { type => 'S', %hash };
1099 push @display, new FS::cust_bill_pkg_display { type => 'R', %hash };
1101 push @display, new FS::cust_bill_pkg_display
1104 ( ( $usage_mandate ) ? ( 'summary' => 'Y' ) : () ),
1108 if ($separate && $section && $summary) {
1109 push @display, new FS::cust_bill_pkg_display { type => 'U',
1114 if ($usage_mandate || $section && $summary) {
1115 $hash{post_total} = 'Y';
1118 if ($separate || $usage_mandate) {
1119 $hash{section} = $section if ($separate || $usage_mandate);
1120 push @display, new FS::cust_bill_pkg_display { type => 'U', %hash };
1124 $cust_bill_pkg->set('display', \@display);
1126 my %tax_cust_bill_pkg = $cust_bill_pkg->disintegrate;
1127 foreach my $key (keys %tax_cust_bill_pkg) {
1128 my @taxes = @{ $taxes{$key} || [] };
1129 my $tax_cust_bill_pkg = $tax_cust_bill_pkg{$key};
1131 my %localtaxlisthash = ();
1132 foreach my $tax ( @taxes ) {
1134 my $taxname = ref( $tax ). ' '. $tax->taxnum;
1135 # $taxname .= ' pkgnum'. $cust_pkg->pkgnum.
1136 # ' locationnum'. $cust_pkg->locationnum
1137 # if $conf->exists('tax-pkg_address') && $cust_pkg->locationnum;
1139 $taxlisthash->{ $taxname } ||= [ $tax ];
1140 push @{ $taxlisthash->{ $taxname } }, $tax_cust_bill_pkg;
1142 $localtaxlisthash{ $taxname } ||= [ $tax ];
1143 push @{ $localtaxlisthash{ $taxname } }, $tax_cust_bill_pkg;
1147 warn "finding taxed taxes...\n" if $DEBUG > 2;
1148 foreach my $tax ( keys %localtaxlisthash ) {
1149 my $tax_object = shift @{ $localtaxlisthash{$tax} };
1150 warn "found possible taxed tax ". $tax_object->taxname. " we call $tax\n"
1152 next unless $tax_object->can('tax_on_tax');
1154 foreach my $tot ( $tax_object->tax_on_tax( $self ) ) {
1155 my $totname = ref( $tot ). ' '. $tot->taxnum;
1157 warn "checking $totname which we call ". $tot->taxname. " as applicable\n"
1159 next unless exists( $localtaxlisthash{ $totname } ); # only increase
1161 warn "adding $totname to taxed taxes\n" if $DEBUG > 2;
1162 my $hashref_or_error =
1163 $tax_object->taxline( $localtaxlisthash{$tax},
1164 'custnum' => $self->custnum,
1165 'invoice_time' => $invoice_time,
1167 return $hashref_or_error
1168 unless ref($hashref_or_error);
1170 $taxlisthash->{ $totname } ||= [ $tot ];
1171 push @{ $taxlisthash->{ $totname } }, $hashref_or_error->{amount};
1183 my $part_pkg = shift;
1187 my $geocode = $self->geocode('cch');
1189 my @taxclassnums = map { $_->taxclassnum }
1190 $part_pkg->part_pkg_taxoverride($class);
1192 unless (@taxclassnums) {
1193 @taxclassnums = map { $_->taxclassnum }
1194 grep { $_->taxable eq 'Y' }
1195 $part_pkg->part_pkg_taxrate('cch', $geocode, $class);
1197 warn "Found taxclassnum values of ". join(',', @taxclassnums)
1202 join(' OR ', map { "taxclassnum = $_" } @taxclassnums ). ")";
1204 @taxes = qsearch({ 'table' => 'tax_rate',
1205 'hashref' => { 'geocode' => $geocode, },
1206 'extra_sql' => $extra_sql,
1208 if scalar(@taxclassnums);
1210 warn "Found taxes ".
1211 join(',', map{ ref($_). " ". $_->get($_->primary_key) } @taxes). "\n"
1218 =item collect [ HASHREF | OPTION => VALUE ... ]
1220 (Attempt to) collect money for this customer's outstanding invoices (see
1221 L<FS::cust_bill>). Usually used after the bill method.
1223 Actions are now triggered by billing events; see L<FS::part_event> and the
1224 billing events web interface. Old-style invoice events (see
1225 L<FS::part_bill_event>) have been deprecated.
1227 If there is an error, returns the error, otherwise returns false.
1229 Options are passed as name-value pairs.
1231 Currently available options are:
1237 Use this time when deciding when to print invoices and late notices on those invoices. The default is now. It is specified as a UNIX timestamp; see L<perlfunc/"time">). Also see L<Time::Local> and L<Date::Parse> for conversion functions.
1241 Retry card/echeck/LEC transactions even when not scheduled by invoice events.
1245 "1d" for the traditional, daily events (the default), or "1m" for the new monthly events (part_event.check_freq)
1249 set true to surpress email card/ACH decline notices.
1253 Debugging level. Default is 0 (no debugging), or can be set to 1 (passed-in options), 2 (traces progress), 3 (more information), or 4 (include full search queries)
1259 # allows for one time override of normal customer billing method
1264 my( $self, %options ) = @_;
1265 my $invoice_time = $options{'invoice_time'} || time;
1268 local $SIG{HUP} = 'IGNORE';
1269 local $SIG{INT} = 'IGNORE';
1270 local $SIG{QUIT} = 'IGNORE';
1271 local $SIG{TERM} = 'IGNORE';
1272 local $SIG{TSTP} = 'IGNORE';
1273 local $SIG{PIPE} = 'IGNORE';
1275 my $oldAutoCommit = $FS::UID::AutoCommit;
1276 local $FS::UID::AutoCommit = 0;
1279 $self->select_for_update; #mutex
1282 my $balance = $self->balance;
1283 warn "$me collect customer ". $self->custnum. ": balance $balance\n"
1286 if ( exists($options{'retry_card'}) ) {
1287 carp 'retry_card option passed to collect is deprecated; use retry';
1288 $options{'retry'} ||= $options{'retry_card'};
1290 if ( exists($options{'retry'}) && $options{'retry'} ) {
1291 my $error = $self->retry_realtime;
1293 $dbh->rollback if $oldAutoCommit;
1298 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1300 #never want to roll back an event just because it returned an error
1301 local $FS::UID::AutoCommit = 1; #$oldAutoCommit;
1303 $self->do_cust_event(
1304 'debug' => ( $options{'debug'} || 0 ),
1305 'time' => $invoice_time,
1306 'check_freq' => $options{'check_freq'},
1307 'stage' => 'collect',
1312 =item retry_realtime
1314 Schedules realtime / batch credit card / electronic check / LEC billing
1315 events for for retry. Useful if card information has changed or manual
1316 retry is desired. The 'collect' method must be called to actually retry
1319 Implementation details: For either this customer, or for each of this
1320 customer's open invoices, changes the status of the first "done" (with
1321 statustext error) realtime processing event to "failed".
1325 sub retry_realtime {
1328 local $SIG{HUP} = 'IGNORE';
1329 local $SIG{INT} = 'IGNORE';
1330 local $SIG{QUIT} = 'IGNORE';
1331 local $SIG{TERM} = 'IGNORE';
1332 local $SIG{TSTP} = 'IGNORE';
1333 local $SIG{PIPE} = 'IGNORE';
1335 my $oldAutoCommit = $FS::UID::AutoCommit;
1336 local $FS::UID::AutoCommit = 0;
1339 #a little false laziness w/due_cust_event (not too bad, really)
1341 my $join = FS::part_event_condition->join_conditions_sql;
1342 my $order = FS::part_event_condition->order_conditions_sql;
1345 . join ( ' OR ' , map {
1346 "( part_event.eventtable = " . dbh->quote($_)
1347 . " AND tablenum IN( SELECT " . dbdef->table($_)->primary_key . " from $_ where custnum = " . dbh->quote( $self->custnum ) . "))" ;
1348 } FS::part_event->eventtables)
1351 #here is the agent virtualization
1352 my $agent_virt = " ( part_event.agentnum IS NULL
1353 OR part_event.agentnum = ". $self->agentnum. ' )';
1355 #XXX this shouldn't be hardcoded, actions should declare it...
1356 my @realtime_events = qw(
1357 cust_bill_realtime_card
1358 cust_bill_realtime_check
1359 cust_bill_realtime_lec
1363 my $is_realtime_event = ' ( '. join(' OR ', map "part_event.action = '$_'",
1368 my @cust_event = qsearchs({
1369 'table' => 'cust_event',
1370 'select' => 'cust_event.*',
1371 'addl_from' => "LEFT JOIN part_event USING ( eventpart ) $join",
1372 'hashref' => { 'status' => 'done' },
1373 'extra_sql' => " AND statustext IS NOT NULL AND statustext != '' ".
1374 " AND $mine AND $is_realtime_event AND $agent_virt $order" # LIMIT 1"
1377 my %seen_invnum = ();
1378 foreach my $cust_event (@cust_event) {
1380 #max one for the customer, one for each open invoice
1381 my $cust_X = $cust_event->cust_X;
1382 next if $seen_invnum{ $cust_event->part_event->eventtable eq 'cust_bill'
1386 or $cust_event->part_event->eventtable eq 'cust_bill'
1389 my $error = $cust_event->retry;
1391 $dbh->rollback if $oldAutoCommit;
1392 return "error scheduling event for retry: $error";
1397 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1402 =item do_cust_event [ HASHREF | OPTION => VALUE ... ]
1404 Runs billing events; see L<FS::part_event> and the billing events web
1407 If there is an error, returns the error, otherwise returns false.
1409 Options are passed as name-value pairs.
1411 Currently available options are:
1417 Use this time when deciding when to print invoices and late notices on those invoices. The default is now. It is specified as a UNIX timestamp; see L<perlfunc/"time">). Also see L<Time::Local> and L<Date::Parse> for conversion functions.
1421 "1d" for the traditional, daily events (the default), or "1m" for the new monthly events (part_event.check_freq)
1425 "collect" (the default) or "pre-bill"
1429 set true to surpress email card/ACH decline notices.
1433 Debugging level. Default is 0 (no debugging), or can be set to 1 (passed-in options), 2 (traces progress), 3 (more information), or 4 (include full search queries)
1439 # allows for one time override of normal customer billing method
1443 # Retry card/echeck/LEC transactions even when not scheduled by invoice events.
1446 my( $self, %options ) = @_;
1447 my $time = $options{'time'} || time;
1450 local $SIG{HUP} = 'IGNORE';
1451 local $SIG{INT} = 'IGNORE';
1452 local $SIG{QUIT} = 'IGNORE';
1453 local $SIG{TERM} = 'IGNORE';
1454 local $SIG{TSTP} = 'IGNORE';
1455 local $SIG{PIPE} = 'IGNORE';
1457 my $oldAutoCommit = $FS::UID::AutoCommit;
1458 local $FS::UID::AutoCommit = 0;
1461 $self->select_for_update; #mutex
1464 my $balance = $self->balance;
1465 warn "$me do_cust_event customer ". $self->custnum. ": balance $balance\n"
1468 # if ( exists($options{'retry_card'}) ) {
1469 # carp 'retry_card option passed to collect is deprecated; use retry';
1470 # $options{'retry'} ||= $options{'retry_card'};
1472 # if ( exists($options{'retry'}) && $options{'retry'} ) {
1473 # my $error = $self->retry_realtime;
1475 # $dbh->rollback if $oldAutoCommit;
1480 # false laziness w/pay_batch::import_results
1482 my $due_cust_event = $self->due_cust_event(
1483 'debug' => ( $options{'debug'} || 0 ),
1485 'check_freq' => $options{'check_freq'},
1486 'stage' => ( $options{'stage'} || 'collect' ),
1488 unless( ref($due_cust_event) ) {
1489 $dbh->rollback if $oldAutoCommit;
1490 return $due_cust_event;
1493 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1494 #never want to roll back an event just because it or a different one
1496 local $FS::UID::AutoCommit = 1; #$oldAutoCommit;
1498 foreach my $cust_event ( @$due_cust_event ) {
1502 #re-eval event conditions (a previous event could have changed things)
1503 unless ( $cust_event->test_conditions( 'time' => $time ) ) {
1504 #don't leave stray "new/locked" records around
1505 my $error = $cust_event->delete;
1506 return $error if $error;
1511 local $FS::cust_main::Billing_Realtime::realtime_bop_decline_quiet = 1
1512 if $options{'quiet'};
1513 warn " running cust_event ". $cust_event->eventnum. "\n"
1516 #if ( my $error = $cust_event->do_event(%options) ) { #XXX %options?
1517 if ( my $error = $cust_event->do_event() ) {
1518 #XXX wtf is this? figure out a proper dealio with return value
1530 =item due_cust_event [ HASHREF | OPTION => VALUE ... ]
1532 Inserts database records for and returns an ordered listref of new events due
1533 for this customer, as FS::cust_event objects (see L<FS::cust_event>). If no
1534 events are due, an empty listref is returned. If there is an error, returns a
1535 scalar error message.
1537 To actually run the events, call each event's test_condition method, and if
1538 still true, call the event's do_event method.
1540 Options are passed as a hashref or as a list of name-value pairs. Available
1547 Search only for events of this check frequency (how often events of this type are checked); currently "1d" (daily, the default) and "1m" (monthly) are recognized.
1551 "collect" (the default) or "pre-bill"
1555 "Current time" for the events.
1559 Debugging level. Default is 0 (no debugging), or can be set to 1 (passed-in options), 2 (traces progress), 3 (more information), or 4 (include full search queries)
1563 Only return events for the specified eventtable (by default, events of all eventtables are returned)
1567 Explicitly pass the objects to be tested (typically used with eventtable).
1571 Set to true to return the objects, but not actually insert them into the
1578 sub due_cust_event {
1580 my %opt = ref($_[0]) ? %{ $_[0] } : @_;
1583 #my $DEBUG = $opt{'debug'}
1584 local($DEBUG) = $opt{'debug'}
1585 if defined($opt{'debug'}) && $opt{'debug'} > $DEBUG;
1587 warn "$me due_cust_event called with options ".
1588 join(', ', map { "$_: $opt{$_}" } keys %opt). "\n"
1591 $opt{'time'} ||= time;
1593 local $SIG{HUP} = 'IGNORE';
1594 local $SIG{INT} = 'IGNORE';
1595 local $SIG{QUIT} = 'IGNORE';
1596 local $SIG{TERM} = 'IGNORE';
1597 local $SIG{TSTP} = 'IGNORE';
1598 local $SIG{PIPE} = 'IGNORE';
1600 my $oldAutoCommit = $FS::UID::AutoCommit;
1601 local $FS::UID::AutoCommit = 0;
1604 $self->select_for_update #mutex
1605 unless $opt{testonly};
1608 # find possible events (initial search)
1611 my @cust_event = ();
1613 my @eventtable = $opt{'eventtable'}
1614 ? ( $opt{'eventtable'} )
1615 : FS::part_event->eventtables_runorder;
1617 my $check_freq = $opt{'check_freq'} || '1d';
1619 foreach my $eventtable ( @eventtable ) {
1622 if ( $opt{'objects'} ) {
1624 @objects = @{ $opt{'objects'} };
1628 #my @objects = $self->$eventtable(); # sub cust_main { @{ [ $self ] }; }
1629 if ( $eventtable eq 'cust_main' ) {
1630 @objects = ( $self );
1634 "LEFT JOIN cust_main USING ( custnum )";
1636 #some false laziness w/Cron::bill bill_where
1638 my $join = FS::part_event_condition->join_conditions_sql( $eventtable);
1639 my $where = FS::part_event_condition->where_conditions_sql($eventtable,
1640 'time'=>$opt{'time'},
1642 $where = $where ? "AND $where" : '';
1644 my $are_part_event =
1645 "EXISTS ( SELECT 1 FROM part_event $join
1646 WHERE check_freq = '$check_freq'
1647 AND eventtable = '$eventtable'
1648 AND ( disabled = '' OR disabled IS NULL )
1654 @objects = $self->$eventtable(
1655 'addl_from' => $cm_join,
1656 'extra_sql' => " AND $are_part_event",
1662 my @e_cust_event = ();
1664 my $cross = "CROSS JOIN $eventtable";
1665 $cross .= ' LEFT JOIN cust_main USING ( custnum )'
1666 unless $eventtable eq 'cust_main';
1668 foreach my $object ( @objects ) {
1670 #this first search uses the condition_sql magic for optimization.
1671 #the more possible events we can eliminate in this step the better
1673 my $cross_where = '';
1674 my $pkey = $object->primary_key;
1675 $cross_where = "$eventtable.$pkey = ". $object->$pkey();
1677 my $join = FS::part_event_condition->join_conditions_sql( $eventtable );
1679 FS::part_event_condition->where_conditions_sql( $eventtable,
1680 'time'=>$opt{'time'}
1682 my $order = FS::part_event_condition->order_conditions_sql( $eventtable );
1684 $extra_sql = "AND $extra_sql" if $extra_sql;
1686 #here is the agent virtualization
1687 $extra_sql .= " AND ( part_event.agentnum IS NULL
1688 OR part_event.agentnum = ". $self->agentnum. ' )';
1690 $extra_sql .= " $order";
1692 warn "searching for events for $eventtable ". $object->$pkey. "\n"
1693 if $opt{'debug'} > 2;
1694 my @part_event = qsearch( {
1695 'debug' => ( $opt{'debug'} > 3 ? 1 : 0 ),
1696 'select' => 'part_event.*',
1697 'table' => 'part_event',
1698 'addl_from' => "$cross $join",
1699 'hashref' => { 'check_freq' => $check_freq,
1700 'eventtable' => $eventtable,
1703 'extra_sql' => "AND $cross_where $extra_sql",
1707 my $pkey = $object->primary_key;
1708 warn " ". scalar(@part_event).
1709 " possible events found for $eventtable ". $object->$pkey(). "\n";
1712 push @e_cust_event, map { $_->new_cust_event($object) } @part_event;
1716 warn " ". scalar(@e_cust_event).
1717 " subtotal possible cust events found for $eventtable\n"
1720 push @cust_event, @e_cust_event;
1724 warn " ". scalar(@cust_event).
1725 " total possible cust events found in initial search\n"
1733 $opt{stage} ||= 'collect';
1735 grep { my $stage = $_->part_event->event_stage;
1736 $opt{stage} eq $stage or ( ! $stage && $opt{stage} eq 'collect' )
1746 @cust_event = grep $_->test_conditions( 'time' => $opt{'time'},
1747 'stats_hashref' => \%unsat ),
1750 warn " ". scalar(@cust_event). " cust events left satisfying conditions\n"
1753 warn " invalid conditions not eliminated with condition_sql:\n".
1754 join('', map " $_: ".$unsat{$_}."\n", keys %unsat )
1755 if keys %unsat && $DEBUG; # > 1;
1761 unless( $opt{testonly} ) {
1762 foreach my $cust_event ( @cust_event ) {
1764 my $error = $cust_event->insert();
1766 $dbh->rollback if $oldAutoCommit;
1773 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1779 warn " returning events: ". Dumper(@cust_event). "\n"
1786 =item apply_payments_and_credits [ OPTION => VALUE ... ]
1788 Applies unapplied payments and credits.
1790 In most cases, this new method should be used in place of sequential
1791 apply_payments and apply_credits methods.
1793 A hash of optional arguments may be passed. Currently "manual" is supported.
1794 If true, a payment receipt is sent instead of a statement when
1795 'payment_receipt_email' configuration option is set.
1797 If there is an error, returns the error, otherwise returns false.
1801 sub apply_payments_and_credits {
1802 my( $self, %options ) = @_;
1804 local $SIG{HUP} = 'IGNORE';
1805 local $SIG{INT} = 'IGNORE';
1806 local $SIG{QUIT} = 'IGNORE';
1807 local $SIG{TERM} = 'IGNORE';
1808 local $SIG{TSTP} = 'IGNORE';
1809 local $SIG{PIPE} = 'IGNORE';
1811 my $oldAutoCommit = $FS::UID::AutoCommit;
1812 local $FS::UID::AutoCommit = 0;
1815 $self->select_for_update; #mutex
1817 foreach my $cust_bill ( $self->open_cust_bill ) {
1818 my $error = $cust_bill->apply_payments_and_credits(%options);
1820 $dbh->rollback if $oldAutoCommit;
1821 return "Error applying: $error";
1825 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1830 =item apply_credits OPTION => VALUE ...
1832 Applies (see L<FS::cust_credit_bill>) unapplied credits (see L<FS::cust_credit>)
1833 to outstanding invoice balances in chronological order (or reverse
1834 chronological order if the I<order> option is set to B<newest>) and returns the
1835 value of any remaining unapplied credits available for refund (see
1836 L<FS::cust_refund>).
1838 Dies if there is an error.
1846 local $SIG{HUP} = 'IGNORE';
1847 local $SIG{INT} = 'IGNORE';
1848 local $SIG{QUIT} = 'IGNORE';
1849 local $SIG{TERM} = 'IGNORE';
1850 local $SIG{TSTP} = 'IGNORE';
1851 local $SIG{PIPE} = 'IGNORE';
1853 my $oldAutoCommit = $FS::UID::AutoCommit;
1854 local $FS::UID::AutoCommit = 0;
1857 $self->select_for_update; #mutex
1859 unless ( $self->total_unapplied_credits ) {
1860 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1864 my @credits = sort { $b->_date <=> $a->_date} (grep { $_->credited > 0 }
1865 qsearch('cust_credit', { 'custnum' => $self->custnum } ) );
1867 my @invoices = $self->open_cust_bill;
1868 @invoices = sort { $b->_date <=> $a->_date } @invoices
1869 if defined($opt{'order'}) && $opt{'order'} eq 'newest';
1871 if ( $conf->exists('pkg-balances') ) {
1872 # limit @credits to those w/ a pkgnum grepped from $self
1874 foreach my $i (@invoices) {
1875 foreach my $li ( $i->cust_bill_pkg ) {
1876 $pkgnums{$li->pkgnum} = 1;
1879 @credits = grep { ! $_->pkgnum || $pkgnums{$_->pkgnum} } @credits;
1884 foreach my $cust_bill ( @invoices ) {
1886 if ( !defined($credit) || $credit->credited == 0) {
1887 $credit = pop @credits or last;
1891 if ( $conf->exists('pkg-balances') && $credit->pkgnum ) {
1892 $owed = $cust_bill->owed_pkgnum($credit->pkgnum);
1894 $owed = $cust_bill->owed;
1896 unless ( $owed > 0 ) {
1897 push @credits, $credit;
1901 my $amount = min( $credit->credited, $owed );
1903 my $cust_credit_bill = new FS::cust_credit_bill ( {
1904 'crednum' => $credit->crednum,
1905 'invnum' => $cust_bill->invnum,
1906 'amount' => $amount,
1908 $cust_credit_bill->pkgnum( $credit->pkgnum )
1909 if $conf->exists('pkg-balances') && $credit->pkgnum;
1910 my $error = $cust_credit_bill->insert;
1912 $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
1916 redo if ($cust_bill->owed > 0) && ! $conf->exists('pkg-balances');
1920 my $total_unapplied_credits = $self->total_unapplied_credits;
1922 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1924 return $total_unapplied_credits;
1927 =item apply_payments [ OPTION => VALUE ... ]
1929 Applies (see L<FS::cust_bill_pay>) unapplied payments (see L<FS::cust_pay>)
1930 to outstanding invoice balances in chronological order.
1932 #and returns the value of any remaining unapplied payments.
1934 A hash of optional arguments may be passed. Currently "manual" is supported.
1935 If true, a payment receipt is sent instead of a statement when
1936 'payment_receipt_email' configuration option is set.
1938 Dies if there is an error.
1942 sub apply_payments {
1943 my( $self, %options ) = @_;
1945 local $SIG{HUP} = 'IGNORE';
1946 local $SIG{INT} = 'IGNORE';
1947 local $SIG{QUIT} = 'IGNORE';
1948 local $SIG{TERM} = 'IGNORE';
1949 local $SIG{TSTP} = 'IGNORE';
1950 local $SIG{PIPE} = 'IGNORE';
1952 my $oldAutoCommit = $FS::UID::AutoCommit;
1953 local $FS::UID::AutoCommit = 0;
1956 $self->select_for_update; #mutex
1960 my @payments = sort { $b->_date <=> $a->_date }
1961 grep { $_->unapplied > 0 }
1964 my @invoices = sort { $a->_date <=> $b->_date}
1965 grep { $_->owed > 0 }
1968 if ( $conf->exists('pkg-balances') ) {
1969 # limit @payments to those w/ a pkgnum grepped from $self
1971 foreach my $i (@invoices) {
1972 foreach my $li ( $i->cust_bill_pkg ) {
1973 $pkgnums{$li->pkgnum} = 1;
1976 @payments = grep { ! $_->pkgnum || $pkgnums{$_->pkgnum} } @payments;
1981 foreach my $cust_bill ( @invoices ) {
1983 if ( !defined($payment) || $payment->unapplied == 0 ) {
1984 $payment = pop @payments or last;
1988 if ( $conf->exists('pkg-balances') && $payment->pkgnum ) {
1989 $owed = $cust_bill->owed_pkgnum($payment->pkgnum);
1991 $owed = $cust_bill->owed;
1993 unless ( $owed > 0 ) {
1994 push @payments, $payment;
1998 my $amount = min( $payment->unapplied, $owed );
2000 my $cust_bill_pay = new FS::cust_bill_pay ( {
2001 'paynum' => $payment->paynum,
2002 'invnum' => $cust_bill->invnum,
2003 'amount' => $amount,
2005 $cust_bill_pay->pkgnum( $payment->pkgnum )
2006 if $conf->exists('pkg-balances') && $payment->pkgnum;
2007 my $error = $cust_bill_pay->insert(%options);
2009 $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
2013 redo if ( $cust_bill->owed > 0) && ! $conf->exists('pkg-balances');
2017 my $total_unapplied_payments = $self->total_unapplied_payments;
2019 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2021 return $total_unapplied_payments;