4 use vars qw( @ISA $conf $Debug $import );
8 eval "use Time::Local;";
9 die "Time::Local minimum version 1.05 required with Perl versions before 5.6"
10 if $] < 5.006 && !defined($Time::Local::VERSION);
11 eval "use Time::Local qw(timelocal timelocal_nocheck);";
15 use Business::CreditCard;
16 use FS::UID qw( getotaker dbh );
17 use FS::Record qw( qsearchs qsearch dbdef );
20 use FS::cust_bill_pkg;
23 use FS::part_referral;
24 use FS::cust_main_county;
26 use FS::cust_main_invoice;
27 use FS::cust_credit_bill;
28 use FS::cust_bill_pay;
29 use FS::prepay_credit;
32 use FS::part_bill_event;
33 use FS::cust_bill_event;
34 use FS::cust_tax_exempt;
36 use FS::Msgcat qw(gettext);
38 @ISA = qw( FS::Record );
45 #ask FS::UID to run this stuff for us later
46 $FS::UID::callback{'FS::cust_main'} = sub {
48 #yes, need it for stuff below (prolly should be cached)
53 my ( $hashref, $cache ) = @_;
54 if ( exists $hashref->{'pkgnum'} ) {
55 # #@{ $self->{'_pkgnum'} } = ();
56 my $subcache = $cache->subcache( 'pkgnum', 'cust_pkg', $hashref->{custnum});
57 $self->{'_pkgnum'} = $subcache;
58 #push @{ $self->{'_pkgnum'} },
59 FS::cust_pkg->new_or_cached($hashref, $subcache) if $hashref->{pkgnum};
65 FS::cust_main - Object methods for cust_main records
71 $record = new FS::cust_main \%hash;
72 $record = new FS::cust_main { 'column' => 'value' };
74 $error = $record->insert;
76 $error = $new_record->replace($old_record);
78 $error = $record->delete;
80 $error = $record->check;
82 @cust_pkg = $record->all_pkgs;
84 @cust_pkg = $record->ncancelled_pkgs;
86 @cust_pkg = $record->suspended_pkgs;
88 $error = $record->bill;
89 $error = $record->bill %options;
90 $error = $record->bill 'time' => $time;
92 $error = $record->collect;
93 $error = $record->collect %options;
94 $error = $record->collect 'invoice_time' => $time,
95 'batch_card' => 'yes',
96 'report_badcard' => 'yes',
101 An FS::cust_main object represents a customer. FS::cust_main inherits from
102 FS::Record. The following fields are currently supported:
106 =item custnum - primary key (assigned automatically for new customers)
108 =item agentnum - agent (see L<FS::agent>)
110 =item refnum - Advertising source (see L<FS::part_referral>)
116 =item ss - social security number (optional)
118 =item company - (optional)
122 =item address2 - (optional)
126 =item county - (optional, see L<FS::cust_main_county>)
128 =item state - (see L<FS::cust_main_county>)
132 =item country - (see L<FS::cust_main_county>)
134 =item daytime - phone (optional)
136 =item night - phone (optional)
138 =item fax - phone (optional)
140 =item ship_first - name
142 =item ship_last - name
144 =item ship_company - (optional)
148 =item ship_address2 - (optional)
152 =item ship_county - (optional, see L<FS::cust_main_county>)
154 =item ship_state - (see L<FS::cust_main_county>)
158 =item ship_country - (see L<FS::cust_main_county>)
160 =item ship_daytime - phone (optional)
162 =item ship_night - phone (optional)
164 =item ship_fax - phone (optional)
166 =item payby - `CARD' (credit cards), `CHEK' (electronic check), `LECB' (Phone bill billing), `BILL' (billing), `COMP' (free), or `PREPAY' (special billing type: applies a credit - see L<FS::prepay_credit> and sets billing type to BILL)
168 =item payinfo - card number, P.O., comp issuer (4-8 lowercase alphanumerics; think username) or prepayment identifier (see L<FS::prepay_credit>)
170 =item paydate - expiration date, mm/yyyy, m/yyyy, mm/yy or m/yy
172 =item payname - name on card or billing name
174 =item tax - tax exempt, empty or `Y'
176 =item otaker - order taker (assigned automatically, see L<FS::UID>)
178 =item comments - comments (optional)
188 Creates a new customer. To add the customer to the database, see L<"insert">.
190 Note that this stores the hash reference, not a distinct copy of the hash it
191 points to. You can ask the object for a copy with the I<hash> method.
195 sub table { 'cust_main'; }
197 =item insert [ CUST_PKG_HASHREF [ , INVOICING_LIST_ARYREF ] [ , OPTION => VALUE ... ] ]
199 Adds this customer to the database. If there is an error, returns the error,
200 otherwise returns false.
202 CUST_PKG_HASHREF: If you pass a Tie::RefHash data structure to the insert
203 method containing FS::cust_pkg and FS::svc_I<tablename> objects, all records
204 are inserted atomicly, or the transaction is rolled back. Passing an empty
205 hash reference is equivalent to not supplying this parameter. There should be
206 a better explanation of this, but until then, here's an example:
209 tie %hash, 'Tie::RefHash'; #this part is important
211 $cust_pkg => [ $svc_acct ],
214 $cust_main->insert( \%hash );
216 INVOICING_LIST_ARYREF: If you pass an arrarref to the insert method, it will
217 be set as the invoicing list (see L<"invoicing_list">). Errors return as
218 expected and rollback the entire transaction; it is not necessary to call
219 check_invoicing_list first. The invoicing_list is set after the records in the
220 CUST_PKG_HASHREF above are inserted, so it is now possible to set an
221 invoicing_list destination to the newly-created svc_acct. Here's an example:
223 $cust_main->insert( {}, [ $email, 'POST' ] );
225 Currently available options are: I<noexport>
227 If I<noexport> is set true, no provisioning jobs (exports) are scheduled.
228 (You can schedule them later with the B<reexport> method.)
234 my $cust_pkgs = @_ ? shift : {};
235 my $invoicing_list = @_ ? shift : '';
238 local $SIG{HUP} = 'IGNORE';
239 local $SIG{INT} = 'IGNORE';
240 local $SIG{QUIT} = 'IGNORE';
241 local $SIG{TERM} = 'IGNORE';
242 local $SIG{TSTP} = 'IGNORE';
243 local $SIG{PIPE} = 'IGNORE';
245 my $oldAutoCommit = $FS::UID::AutoCommit;
246 local $FS::UID::AutoCommit = 0;
251 if ( $self->payby eq 'PREPAY' ) {
252 $self->payby('BILL');
253 my $prepay_credit = qsearchs(
255 { 'identifier' => $self->payinfo },
259 warn "WARNING: can't find pre-found prepay_credit: ". $self->payinfo
260 unless $prepay_credit;
261 $amount = $prepay_credit->amount;
262 $seconds = $prepay_credit->seconds;
263 my $error = $prepay_credit->delete;
265 $dbh->rollback if $oldAutoCommit;
266 return "removing prepay_credit (transaction rolled back): $error";
270 my $error = $self->SUPER::insert;
272 $dbh->rollback if $oldAutoCommit;
273 #return "inserting cust_main record (transaction rolled back): $error";
278 if ( $invoicing_list ) {
279 $error = $self->check_invoicing_list( $invoicing_list );
281 $dbh->rollback if $oldAutoCommit;
282 return "checking invoicing_list (transaction rolled back): $error";
284 $self->invoicing_list( $invoicing_list );
288 #local $FS::svc_Common::noexport_hack = 1 if $options{'noexport'};
289 $error = $self->order_pkgs($cust_pkgs, \$seconds, %options);
291 $dbh->rollback if $oldAutoCommit;
296 $dbh->rollback if $oldAutoCommit;
297 return "No svc_acct record to apply pre-paid time";
301 my $cust_credit = new FS::cust_credit {
302 'custnum' => $self->custnum,
305 $error = $cust_credit->insert;
307 $dbh->rollback if $oldAutoCommit;
308 return "inserting credit (transaction rolled back): $error";
312 $error = $self->queue_fuzzyfiles_update;
314 $dbh->rollback if $oldAutoCommit;
315 return "updating fuzzy search cache: $error";
318 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
323 =item order_pkgs HASHREF, [ , OPTION => VALUE ... ] ]
325 Like the insert method on an existing record, this method orders a package
326 and included services atomicaly. Pass a Tie::RefHash data structure to this
327 method containing FS::cust_pkg and FS::svc_I<tablename> objects. There should
328 be a better explanation of this, but until then, here's an example:
331 tie %hash, 'Tie::RefHash'; #this part is important
333 $cust_pkg => [ $svc_acct ],
336 $cust_main->order_pkgs( \%hash, 'noexport'=>1 );
338 Currently available options are: I<noexport>
340 If I<noexport> is set true, no provisioning jobs (exports) are scheduled.
341 (You can schedule them later with the B<reexport> method for each
342 cust_pkg object. Using the B<reexport> method on the cust_main object is not
343 recommended, as existing services will also be reexported.)
349 my $cust_pkgs = shift;
353 local $SIG{HUP} = 'IGNORE';
354 local $SIG{INT} = 'IGNORE';
355 local $SIG{QUIT} = 'IGNORE';
356 local $SIG{TERM} = 'IGNORE';
357 local $SIG{TSTP} = 'IGNORE';
358 local $SIG{PIPE} = 'IGNORE';
360 my $oldAutoCommit = $FS::UID::AutoCommit;
361 local $FS::UID::AutoCommit = 0;
364 local $FS::svc_Common::noexport_hack = 1 if $options{'noexport'};
366 foreach my $cust_pkg ( keys %$cust_pkgs ) {
367 $cust_pkg->custnum( $self->custnum );
368 my $error = $cust_pkg->insert;
370 $dbh->rollback if $oldAutoCommit;
371 return "inserting cust_pkg (transaction rolled back): $error";
373 foreach my $svc_something ( @{$cust_pkgs->{$cust_pkg}} ) {
374 $svc_something->pkgnum( $cust_pkg->pkgnum );
375 if ( $seconds && $$seconds && $svc_something->isa('FS::svc_acct') ) {
376 $svc_something->seconds( $svc_something->seconds + $$seconds );
379 $error = $svc_something->insert;
381 $dbh->rollback if $oldAutoCommit;
382 #return "inserting svc_ (transaction rolled back): $error";
388 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
394 Re-schedules all exports by calling the B<reexport> method of all associated
395 packages (see L<FS::cust_pkg>). If there is an error, returns the error;
396 otherwise returns false.
403 local $SIG{HUP} = 'IGNORE';
404 local $SIG{INT} = 'IGNORE';
405 local $SIG{QUIT} = 'IGNORE';
406 local $SIG{TERM} = 'IGNORE';
407 local $SIG{TSTP} = 'IGNORE';
408 local $SIG{PIPE} = 'IGNORE';
410 my $oldAutoCommit = $FS::UID::AutoCommit;
411 local $FS::UID::AutoCommit = 0;
414 foreach my $cust_pkg ( $self->ncancelled_pkgs ) {
415 my $error = $cust_pkg->reexport;
417 $dbh->rollback if $oldAutoCommit;
422 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
427 =item delete NEW_CUSTNUM
429 This deletes the customer. If there is an error, returns the error, otherwise
432 This will completely remove all traces of the customer record. This is not
433 what you want when a customer cancels service; for that, cancel all of the
434 customer's packages (see L</cancel>).
436 If the customer has any uncancelled packages, you need to pass a new (valid)
437 customer number for those packages to be transferred to. Cancelled packages
438 will be deleted. Did I mention that this is NOT what you want when a customer
439 cancels service and that you really should be looking see L<FS::cust_pkg/cancel>?
441 You can't delete a customer with invoices (see L<FS::cust_bill>),
442 or credits (see L<FS::cust_credit>), payments (see L<FS::cust_pay>) or
443 refunds (see L<FS::cust_refund>).
450 local $SIG{HUP} = 'IGNORE';
451 local $SIG{INT} = 'IGNORE';
452 local $SIG{QUIT} = 'IGNORE';
453 local $SIG{TERM} = 'IGNORE';
454 local $SIG{TSTP} = 'IGNORE';
455 local $SIG{PIPE} = 'IGNORE';
457 my $oldAutoCommit = $FS::UID::AutoCommit;
458 local $FS::UID::AutoCommit = 0;
461 if ( qsearch( 'cust_bill', { 'custnum' => $self->custnum } ) ) {
462 $dbh->rollback if $oldAutoCommit;
463 return "Can't delete a customer with invoices";
465 if ( qsearch( 'cust_credit', { 'custnum' => $self->custnum } ) ) {
466 $dbh->rollback if $oldAutoCommit;
467 return "Can't delete a customer with credits";
469 if ( qsearch( 'cust_pay', { 'custnum' => $self->custnum } ) ) {
470 $dbh->rollback if $oldAutoCommit;
471 return "Can't delete a customer with payments";
473 if ( qsearch( 'cust_refund', { 'custnum' => $self->custnum } ) ) {
474 $dbh->rollback if $oldAutoCommit;
475 return "Can't delete a customer with refunds";
478 my @cust_pkg = $self->ncancelled_pkgs;
480 my $new_custnum = shift;
481 unless ( qsearchs( 'cust_main', { 'custnum' => $new_custnum } ) ) {
482 $dbh->rollback if $oldAutoCommit;
483 return "Invalid new customer number: $new_custnum";
485 foreach my $cust_pkg ( @cust_pkg ) {
486 my %hash = $cust_pkg->hash;
487 $hash{'custnum'} = $new_custnum;
488 my $new_cust_pkg = new FS::cust_pkg ( \%hash );
489 my $error = $new_cust_pkg->replace($cust_pkg);
491 $dbh->rollback if $oldAutoCommit;
496 my @cancelled_cust_pkg = $self->all_pkgs;
497 foreach my $cust_pkg ( @cancelled_cust_pkg ) {
498 my $error = $cust_pkg->delete;
500 $dbh->rollback if $oldAutoCommit;
505 foreach my $cust_main_invoice ( #(email invoice destinations, not invoices)
506 qsearch( 'cust_main_invoice', { 'custnum' => $self->custnum } )
508 my $error = $cust_main_invoice->delete;
510 $dbh->rollback if $oldAutoCommit;
515 my $error = $self->SUPER::delete;
517 $dbh->rollback if $oldAutoCommit;
521 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
526 =item replace OLD_RECORD [ INVOICING_LIST_ARYREF ]
528 Replaces the OLD_RECORD with this one in the database. If there is an error,
529 returns the error, otherwise returns false.
531 INVOICING_LIST_ARYREF: If you pass an arrarref to the insert method, it will
532 be set as the invoicing list (see L<"invoicing_list">). Errors return as
533 expected and rollback the entire transaction; it is not necessary to call
534 check_invoicing_list first. Here's an example:
536 $new_cust_main->replace( $old_cust_main, [ $email, 'POST' ] );
545 local $SIG{HUP} = 'IGNORE';
546 local $SIG{INT} = 'IGNORE';
547 local $SIG{QUIT} = 'IGNORE';
548 local $SIG{TERM} = 'IGNORE';
549 local $SIG{TSTP} = 'IGNORE';
550 local $SIG{PIPE} = 'IGNORE';
552 if ( $self->payby eq 'COMP' && $self->payby ne $old->payby
553 && $conf->config('users-allow_comp') ) {
554 return "You are not permitted to create complimentary accounts."
555 unless grep { $_ eq getotaker } $conf->config('users-allow_comp');
558 my $oldAutoCommit = $FS::UID::AutoCommit;
559 local $FS::UID::AutoCommit = 0;
562 my $error = $self->SUPER::replace($old);
565 $dbh->rollback if $oldAutoCommit;
569 if ( @param ) { # INVOICING_LIST_ARYREF
570 my $invoicing_list = shift @param;
571 $error = $self->check_invoicing_list( $invoicing_list );
573 $dbh->rollback if $oldAutoCommit;
576 $self->invoicing_list( $invoicing_list );
579 if ( $self->payby =~ /^(CARD|CHEK|LECB)$/ &&
580 grep { $self->get($_) ne $old->get($_) } qw(payinfo paydate payname) ) {
581 # card/check/lec info has changed, want to retry realtime_ invoice events
582 my $error = $self->retry_realtime;
584 $dbh->rollback if $oldAutoCommit;
589 $error = $self->queue_fuzzyfiles_update;
591 $dbh->rollback if $oldAutoCommit;
592 return "updating fuzzy search cache: $error";
595 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
600 =item queue_fuzzyfiles_update
602 Used by insert & replace to update the fuzzy search cache
606 sub queue_fuzzyfiles_update {
609 local $SIG{HUP} = 'IGNORE';
610 local $SIG{INT} = 'IGNORE';
611 local $SIG{QUIT} = 'IGNORE';
612 local $SIG{TERM} = 'IGNORE';
613 local $SIG{TSTP} = 'IGNORE';
614 local $SIG{PIPE} = 'IGNORE';
616 my $oldAutoCommit = $FS::UID::AutoCommit;
617 local $FS::UID::AutoCommit = 0;
620 my $queue = new FS::queue { 'job' => 'FS::cust_main::append_fuzzyfiles' };
621 my $error = $queue->insert($self->getfield('last'), $self->company);
623 $dbh->rollback if $oldAutoCommit;
624 return "queueing job (transaction rolled back): $error";
627 if ( defined $self->dbdef_table->column('ship_last') && $self->ship_last ) {
628 $queue = new FS::queue { 'job' => 'FS::cust_main::append_fuzzyfiles' };
629 $error = $queue->insert($self->getfield('ship_last'), $self->ship_company);
631 $dbh->rollback if $oldAutoCommit;
632 return "queueing job (transaction rolled back): $error";
636 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
643 Checks all fields to make sure this is a valid customer record. If there is
644 an error, returns the error, otherwise returns false. Called by the insert
652 #warn "BEFORE: \n". $self->_dump;
655 $self->ut_numbern('custnum')
656 || $self->ut_number('agentnum')
657 || $self->ut_number('refnum')
658 || $self->ut_name('last')
659 || $self->ut_name('first')
660 || $self->ut_textn('company')
661 || $self->ut_text('address1')
662 || $self->ut_textn('address2')
663 || $self->ut_text('city')
664 || $self->ut_textn('county')
665 || $self->ut_textn('state')
666 || $self->ut_country('country')
667 || $self->ut_anything('comments')
668 || $self->ut_numbern('referral_custnum')
670 #barf. need message catalogs. i18n. etc.
671 $error .= "Please select an advertising source."
672 if $error =~ /^Illegal or empty \(numeric\) refnum: /;
673 return $error if $error;
675 return "Unknown agent"
676 unless qsearchs( 'agent', { 'agentnum' => $self->agentnum } );
678 return "Unknown refnum"
679 unless qsearchs( 'part_referral', { 'refnum' => $self->refnum } );
681 return "Unknown referring custnum ". $self->referral_custnum
682 unless ! $self->referral_custnum
683 || qsearchs( 'cust_main', { 'custnum' => $self->referral_custnum } );
685 if ( $self->ss eq '' ) {
690 $ss =~ /^(\d{3})(\d{2})(\d{4})$/
691 or return "Illegal social security number: ". $self->ss;
692 $self->ss("$1-$2-$3");
696 # bad idea to disable, causes billing to fail because of no tax rates later
697 # unless ( $import ) {
698 unless ( qsearch('cust_main_county', {
699 'country' => $self->country,
702 return "Unknown state/county/country: ".
703 $self->state. "/". $self->county. "/". $self->country
704 unless qsearch('cust_main_county',{
705 'state' => $self->state,
706 'county' => $self->county,
707 'country' => $self->country,
713 $self->ut_phonen('daytime', $self->country)
714 || $self->ut_phonen('night', $self->country)
715 || $self->ut_phonen('fax', $self->country)
716 || $self->ut_zip('zip', $self->country)
718 return $error if $error;
721 last first company address1 address2 city county state zip
722 country daytime night fax
725 if ( defined $self->dbdef_table->column('ship_last') ) {
726 if ( scalar ( grep { $self->getfield($_) ne $self->getfield("ship_$_") }
728 && scalar ( grep { $self->getfield("ship_$_") ne '' } @addfields )
732 $self->ut_name('ship_last')
733 || $self->ut_name('ship_first')
734 || $self->ut_textn('ship_company')
735 || $self->ut_text('ship_address1')
736 || $self->ut_textn('ship_address2')
737 || $self->ut_text('ship_city')
738 || $self->ut_textn('ship_county')
739 || $self->ut_textn('ship_state')
740 || $self->ut_country('ship_country')
742 return $error if $error;
744 #false laziness with above
745 unless ( qsearchs('cust_main_county', {
746 'country' => $self->ship_country,
749 return "Unknown ship_state/ship_county/ship_country: ".
750 $self->ship_state. "/". $self->ship_county. "/". $self->ship_country
751 unless qsearchs('cust_main_county',{
752 'state' => $self->ship_state,
753 'county' => $self->ship_county,
754 'country' => $self->ship_country,
760 $self->ut_phonen('ship_daytime', $self->ship_country)
761 || $self->ut_phonen('ship_night', $self->ship_country)
762 || $self->ut_phonen('ship_fax', $self->ship_country)
763 || $self->ut_zip('ship_zip', $self->ship_country)
765 return $error if $error;
767 } else { # ship_ info eq billing info, so don't store dup info in database
768 $self->setfield("ship_$_", '')
769 foreach qw( last first company address1 address2 city county state zip
770 country daytime night fax );
774 $self->payby =~ /^(CARD|CHEK|LECB|BILL|COMP|PREPAY)$/
775 or return "Illegal payby: ". $self->payby;
778 if ( $self->payby eq 'CARD' ) {
780 my $payinfo = $self->payinfo;
782 $payinfo =~ /^(\d{13,16})$/
783 or return gettext('invalid_card'); # . ": ". $self->payinfo;
785 $self->payinfo($payinfo);
787 or return gettext('invalid_card'); # . ": ". $self->payinfo;
788 return gettext('unknown_card_type')
789 if cardtype($self->payinfo) eq "Unknown";
790 if ( defined $self->dbdef_table->column('paycvv') ) {
791 if ( length($self->paycvv) ) {
792 if ( cardtype($self->payinfo) eq 'American Express card' ) {
793 $self->paycvv =~ /^(\d{4})$/
794 or return "CVV2 (CID) for American Express cards is four digits.";
797 $self->paycvv =~ /^(\d{3})$/
798 or return "CVV2 (CVC2/CID) is three digits.";
806 } elsif ( $self->payby eq 'CHEK' ) {
808 my $payinfo = $self->payinfo;
809 $payinfo =~ s/[^\d\@]//g;
810 $payinfo =~ /^(\d+)\@(\d{9})$/ or return 'invalid echeck account@aba';
812 $self->payinfo($payinfo);
813 $self->paycvv('') if $self->dbdef_table->column('paycvv');
815 } elsif ( $self->payby eq 'LECB' ) {
817 my $payinfo = $self->payinfo;
819 $payinfo =~ /^1?(\d{10})$/ or return 'invalid btn billing telephone number';
821 $self->payinfo($payinfo);
822 $self->paycvv('') if $self->dbdef_table->column('paycvv');
824 } elsif ( $self->payby eq 'BILL' ) {
826 $error = $self->ut_textn('payinfo');
827 return "Illegal P.O. number: ". $self->payinfo if $error;
828 $self->paycvv('') if $self->dbdef_table->column('paycvv');
830 } elsif ( $self->payby eq 'COMP' ) {
832 if ( !$self->custnum && $conf->config('users-allow_comp') ) {
833 return "You are not permitted to create complimentary accounts."
834 unless grep { $_ eq getotaker } $conf->config('users-allow_comp');
837 $error = $self->ut_textn('payinfo');
838 return "Illegal comp account issuer: ". $self->payinfo if $error;
839 $self->paycvv('') if $self->dbdef_table->column('paycvv');
841 } elsif ( $self->payby eq 'PREPAY' ) {
843 my $payinfo = $self->payinfo;
844 $payinfo =~ s/\W//g; #anything else would just confuse things
845 $self->payinfo($payinfo);
846 $error = $self->ut_alpha('payinfo');
847 return "Illegal prepayment identifier: ". $self->payinfo if $error;
848 return "Unknown prepayment identifier"
849 unless qsearchs('prepay_credit', { 'identifier' => $self->payinfo } );
850 $self->paycvv('') if $self->dbdef_table->column('paycvv');
854 if ( $self->paydate eq '' || $self->paydate eq '-' ) {
855 return "Expriation date required"
856 unless $self->payby =~ /^(BILL|PREPAY|CHEK|LECB)$/;
859 $self->paydate =~ /^(\d{1,2})[\/\-](\d{2}(\d{2})?)$/
860 or return "Illegal expiration date: ". $self->paydate;
861 my $y = length($2) == 4 ? $2 : "20$2";
862 $self->paydate("$y-$1-01");
863 my($nowm,$nowy)=(localtime(time))[4,5]; $nowm++; $nowy+=1900;
864 return gettext('expired_card')
865 if !$import && ( $y<$nowy || ( $y==$nowy && $1<$nowm ) );
868 if ( $self->payname eq '' && $self->payby ne 'CHEK' &&
869 ( ! $conf->exists('require_cardname') || $self->payby ne 'CARD' ) ) {
870 $self->payname( $self->first. " ". $self->getfield('last') );
872 $self->payname =~ /^([\w \,\.\-\']+)$/
873 or return gettext('illegal_name'). " payname: ". $self->payname;
877 $self->tax =~ /^(Y?)$/ or return "Illegal tax: ". $self->tax;
880 $self->otaker(getotaker) unless $self->otaker;
882 #warn "AFTER: \n". $self->_dump;
889 Returns all packages (see L<FS::cust_pkg>) for this customer.
895 if ( $self->{'_pkgnum'} ) {
896 values %{ $self->{'_pkgnum'}->cache };
898 qsearch( 'cust_pkg', { 'custnum' => $self->custnum });
902 =item ncancelled_pkgs
904 Returns all non-cancelled packages (see L<FS::cust_pkg>) for this customer.
908 sub ncancelled_pkgs {
910 if ( $self->{'_pkgnum'} ) {
911 grep { ! $_->getfield('cancel') } values %{ $self->{'_pkgnum'}->cache };
913 @{ [ # force list context
914 qsearch( 'cust_pkg', {
915 'custnum' => $self->custnum,
918 qsearch( 'cust_pkg', {
919 'custnum' => $self->custnum,
928 Returns all suspended packages (see L<FS::cust_pkg>) for this customer.
934 grep { $_->susp } $self->ncancelled_pkgs;
937 =item unflagged_suspended_pkgs
939 Returns all unflagged suspended packages (see L<FS::cust_pkg>) for this
940 customer (thouse packages without the `manual_flag' set).
944 sub unflagged_suspended_pkgs {
946 return $self->suspended_pkgs
947 unless dbdef->table('cust_pkg')->column('manual_flag');
948 grep { ! $_->manual_flag } $self->suspended_pkgs;
951 =item unsuspended_pkgs
953 Returns all unsuspended (and uncancelled) packages (see L<FS::cust_pkg>) for
958 sub unsuspended_pkgs {
960 grep { ! $_->susp } $self->ncancelled_pkgs;
965 Unsuspends all unflagged suspended packages (see L</unflagged_suspended_pkgs>
966 and L<FS::cust_pkg>) for this customer. Always returns a list: an empty list
967 on success or a list of errors.
973 grep { $_->unsuspend } $self->suspended_pkgs;
978 Suspends all unsuspended packages (see L<FS::cust_pkg>) for this customer.
979 Always returns a list: an empty list on success or a list of errors.
985 grep { $_->suspend } $self->unsuspended_pkgs;
988 =item cancel [ OPTION => VALUE ... ]
990 Cancels all uncancelled packages (see L<FS::cust_pkg>) for this customer.
992 Available options are: I<quiet>
994 I<quiet> can be set true to supress email cancellation notices.
996 Always returns a list: an empty list on success or a list of errors.
1002 grep { $_->cancel(@_) } $self->ncancelled_pkgs;
1007 Returns the agent (see L<FS::agent>) for this customer.
1013 qsearchs( 'agent', { 'agentnum' => $self->agentnum } );
1018 Generates invoices (see L<FS::cust_bill>) for this customer. Usually used in
1019 conjunction with the collect method.
1021 Options are passed as name-value pairs.
1023 Currently available options are:
1025 resetup - if set true, re-charges setup fees.
1027 time - bills the customer as if it were that time. Specified as a UNIX
1028 timestamp; see L<perlfunc/"time">). Also see L<Time::Local> and
1029 L<Date::Parse> for conversion functions. For example:
1033 $cust_main->bill( 'time' => str2time('April 20th, 2001') );
1036 If there is an error, returns the error, otherwise returns false.
1041 my( $self, %options ) = @_;
1042 my $time = $options{'time'} || time;
1047 local $SIG{HUP} = 'IGNORE';
1048 local $SIG{INT} = 'IGNORE';
1049 local $SIG{QUIT} = 'IGNORE';
1050 local $SIG{TERM} = 'IGNORE';
1051 local $SIG{TSTP} = 'IGNORE';
1052 local $SIG{PIPE} = 'IGNORE';
1054 my $oldAutoCommit = $FS::UID::AutoCommit;
1055 local $FS::UID::AutoCommit = 0;
1058 # find the packages which are due for billing, find out how much they are
1059 # & generate invoice database.
1061 my( $total_setup, $total_recur ) = ( 0, 0 );
1062 #my( $taxable_setup, $taxable_recur ) = ( 0, 0 );
1063 my @cust_bill_pkg = ();
1065 #my $taxable_charged = 0;##
1070 foreach my $cust_pkg (
1071 qsearch('cust_pkg', { 'custnum' => $self->custnum } )
1074 #NO!! next if $cust_pkg->cancel;
1075 next if $cust_pkg->getfield('cancel');
1077 #? to avoid use of uninitialized value errors... ?
1078 $cust_pkg->setfield('bill', '')
1079 unless defined($cust_pkg->bill);
1081 my $part_pkg = $cust_pkg->part_pkg;
1083 #so we don't modify cust_pkg record unnecessarily
1084 my $cust_pkg_mod_flag = 0;
1085 my %hash = $cust_pkg->hash;
1086 my $old_cust_pkg = new FS::cust_pkg \%hash;
1090 if ( !$cust_pkg->setup || $options{'resetup'} ) {
1091 my $setup_prog = $part_pkg->getfield('setup');
1092 $setup_prog =~ /^(.*)$/ or do {
1093 $dbh->rollback if $oldAutoCommit;
1094 return "Illegal setup for pkgpart ". $part_pkg->pkgpart.
1098 $setup_prog = '0' if $setup_prog =~ /^\s*$/;
1100 #my $cpt = new Safe;
1101 ##$cpt->permit(); #what is necessary?
1102 #$cpt->share(qw( $cust_pkg )); #can $cpt now use $cust_pkg methods?
1103 #$setup = $cpt->reval($setup_prog);
1104 $setup = eval $setup_prog;
1105 unless ( defined($setup) ) {
1106 $dbh->rollback if $oldAutoCommit;
1107 return "Error eval-ing part_pkg->setup pkgpart ". $part_pkg->pkgpart.
1108 "(expression $setup_prog): $@";
1110 $cust_pkg->setfield('setup', $time) unless $cust_pkg->setup;
1111 $cust_pkg_mod_flag=1;
1117 if ( $part_pkg->getfield('freq') ne '0' &&
1118 ! $cust_pkg->getfield('susp') &&
1119 ( $cust_pkg->getfield('bill') || 0 ) <= $time
1121 my $recur_prog = $part_pkg->getfield('recur');
1122 $recur_prog =~ /^(.*)$/ or do {
1123 $dbh->rollback if $oldAutoCommit;
1124 return "Illegal recur for pkgpart ". $part_pkg->pkgpart.
1128 $recur_prog = '0' if $recur_prog =~ /^\s*$/;
1130 # shared with $recur_prog
1131 $sdate = $cust_pkg->bill || $cust_pkg->setup || $time;
1133 #my $cpt = new Safe;
1134 ##$cpt->permit(); #what is necessary?
1135 #$cpt->share(qw( $cust_pkg )); #can $cpt now use $cust_pkg methods?
1136 #$recur = $cpt->reval($recur_prog);
1137 $recur = eval $recur_prog;
1138 unless ( defined($recur) ) {
1139 $dbh->rollback if $oldAutoCommit;
1140 return "Error eval-ing part_pkg->recur pkgpart ". $part_pkg->pkgpart.
1141 "(expression $recur_prog): $@";
1143 #change this bit to use Date::Manip? CAREFUL with timezones (see
1144 # mailing list archive)
1145 my ($sec,$min,$hour,$mday,$mon,$year) =
1146 (localtime($sdate) )[0,1,2,3,4,5];
1148 #pro-rating magic - if $recur_prog fiddles $sdate, want to use that
1149 # only for figuring next bill date, nothing else, so, reset $sdate again
1151 $sdate = $cust_pkg->bill || $cust_pkg->setup || $time;
1152 $cust_pkg->last_bill($sdate)
1153 if $cust_pkg->dbdef_table->column('last_bill');
1155 if ( $part_pkg->freq =~ /^\d+$/ ) {
1156 $mon += $part_pkg->freq;
1157 until ( $mon < 12 ) { $mon -= 12; $year++; }
1158 } elsif ( $part_pkg->freq =~ /^(\d+)w$/ ) {
1160 $mday += $weeks * 7;
1161 } elsif ( $part_pkg->freq =~ /^(\d+)d$/ ) {
1165 $dbh->rollback if $oldAutoCommit;
1166 return "unparsable frequency: ". $part_pkg->freq;
1168 $cust_pkg->setfield('bill',
1169 timelocal_nocheck($sec,$min,$hour,$mday,$mon,$year));
1170 $cust_pkg_mod_flag = 1;
1173 warn "\$setup is undefined" unless defined($setup);
1174 warn "\$recur is undefined" unless defined($recur);
1175 warn "\$cust_pkg->bill is undefined" unless defined($cust_pkg->bill);
1177 if ( $cust_pkg_mod_flag ) {
1178 $error=$cust_pkg->replace($old_cust_pkg);
1179 if ( $error ) { #just in case
1180 $dbh->rollback if $oldAutoCommit;
1181 return "Error modifying pkgnum ". $cust_pkg->pkgnum. ": $error";
1183 $setup = sprintf( "%.2f", $setup );
1184 $recur = sprintf( "%.2f", $recur );
1186 $dbh->rollback if $oldAutoCommit;
1187 return "negative setup $setup for pkgnum ". $cust_pkg->pkgnum;
1190 $dbh->rollback if $oldAutoCommit;
1191 return "negative recur $recur for pkgnum ". $cust_pkg->pkgnum;
1193 if ( $setup > 0 || $recur > 0 ) {
1194 my $cust_bill_pkg = new FS::cust_bill_pkg ({
1195 'pkgnum' => $cust_pkg->pkgnum,
1199 'edate' => $cust_pkg->bill,
1201 push @cust_bill_pkg, $cust_bill_pkg;
1202 $total_setup += $setup;
1203 $total_recur += $recur;
1205 unless ( $self->tax =~ /Y/i || $self->payby eq 'COMP' ) {
1207 my @taxes = qsearch( 'cust_main_county', {
1208 'state' => $self->state,
1209 'county' => $self->county,
1210 'country' => $self->country,
1211 'taxclass' => $part_pkg->taxclass,
1214 @taxes = qsearch( 'cust_main_county', {
1215 'state' => $self->state,
1216 'county' => $self->county,
1217 'country' => $self->country,
1222 # maybe eliminate this entirely, along with all the 0% records
1224 $dbh->rollback if $oldAutoCommit;
1226 "fatal: can't find tax rate for state/county/country/taxclass ".
1227 join('/', ( map $self->$_(), qw(state county country) ),
1228 $part_pkg->taxclass ). "\n";
1231 foreach my $tax ( @taxes ) {
1233 my $taxable_charged = 0;
1234 $taxable_charged += $setup
1235 unless $part_pkg->setuptax =~ /^Y$/i
1236 || $tax->setuptax =~ /^Y$/i;
1237 $taxable_charged += $recur
1238 unless $part_pkg->recurtax =~ /^Y$/i
1239 || $tax->recurtax =~ /^Y$/i;
1240 next unless $taxable_charged;
1242 if ( $tax->exempt_amount > 0 ) {
1243 my ($mon,$year) = (localtime($sdate) )[4,5];
1245 my $freq = $part_pkg->freq || 1;
1246 if ( $freq !~ /(\d+)$/ ) {
1247 $dbh->rollback if $oldAutoCommit;
1248 return "daily/weekly package definitions not (yet?)".
1249 " compatible with monthly tax exemptions";
1251 my $taxable_per_month = sprintf("%.2f", $taxable_charged / $freq );
1252 foreach my $which_month ( 1 .. $freq ) {
1254 'custnum' => $self->custnum,
1255 'taxnum' => $tax->taxnum,
1256 'year' => 1900+$year,
1259 #until ( $mon < 12 ) { $mon -= 12; $year++; }
1260 until ( $mon < 13 ) { $mon -= 12; $year++; }
1261 my $cust_tax_exempt =
1262 qsearchs('cust_tax_exempt', \%hash)
1263 || new FS::cust_tax_exempt( { %hash, 'amount' => 0 } );
1264 my $remaining_exemption = sprintf("%.2f",
1265 $tax->exempt_amount - $cust_tax_exempt->amount );
1266 if ( $remaining_exemption > 0 ) {
1267 my $addl = $remaining_exemption > $taxable_per_month
1268 ? $taxable_per_month
1269 : $remaining_exemption;
1270 $taxable_charged -= $addl;
1271 my $new_cust_tax_exempt = new FS::cust_tax_exempt ( {
1272 $cust_tax_exempt->hash,
1274 sprintf("%.2f", $cust_tax_exempt->amount + $addl),
1276 $error = $new_cust_tax_exempt->exemptnum
1277 ? $new_cust_tax_exempt->replace($cust_tax_exempt)
1278 : $new_cust_tax_exempt->insert;
1280 $dbh->rollback if $oldAutoCommit;
1281 return "fatal: can't update cust_tax_exempt: $error";
1284 } # if $remaining_exemption > 0
1286 } #foreach $which_month
1288 } #if $tax->exempt_amount
1290 $taxable_charged = sprintf( "%.2f", $taxable_charged);
1292 #$tax += $taxable_charged * $cust_main_county->tax / 100
1293 $tax{ $tax->taxname || 'Tax' } +=
1294 $taxable_charged * $tax->tax / 100
1296 } #foreach my $tax ( @taxes )
1298 } #unless $self->tax =~ /Y/i || $self->payby eq 'COMP'
1300 } #if $setup > 0 || $recur > 0
1302 } #if $cust_pkg_mod_flag
1304 } #foreach my $cust_pkg
1306 my $charged = sprintf( "%.2f", $total_setup + $total_recur );
1307 # my $taxable_charged = sprintf( "%.2f", $taxable_setup + $taxable_recur );
1309 unless ( @cust_bill_pkg ) { #don't create invoices with no line items
1310 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1314 # unless ( $self->tax =~ /Y/i
1315 # || $self->payby eq 'COMP'
1316 # || $taxable_charged == 0 ) {
1317 # my $cust_main_county = qsearchs('cust_main_county',{
1318 # 'state' => $self->state,
1319 # 'county' => $self->county,
1320 # 'country' => $self->country,
1321 # } ) or die "fatal: can't find tax rate for state/county/country ".
1322 # $self->state. "/". $self->county. "/". $self->country. "\n";
1323 # my $tax = sprintf( "%.2f",
1324 # $taxable_charged * ( $cust_main_county->getfield('tax') / 100 )
1327 if ( dbdef->table('cust_bill_pkg')->column('itemdesc') ) { #1.5 schema
1329 foreach my $taxname ( grep { $tax{$_} > 0 } keys %tax ) {
1330 my $tax = sprintf("%.2f", $tax{$taxname} );
1331 $charged = sprintf( "%.2f", $charged+$tax );
1333 my $cust_bill_pkg = new FS::cust_bill_pkg ({
1339 'itemdesc' => $taxname,
1341 push @cust_bill_pkg, $cust_bill_pkg;
1344 } else { #1.4 schema
1347 foreach ( values %tax ) { $tax += $_ };
1348 $tax = sprintf("%.2f", $tax);
1350 $charged = sprintf( "%.2f", $charged+$tax );
1352 my $cust_bill_pkg = new FS::cust_bill_pkg ({
1359 push @cust_bill_pkg, $cust_bill_pkg;
1364 my $cust_bill = new FS::cust_bill ( {
1365 'custnum' => $self->custnum,
1367 'charged' => $charged,
1369 $error = $cust_bill->insert;
1371 $dbh->rollback if $oldAutoCommit;
1372 return "can't create invoice for customer #". $self->custnum. ": $error";
1375 my $invnum = $cust_bill->invnum;
1377 foreach $cust_bill_pkg ( @cust_bill_pkg ) {
1379 $cust_bill_pkg->invnum($invnum);
1380 $error = $cust_bill_pkg->insert;
1382 $dbh->rollback if $oldAutoCommit;
1383 return "can't create invoice line item for customer #". $self->custnum.
1388 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1394 document me. Re-schedules all exports by calling the B<reexport> method
1395 of all associated packages (see L<FS::cust_pkg>). If there is an error,
1396 returns the error; otherwise returns false.
1403 local $SIG{HUP} = 'IGNORE';
1404 local $SIG{INT} = 'IGNORE';
1405 local $SIG{QUIT} = 'IGNORE';
1406 local $SIG{TERM} = 'IGNORE';
1407 local $SIG{TSTP} = 'IGNORE';
1408 local $SIG{PIPE} = 'IGNORE';
1410 my $oldAutoCommit = $FS::UID::AutoCommit;
1411 local $FS::UID::AutoCommit = 0;
1414 foreach my $cust_pkg ( $self->ncancelled_pkgs ) {
1415 my $error = $cust_pkg->reexport;
1417 $dbh->rollback if $oldAutoCommit;
1422 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1427 =item collect OPTIONS
1429 (Attempt to) collect money for this customer's outstanding invoices (see
1430 L<FS::cust_bill>). Usually used after the bill method.
1432 Depending on the value of `payby', this may print an invoice (`BILL'), charge
1433 a credit card (`CARD'), or just add any necessary (pseudo-)payment (`COMP').
1435 Most actions are now triggered by invoice events; see L<FS::part_bill_event>
1436 and the invoice events web interface.
1438 If there is an error, returns the error, otherwise returns false.
1440 Options are passed as name-value pairs.
1442 Currently available options are:
1444 invoice_time - Use this time when deciding when to print invoices and
1445 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>
1446 for conversion functions.
1448 retry - Retry card/echeck/LEC transactions even when not scheduled by invoice
1451 retry_card - Deprecated alias for 'retry'
1453 batch_card - This option is deprecated. See the invoice events web interface
1454 to control whether cards are batched or run against a realtime gateway.
1456 report_badcard - This option is deprecated.
1458 force_print - This option is deprecated; see the invoice events web interface.
1460 quiet - set true to surpress email card/ACH decline notices.
1465 my( $self, %options ) = @_;
1466 my $invoice_time = $options{'invoice_time'} || time;
1469 local $SIG{HUP} = 'IGNORE';
1470 local $SIG{INT} = 'IGNORE';
1471 local $SIG{QUIT} = 'IGNORE';
1472 local $SIG{TERM} = 'IGNORE';
1473 local $SIG{TSTP} = 'IGNORE';
1474 local $SIG{PIPE} = 'IGNORE';
1476 my $oldAutoCommit = $FS::UID::AutoCommit;
1477 local $FS::UID::AutoCommit = 0;
1480 my $balance = $self->balance;
1481 warn "collect customer". $self->custnum. ": balance $balance" if $Debug;
1482 unless ( $balance > 0 ) { #redundant?????
1483 $dbh->rollback if $oldAutoCommit; #hmm
1487 if ( exists($options{'retry_card'}) ) {
1488 carp 'retry_card option passed to collect is deprecated; use retry';
1489 $options{'retry'} ||= $options{'retry_card'};
1491 if ( exists($options{'retry'}) && $options{'retry'} ) {
1492 my $error = $self->retry_realtime;
1494 $dbh->rollback if $oldAutoCommit;
1499 foreach my $cust_bill ( $self->cust_bill ) {
1501 #this has to be before next's
1502 my $amount = sprintf( "%.2f", $balance < $cust_bill->owed
1506 $balance = sprintf( "%.2f", $balance - $amount );
1508 next unless $cust_bill->owed > 0;
1510 # don't try to charge for the same invoice if it's already in a batch
1511 #next if qsearchs( 'cust_pay_batch', { 'invnum' => $cust_bill->invnum } );
1513 warn "invnum ". $cust_bill->invnum. " (owed ". $cust_bill->owed. ", amount $amount, balance $balance)" if $Debug;
1515 next unless $amount > 0;
1518 foreach my $part_bill_event (
1519 sort { $a->seconds <=> $b->seconds
1520 || $a->weight <=> $b->weight
1521 || $a->eventpart <=> $b->eventpart }
1522 grep { $_->seconds <= ( $invoice_time - $cust_bill->_date )
1523 && ! qsearchs( 'cust_bill_event', {
1524 'invnum' => $cust_bill->invnum,
1525 'eventpart' => $_->eventpart,
1529 qsearch('part_bill_event', { 'payby' => $self->payby,
1530 'disabled' => '', } )
1533 last unless $cust_bill->owed > 0; #don't run subsequent events if owed=0
1535 warn "calling invoice event (". $part_bill_event->eventcode. ")\n"
1537 my $cust_main = $self; #for callback
1541 #supress "used only once" warning
1542 $FS::cust_bill::realtime_bop_decline_quiet += 0;
1543 local $FS::cust_bill::realtime_bop_decline_quiet = 1
1544 if $options{'quiet'};
1545 $error = eval $part_bill_event->eventcode;
1549 my $statustext = '';
1553 } elsif ( $error ) {
1555 $statustext = $error;
1560 #add cust_bill_event
1561 my $cust_bill_event = new FS::cust_bill_event {
1562 'invnum' => $cust_bill->invnum,
1563 'eventpart' => $part_bill_event->eventpart,
1564 #'_date' => $invoice_time,
1566 'status' => $status,
1567 'statustext' => $statustext,
1569 $error = $cust_bill_event->insert;
1571 #$dbh->rollback if $oldAutoCommit;
1572 #return "error: $error";
1574 # gah, even with transactions.
1575 $dbh->commit if $oldAutoCommit; #well.
1576 my $e = 'WARNING: Event run but database not updated - '.
1577 'error inserting cust_bill_event, invnum #'. $cust_bill->invnum.
1578 ', eventpart '. $part_bill_event->eventpart.
1589 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1594 =item retry_realtime
1596 Schedules realtime credit card / electronic check / LEC billing events for
1597 for retry. Useful if card information has changed or manual retry is desired.
1598 The 'collect' method must be called to actually retry the transaction.
1600 Implementation details: For each of this customer's open invoices, changes
1601 the status of the first "done" (with statustext error) realtime processing
1606 sub retry_realtime {
1609 local $SIG{HUP} = 'IGNORE';
1610 local $SIG{INT} = 'IGNORE';
1611 local $SIG{QUIT} = 'IGNORE';
1612 local $SIG{TERM} = 'IGNORE';
1613 local $SIG{TSTP} = 'IGNORE';
1614 local $SIG{PIPE} = 'IGNORE';
1616 my $oldAutoCommit = $FS::UID::AutoCommit;
1617 local $FS::UID::AutoCommit = 0;
1620 foreach my $cust_bill (
1621 grep { $_->cust_bill_event }
1622 $self->open_cust_bill
1624 my @cust_bill_event =
1625 sort { $a->part_bill_event->seconds <=> $b->part_bill_event->seconds }
1627 #$_->part_bill_event->plan eq 'realtime-card'
1628 $_->part_bill_event->eventcode =~
1629 /\$cust_bill\->realtime_(card|ach|lec)/
1630 && $_->status eq 'done'
1633 $cust_bill->cust_bill_event;
1634 next unless @cust_bill_event;
1635 my $error = $cust_bill_event[0]->retry;
1637 $dbh->rollback if $oldAutoCommit;
1638 return "error scheduling invoice event for retry: $error";
1643 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1650 Returns the total owed for this customer on all invoices
1651 (see L<FS::cust_bill/owed>).
1657 $self->total_owed_date(2145859200); #12/31/2037
1660 =item total_owed_date TIME
1662 Returns the total owed for this customer on all invoices with date earlier than
1663 TIME. TIME is specified as a UNIX timestamp; see L<perlfunc/"time">). Also
1664 see L<Time::Local> and L<Date::Parse> for conversion functions.
1668 sub total_owed_date {
1672 foreach my $cust_bill (
1673 grep { $_->_date <= $time }
1674 qsearch('cust_bill', { 'custnum' => $self->custnum, } )
1676 $total_bill += $cust_bill->owed;
1678 sprintf( "%.2f", $total_bill );
1683 Applies (see L<FS::cust_credit_bill>) unapplied credits (see L<FS::cust_credit>)
1684 to outstanding invoice balances in chronological order and returns the value
1685 of any remaining unapplied credits available for refund
1686 (see L<FS::cust_refund>).
1693 return 0 unless $self->total_credited;
1695 my @credits = sort { $b->_date <=> $a->_date} (grep { $_->credited > 0 }
1696 qsearch('cust_credit', { 'custnum' => $self->custnum } ) );
1698 my @invoices = sort { $a->_date <=> $b->_date} (grep { $_->owed > 0 }
1699 qsearch('cust_bill', { 'custnum' => $self->custnum } ) );
1703 foreach my $cust_bill ( @invoices ) {
1706 if ( !defined($credit) || $credit->credited == 0) {
1707 $credit = pop @credits or last;
1710 if ($cust_bill->owed >= $credit->credited) {
1711 $amount=$credit->credited;
1713 $amount=$cust_bill->owed;
1716 my $cust_credit_bill = new FS::cust_credit_bill ( {
1717 'crednum' => $credit->crednum,
1718 'invnum' => $cust_bill->invnum,
1719 'amount' => $amount,
1721 my $error = $cust_credit_bill->insert;
1722 die $error if $error;
1724 redo if ($cust_bill->owed > 0);
1728 return $self->total_credited;
1731 =item apply_payments
1733 Applies (see L<FS::cust_bill_pay>) unapplied payments (see L<FS::cust_pay>)
1734 to outstanding invoice balances in chronological order.
1736 #and returns the value of any remaining unapplied payments.
1740 sub apply_payments {
1745 my @payments = sort { $b->_date <=> $a->_date } ( grep { $_->unapplied > 0 }
1746 qsearch('cust_pay', { 'custnum' => $self->custnum } ) );
1748 my @invoices = sort { $a->_date <=> $b->_date} (grep { $_->owed > 0 }
1749 qsearch('cust_bill', { 'custnum' => $self->custnum } ) );
1753 foreach my $cust_bill ( @invoices ) {
1756 if ( !defined($payment) || $payment->unapplied == 0 ) {
1757 $payment = pop @payments or last;
1760 if ( $cust_bill->owed >= $payment->unapplied ) {
1761 $amount = $payment->unapplied;
1763 $amount = $cust_bill->owed;
1766 my $cust_bill_pay = new FS::cust_bill_pay ( {
1767 'paynum' => $payment->paynum,
1768 'invnum' => $cust_bill->invnum,
1769 'amount' => $amount,
1771 my $error = $cust_bill_pay->insert;
1772 die $error if $error;
1774 redo if ( $cust_bill->owed > 0);
1778 return $self->total_unapplied_payments;
1781 =item total_credited
1783 Returns the total outstanding credit (see L<FS::cust_credit>) for this
1784 customer. See L<FS::cust_credit/credited>.
1788 sub total_credited {
1790 my $total_credit = 0;
1791 foreach my $cust_credit ( qsearch('cust_credit', {
1792 'custnum' => $self->custnum,
1794 $total_credit += $cust_credit->credited;
1796 sprintf( "%.2f", $total_credit );
1799 =item total_unapplied_payments
1801 Returns the total unapplied payments (see L<FS::cust_pay>) for this customer.
1802 See L<FS::cust_pay/unapplied>.
1806 sub total_unapplied_payments {
1808 my $total_unapplied = 0;
1809 foreach my $cust_pay ( qsearch('cust_pay', {
1810 'custnum' => $self->custnum,
1812 $total_unapplied += $cust_pay->unapplied;
1814 sprintf( "%.2f", $total_unapplied );
1819 Returns the balance for this customer (total_owed minus total_credited
1820 minus total_unapplied_payments).
1827 $self->total_owed - $self->total_credited - $self->total_unapplied_payments
1831 =item balance_date TIME
1833 Returns the balance for this customer, only considering invoices with date
1834 earlier than TIME (total_owed_date minus total_credited minus
1835 total_unapplied_payments). TIME is specified as a UNIX timestamp; see
1836 L<perlfunc/"time">). Also see L<Time::Local> and L<Date::Parse> for conversion
1845 $self->total_owed_date($time)
1846 - $self->total_credited
1847 - $self->total_unapplied_payments
1851 =item invoicing_list [ ARRAYREF ]
1853 If an arguement is given, sets these email addresses as invoice recipients
1854 (see L<FS::cust_main_invoice>). Errors are not fatal and are not reported
1855 (except as warnings), so use check_invoicing_list first.
1857 Returns a list of email addresses (with svcnum entries expanded).
1859 Note: You can clear the invoicing list by passing an empty ARRAYREF. You can
1860 check it without disturbing anything by passing nothing.
1862 This interface may change in the future.
1866 sub invoicing_list {
1867 my( $self, $arrayref ) = @_;
1869 my @cust_main_invoice;
1870 if ( $self->custnum ) {
1871 @cust_main_invoice =
1872 qsearch( 'cust_main_invoice', { 'custnum' => $self->custnum } );
1874 @cust_main_invoice = ();
1876 foreach my $cust_main_invoice ( @cust_main_invoice ) {
1877 #warn $cust_main_invoice->destnum;
1878 unless ( grep { $cust_main_invoice->address eq $_ } @{$arrayref} ) {
1879 #warn $cust_main_invoice->destnum;
1880 my $error = $cust_main_invoice->delete;
1881 warn $error if $error;
1884 if ( $self->custnum ) {
1885 @cust_main_invoice =
1886 qsearch( 'cust_main_invoice', { 'custnum' => $self->custnum } );
1888 @cust_main_invoice = ();
1890 my %seen = map { $_->address => 1 } @cust_main_invoice;
1891 foreach my $address ( @{$arrayref} ) {
1892 next if exists $seen{$address} && $seen{$address};
1893 $seen{$address} = 1;
1894 my $cust_main_invoice = new FS::cust_main_invoice ( {
1895 'custnum' => $self->custnum,
1898 my $error = $cust_main_invoice->insert;
1899 warn $error if $error;
1902 if ( $self->custnum ) {
1904 qsearch( 'cust_main_invoice', { 'custnum' => $self->custnum } );
1910 =item check_invoicing_list ARRAYREF
1912 Checks these arguements as valid input for the invoicing_list method. If there
1913 is an error, returns the error, otherwise returns false.
1917 sub check_invoicing_list {
1918 my( $self, $arrayref ) = @_;
1919 foreach my $address ( @{$arrayref} ) {
1920 my $cust_main_invoice = new FS::cust_main_invoice ( {
1921 'custnum' => $self->custnum,
1924 my $error = $self->custnum
1925 ? $cust_main_invoice->check
1926 : $cust_main_invoice->checkdest
1928 return $error if $error;
1933 =item set_default_invoicing_list
1935 Sets the invoicing list to all accounts associated with this customer,
1936 overwriting any previous invoicing list.
1940 sub set_default_invoicing_list {
1942 $self->invoicing_list($self->all_emails);
1947 Returns the email addresses of all accounts provisioned for this customer.
1954 foreach my $cust_pkg ( $self->all_pkgs ) {
1955 my @cust_svc = qsearch('cust_svc', { 'pkgnum' => $cust_pkg->pkgnum } );
1957 map { qsearchs('svc_acct', { 'svcnum' => $_->svcnum } ) }
1958 grep { qsearchs('svc_acct', { 'svcnum' => $_->svcnum } ) }
1960 $list{$_}=1 foreach map { $_->email } @svc_acct;
1965 =item invoicing_list_addpost
1967 Adds postal invoicing to this customer. If this customer is already configured
1968 to receive postal invoices, does nothing.
1972 sub invoicing_list_addpost {
1974 return if grep { $_ eq 'POST' } $self->invoicing_list;
1975 my @invoicing_list = $self->invoicing_list;
1976 push @invoicing_list, 'POST';
1977 $self->invoicing_list(\@invoicing_list);
1980 =item referral_cust_main [ DEPTH [ EXCLUDE_HASHREF ] ]
1982 Returns an array of customers referred by this customer (referral_custnum set
1983 to this custnum). If DEPTH is given, recurses up to the given depth, returning
1984 customers referred by customers referred by this customer and so on, inclusive.
1985 The default behavior is DEPTH 1 (no recursion).
1989 sub referral_cust_main {
1991 my $depth = @_ ? shift : 1;
1992 my $exclude = @_ ? shift : {};
1995 map { $exclude->{$_->custnum}++; $_; }
1996 grep { ! $exclude->{ $_->custnum } }
1997 qsearch( 'cust_main', { 'referral_custnum' => $self->custnum } );
2001 map { $_->referral_cust_main($depth-1, $exclude) }
2008 =item referral_cust_main_ncancelled
2010 Same as referral_cust_main, except only returns customers with uncancelled
2015 sub referral_cust_main_ncancelled {
2017 grep { scalar($_->ncancelled_pkgs) } $self->referral_cust_main;
2020 =item referral_cust_pkg [ DEPTH ]
2022 Like referral_cust_main, except returns a flat list of all unsuspended (and
2023 uncancelled) packages for each customer. The number of items in this list may
2024 be useful for comission calculations (perhaps after a C<grep { my $pkgpart = $_->pkgpart; grep { $_ == $pkgpart } @commission_worthy_pkgparts> } $cust_main-> ).
2028 sub referral_cust_pkg {
2030 my $depth = @_ ? shift : 1;
2032 map { $_->unsuspended_pkgs }
2033 grep { $_->unsuspended_pkgs }
2034 $self->referral_cust_main($depth);
2037 =item credit AMOUNT, REASON
2039 Applies a credit to this customer. If there is an error, returns the error,
2040 otherwise returns false.
2045 my( $self, $amount, $reason ) = @_;
2046 my $cust_credit = new FS::cust_credit {
2047 'custnum' => $self->custnum,
2048 'amount' => $amount,
2049 'reason' => $reason,
2051 $cust_credit->insert;
2054 =item charge AMOUNT [ PKG [ COMMENT [ TAXCLASS ] ] ]
2056 Creates a one-time charge for this customer. If there is an error, returns
2057 the error, otherwise returns false.
2062 my ( $self, $amount ) = ( shift, shift );
2063 my $pkg = @_ ? shift : 'One-time charge';
2064 my $comment = @_ ? shift : '$'. sprintf("%.2f",$amount);
2065 my $taxclass = @_ ? shift : '';
2067 local $SIG{HUP} = 'IGNORE';
2068 local $SIG{INT} = 'IGNORE';
2069 local $SIG{QUIT} = 'IGNORE';
2070 local $SIG{TERM} = 'IGNORE';
2071 local $SIG{TSTP} = 'IGNORE';
2072 local $SIG{PIPE} = 'IGNORE';
2074 my $oldAutoCommit = $FS::UID::AutoCommit;
2075 local $FS::UID::AutoCommit = 0;
2078 my $part_pkg = new FS::part_pkg ( {
2080 'comment' => $comment,
2085 'taxclass' => $taxclass,
2088 my $error = $part_pkg->insert;
2090 $dbh->rollback if $oldAutoCommit;
2094 my $pkgpart = $part_pkg->pkgpart;
2095 my %type_pkgs = ( 'typenum' => $self->agent->typenum, 'pkgpart' => $pkgpart );
2096 unless ( qsearchs('type_pkgs', \%type_pkgs ) ) {
2097 my $type_pkgs = new FS::type_pkgs \%type_pkgs;
2098 $error = $type_pkgs->insert;
2100 $dbh->rollback if $oldAutoCommit;
2105 my $cust_pkg = new FS::cust_pkg ( {
2106 'custnum' => $self->custnum,
2107 'pkgpart' => $pkgpart,
2110 $error = $cust_pkg->insert;
2112 $dbh->rollback if $oldAutoCommit;
2116 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2123 Returns all the invoices (see L<FS::cust_bill>) for this customer.
2129 sort { $a->_date <=> $b->_date }
2130 qsearch('cust_bill', { 'custnum' => $self->custnum, } )
2133 =item open_cust_bill
2135 Returns all the open (owed > 0) invoices (see L<FS::cust_bill>) for this
2140 sub open_cust_bill {
2142 grep { $_->owed > 0 } $self->cust_bill;
2151 =item check_and_rebuild_fuzzyfiles
2155 sub check_and_rebuild_fuzzyfiles {
2156 my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
2157 -e "$dir/cust_main.last" && -e "$dir/cust_main.company"
2158 or &rebuild_fuzzyfiles;
2161 =item rebuild_fuzzyfiles
2165 sub rebuild_fuzzyfiles {
2167 use Fcntl qw(:flock);
2169 my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
2173 open(LASTLOCK,">>$dir/cust_main.last")
2174 or die "can't open $dir/cust_main.last: $!";
2175 flock(LASTLOCK,LOCK_EX)
2176 or die "can't lock $dir/cust_main.last: $!";
2178 my @all_last = map $_->getfield('last'), qsearch('cust_main', {});
2180 grep $_, map $_->getfield('ship_last'), qsearch('cust_main',{})
2181 if defined dbdef->table('cust_main')->column('ship_last');
2183 open (LASTCACHE,">$dir/cust_main.last.tmp")
2184 or die "can't open $dir/cust_main.last.tmp: $!";
2185 print LASTCACHE join("\n", @all_last), "\n";
2186 close LASTCACHE or die "can't close $dir/cust_main.last.tmp: $!";
2188 rename "$dir/cust_main.last.tmp", "$dir/cust_main.last";
2193 open(COMPANYLOCK,">>$dir/cust_main.company")
2194 or die "can't open $dir/cust_main.company: $!";
2195 flock(COMPANYLOCK,LOCK_EX)
2196 or die "can't lock $dir/cust_main.company: $!";
2198 my @all_company = grep $_ ne '', map $_->company, qsearch('cust_main',{});
2200 grep $_ ne '', map $_->ship_company, qsearch('cust_main', {})
2201 if defined dbdef->table('cust_main')->column('ship_last');
2203 open (COMPANYCACHE,">$dir/cust_main.company.tmp")
2204 or die "can't open $dir/cust_main.company.tmp: $!";
2205 print COMPANYCACHE join("\n", @all_company), "\n";
2206 close COMPANYCACHE or die "can't close $dir/cust_main.company.tmp: $!";
2208 rename "$dir/cust_main.company.tmp", "$dir/cust_main.company";
2218 my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
2219 open(LASTCACHE,"<$dir/cust_main.last")
2220 or die "can't open $dir/cust_main.last: $!";
2221 my @array = map { chomp; $_; } <LASTCACHE>;
2231 my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
2232 open(COMPANYCACHE,"<$dir/cust_main.company")
2233 or die "can't open $dir/cust_main.last: $!";
2234 my @array = map { chomp; $_; } <COMPANYCACHE>;
2239 =item append_fuzzyfiles LASTNAME COMPANY
2243 sub append_fuzzyfiles {
2244 my( $last, $company ) = @_;
2246 &check_and_rebuild_fuzzyfiles;
2248 use Fcntl qw(:flock);
2250 my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
2254 open(LAST,">>$dir/cust_main.last")
2255 or die "can't open $dir/cust_main.last: $!";
2257 or die "can't lock $dir/cust_main.last: $!";
2259 print LAST "$last\n";
2262 or die "can't unlock $dir/cust_main.last: $!";
2268 open(COMPANY,">>$dir/cust_main.company")
2269 or die "can't open $dir/cust_main.company: $!";
2270 flock(COMPANY,LOCK_EX)
2271 or die "can't lock $dir/cust_main.company: $!";
2273 print COMPANY "$company\n";
2275 flock(COMPANY,LOCK_UN)
2276 or die "can't unlock $dir/cust_main.company: $!";
2290 #warn join('-',keys %$param);
2291 my $fh = $param->{filehandle};
2292 my $agentnum = $param->{agentnum};
2293 my $refnum = $param->{refnum};
2294 my $pkgpart = $param->{pkgpart};
2295 my @fields = @{$param->{fields}};
2297 eval "use Date::Parse;";
2299 eval "use Text::CSV_XS;";
2302 my $csv = new Text::CSV_XS;
2309 local $SIG{HUP} = 'IGNORE';
2310 local $SIG{INT} = 'IGNORE';
2311 local $SIG{QUIT} = 'IGNORE';
2312 local $SIG{TERM} = 'IGNORE';
2313 local $SIG{TSTP} = 'IGNORE';
2314 local $SIG{PIPE} = 'IGNORE';
2316 my $oldAutoCommit = $FS::UID::AutoCommit;
2317 local $FS::UID::AutoCommit = 0;
2320 #while ( $columns = $csv->getline($fh) ) {
2322 while ( defined($line=<$fh>) ) {
2324 $csv->parse($line) or do {
2325 $dbh->rollback if $oldAutoCommit;
2326 return "can't parse: ". $csv->error_input();
2329 my @columns = $csv->fields();
2330 #warn join('-',@columns);
2333 agentnum => $agentnum,
2335 country => 'US', #default
2336 payby => 'BILL', #default
2337 paydate => '12/2037', #default
2339 my $billtime = time;
2340 my %cust_pkg = ( pkgpart => $pkgpart );
2341 foreach my $field ( @fields ) {
2342 if ( $field =~ /^cust_pkg\.(setup|bill|susp|expire|cancel)$/ ) {
2343 #$cust_pkg{$1} = str2time( shift @$columns );
2344 if ( $1 eq 'setup' ) {
2345 $billtime = str2time(shift @columns);
2347 $cust_pkg{$1} = str2time( shift @columns );
2350 #$cust_main{$field} = shift @$columns;
2351 $cust_main{$field} = shift @columns;
2355 my $cust_pkg = new FS::cust_pkg ( \%cust_pkg ) if $pkgpart;
2356 my $cust_main = new FS::cust_main ( \%cust_main );
2358 tie my %hash, 'Tie::RefHash'; #this part is important
2359 $hash{$cust_pkg} = [] if $pkgpart;
2360 my $error = $cust_main->insert( \%hash );
2363 $dbh->rollback if $oldAutoCommit;
2364 return "can't insert customer for $line: $error";
2367 #false laziness w/bill.cgi
2368 $error = $cust_main->bill( 'time' => $billtime );
2370 $dbh->rollback if $oldAutoCommit;
2371 return "can't bill customer for $line: $error";
2374 $cust_main->apply_payments;
2375 $cust_main->apply_credits;
2377 $error = $cust_main->collect();
2379 $dbh->rollback if $oldAutoCommit;
2380 return "can't collect customer for $line: $error";
2386 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2388 return "Empty file!" unless $imported;
2400 #warn join('-',keys %$param);
2401 my $fh = $param->{filehandle};
2402 my @fields = @{$param->{fields}};
2404 eval "use Date::Parse;";
2406 eval "use Text::CSV_XS;";
2409 my $csv = new Text::CSV_XS;
2416 local $SIG{HUP} = 'IGNORE';
2417 local $SIG{INT} = 'IGNORE';
2418 local $SIG{QUIT} = 'IGNORE';
2419 local $SIG{TERM} = 'IGNORE';
2420 local $SIG{TSTP} = 'IGNORE';
2421 local $SIG{PIPE} = 'IGNORE';
2423 my $oldAutoCommit = $FS::UID::AutoCommit;
2424 local $FS::UID::AutoCommit = 0;
2427 #while ( $columns = $csv->getline($fh) ) {
2429 while ( defined($line=<$fh>) ) {
2431 $csv->parse($line) or do {
2432 $dbh->rollback if $oldAutoCommit;
2433 return "can't parse: ". $csv->error_input();
2436 my @columns = $csv->fields();
2437 #warn join('-',@columns);
2440 foreach my $field ( @fields ) {
2441 $row{$field} = shift @columns;
2444 my $cust_main = qsearchs('cust_main', { 'custnum' => $row{'custnum'} } );
2445 unless ( $cust_main ) {
2446 $dbh->rollback if $oldAutoCommit;
2447 return "unknown custnum $row{'custnum'}";
2450 if ( $row{'amount'} > 0 ) {
2451 my $error = $cust_main->charge($row{'amount'}, $row{'pkg'});
2453 $dbh->rollback if $oldAutoCommit;
2457 } elsif ( $row{'amount'} < 0 ) {
2458 my $error = $cust_main->credit( sprintf( "%.2f", 0-$row{'amount'} ),
2461 $dbh->rollback if $oldAutoCommit;
2471 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2473 return "Empty file!" unless $imported;
2485 The delete method should possibly take an FS::cust_main object reference
2486 instead of a scalar customer number.
2488 Bill and collect options should probably be passed as references instead of a
2491 There should probably be a configuration file with a list of allowed credit
2494 No multiple currency support (probably a larger project than just this module).
2498 L<FS::Record>, L<FS::cust_pkg>, L<FS::cust_bill>, L<FS::cust_credit>
2499 L<FS::agent>, L<FS::part_referral>, L<FS::cust_main_county>,
2500 L<FS::cust_main_invoice>, L<FS::UID>, schema.html from the base documentation.