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;
24 use FS::part_referral;
25 use FS::cust_main_county;
27 use FS::cust_main_invoice;
28 use FS::cust_credit_bill;
29 use FS::cust_bill_pay;
30 use FS::prepay_credit;
33 use FS::part_bill_event;
34 use FS::cust_bill_event;
35 use FS::cust_tax_exempt;
37 use FS::Msgcat qw(gettext);
39 @ISA = qw( FS::Record );
46 #ask FS::UID to run this stuff for us later
47 $FS::UID::callback{'FS::cust_main'} = sub {
49 #yes, need it for stuff below (prolly should be cached)
54 my ( $hashref, $cache ) = @_;
55 if ( exists $hashref->{'pkgnum'} ) {
56 # #@{ $self->{'_pkgnum'} } = ();
57 my $subcache = $cache->subcache( 'pkgnum', 'cust_pkg', $hashref->{custnum});
58 $self->{'_pkgnum'} = $subcache;
59 #push @{ $self->{'_pkgnum'} },
60 FS::cust_pkg->new_or_cached($hashref, $subcache) if $hashref->{pkgnum};
66 FS::cust_main - Object methods for cust_main records
72 $record = new FS::cust_main \%hash;
73 $record = new FS::cust_main { 'column' => 'value' };
75 $error = $record->insert;
77 $error = $new_record->replace($old_record);
79 $error = $record->delete;
81 $error = $record->check;
83 @cust_pkg = $record->all_pkgs;
85 @cust_pkg = $record->ncancelled_pkgs;
87 @cust_pkg = $record->suspended_pkgs;
89 $error = $record->bill;
90 $error = $record->bill %options;
91 $error = $record->bill 'time' => $time;
93 $error = $record->collect;
94 $error = $record->collect %options;
95 $error = $record->collect 'invoice_time' => $time,
96 'batch_card' => 'yes',
97 'report_badcard' => 'yes',
102 An FS::cust_main object represents a customer. FS::cust_main inherits from
103 FS::Record. The following fields are currently supported:
107 =item custnum - primary key (assigned automatically for new customers)
109 =item agentnum - agent (see L<FS::agent>)
111 =item refnum - Advertising source (see L<FS::part_referral>)
117 =item ss - social security number (optional)
119 =item company - (optional)
123 =item address2 - (optional)
127 =item county - (optional, see L<FS::cust_main_county>)
129 =item state - (see L<FS::cust_main_county>)
133 =item country - (see L<FS::cust_main_county>)
135 =item daytime - phone (optional)
137 =item night - phone (optional)
139 =item fax - phone (optional)
141 =item ship_first - name
143 =item ship_last - name
145 =item ship_company - (optional)
149 =item ship_address2 - (optional)
153 =item ship_county - (optional, see L<FS::cust_main_county>)
155 =item ship_state - (see L<FS::cust_main_county>)
159 =item ship_country - (see L<FS::cust_main_county>)
161 =item ship_daytime - phone (optional)
163 =item ship_night - phone (optional)
165 =item ship_fax - phone (optional)
167 =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)
169 =item payinfo - card number, P.O., comp issuer (4-8 lowercase alphanumerics; think username) or prepayment identifier (see L<FS::prepay_credit>)
171 =item paydate - expiration date, mm/yyyy, m/yyyy, mm/yy or m/yy
173 =item payname - name on card or billing name
175 =item tax - tax exempt, empty or `Y'
177 =item otaker - order taker (assigned automatically, see L<FS::UID>)
179 =item comments - comments (optional)
189 Creates a new customer. To add the customer to the database, see L<"insert">.
191 Note that this stores the hash reference, not a distinct copy of the hash it
192 points to. You can ask the object for a copy with the I<hash> method.
196 sub table { 'cust_main'; }
198 =item insert [ CUST_PKG_HASHREF [ , INVOICING_LIST_ARYREF ] [ , OPTION => VALUE ... ] ]
200 Adds this customer to the database. If there is an error, returns the error,
201 otherwise returns false.
203 CUST_PKG_HASHREF: If you pass a Tie::RefHash data structure to the insert
204 method containing FS::cust_pkg and FS::svc_I<tablename> objects, all records
205 are inserted atomicly, or the transaction is rolled back. Passing an empty
206 hash reference is equivalent to not supplying this parameter. There should be
207 a better explanation of this, but until then, here's an example:
210 tie %hash, 'Tie::RefHash'; #this part is important
212 $cust_pkg => [ $svc_acct ],
215 $cust_main->insert( \%hash );
217 INVOICING_LIST_ARYREF: If you pass an arrarref to the insert method, it will
218 be set as the invoicing list (see L<"invoicing_list">). Errors return as
219 expected and rollback the entire transaction; it is not necessary to call
220 check_invoicing_list first. The invoicing_list is set after the records in the
221 CUST_PKG_HASHREF above are inserted, so it is now possible to set an
222 invoicing_list destination to the newly-created svc_acct. Here's an example:
224 $cust_main->insert( {}, [ $email, 'POST' ] );
226 Currently available options are: I<noexport>
228 If I<noexport> is set true, no provisioning jobs (exports) are scheduled.
229 (You can schedule them later with the B<reexport> method.)
235 my $cust_pkgs = @_ ? shift : {};
236 my $invoicing_list = @_ ? shift : '';
239 local $SIG{HUP} = 'IGNORE';
240 local $SIG{INT} = 'IGNORE';
241 local $SIG{QUIT} = 'IGNORE';
242 local $SIG{TERM} = 'IGNORE';
243 local $SIG{TSTP} = 'IGNORE';
244 local $SIG{PIPE} = 'IGNORE';
246 my $oldAutoCommit = $FS::UID::AutoCommit;
247 local $FS::UID::AutoCommit = 0;
252 if ( $self->payby eq 'PREPAY' ) {
253 $self->payby('BILL');
254 my $prepay_credit = qsearchs(
256 { 'identifier' => $self->payinfo },
260 warn "WARNING: can't find pre-found prepay_credit: ". $self->payinfo
261 unless $prepay_credit;
262 $amount = $prepay_credit->amount;
263 $seconds = $prepay_credit->seconds;
264 my $error = $prepay_credit->delete;
266 $dbh->rollback if $oldAutoCommit;
267 return "removing prepay_credit (transaction rolled back): $error";
271 my $error = $self->SUPER::insert;
273 $dbh->rollback if $oldAutoCommit;
274 #return "inserting cust_main record (transaction rolled back): $error";
279 if ( $invoicing_list ) {
280 $error = $self->check_invoicing_list( $invoicing_list );
282 $dbh->rollback if $oldAutoCommit;
283 return "checking invoicing_list (transaction rolled back): $error";
285 $self->invoicing_list( $invoicing_list );
289 #local $FS::svc_Common::noexport_hack = 1 if $options{'noexport'};
290 $error = $self->order_pkgs($cust_pkgs, \$seconds, %options);
292 $dbh->rollback if $oldAutoCommit;
297 $dbh->rollback if $oldAutoCommit;
298 return "No svc_acct record to apply pre-paid time";
302 my $cust_credit = new FS::cust_credit {
303 'custnum' => $self->custnum,
306 $error = $cust_credit->insert;
308 $dbh->rollback if $oldAutoCommit;
309 return "inserting credit (transaction rolled back): $error";
313 $error = $self->queue_fuzzyfiles_update;
315 $dbh->rollback if $oldAutoCommit;
316 return "updating fuzzy search cache: $error";
319 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
324 =item order_pkgs HASHREF, [ , OPTION => VALUE ... ] ]
326 Like the insert method on an existing record, this method orders a package
327 and included services atomicaly. Pass a Tie::RefHash data structure to this
328 method containing FS::cust_pkg and FS::svc_I<tablename> objects. There should
329 be a better explanation of this, but until then, here's an example:
332 tie %hash, 'Tie::RefHash'; #this part is important
334 $cust_pkg => [ $svc_acct ],
337 $cust_main->order_pkgs( \%hash, 'noexport'=>1 );
339 Currently available options are: I<noexport>
341 If I<noexport> is set true, no provisioning jobs (exports) are scheduled.
342 (You can schedule them later with the B<reexport> method for each
343 cust_pkg object. Using the B<reexport> method on the cust_main object is not
344 recommended, as existing services will also be reexported.)
350 my $cust_pkgs = shift;
354 local $SIG{HUP} = 'IGNORE';
355 local $SIG{INT} = 'IGNORE';
356 local $SIG{QUIT} = 'IGNORE';
357 local $SIG{TERM} = 'IGNORE';
358 local $SIG{TSTP} = 'IGNORE';
359 local $SIG{PIPE} = 'IGNORE';
361 my $oldAutoCommit = $FS::UID::AutoCommit;
362 local $FS::UID::AutoCommit = 0;
365 local $FS::svc_Common::noexport_hack = 1 if $options{'noexport'};
367 foreach my $cust_pkg ( keys %$cust_pkgs ) {
368 $cust_pkg->custnum( $self->custnum );
369 my $error = $cust_pkg->insert;
371 $dbh->rollback if $oldAutoCommit;
372 return "inserting cust_pkg (transaction rolled back): $error";
374 foreach my $svc_something ( @{$cust_pkgs->{$cust_pkg}} ) {
375 $svc_something->pkgnum( $cust_pkg->pkgnum );
376 if ( $seconds && $$seconds && $svc_something->isa('FS::svc_acct') ) {
377 $svc_something->seconds( $svc_something->seconds + $$seconds );
380 $error = $svc_something->insert;
382 $dbh->rollback if $oldAutoCommit;
383 #return "inserting svc_ (transaction rolled back): $error";
389 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
395 Re-schedules all exports by calling the B<reexport> method of all associated
396 packages (see L<FS::cust_pkg>). If there is an error, returns the error;
397 otherwise returns false.
404 local $SIG{HUP} = 'IGNORE';
405 local $SIG{INT} = 'IGNORE';
406 local $SIG{QUIT} = 'IGNORE';
407 local $SIG{TERM} = 'IGNORE';
408 local $SIG{TSTP} = 'IGNORE';
409 local $SIG{PIPE} = 'IGNORE';
411 my $oldAutoCommit = $FS::UID::AutoCommit;
412 local $FS::UID::AutoCommit = 0;
415 foreach my $cust_pkg ( $self->ncancelled_pkgs ) {
416 my $error = $cust_pkg->reexport;
418 $dbh->rollback if $oldAutoCommit;
423 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
428 =item delete NEW_CUSTNUM
430 This deletes the customer. If there is an error, returns the error, otherwise
433 This will completely remove all traces of the customer record. This is not
434 what you want when a customer cancels service; for that, cancel all of the
435 customer's packages (see L</cancel>).
437 If the customer has any uncancelled packages, you need to pass a new (valid)
438 customer number for those packages to be transferred to. Cancelled packages
439 will be deleted. Did I mention that this is NOT what you want when a customer
440 cancels service and that you really should be looking see L<FS::cust_pkg/cancel>?
442 You can't delete a customer with invoices (see L<FS::cust_bill>),
443 or credits (see L<FS::cust_credit>), payments (see L<FS::cust_pay>) or
444 refunds (see L<FS::cust_refund>).
451 local $SIG{HUP} = 'IGNORE';
452 local $SIG{INT} = 'IGNORE';
453 local $SIG{QUIT} = 'IGNORE';
454 local $SIG{TERM} = 'IGNORE';
455 local $SIG{TSTP} = 'IGNORE';
456 local $SIG{PIPE} = 'IGNORE';
458 my $oldAutoCommit = $FS::UID::AutoCommit;
459 local $FS::UID::AutoCommit = 0;
462 if ( $self->cust_bill ) {
463 $dbh->rollback if $oldAutoCommit;
464 return "Can't delete a customer with invoices";
466 if ( $self->cust_credit ) {
467 $dbh->rollback if $oldAutoCommit;
468 return "Can't delete a customer with credits";
470 if ( $self->cust_pay ) {
471 $dbh->rollback if $oldAutoCommit;
472 return "Can't delete a customer with payments";
474 if ( $self->cust_refund ) {
475 $dbh->rollback if $oldAutoCommit;
476 return "Can't delete a customer with refunds";
479 my @cust_pkg = $self->ncancelled_pkgs;
481 my $new_custnum = shift;
482 unless ( qsearchs( 'cust_main', { 'custnum' => $new_custnum } ) ) {
483 $dbh->rollback if $oldAutoCommit;
484 return "Invalid new customer number: $new_custnum";
486 foreach my $cust_pkg ( @cust_pkg ) {
487 my %hash = $cust_pkg->hash;
488 $hash{'custnum'} = $new_custnum;
489 my $new_cust_pkg = new FS::cust_pkg ( \%hash );
490 my $error = $new_cust_pkg->replace($cust_pkg);
492 $dbh->rollback if $oldAutoCommit;
497 my @cancelled_cust_pkg = $self->all_pkgs;
498 foreach my $cust_pkg ( @cancelled_cust_pkg ) {
499 my $error = $cust_pkg->delete;
501 $dbh->rollback if $oldAutoCommit;
506 foreach my $cust_main_invoice ( #(email invoice destinations, not invoices)
507 qsearch( 'cust_main_invoice', { 'custnum' => $self->custnum } )
509 my $error = $cust_main_invoice->delete;
511 $dbh->rollback if $oldAutoCommit;
516 my $error = $self->SUPER::delete;
518 $dbh->rollback if $oldAutoCommit;
522 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
527 =item replace OLD_RECORD [ INVOICING_LIST_ARYREF ]
529 Replaces the OLD_RECORD with this one in the database. If there is an error,
530 returns the error, otherwise returns false.
532 INVOICING_LIST_ARYREF: If you pass an arrarref to the insert method, it will
533 be set as the invoicing list (see L<"invoicing_list">). Errors return as
534 expected and rollback the entire transaction; it is not necessary to call
535 check_invoicing_list first. Here's an example:
537 $new_cust_main->replace( $old_cust_main, [ $email, 'POST' ] );
546 local $SIG{HUP} = 'IGNORE';
547 local $SIG{INT} = 'IGNORE';
548 local $SIG{QUIT} = 'IGNORE';
549 local $SIG{TERM} = 'IGNORE';
550 local $SIG{TSTP} = 'IGNORE';
551 local $SIG{PIPE} = 'IGNORE';
553 if ( $self->payby eq 'COMP' && $self->payby ne $old->payby
554 && $conf->config('users-allow_comp') ) {
555 return "You are not permitted to create complimentary accounts."
556 unless grep { $_ eq getotaker } $conf->config('users-allow_comp');
559 my $oldAutoCommit = $FS::UID::AutoCommit;
560 local $FS::UID::AutoCommit = 0;
563 my $error = $self->SUPER::replace($old);
566 $dbh->rollback if $oldAutoCommit;
570 if ( @param ) { # INVOICING_LIST_ARYREF
571 my $invoicing_list = shift @param;
572 $error = $self->check_invoicing_list( $invoicing_list );
574 $dbh->rollback if $oldAutoCommit;
577 $self->invoicing_list( $invoicing_list );
580 if ( $self->payby =~ /^(CARD|CHEK|LECB)$/ &&
581 grep { $self->get($_) ne $old->get($_) } qw(payinfo paydate payname) ) {
582 # card/check/lec info has changed, want to retry realtime_ invoice events
583 my $error = $self->retry_realtime;
585 $dbh->rollback if $oldAutoCommit;
590 $error = $self->queue_fuzzyfiles_update;
592 $dbh->rollback if $oldAutoCommit;
593 return "updating fuzzy search cache: $error";
596 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
601 =item queue_fuzzyfiles_update
603 Used by insert & replace to update the fuzzy search cache
607 sub queue_fuzzyfiles_update {
610 local $SIG{HUP} = 'IGNORE';
611 local $SIG{INT} = 'IGNORE';
612 local $SIG{QUIT} = 'IGNORE';
613 local $SIG{TERM} = 'IGNORE';
614 local $SIG{TSTP} = 'IGNORE';
615 local $SIG{PIPE} = 'IGNORE';
617 my $oldAutoCommit = $FS::UID::AutoCommit;
618 local $FS::UID::AutoCommit = 0;
621 my $queue = new FS::queue { 'job' => 'FS::cust_main::append_fuzzyfiles' };
622 my $error = $queue->insert($self->getfield('last'), $self->company);
624 $dbh->rollback if $oldAutoCommit;
625 return "queueing job (transaction rolled back): $error";
628 if ( defined $self->dbdef_table->column('ship_last') && $self->ship_last ) {
629 $queue = new FS::queue { 'job' => 'FS::cust_main::append_fuzzyfiles' };
630 $error = $queue->insert($self->getfield('ship_last'), $self->ship_company);
632 $dbh->rollback if $oldAutoCommit;
633 return "queueing job (transaction rolled back): $error";
637 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
644 Checks all fields to make sure this is a valid customer record. If there is
645 an error, returns the error, otherwise returns false. Called by the insert
653 #warn "BEFORE: \n". $self->_dump;
656 $self->ut_numbern('custnum')
657 || $self->ut_number('agentnum')
658 || $self->ut_number('refnum')
659 || $self->ut_name('last')
660 || $self->ut_name('first')
661 || $self->ut_textn('company')
662 || $self->ut_text('address1')
663 || $self->ut_textn('address2')
664 || $self->ut_text('city')
665 || $self->ut_textn('county')
666 || $self->ut_textn('state')
667 || $self->ut_country('country')
668 || $self->ut_anything('comments')
669 || $self->ut_numbern('referral_custnum')
671 #barf. need message catalogs. i18n. etc.
672 $error .= "Please select an advertising source."
673 if $error =~ /^Illegal or empty \(numeric\) refnum: /;
674 return $error if $error;
676 return "Unknown agent"
677 unless qsearchs( 'agent', { 'agentnum' => $self->agentnum } );
679 return "Unknown refnum"
680 unless qsearchs( 'part_referral', { 'refnum' => $self->refnum } );
682 return "Unknown referring custnum ". $self->referral_custnum
683 unless ! $self->referral_custnum
684 || qsearchs( 'cust_main', { 'custnum' => $self->referral_custnum } );
686 if ( $self->ss eq '' ) {
691 $ss =~ /^(\d{3})(\d{2})(\d{4})$/
692 or return "Illegal social security number: ". $self->ss;
693 $self->ss("$1-$2-$3");
697 # bad idea to disable, causes billing to fail because of no tax rates later
698 # unless ( $import ) {
699 unless ( qsearch('cust_main_county', {
700 'country' => $self->country,
703 return "Unknown state/county/country: ".
704 $self->state. "/". $self->county. "/". $self->country
705 unless qsearch('cust_main_county',{
706 'state' => $self->state,
707 'county' => $self->county,
708 'country' => $self->country,
714 $self->ut_phonen('daytime', $self->country)
715 || $self->ut_phonen('night', $self->country)
716 || $self->ut_phonen('fax', $self->country)
717 || $self->ut_zip('zip', $self->country)
719 return $error if $error;
722 last first company address1 address2 city county state zip
723 country daytime night fax
726 if ( defined $self->dbdef_table->column('ship_last') ) {
727 if ( scalar ( grep { $self->getfield($_) ne $self->getfield("ship_$_") }
729 && scalar ( grep { $self->getfield("ship_$_") ne '' } @addfields )
733 $self->ut_name('ship_last')
734 || $self->ut_name('ship_first')
735 || $self->ut_textn('ship_company')
736 || $self->ut_text('ship_address1')
737 || $self->ut_textn('ship_address2')
738 || $self->ut_text('ship_city')
739 || $self->ut_textn('ship_county')
740 || $self->ut_textn('ship_state')
741 || $self->ut_country('ship_country')
743 return $error if $error;
745 #false laziness with above
746 unless ( qsearchs('cust_main_county', {
747 'country' => $self->ship_country,
750 return "Unknown ship_state/ship_county/ship_country: ".
751 $self->ship_state. "/". $self->ship_county. "/". $self->ship_country
752 unless qsearchs('cust_main_county',{
753 'state' => $self->ship_state,
754 'county' => $self->ship_county,
755 'country' => $self->ship_country,
761 $self->ut_phonen('ship_daytime', $self->ship_country)
762 || $self->ut_phonen('ship_night', $self->ship_country)
763 || $self->ut_phonen('ship_fax', $self->ship_country)
764 || $self->ut_zip('ship_zip', $self->ship_country)
766 return $error if $error;
768 } else { # ship_ info eq billing info, so don't store dup info in database
769 $self->setfield("ship_$_", '')
770 foreach qw( last first company address1 address2 city county state zip
771 country daytime night fax );
775 $self->payby =~ /^(CARD|CHEK|LECB|BILL|COMP|PREPAY)$/
776 or return "Illegal payby: ". $self->payby;
779 if ( $self->payby eq 'CARD' ) {
781 my $payinfo = $self->payinfo;
783 $payinfo =~ /^(\d{13,16})$/
784 or return gettext('invalid_card'); # . ": ". $self->payinfo;
786 $self->payinfo($payinfo);
788 or return gettext('invalid_card'); # . ": ". $self->payinfo;
789 return gettext('unknown_card_type')
790 if cardtype($self->payinfo) eq "Unknown";
791 if ( defined $self->dbdef_table->column('paycvv') ) {
792 if ( length($self->paycvv) ) {
793 if ( cardtype($self->payinfo) eq 'American Express card' ) {
794 $self->paycvv =~ /^(\d{4})$/
795 or return "CVV2 (CID) for American Express cards is four digits.";
798 $self->paycvv =~ /^(\d{3})$/
799 or return "CVV2 (CVC2/CID) is three digits.";
807 } elsif ( $self->payby eq 'CHEK' ) {
809 my $payinfo = $self->payinfo;
810 $payinfo =~ s/[^\d\@]//g;
811 $payinfo =~ /^(\d+)\@(\d{9})$/ or return 'invalid echeck account@aba';
813 $self->payinfo($payinfo);
814 $self->paycvv('') if $self->dbdef_table->column('paycvv');
816 } elsif ( $self->payby eq 'LECB' ) {
818 my $payinfo = $self->payinfo;
820 $payinfo =~ /^1?(\d{10})$/ or return 'invalid btn billing telephone number';
822 $self->payinfo($payinfo);
823 $self->paycvv('') if $self->dbdef_table->column('paycvv');
825 } elsif ( $self->payby eq 'BILL' ) {
827 $error = $self->ut_textn('payinfo');
828 return "Illegal P.O. number: ". $self->payinfo if $error;
829 $self->paycvv('') if $self->dbdef_table->column('paycvv');
831 } elsif ( $self->payby eq 'COMP' ) {
833 if ( !$self->custnum && $conf->config('users-allow_comp') ) {
834 return "You are not permitted to create complimentary accounts."
835 unless grep { $_ eq getotaker } $conf->config('users-allow_comp');
838 $error = $self->ut_textn('payinfo');
839 return "Illegal comp account issuer: ". $self->payinfo if $error;
840 $self->paycvv('') if $self->dbdef_table->column('paycvv');
842 } elsif ( $self->payby eq 'PREPAY' ) {
844 my $payinfo = $self->payinfo;
845 $payinfo =~ s/\W//g; #anything else would just confuse things
846 $self->payinfo($payinfo);
847 $error = $self->ut_alpha('payinfo');
848 return "Illegal prepayment identifier: ". $self->payinfo if $error;
849 return "Unknown prepayment identifier"
850 unless qsearchs('prepay_credit', { 'identifier' => $self->payinfo } );
851 $self->paycvv('') if $self->dbdef_table->column('paycvv');
855 if ( $self->paydate eq '' || $self->paydate eq '-' ) {
856 return "Expriation date required"
857 unless $self->payby =~ /^(BILL|PREPAY|CHEK|LECB)$/;
860 $self->paydate =~ /^(\d{1,2})[\/\-](\d{2}(\d{2})?)$/
861 or return "Illegal expiration date: ". $self->paydate;
862 my $y = length($2) == 4 ? $2 : "20$2";
863 $self->paydate("$y-$1-01");
864 my($nowm,$nowy)=(localtime(time))[4,5]; $nowm++; $nowy+=1900;
865 return gettext('expired_card')
866 if !$import && ( $y<$nowy || ( $y==$nowy && $1<$nowm ) );
869 if ( $self->payname eq '' && $self->payby ne 'CHEK' &&
870 ( ! $conf->exists('require_cardname') || $self->payby ne 'CARD' ) ) {
871 $self->payname( $self->first. " ". $self->getfield('last') );
873 $self->payname =~ /^([\w \,\.\-\']+)$/
874 or return gettext('illegal_name'). " payname: ". $self->payname;
878 $self->tax =~ /^(Y?)$/ or return "Illegal tax: ". $self->tax;
881 $self->otaker(getotaker) unless $self->otaker;
883 #warn "AFTER: \n". $self->_dump;
890 Returns all packages (see L<FS::cust_pkg>) for this customer.
896 if ( $self->{'_pkgnum'} ) {
897 values %{ $self->{'_pkgnum'}->cache };
899 qsearch( 'cust_pkg', { 'custnum' => $self->custnum });
903 =item ncancelled_pkgs
905 Returns all non-cancelled packages (see L<FS::cust_pkg>) for this customer.
909 sub ncancelled_pkgs {
911 if ( $self->{'_pkgnum'} ) {
912 grep { ! $_->getfield('cancel') } values %{ $self->{'_pkgnum'}->cache };
914 @{ [ # force list context
915 qsearch( 'cust_pkg', {
916 'custnum' => $self->custnum,
919 qsearch( 'cust_pkg', {
920 'custnum' => $self->custnum,
929 Returns all suspended packages (see L<FS::cust_pkg>) for this customer.
935 grep { $_->susp } $self->ncancelled_pkgs;
938 =item unflagged_suspended_pkgs
940 Returns all unflagged suspended packages (see L<FS::cust_pkg>) for this
941 customer (thouse packages without the `manual_flag' set).
945 sub unflagged_suspended_pkgs {
947 return $self->suspended_pkgs
948 unless dbdef->table('cust_pkg')->column('manual_flag');
949 grep { ! $_->manual_flag } $self->suspended_pkgs;
952 =item unsuspended_pkgs
954 Returns all unsuspended (and uncancelled) packages (see L<FS::cust_pkg>) for
959 sub unsuspended_pkgs {
961 grep { ! $_->susp } $self->ncancelled_pkgs;
966 Unsuspends all unflagged suspended packages (see L</unflagged_suspended_pkgs>
967 and L<FS::cust_pkg>) for this customer. Always returns a list: an empty list
968 on success or a list of errors.
974 grep { $_->unsuspend } $self->suspended_pkgs;
979 Suspends all unsuspended packages (see L<FS::cust_pkg>) for this customer.
980 Always returns a list: an empty list on success or a list of errors.
986 grep { $_->suspend } $self->unsuspended_pkgs;
989 =item cancel [ OPTION => VALUE ... ]
991 Cancels all uncancelled packages (see L<FS::cust_pkg>) for this customer.
993 Available options are: I<quiet>
995 I<quiet> can be set true to supress email cancellation notices.
997 Always returns a list: an empty list on success or a list of errors.
1003 grep { $_->cancel(@_) } $self->ncancelled_pkgs;
1008 Returns the agent (see L<FS::agent>) for this customer.
1014 qsearchs( 'agent', { 'agentnum' => $self->agentnum } );
1019 Generates invoices (see L<FS::cust_bill>) for this customer. Usually used in
1020 conjunction with the collect method.
1022 Options are passed as name-value pairs.
1024 Currently available options are:
1026 resetup - if set true, re-charges setup fees.
1028 time - bills the customer as if it were that time. Specified as a UNIX
1029 timestamp; see L<perlfunc/"time">). Also see L<Time::Local> and
1030 L<Date::Parse> for conversion functions. For example:
1034 $cust_main->bill( 'time' => str2time('April 20th, 2001') );
1037 If there is an error, returns the error, otherwise returns false.
1042 my( $self, %options ) = @_;
1043 my $time = $options{'time'} || time;
1048 local $SIG{HUP} = 'IGNORE';
1049 local $SIG{INT} = 'IGNORE';
1050 local $SIG{QUIT} = 'IGNORE';
1051 local $SIG{TERM} = 'IGNORE';
1052 local $SIG{TSTP} = 'IGNORE';
1053 local $SIG{PIPE} = 'IGNORE';
1055 my $oldAutoCommit = $FS::UID::AutoCommit;
1056 local $FS::UID::AutoCommit = 0;
1059 # find the packages which are due for billing, find out how much they are
1060 # & generate invoice database.
1062 my( $total_setup, $total_recur ) = ( 0, 0 );
1063 #my( $taxable_setup, $taxable_recur ) = ( 0, 0 );
1064 my @cust_bill_pkg = ();
1066 #my $taxable_charged = 0;##
1071 foreach my $cust_pkg (
1072 qsearch('cust_pkg', { 'custnum' => $self->custnum } )
1075 #NO!! next if $cust_pkg->cancel;
1076 next if $cust_pkg->getfield('cancel');
1078 #? to avoid use of uninitialized value errors... ?
1079 $cust_pkg->setfield('bill', '')
1080 unless defined($cust_pkg->bill);
1082 my $part_pkg = $cust_pkg->part_pkg;
1084 #so we don't modify cust_pkg record unnecessarily
1085 my $cust_pkg_mod_flag = 0;
1086 my %hash = $cust_pkg->hash;
1087 my $old_cust_pkg = new FS::cust_pkg \%hash;
1091 if ( !$cust_pkg->setup || $options{'resetup'} ) {
1092 my $setup_prog = $part_pkg->getfield('setup');
1093 $setup_prog =~ /^(.*)$/ or do {
1094 $dbh->rollback if $oldAutoCommit;
1095 return "Illegal setup for pkgpart ". $part_pkg->pkgpart.
1099 $setup_prog = '0' if $setup_prog =~ /^\s*$/;
1101 #my $cpt = new Safe;
1102 ##$cpt->permit(); #what is necessary?
1103 #$cpt->share(qw( $cust_pkg )); #can $cpt now use $cust_pkg methods?
1104 #$setup = $cpt->reval($setup_prog);
1105 $setup = eval $setup_prog;
1106 unless ( defined($setup) ) {
1107 $dbh->rollback if $oldAutoCommit;
1108 return "Error eval-ing part_pkg->setup pkgpart ". $part_pkg->pkgpart.
1109 "(expression $setup_prog): $@";
1111 $cust_pkg->setfield('setup', $time) unless $cust_pkg->setup;
1112 $cust_pkg_mod_flag=1;
1118 if ( $part_pkg->getfield('freq') ne '0' &&
1119 ! $cust_pkg->getfield('susp') &&
1120 ( $cust_pkg->getfield('bill') || 0 ) <= $time
1122 my $recur_prog = $part_pkg->getfield('recur');
1123 $recur_prog =~ /^(.*)$/ or do {
1124 $dbh->rollback if $oldAutoCommit;
1125 return "Illegal recur for pkgpart ". $part_pkg->pkgpart.
1129 $recur_prog = '0' if $recur_prog =~ /^\s*$/;
1131 # shared with $recur_prog
1132 $sdate = $cust_pkg->bill || $cust_pkg->setup || $time;
1134 #my $cpt = new Safe;
1135 ##$cpt->permit(); #what is necessary?
1136 #$cpt->share(qw( $cust_pkg )); #can $cpt now use $cust_pkg methods?
1137 #$recur = $cpt->reval($recur_prog);
1138 $recur = eval $recur_prog;
1139 unless ( defined($recur) ) {
1140 $dbh->rollback if $oldAutoCommit;
1141 return "Error eval-ing part_pkg->recur pkgpart ". $part_pkg->pkgpart.
1142 "(expression $recur_prog): $@";
1144 #change this bit to use Date::Manip? CAREFUL with timezones (see
1145 # mailing list archive)
1146 my ($sec,$min,$hour,$mday,$mon,$year) =
1147 (localtime($sdate) )[0,1,2,3,4,5];
1149 #pro-rating magic - if $recur_prog fiddles $sdate, want to use that
1150 # only for figuring next bill date, nothing else, so, reset $sdate again
1152 $sdate = $cust_pkg->bill || $cust_pkg->setup || $time;
1153 $cust_pkg->last_bill($sdate)
1154 if $cust_pkg->dbdef_table->column('last_bill');
1156 if ( $part_pkg->freq =~ /^\d+$/ ) {
1157 $mon += $part_pkg->freq;
1158 until ( $mon < 12 ) { $mon -= 12; $year++; }
1159 } elsif ( $part_pkg->freq =~ /^(\d+)w$/ ) {
1161 $mday += $weeks * 7;
1162 } elsif ( $part_pkg->freq =~ /^(\d+)d$/ ) {
1166 $dbh->rollback if $oldAutoCommit;
1167 return "unparsable frequency: ". $part_pkg->freq;
1169 $cust_pkg->setfield('bill',
1170 timelocal_nocheck($sec,$min,$hour,$mday,$mon,$year));
1171 $cust_pkg_mod_flag = 1;
1174 warn "\$setup is undefined" unless defined($setup);
1175 warn "\$recur is undefined" unless defined($recur);
1176 warn "\$cust_pkg->bill is undefined" unless defined($cust_pkg->bill);
1178 if ( $cust_pkg_mod_flag ) {
1179 $error=$cust_pkg->replace($old_cust_pkg);
1180 if ( $error ) { #just in case
1181 $dbh->rollback if $oldAutoCommit;
1182 return "Error modifying pkgnum ". $cust_pkg->pkgnum. ": $error";
1184 $setup = sprintf( "%.2f", $setup );
1185 $recur = sprintf( "%.2f", $recur );
1187 $dbh->rollback if $oldAutoCommit;
1188 return "negative setup $setup for pkgnum ". $cust_pkg->pkgnum;
1191 $dbh->rollback if $oldAutoCommit;
1192 return "negative recur $recur for pkgnum ". $cust_pkg->pkgnum;
1194 if ( $setup > 0 || $recur > 0 ) {
1195 my $cust_bill_pkg = new FS::cust_bill_pkg ({
1196 'pkgnum' => $cust_pkg->pkgnum,
1200 'edate' => $cust_pkg->bill,
1202 push @cust_bill_pkg, $cust_bill_pkg;
1203 $total_setup += $setup;
1204 $total_recur += $recur;
1206 unless ( $self->tax =~ /Y/i || $self->payby eq 'COMP' ) {
1208 my @taxes = qsearch( 'cust_main_county', {
1209 'state' => $self->state,
1210 'county' => $self->county,
1211 'country' => $self->country,
1212 'taxclass' => $part_pkg->taxclass,
1215 @taxes = qsearch( 'cust_main_county', {
1216 'state' => $self->state,
1217 'county' => $self->county,
1218 'country' => $self->country,
1223 # maybe eliminate this entirely, along with all the 0% records
1225 $dbh->rollback if $oldAutoCommit;
1227 "fatal: can't find tax rate for state/county/country/taxclass ".
1228 join('/', ( map $self->$_(), qw(state county country) ),
1229 $part_pkg->taxclass ). "\n";
1232 foreach my $tax ( @taxes ) {
1234 my $taxable_charged = 0;
1235 $taxable_charged += $setup
1236 unless $part_pkg->setuptax =~ /^Y$/i
1237 || $tax->setuptax =~ /^Y$/i;
1238 $taxable_charged += $recur
1239 unless $part_pkg->recurtax =~ /^Y$/i
1240 || $tax->recurtax =~ /^Y$/i;
1241 next unless $taxable_charged;
1243 if ( $tax->exempt_amount > 0 ) {
1244 my ($mon,$year) = (localtime($sdate) )[4,5];
1246 my $freq = $part_pkg->freq || 1;
1247 if ( $freq !~ /(\d+)$/ ) {
1248 $dbh->rollback if $oldAutoCommit;
1249 return "daily/weekly package definitions not (yet?)".
1250 " compatible with monthly tax exemptions";
1252 my $taxable_per_month = sprintf("%.2f", $taxable_charged / $freq );
1253 foreach my $which_month ( 1 .. $freq ) {
1255 'custnum' => $self->custnum,
1256 'taxnum' => $tax->taxnum,
1257 'year' => 1900+$year,
1260 #until ( $mon < 12 ) { $mon -= 12; $year++; }
1261 until ( $mon < 13 ) { $mon -= 12; $year++; }
1262 my $cust_tax_exempt =
1263 qsearchs('cust_tax_exempt', \%hash)
1264 || new FS::cust_tax_exempt( { %hash, 'amount' => 0 } );
1265 my $remaining_exemption = sprintf("%.2f",
1266 $tax->exempt_amount - $cust_tax_exempt->amount );
1267 if ( $remaining_exemption > 0 ) {
1268 my $addl = $remaining_exemption > $taxable_per_month
1269 ? $taxable_per_month
1270 : $remaining_exemption;
1271 $taxable_charged -= $addl;
1272 my $new_cust_tax_exempt = new FS::cust_tax_exempt ( {
1273 $cust_tax_exempt->hash,
1275 sprintf("%.2f", $cust_tax_exempt->amount + $addl),
1277 $error = $new_cust_tax_exempt->exemptnum
1278 ? $new_cust_tax_exempt->replace($cust_tax_exempt)
1279 : $new_cust_tax_exempt->insert;
1281 $dbh->rollback if $oldAutoCommit;
1282 return "fatal: can't update cust_tax_exempt: $error";
1285 } # if $remaining_exemption > 0
1287 } #foreach $which_month
1289 } #if $tax->exempt_amount
1291 $taxable_charged = sprintf( "%.2f", $taxable_charged);
1293 #$tax += $taxable_charged * $cust_main_county->tax / 100
1294 $tax{ $tax->taxname || 'Tax' } +=
1295 $taxable_charged * $tax->tax / 100
1297 } #foreach my $tax ( @taxes )
1299 } #unless $self->tax =~ /Y/i || $self->payby eq 'COMP'
1301 } #if $setup > 0 || $recur > 0
1303 } #if $cust_pkg_mod_flag
1305 } #foreach my $cust_pkg
1307 my $charged = sprintf( "%.2f", $total_setup + $total_recur );
1308 # my $taxable_charged = sprintf( "%.2f", $taxable_setup + $taxable_recur );
1310 unless ( @cust_bill_pkg ) { #don't create invoices with no line items
1311 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1315 # unless ( $self->tax =~ /Y/i
1316 # || $self->payby eq 'COMP'
1317 # || $taxable_charged == 0 ) {
1318 # my $cust_main_county = qsearchs('cust_main_county',{
1319 # 'state' => $self->state,
1320 # 'county' => $self->county,
1321 # 'country' => $self->country,
1322 # } ) or die "fatal: can't find tax rate for state/county/country ".
1323 # $self->state. "/". $self->county. "/". $self->country. "\n";
1324 # my $tax = sprintf( "%.2f",
1325 # $taxable_charged * ( $cust_main_county->getfield('tax') / 100 )
1328 if ( dbdef->table('cust_bill_pkg')->column('itemdesc') ) { #1.5 schema
1330 foreach my $taxname ( grep { $tax{$_} > 0 } keys %tax ) {
1331 my $tax = sprintf("%.2f", $tax{$taxname} );
1332 $charged = sprintf( "%.2f", $charged+$tax );
1334 my $cust_bill_pkg = new FS::cust_bill_pkg ({
1340 'itemdesc' => $taxname,
1342 push @cust_bill_pkg, $cust_bill_pkg;
1345 } else { #1.4 schema
1348 foreach ( values %tax ) { $tax += $_ };
1349 $tax = sprintf("%.2f", $tax);
1351 $charged = sprintf( "%.2f", $charged+$tax );
1353 my $cust_bill_pkg = new FS::cust_bill_pkg ({
1360 push @cust_bill_pkg, $cust_bill_pkg;
1365 my $cust_bill = new FS::cust_bill ( {
1366 'custnum' => $self->custnum,
1368 'charged' => $charged,
1370 $error = $cust_bill->insert;
1372 $dbh->rollback if $oldAutoCommit;
1373 return "can't create invoice for customer #". $self->custnum. ": $error";
1376 my $invnum = $cust_bill->invnum;
1378 foreach $cust_bill_pkg ( @cust_bill_pkg ) {
1380 $cust_bill_pkg->invnum($invnum);
1381 $error = $cust_bill_pkg->insert;
1383 $dbh->rollback if $oldAutoCommit;
1384 return "can't create invoice line item for customer #". $self->custnum.
1389 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1393 =item collect OPTIONS
1395 (Attempt to) collect money for this customer's outstanding invoices (see
1396 L<FS::cust_bill>). Usually used after the bill method.
1398 Depending on the value of `payby', this may print an invoice (`BILL'), charge
1399 a credit card (`CARD'), or just add any necessary (pseudo-)payment (`COMP').
1401 Most actions are now triggered by invoice events; see L<FS::part_bill_event>
1402 and the invoice events web interface.
1404 If there is an error, returns the error, otherwise returns false.
1406 Options are passed as name-value pairs.
1408 Currently available options are:
1410 invoice_time - Use this time when deciding when to print invoices and
1411 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>
1412 for conversion functions.
1414 retry - Retry card/echeck/LEC transactions even when not scheduled by invoice
1417 retry_card - Deprecated alias for 'retry'
1419 batch_card - This option is deprecated. See the invoice events web interface
1420 to control whether cards are batched or run against a realtime gateway.
1422 report_badcard - This option is deprecated.
1424 force_print - This option is deprecated; see the invoice events web interface.
1426 quiet - set true to surpress email card/ACH decline notices.
1431 my( $self, %options ) = @_;
1432 my $invoice_time = $options{'invoice_time'} || time;
1435 local $SIG{HUP} = 'IGNORE';
1436 local $SIG{INT} = 'IGNORE';
1437 local $SIG{QUIT} = 'IGNORE';
1438 local $SIG{TERM} = 'IGNORE';
1439 local $SIG{TSTP} = 'IGNORE';
1440 local $SIG{PIPE} = 'IGNORE';
1442 my $oldAutoCommit = $FS::UID::AutoCommit;
1443 local $FS::UID::AutoCommit = 0;
1446 my $balance = $self->balance;
1447 warn "collect customer". $self->custnum. ": balance $balance" if $Debug;
1448 unless ( $balance > 0 ) { #redundant?????
1449 $dbh->rollback if $oldAutoCommit; #hmm
1453 if ( exists($options{'retry_card'}) ) {
1454 carp 'retry_card option passed to collect is deprecated; use retry';
1455 $options{'retry'} ||= $options{'retry_card'};
1457 if ( exists($options{'retry'}) && $options{'retry'} ) {
1458 my $error = $self->retry_realtime;
1460 $dbh->rollback if $oldAutoCommit;
1465 foreach my $cust_bill ( $self->open_cust_bill ) {
1467 # don't try to charge for the same invoice if it's already in a batch
1468 #next if qsearchs( 'cust_pay_batch', { 'invnum' => $cust_bill->invnum } );
1470 last if $self->balance <= 0;
1472 warn "invnum ". $cust_bill->invnum. " (owed ". $cust_bill->owed. ")"
1475 foreach my $part_bill_event (
1476 sort { $a->seconds <=> $b->seconds
1477 || $a->weight <=> $b->weight
1478 || $a->eventpart <=> $b->eventpart }
1479 grep { $_->seconds <= ( $invoice_time - $cust_bill->_date )
1480 && ! qsearchs( 'cust_bill_event', {
1481 'invnum' => $cust_bill->invnum,
1482 'eventpart' => $_->eventpart,
1486 qsearch('part_bill_event', { 'payby' => $self->payby,
1487 'disabled' => '', } )
1490 last if $cust_bill->owed <= 0 # don't run subsequent events if owed<=0
1491 || $self->balance <= 0; # or if balance<=0
1493 warn "calling invoice event (". $part_bill_event->eventcode. ")\n"
1495 my $cust_main = $self; #for callback
1499 #supress "used only once" warning
1500 $FS::cust_bill::realtime_bop_decline_quiet += 0;
1501 local $FS::cust_bill::realtime_bop_decline_quiet = 1
1502 if $options{'quiet'};
1503 $error = eval $part_bill_event->eventcode;
1507 my $statustext = '';
1511 } elsif ( $error ) {
1513 $statustext = $error;
1518 #add cust_bill_event
1519 my $cust_bill_event = new FS::cust_bill_event {
1520 'invnum' => $cust_bill->invnum,
1521 'eventpart' => $part_bill_event->eventpart,
1522 #'_date' => $invoice_time,
1524 'status' => $status,
1525 'statustext' => $statustext,
1527 $error = $cust_bill_event->insert;
1529 #$dbh->rollback if $oldAutoCommit;
1530 #return "error: $error";
1532 # gah, even with transactions.
1533 $dbh->commit if $oldAutoCommit; #well.
1534 my $e = 'WARNING: Event run but database not updated - '.
1535 'error inserting cust_bill_event, invnum #'. $cust_bill->invnum.
1536 ', eventpart '. $part_bill_event->eventpart.
1547 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1552 =item retry_realtime
1554 Schedules realtime credit card / electronic check / LEC billing events for
1555 for retry. Useful if card information has changed or manual retry is desired.
1556 The 'collect' method must be called to actually retry the transaction.
1558 Implementation details: For each of this customer's open invoices, changes
1559 the status of the first "done" (with statustext error) realtime processing
1564 sub retry_realtime {
1567 local $SIG{HUP} = 'IGNORE';
1568 local $SIG{INT} = 'IGNORE';
1569 local $SIG{QUIT} = 'IGNORE';
1570 local $SIG{TERM} = 'IGNORE';
1571 local $SIG{TSTP} = 'IGNORE';
1572 local $SIG{PIPE} = 'IGNORE';
1574 my $oldAutoCommit = $FS::UID::AutoCommit;
1575 local $FS::UID::AutoCommit = 0;
1578 foreach my $cust_bill (
1579 grep { $_->cust_bill_event }
1580 $self->open_cust_bill
1582 my @cust_bill_event =
1583 sort { $a->part_bill_event->seconds <=> $b->part_bill_event->seconds }
1585 #$_->part_bill_event->plan eq 'realtime-card'
1586 $_->part_bill_event->eventcode =~
1587 /\$cust_bill\->realtime_(card|ach|lec)/
1588 && $_->status eq 'done'
1591 $cust_bill->cust_bill_event;
1592 next unless @cust_bill_event;
1593 my $error = $cust_bill_event[0]->retry;
1595 $dbh->rollback if $oldAutoCommit;
1596 return "error scheduling invoice event for retry: $error";
1601 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1608 Returns the total owed for this customer on all invoices
1609 (see L<FS::cust_bill/owed>).
1615 $self->total_owed_date(2145859200); #12/31/2037
1618 =item total_owed_date TIME
1620 Returns the total owed for this customer on all invoices with date earlier than
1621 TIME. TIME is specified as a UNIX timestamp; see L<perlfunc/"time">). Also
1622 see L<Time::Local> and L<Date::Parse> for conversion functions.
1626 sub total_owed_date {
1630 foreach my $cust_bill (
1631 grep { $_->_date <= $time }
1632 qsearch('cust_bill', { 'custnum' => $self->custnum, } )
1634 $total_bill += $cust_bill->owed;
1636 sprintf( "%.2f", $total_bill );
1641 Applies (see L<FS::cust_credit_bill>) unapplied credits (see L<FS::cust_credit>)
1642 to outstanding invoice balances in chronological order and returns the value
1643 of any remaining unapplied credits available for refund
1644 (see L<FS::cust_refund>).
1651 return 0 unless $self->total_credited;
1653 my @credits = sort { $b->_date <=> $a->_date} (grep { $_->credited > 0 }
1654 qsearch('cust_credit', { 'custnum' => $self->custnum } ) );
1656 my @invoices = sort { $a->_date <=> $b->_date} (grep { $_->owed > 0 }
1657 qsearch('cust_bill', { 'custnum' => $self->custnum } ) );
1661 foreach my $cust_bill ( @invoices ) {
1664 if ( !defined($credit) || $credit->credited == 0) {
1665 $credit = pop @credits or last;
1668 if ($cust_bill->owed >= $credit->credited) {
1669 $amount=$credit->credited;
1671 $amount=$cust_bill->owed;
1674 my $cust_credit_bill = new FS::cust_credit_bill ( {
1675 'crednum' => $credit->crednum,
1676 'invnum' => $cust_bill->invnum,
1677 'amount' => $amount,
1679 my $error = $cust_credit_bill->insert;
1680 die $error if $error;
1682 redo if ($cust_bill->owed > 0);
1686 return $self->total_credited;
1689 =item apply_payments
1691 Applies (see L<FS::cust_bill_pay>) unapplied payments (see L<FS::cust_pay>)
1692 to outstanding invoice balances in chronological order.
1694 #and returns the value of any remaining unapplied payments.
1698 sub apply_payments {
1703 my @payments = sort { $b->_date <=> $a->_date } ( grep { $_->unapplied > 0 }
1704 qsearch('cust_pay', { 'custnum' => $self->custnum } ) );
1706 my @invoices = sort { $a->_date <=> $b->_date} (grep { $_->owed > 0 }
1707 qsearch('cust_bill', { 'custnum' => $self->custnum } ) );
1711 foreach my $cust_bill ( @invoices ) {
1714 if ( !defined($payment) || $payment->unapplied == 0 ) {
1715 $payment = pop @payments or last;
1718 if ( $cust_bill->owed >= $payment->unapplied ) {
1719 $amount = $payment->unapplied;
1721 $amount = $cust_bill->owed;
1724 my $cust_bill_pay = new FS::cust_bill_pay ( {
1725 'paynum' => $payment->paynum,
1726 'invnum' => $cust_bill->invnum,
1727 'amount' => $amount,
1729 my $error = $cust_bill_pay->insert;
1730 die $error if $error;
1732 redo if ( $cust_bill->owed > 0);
1736 return $self->total_unapplied_payments;
1739 =item total_credited
1741 Returns the total outstanding credit (see L<FS::cust_credit>) for this
1742 customer. See L<FS::cust_credit/credited>.
1746 sub total_credited {
1748 my $total_credit = 0;
1749 foreach my $cust_credit ( qsearch('cust_credit', {
1750 'custnum' => $self->custnum,
1752 $total_credit += $cust_credit->credited;
1754 sprintf( "%.2f", $total_credit );
1757 =item total_unapplied_payments
1759 Returns the total unapplied payments (see L<FS::cust_pay>) for this customer.
1760 See L<FS::cust_pay/unapplied>.
1764 sub total_unapplied_payments {
1766 my $total_unapplied = 0;
1767 foreach my $cust_pay ( qsearch('cust_pay', {
1768 'custnum' => $self->custnum,
1770 $total_unapplied += $cust_pay->unapplied;
1772 sprintf( "%.2f", $total_unapplied );
1777 Returns the balance for this customer (total_owed minus total_credited
1778 minus total_unapplied_payments).
1785 $self->total_owed - $self->total_credited - $self->total_unapplied_payments
1789 =item balance_date TIME
1791 Returns the balance for this customer, only considering invoices with date
1792 earlier than TIME (total_owed_date minus total_credited minus
1793 total_unapplied_payments). TIME is specified as a UNIX timestamp; see
1794 L<perlfunc/"time">). Also see L<Time::Local> and L<Date::Parse> for conversion
1803 $self->total_owed_date($time)
1804 - $self->total_credited
1805 - $self->total_unapplied_payments
1809 =item invoicing_list [ ARRAYREF ]
1811 If an arguement is given, sets these email addresses as invoice recipients
1812 (see L<FS::cust_main_invoice>). Errors are not fatal and are not reported
1813 (except as warnings), so use check_invoicing_list first.
1815 Returns a list of email addresses (with svcnum entries expanded).
1817 Note: You can clear the invoicing list by passing an empty ARRAYREF. You can
1818 check it without disturbing anything by passing nothing.
1820 This interface may change in the future.
1824 sub invoicing_list {
1825 my( $self, $arrayref ) = @_;
1827 my @cust_main_invoice;
1828 if ( $self->custnum ) {
1829 @cust_main_invoice =
1830 qsearch( 'cust_main_invoice', { 'custnum' => $self->custnum } );
1832 @cust_main_invoice = ();
1834 foreach my $cust_main_invoice ( @cust_main_invoice ) {
1835 #warn $cust_main_invoice->destnum;
1836 unless ( grep { $cust_main_invoice->address eq $_ } @{$arrayref} ) {
1837 #warn $cust_main_invoice->destnum;
1838 my $error = $cust_main_invoice->delete;
1839 warn $error if $error;
1842 if ( $self->custnum ) {
1843 @cust_main_invoice =
1844 qsearch( 'cust_main_invoice', { 'custnum' => $self->custnum } );
1846 @cust_main_invoice = ();
1848 my %seen = map { $_->address => 1 } @cust_main_invoice;
1849 foreach my $address ( @{$arrayref} ) {
1850 next if exists $seen{$address} && $seen{$address};
1851 $seen{$address} = 1;
1852 my $cust_main_invoice = new FS::cust_main_invoice ( {
1853 'custnum' => $self->custnum,
1856 my $error = $cust_main_invoice->insert;
1857 warn $error if $error;
1860 if ( $self->custnum ) {
1862 qsearch( 'cust_main_invoice', { 'custnum' => $self->custnum } );
1868 =item check_invoicing_list ARRAYREF
1870 Checks these arguements as valid input for the invoicing_list method. If there
1871 is an error, returns the error, otherwise returns false.
1875 sub check_invoicing_list {
1876 my( $self, $arrayref ) = @_;
1877 foreach my $address ( @{$arrayref} ) {
1878 my $cust_main_invoice = new FS::cust_main_invoice ( {
1879 'custnum' => $self->custnum,
1882 my $error = $self->custnum
1883 ? $cust_main_invoice->check
1884 : $cust_main_invoice->checkdest
1886 return $error if $error;
1891 =item set_default_invoicing_list
1893 Sets the invoicing list to all accounts associated with this customer,
1894 overwriting any previous invoicing list.
1898 sub set_default_invoicing_list {
1900 $self->invoicing_list($self->all_emails);
1905 Returns the email addresses of all accounts provisioned for this customer.
1912 foreach my $cust_pkg ( $self->all_pkgs ) {
1913 my @cust_svc = qsearch('cust_svc', { 'pkgnum' => $cust_pkg->pkgnum } );
1915 map { qsearchs('svc_acct', { 'svcnum' => $_->svcnum } ) }
1916 grep { qsearchs('svc_acct', { 'svcnum' => $_->svcnum } ) }
1918 $list{$_}=1 foreach map { $_->email } @svc_acct;
1923 =item invoicing_list_addpost
1925 Adds postal invoicing to this customer. If this customer is already configured
1926 to receive postal invoices, does nothing.
1930 sub invoicing_list_addpost {
1932 return if grep { $_ eq 'POST' } $self->invoicing_list;
1933 my @invoicing_list = $self->invoicing_list;
1934 push @invoicing_list, 'POST';
1935 $self->invoicing_list(\@invoicing_list);
1938 =item referral_cust_main [ DEPTH [ EXCLUDE_HASHREF ] ]
1940 Returns an array of customers referred by this customer (referral_custnum set
1941 to this custnum). If DEPTH is given, recurses up to the given depth, returning
1942 customers referred by customers referred by this customer and so on, inclusive.
1943 The default behavior is DEPTH 1 (no recursion).
1947 sub referral_cust_main {
1949 my $depth = @_ ? shift : 1;
1950 my $exclude = @_ ? shift : {};
1953 map { $exclude->{$_->custnum}++; $_; }
1954 grep { ! $exclude->{ $_->custnum } }
1955 qsearch( 'cust_main', { 'referral_custnum' => $self->custnum } );
1959 map { $_->referral_cust_main($depth-1, $exclude) }
1966 =item referral_cust_main_ncancelled
1968 Same as referral_cust_main, except only returns customers with uncancelled
1973 sub referral_cust_main_ncancelled {
1975 grep { scalar($_->ncancelled_pkgs) } $self->referral_cust_main;
1978 =item referral_cust_pkg [ DEPTH ]
1980 Like referral_cust_main, except returns a flat list of all unsuspended (and
1981 uncancelled) packages for each customer. The number of items in this list may
1982 be useful for comission calculations (perhaps after a C<grep { my $pkgpart = $_->pkgpart; grep { $_ == $pkgpart } @commission_worthy_pkgparts> } $cust_main-> ).
1986 sub referral_cust_pkg {
1988 my $depth = @_ ? shift : 1;
1990 map { $_->unsuspended_pkgs }
1991 grep { $_->unsuspended_pkgs }
1992 $self->referral_cust_main($depth);
1995 =item credit AMOUNT, REASON
1997 Applies a credit to this customer. If there is an error, returns the error,
1998 otherwise returns false.
2003 my( $self, $amount, $reason ) = @_;
2004 my $cust_credit = new FS::cust_credit {
2005 'custnum' => $self->custnum,
2006 'amount' => $amount,
2007 'reason' => $reason,
2009 $cust_credit->insert;
2012 =item charge AMOUNT [ PKG [ COMMENT [ TAXCLASS ] ] ]
2014 Creates a one-time charge for this customer. If there is an error, returns
2015 the error, otherwise returns false.
2020 my ( $self, $amount ) = ( shift, shift );
2021 my $pkg = @_ ? shift : 'One-time charge';
2022 my $comment = @_ ? shift : '$'. sprintf("%.2f",$amount);
2023 my $taxclass = @_ ? shift : '';
2025 local $SIG{HUP} = 'IGNORE';
2026 local $SIG{INT} = 'IGNORE';
2027 local $SIG{QUIT} = 'IGNORE';
2028 local $SIG{TERM} = 'IGNORE';
2029 local $SIG{TSTP} = 'IGNORE';
2030 local $SIG{PIPE} = 'IGNORE';
2032 my $oldAutoCommit = $FS::UID::AutoCommit;
2033 local $FS::UID::AutoCommit = 0;
2036 my $part_pkg = new FS::part_pkg ( {
2038 'comment' => $comment,
2043 'taxclass' => $taxclass,
2046 my $error = $part_pkg->insert;
2048 $dbh->rollback if $oldAutoCommit;
2052 my $pkgpart = $part_pkg->pkgpart;
2053 my %type_pkgs = ( 'typenum' => $self->agent->typenum, 'pkgpart' => $pkgpart );
2054 unless ( qsearchs('type_pkgs', \%type_pkgs ) ) {
2055 my $type_pkgs = new FS::type_pkgs \%type_pkgs;
2056 $error = $type_pkgs->insert;
2058 $dbh->rollback if $oldAutoCommit;
2063 my $cust_pkg = new FS::cust_pkg ( {
2064 'custnum' => $self->custnum,
2065 'pkgpart' => $pkgpart,
2068 $error = $cust_pkg->insert;
2070 $dbh->rollback if $oldAutoCommit;
2074 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2081 Returns all the invoices (see L<FS::cust_bill>) for this customer.
2087 sort { $a->_date <=> $b->_date }
2088 qsearch('cust_bill', { 'custnum' => $self->custnum, } )
2091 =item open_cust_bill
2093 Returns all the open (owed > 0) invoices (see L<FS::cust_bill>) for this
2098 sub open_cust_bill {
2100 grep { $_->owed > 0 } $self->cust_bill;
2105 Returns all the credits (see L<FS::cust_credit>) for this customer.
2111 sort { $a->_date <=> $b->_date }
2112 qsearch( 'cust_credit', { 'custnum' => $self->custnum } )
2117 Returns all the payments (see L<FS::cust_pay>) for this customer.
2123 sort { $a->_date <=> $b->_date }
2124 qsearch( 'cust_pay', { 'custnum' => $self->custnum } )
2129 Returns all the refunds (see L<FS::cust_refund>) for this customer.
2135 sort { $a->_date <=> $b->_date }
2136 qsearch( 'cust_refund', { 'custnum' => $self->custnum } )
2145 =item check_and_rebuild_fuzzyfiles
2149 sub check_and_rebuild_fuzzyfiles {
2150 my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
2151 -e "$dir/cust_main.last" && -e "$dir/cust_main.company"
2152 or &rebuild_fuzzyfiles;
2155 =item rebuild_fuzzyfiles
2159 sub rebuild_fuzzyfiles {
2161 use Fcntl qw(:flock);
2163 my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
2167 open(LASTLOCK,">>$dir/cust_main.last")
2168 or die "can't open $dir/cust_main.last: $!";
2169 flock(LASTLOCK,LOCK_EX)
2170 or die "can't lock $dir/cust_main.last: $!";
2172 my @all_last = map $_->getfield('last'), qsearch('cust_main', {});
2174 grep $_, map $_->getfield('ship_last'), qsearch('cust_main',{})
2175 if defined dbdef->table('cust_main')->column('ship_last');
2177 open (LASTCACHE,">$dir/cust_main.last.tmp")
2178 or die "can't open $dir/cust_main.last.tmp: $!";
2179 print LASTCACHE join("\n", @all_last), "\n";
2180 close LASTCACHE or die "can't close $dir/cust_main.last.tmp: $!";
2182 rename "$dir/cust_main.last.tmp", "$dir/cust_main.last";
2187 open(COMPANYLOCK,">>$dir/cust_main.company")
2188 or die "can't open $dir/cust_main.company: $!";
2189 flock(COMPANYLOCK,LOCK_EX)
2190 or die "can't lock $dir/cust_main.company: $!";
2192 my @all_company = grep $_ ne '', map $_->company, qsearch('cust_main',{});
2194 grep $_ ne '', map $_->ship_company, qsearch('cust_main', {})
2195 if defined dbdef->table('cust_main')->column('ship_last');
2197 open (COMPANYCACHE,">$dir/cust_main.company.tmp")
2198 or die "can't open $dir/cust_main.company.tmp: $!";
2199 print COMPANYCACHE join("\n", @all_company), "\n";
2200 close COMPANYCACHE or die "can't close $dir/cust_main.company.tmp: $!";
2202 rename "$dir/cust_main.company.tmp", "$dir/cust_main.company";
2212 my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
2213 open(LASTCACHE,"<$dir/cust_main.last")
2214 or die "can't open $dir/cust_main.last: $!";
2215 my @array = map { chomp; $_; } <LASTCACHE>;
2225 my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
2226 open(COMPANYCACHE,"<$dir/cust_main.company")
2227 or die "can't open $dir/cust_main.last: $!";
2228 my @array = map { chomp; $_; } <COMPANYCACHE>;
2233 =item append_fuzzyfiles LASTNAME COMPANY
2237 sub append_fuzzyfiles {
2238 my( $last, $company ) = @_;
2240 &check_and_rebuild_fuzzyfiles;
2242 use Fcntl qw(:flock);
2244 my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
2248 open(LAST,">>$dir/cust_main.last")
2249 or die "can't open $dir/cust_main.last: $!";
2251 or die "can't lock $dir/cust_main.last: $!";
2253 print LAST "$last\n";
2256 or die "can't unlock $dir/cust_main.last: $!";
2262 open(COMPANY,">>$dir/cust_main.company")
2263 or die "can't open $dir/cust_main.company: $!";
2264 flock(COMPANY,LOCK_EX)
2265 or die "can't lock $dir/cust_main.company: $!";
2267 print COMPANY "$company\n";
2269 flock(COMPANY,LOCK_UN)
2270 or die "can't unlock $dir/cust_main.company: $!";
2284 #warn join('-',keys %$param);
2285 my $fh = $param->{filehandle};
2286 my $agentnum = $param->{agentnum};
2287 my $refnum = $param->{refnum};
2288 my $pkgpart = $param->{pkgpart};
2289 my @fields = @{$param->{fields}};
2291 eval "use Date::Parse;";
2293 eval "use Text::CSV_XS;";
2296 my $csv = new Text::CSV_XS;
2303 local $SIG{HUP} = 'IGNORE';
2304 local $SIG{INT} = 'IGNORE';
2305 local $SIG{QUIT} = 'IGNORE';
2306 local $SIG{TERM} = 'IGNORE';
2307 local $SIG{TSTP} = 'IGNORE';
2308 local $SIG{PIPE} = 'IGNORE';
2310 my $oldAutoCommit = $FS::UID::AutoCommit;
2311 local $FS::UID::AutoCommit = 0;
2314 #while ( $columns = $csv->getline($fh) ) {
2316 while ( defined($line=<$fh>) ) {
2318 $csv->parse($line) or do {
2319 $dbh->rollback if $oldAutoCommit;
2320 return "can't parse: ". $csv->error_input();
2323 my @columns = $csv->fields();
2324 #warn join('-',@columns);
2327 agentnum => $agentnum,
2329 country => 'US', #default
2330 payby => 'BILL', #default
2331 paydate => '12/2037', #default
2333 my $billtime = time;
2334 my %cust_pkg = ( pkgpart => $pkgpart );
2335 foreach my $field ( @fields ) {
2336 if ( $field =~ /^cust_pkg\.(setup|bill|susp|expire|cancel)$/ ) {
2337 #$cust_pkg{$1} = str2time( shift @$columns );
2338 if ( $1 eq 'setup' ) {
2339 $billtime = str2time(shift @columns);
2341 $cust_pkg{$1} = str2time( shift @columns );
2344 #$cust_main{$field} = shift @$columns;
2345 $cust_main{$field} = shift @columns;
2349 my $cust_pkg = new FS::cust_pkg ( \%cust_pkg ) if $pkgpart;
2350 my $cust_main = new FS::cust_main ( \%cust_main );
2352 tie my %hash, 'Tie::RefHash'; #this part is important
2353 $hash{$cust_pkg} = [] if $pkgpart;
2354 my $error = $cust_main->insert( \%hash );
2357 $dbh->rollback if $oldAutoCommit;
2358 return "can't insert customer for $line: $error";
2361 #false laziness w/bill.cgi
2362 $error = $cust_main->bill( 'time' => $billtime );
2364 $dbh->rollback if $oldAutoCommit;
2365 return "can't bill customer for $line: $error";
2368 $cust_main->apply_payments;
2369 $cust_main->apply_credits;
2371 $error = $cust_main->collect();
2373 $dbh->rollback if $oldAutoCommit;
2374 return "can't collect customer for $line: $error";
2380 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2382 return "Empty file!" unless $imported;
2394 #warn join('-',keys %$param);
2395 my $fh = $param->{filehandle};
2396 my @fields = @{$param->{fields}};
2398 eval "use Date::Parse;";
2400 eval "use Text::CSV_XS;";
2403 my $csv = new Text::CSV_XS;
2410 local $SIG{HUP} = 'IGNORE';
2411 local $SIG{INT} = 'IGNORE';
2412 local $SIG{QUIT} = 'IGNORE';
2413 local $SIG{TERM} = 'IGNORE';
2414 local $SIG{TSTP} = 'IGNORE';
2415 local $SIG{PIPE} = 'IGNORE';
2417 my $oldAutoCommit = $FS::UID::AutoCommit;
2418 local $FS::UID::AutoCommit = 0;
2421 #while ( $columns = $csv->getline($fh) ) {
2423 while ( defined($line=<$fh>) ) {
2425 $csv->parse($line) or do {
2426 $dbh->rollback if $oldAutoCommit;
2427 return "can't parse: ". $csv->error_input();
2430 my @columns = $csv->fields();
2431 #warn join('-',@columns);
2434 foreach my $field ( @fields ) {
2435 $row{$field} = shift @columns;
2438 my $cust_main = qsearchs('cust_main', { 'custnum' => $row{'custnum'} } );
2439 unless ( $cust_main ) {
2440 $dbh->rollback if $oldAutoCommit;
2441 return "unknown custnum $row{'custnum'}";
2444 if ( $row{'amount'} > 0 ) {
2445 my $error = $cust_main->charge($row{'amount'}, $row{'pkg'});
2447 $dbh->rollback if $oldAutoCommit;
2451 } elsif ( $row{'amount'} < 0 ) {
2452 my $error = $cust_main->credit( sprintf( "%.2f", 0-$row{'amount'} ),
2455 $dbh->rollback if $oldAutoCommit;
2465 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2467 return "Empty file!" unless $imported;
2479 The delete method should possibly take an FS::cust_main object reference
2480 instead of a scalar customer number.
2482 Bill and collect options should probably be passed as references instead of a
2485 There should probably be a configuration file with a list of allowed credit
2488 No multiple currency support (probably a larger project than just this module).
2492 L<FS::Record>, L<FS::cust_pkg>, L<FS::cust_bill>, L<FS::cust_credit>
2493 L<FS::agent>, L<FS::part_referral>, L<FS::cust_main_county>,
2494 L<FS::cust_main_invoice>, L<FS::UID>, schema.html from the base documentation.