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 foreach my $cust_pkg ( keys %$cust_pkgs ) {
290 $cust_pkg->custnum( $self->custnum );
291 $error = $cust_pkg->insert;
293 $dbh->rollback if $oldAutoCommit;
294 return "inserting cust_pkg (transaction rolled back): $error";
296 foreach my $svc_something ( @{$cust_pkgs->{$cust_pkg}} ) {
297 $svc_something->pkgnum( $cust_pkg->pkgnum );
298 if ( $seconds && $svc_something->isa('FS::svc_acct') ) {
299 $svc_something->seconds( $svc_something->seconds + $seconds );
302 $error = $svc_something->insert;
304 $dbh->rollback if $oldAutoCommit;
305 #return "inserting svc_ (transaction rolled back): $error";
312 $dbh->rollback if $oldAutoCommit;
313 return "No svc_acct record to apply pre-paid time";
317 my $cust_credit = new FS::cust_credit {
318 'custnum' => $self->custnum,
321 $error = $cust_credit->insert;
323 $dbh->rollback if $oldAutoCommit;
324 return "inserting credit (transaction rolled back): $error";
328 $error = $self->queue_fuzzyfiles_update;
330 $dbh->rollback if $oldAutoCommit;
331 return "updating fuzzy search cache: $error";
334 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
339 =item delete NEW_CUSTNUM
341 This deletes the customer. If there is an error, returns the error, otherwise
344 This will completely remove all traces of the customer record. This is not
345 what you want when a customer cancels service; for that, cancel all of the
346 customer's packages (see L</cancel>).
348 If the customer has any uncancelled packages, you need to pass a new (valid)
349 customer number for those packages to be transferred to. Cancelled packages
350 will be deleted. Did I mention that this is NOT what you want when a customer
351 cancels service and that you really should be looking see L<FS::cust_pkg/cancel>?
353 You can't delete a customer with invoices (see L<FS::cust_bill>),
354 or credits (see L<FS::cust_credit>), payments (see L<FS::cust_pay>) or
355 refunds (see L<FS::cust_refund>).
362 local $SIG{HUP} = 'IGNORE';
363 local $SIG{INT} = 'IGNORE';
364 local $SIG{QUIT} = 'IGNORE';
365 local $SIG{TERM} = 'IGNORE';
366 local $SIG{TSTP} = 'IGNORE';
367 local $SIG{PIPE} = 'IGNORE';
369 my $oldAutoCommit = $FS::UID::AutoCommit;
370 local $FS::UID::AutoCommit = 0;
373 if ( qsearch( 'cust_bill', { 'custnum' => $self->custnum } ) ) {
374 $dbh->rollback if $oldAutoCommit;
375 return "Can't delete a customer with invoices";
377 if ( qsearch( 'cust_credit', { 'custnum' => $self->custnum } ) ) {
378 $dbh->rollback if $oldAutoCommit;
379 return "Can't delete a customer with credits";
381 if ( qsearch( 'cust_pay', { 'custnum' => $self->custnum } ) ) {
382 $dbh->rollback if $oldAutoCommit;
383 return "Can't delete a customer with payments";
385 if ( qsearch( 'cust_refund', { 'custnum' => $self->custnum } ) ) {
386 $dbh->rollback if $oldAutoCommit;
387 return "Can't delete a customer with refunds";
390 my @cust_pkg = $self->ncancelled_pkgs;
392 my $new_custnum = shift;
393 unless ( qsearchs( 'cust_main', { 'custnum' => $new_custnum } ) ) {
394 $dbh->rollback if $oldAutoCommit;
395 return "Invalid new customer number: $new_custnum";
397 foreach my $cust_pkg ( @cust_pkg ) {
398 my %hash = $cust_pkg->hash;
399 $hash{'custnum'} = $new_custnum;
400 my $new_cust_pkg = new FS::cust_pkg ( \%hash );
401 my $error = $new_cust_pkg->replace($cust_pkg);
403 $dbh->rollback if $oldAutoCommit;
408 my @cancelled_cust_pkg = $self->all_pkgs;
409 foreach my $cust_pkg ( @cancelled_cust_pkg ) {
410 my $error = $cust_pkg->delete;
412 $dbh->rollback if $oldAutoCommit;
417 foreach my $cust_main_invoice ( #(email invoice destinations, not invoices)
418 qsearch( 'cust_main_invoice', { 'custnum' => $self->custnum } )
420 my $error = $cust_main_invoice->delete;
422 $dbh->rollback if $oldAutoCommit;
427 my $error = $self->SUPER::delete;
429 $dbh->rollback if $oldAutoCommit;
433 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
438 =item replace OLD_RECORD [ INVOICING_LIST_ARYREF ]
440 Replaces the OLD_RECORD with this one in the database. If there is an error,
441 returns the error, otherwise returns false.
443 INVOICING_LIST_ARYREF: If you pass an arrarref to the insert method, it will
444 be set as the invoicing list (see L<"invoicing_list">). Errors return as
445 expected and rollback the entire transaction; it is not necessary to call
446 check_invoicing_list first. Here's an example:
448 $new_cust_main->replace( $old_cust_main, [ $email, 'POST' ] );
457 local $SIG{HUP} = 'IGNORE';
458 local $SIG{INT} = 'IGNORE';
459 local $SIG{QUIT} = 'IGNORE';
460 local $SIG{TERM} = 'IGNORE';
461 local $SIG{TSTP} = 'IGNORE';
462 local $SIG{PIPE} = 'IGNORE';
464 if ( $self->payby eq 'COMP' && $self->payby ne $old->payby
465 && $conf->config('users-allow_comp') ) {
466 return "You are not permitted to create complimentary accounts."
467 unless grep { $_ eq getotaker } $conf->config('users-allow_comp');
470 my $oldAutoCommit = $FS::UID::AutoCommit;
471 local $FS::UID::AutoCommit = 0;
474 my $error = $self->SUPER::replace($old);
477 $dbh->rollback if $oldAutoCommit;
481 if ( @param ) { # INVOICING_LIST_ARYREF
482 my $invoicing_list = shift @param;
483 $error = $self->check_invoicing_list( $invoicing_list );
485 $dbh->rollback if $oldAutoCommit;
488 $self->invoicing_list( $invoicing_list );
491 if ( $self->payby =~ /^(CARD|CHEK|LECB)$/ &&
492 grep { $self->get($_) ne $old->get($_) } qw(payinfo paydate payname) ) {
493 # card/check/lec info has changed, want to retry realtime_ invoice events
494 my $error = $self->retry_realtime;
496 $dbh->rollback if $oldAutoCommit;
501 $error = $self->queue_fuzzyfiles_update;
503 $dbh->rollback if $oldAutoCommit;
504 return "updating fuzzy search cache: $error";
507 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
512 =item queue_fuzzyfiles_update
514 Used by insert & replace to update the fuzzy search cache
518 sub queue_fuzzyfiles_update {
521 local $SIG{HUP} = 'IGNORE';
522 local $SIG{INT} = 'IGNORE';
523 local $SIG{QUIT} = 'IGNORE';
524 local $SIG{TERM} = 'IGNORE';
525 local $SIG{TSTP} = 'IGNORE';
526 local $SIG{PIPE} = 'IGNORE';
528 my $oldAutoCommit = $FS::UID::AutoCommit;
529 local $FS::UID::AutoCommit = 0;
532 my $queue = new FS::queue { 'job' => 'FS::cust_main::append_fuzzyfiles' };
533 my $error = $queue->insert($self->getfield('last'), $self->company);
535 $dbh->rollback if $oldAutoCommit;
536 return "queueing job (transaction rolled back): $error";
539 if ( defined $self->dbdef_table->column('ship_last') && $self->ship_last ) {
540 $queue = new FS::queue { 'job' => 'FS::cust_main::append_fuzzyfiles' };
541 $error = $queue->insert($self->getfield('ship_last'), $self->ship_company);
543 $dbh->rollback if $oldAutoCommit;
544 return "queueing job (transaction rolled back): $error";
548 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
555 Checks all fields to make sure this is a valid customer record. If there is
556 an error, returns the error, otherwise returns false. Called by the insert
564 #warn "BEFORE: \n". $self->_dump;
567 $self->ut_numbern('custnum')
568 || $self->ut_number('agentnum')
569 || $self->ut_number('refnum')
570 || $self->ut_name('last')
571 || $self->ut_name('first')
572 || $self->ut_textn('company')
573 || $self->ut_text('address1')
574 || $self->ut_textn('address2')
575 || $self->ut_text('city')
576 || $self->ut_textn('county')
577 || $self->ut_textn('state')
578 || $self->ut_country('country')
579 || $self->ut_anything('comments')
580 || $self->ut_numbern('referral_custnum')
582 #barf. need message catalogs. i18n. etc.
583 $error .= "Please select an advertising source."
584 if $error =~ /^Illegal or empty \(numeric\) refnum: /;
585 return $error if $error;
587 return "Unknown agent"
588 unless qsearchs( 'agent', { 'agentnum' => $self->agentnum } );
590 return "Unknown refnum"
591 unless qsearchs( 'part_referral', { 'refnum' => $self->refnum } );
593 return "Unknown referring custnum ". $self->referral_custnum
594 unless ! $self->referral_custnum
595 || qsearchs( 'cust_main', { 'custnum' => $self->referral_custnum } );
597 if ( $self->ss eq '' ) {
602 $ss =~ /^(\d{3})(\d{2})(\d{4})$/
603 or return "Illegal social security number: ". $self->ss;
604 $self->ss("$1-$2-$3");
608 # bad idea to disable, causes billing to fail because of no tax rates later
609 # unless ( $import ) {
610 unless ( qsearch('cust_main_county', {
611 'country' => $self->country,
614 return "Unknown state/county/country: ".
615 $self->state. "/". $self->county. "/". $self->country
616 unless qsearch('cust_main_county',{
617 'state' => $self->state,
618 'county' => $self->county,
619 'country' => $self->country,
625 $self->ut_phonen('daytime', $self->country)
626 || $self->ut_phonen('night', $self->country)
627 || $self->ut_phonen('fax', $self->country)
628 || $self->ut_zip('zip', $self->country)
630 return $error if $error;
633 last first company address1 address2 city county state zip
634 country daytime night fax
637 if ( defined $self->dbdef_table->column('ship_last') ) {
638 if ( scalar ( grep { $self->getfield($_) ne $self->getfield("ship_$_") }
640 && scalar ( grep { $self->getfield("ship_$_") ne '' } @addfields )
644 $self->ut_name('ship_last')
645 || $self->ut_name('ship_first')
646 || $self->ut_textn('ship_company')
647 || $self->ut_text('ship_address1')
648 || $self->ut_textn('ship_address2')
649 || $self->ut_text('ship_city')
650 || $self->ut_textn('ship_county')
651 || $self->ut_textn('ship_state')
652 || $self->ut_country('ship_country')
654 return $error if $error;
656 #false laziness with above
657 unless ( qsearchs('cust_main_county', {
658 'country' => $self->ship_country,
661 return "Unknown ship_state/ship_county/ship_country: ".
662 $self->ship_state. "/". $self->ship_county. "/". $self->ship_country
663 unless qsearchs('cust_main_county',{
664 'state' => $self->ship_state,
665 'county' => $self->ship_county,
666 'country' => $self->ship_country,
672 $self->ut_phonen('ship_daytime', $self->ship_country)
673 || $self->ut_phonen('ship_night', $self->ship_country)
674 || $self->ut_phonen('ship_fax', $self->ship_country)
675 || $self->ut_zip('ship_zip', $self->ship_country)
677 return $error if $error;
679 } else { # ship_ info eq billing info, so don't store dup info in database
680 $self->setfield("ship_$_", '')
681 foreach qw( last first company address1 address2 city county state zip
682 country daytime night fax );
686 $self->payby =~ /^(CARD|CHEK|LECB|BILL|COMP|PREPAY)$/
687 or return "Illegal payby: ". $self->payby;
690 if ( $self->payby eq 'CARD' ) {
692 my $payinfo = $self->payinfo;
694 $payinfo =~ /^(\d{13,16})$/
695 or return gettext('invalid_card'); # . ": ". $self->payinfo;
697 $self->payinfo($payinfo);
699 or return gettext('invalid_card'); # . ": ". $self->payinfo;
700 return gettext('unknown_card_type')
701 if cardtype($self->payinfo) eq "Unknown";
702 if ( defined $self->dbdef_table->column('paycvv') ) {
703 if ( length($self->paycvv) ) {
704 if ( cardtype($self->payinfo) eq 'American Express card' ) {
705 $self->paycvv =~ /^(\d{4})$/
706 or return "CVV2 (CID) for American Express cards is four digits.";
709 $self->paycvv =~ /^(\d{3})$/
710 or return "CVV2 (CVC2/CID) is three digits.";
718 } elsif ( $self->payby eq 'CHEK' ) {
720 my $payinfo = $self->payinfo;
721 $payinfo =~ s/[^\d\@]//g;
722 $payinfo =~ /^(\d+)\@(\d{9})$/ or return 'invalid echeck account@aba';
724 $self->payinfo($payinfo);
725 $self->paycvv('') if $self->dbdef_table->column('paycvv');
727 } elsif ( $self->payby eq 'LECB' ) {
729 my $payinfo = $self->payinfo;
731 $payinfo =~ /^1?(\d{10})$/ or return 'invalid btn billing telephone number';
733 $self->payinfo($payinfo);
734 $self->paycvv('') if $self->dbdef_table->column('paycvv');
736 } elsif ( $self->payby eq 'BILL' ) {
738 $error = $self->ut_textn('payinfo');
739 return "Illegal P.O. number: ". $self->payinfo if $error;
740 $self->paycvv('') if $self->dbdef_table->column('paycvv');
742 } elsif ( $self->payby eq 'COMP' ) {
744 if ( !$self->custnum && $conf->config('users-allow_comp') ) {
745 return "You are not permitted to create complimentary accounts."
746 unless grep { $_ eq getotaker } $conf->config('users-allow_comp');
749 $error = $self->ut_textn('payinfo');
750 return "Illegal comp account issuer: ". $self->payinfo if $error;
751 $self->paycvv('') if $self->dbdef_table->column('paycvv');
753 } elsif ( $self->payby eq 'PREPAY' ) {
755 my $payinfo = $self->payinfo;
756 $payinfo =~ s/\W//g; #anything else would just confuse things
757 $self->payinfo($payinfo);
758 $error = $self->ut_alpha('payinfo');
759 return "Illegal prepayment identifier: ". $self->payinfo if $error;
760 return "Unknown prepayment identifier"
761 unless qsearchs('prepay_credit', { 'identifier' => $self->payinfo } );
762 $self->paycvv('') if $self->dbdef_table->column('paycvv');
766 if ( $self->paydate eq '' || $self->paydate eq '-' ) {
767 return "Expriation date required"
768 unless $self->payby =~ /^(BILL|PREPAY|CHEK|LECB)$/;
771 $self->paydate =~ /^(\d{1,2})[\/\-](\d{2}(\d{2})?)$/
772 or return "Illegal expiration date: ". $self->paydate;
773 my $y = length($2) == 4 ? $2 : "20$2";
774 $self->paydate("$y-$1-01");
775 my($nowm,$nowy)=(localtime(time))[4,5]; $nowm++; $nowy+=1900;
776 return gettext('expired_card')
777 if !$import && ( $y<$nowy || ( $y==$nowy && $1<$nowm ) );
780 if ( $self->payname eq '' && $self->payby ne 'CHEK' &&
781 ( ! $conf->exists('require_cardname') || $self->payby ne 'CARD' ) ) {
782 $self->payname( $self->first. " ". $self->getfield('last') );
784 $self->payname =~ /^([\w \,\.\-\']+)$/
785 or return gettext('illegal_name'). " payname: ". $self->payname;
789 $self->tax =~ /^(Y?)$/ or return "Illegal tax: ". $self->tax;
792 $self->otaker(getotaker);
794 #warn "AFTER: \n". $self->_dump;
801 Returns all packages (see L<FS::cust_pkg>) for this customer.
807 if ( $self->{'_pkgnum'} ) {
808 values %{ $self->{'_pkgnum'}->cache };
810 qsearch( 'cust_pkg', { 'custnum' => $self->custnum });
814 =item ncancelled_pkgs
816 Returns all non-cancelled packages (see L<FS::cust_pkg>) for this customer.
820 sub ncancelled_pkgs {
822 if ( $self->{'_pkgnum'} ) {
823 grep { ! $_->getfield('cancel') } values %{ $self->{'_pkgnum'}->cache };
825 @{ [ # force list context
826 qsearch( 'cust_pkg', {
827 'custnum' => $self->custnum,
830 qsearch( 'cust_pkg', {
831 'custnum' => $self->custnum,
840 Returns all suspended packages (see L<FS::cust_pkg>) for this customer.
846 grep { $_->susp } $self->ncancelled_pkgs;
849 =item unflagged_suspended_pkgs
851 Returns all unflagged suspended packages (see L<FS::cust_pkg>) for this
852 customer (thouse packages without the `manual_flag' set).
856 sub unflagged_suspended_pkgs {
858 return $self->suspended_pkgs
859 unless dbdef->table('cust_pkg')->column('manual_flag');
860 grep { ! $_->manual_flag } $self->suspended_pkgs;
863 =item unsuspended_pkgs
865 Returns all unsuspended (and uncancelled) packages (see L<FS::cust_pkg>) for
870 sub unsuspended_pkgs {
872 grep { ! $_->susp } $self->ncancelled_pkgs;
877 Unsuspends all unflagged suspended packages (see L</unflagged_suspended_pkgs>
878 and L<FS::cust_pkg>) for this customer. Always returns a list: an empty list
879 on success or a list of errors.
885 grep { $_->unsuspend } $self->suspended_pkgs;
890 Suspends all unsuspended packages (see L<FS::cust_pkg>) for this customer.
891 Always returns a list: an empty list on success or a list of errors.
897 grep { $_->suspend } $self->unsuspended_pkgs;
900 =item cancel [ OPTION => VALUE ... ]
902 Cancels all uncancelled packages (see L<FS::cust_pkg>) for this customer.
904 Available options are: I<quiet>
906 I<quiet> can be set true to supress email cancellation notices.
908 Always returns a list: an empty list on success or a list of errors.
914 grep { $_->cancel(@_) } $self->ncancelled_pkgs;
919 Returns the agent (see L<FS::agent>) for this customer.
925 qsearchs( 'agent', { 'agentnum' => $self->agentnum } );
930 Generates invoices (see L<FS::cust_bill>) for this customer. Usually used in
931 conjunction with the collect method.
933 Options are passed as name-value pairs.
935 Currently available options are:
937 resetup - if set true, re-charges setup fees.
939 time - bills the customer as if it were that time. Specified as a UNIX
940 timestamp; see L<perlfunc/"time">). Also see L<Time::Local> and
941 L<Date::Parse> for conversion functions. For example:
945 $cust_main->bill( 'time' => str2time('April 20th, 2001') );
948 If there is an error, returns the error, otherwise returns false.
953 my( $self, %options ) = @_;
954 my $time = $options{'time'} || time;
959 local $SIG{HUP} = 'IGNORE';
960 local $SIG{INT} = 'IGNORE';
961 local $SIG{QUIT} = 'IGNORE';
962 local $SIG{TERM} = 'IGNORE';
963 local $SIG{TSTP} = 'IGNORE';
964 local $SIG{PIPE} = 'IGNORE';
966 my $oldAutoCommit = $FS::UID::AutoCommit;
967 local $FS::UID::AutoCommit = 0;
970 # find the packages which are due for billing, find out how much they are
971 # & generate invoice database.
973 my( $total_setup, $total_recur ) = ( 0, 0 );
974 #my( $taxable_setup, $taxable_recur ) = ( 0, 0 );
975 my @cust_bill_pkg = ();
977 #my $taxable_charged = 0;##
982 foreach my $cust_pkg (
983 qsearch('cust_pkg', { 'custnum' => $self->custnum } )
986 #NO!! next if $cust_pkg->cancel;
987 next if $cust_pkg->getfield('cancel');
989 #? to avoid use of uninitialized value errors... ?
990 $cust_pkg->setfield('bill', '')
991 unless defined($cust_pkg->bill);
993 my $part_pkg = $cust_pkg->part_pkg;
995 #so we don't modify cust_pkg record unnecessarily
996 my $cust_pkg_mod_flag = 0;
997 my %hash = $cust_pkg->hash;
998 my $old_cust_pkg = new FS::cust_pkg \%hash;
1002 if ( !$cust_pkg->setup || $options{'resetup'} ) {
1003 my $setup_prog = $part_pkg->getfield('setup');
1004 $setup_prog =~ /^(.*)$/ or do {
1005 $dbh->rollback if $oldAutoCommit;
1006 return "Illegal setup for pkgpart ". $part_pkg->pkgpart.
1010 $setup_prog = '0' if $setup_prog =~ /^\s*$/;
1012 #my $cpt = new Safe;
1013 ##$cpt->permit(); #what is necessary?
1014 #$cpt->share(qw( $cust_pkg )); #can $cpt now use $cust_pkg methods?
1015 #$setup = $cpt->reval($setup_prog);
1016 $setup = eval $setup_prog;
1017 unless ( defined($setup) ) {
1018 $dbh->rollback if $oldAutoCommit;
1019 return "Error eval-ing part_pkg->setup pkgpart ". $part_pkg->pkgpart.
1020 "(expression $setup_prog): $@";
1022 $cust_pkg->setfield('setup', $time) unless $cust_pkg->setup;
1023 $cust_pkg_mod_flag=1;
1029 if ( $part_pkg->getfield('freq') ne '0' &&
1030 ! $cust_pkg->getfield('susp') &&
1031 ( $cust_pkg->getfield('bill') || 0 ) <= $time
1033 my $recur_prog = $part_pkg->getfield('recur');
1034 $recur_prog =~ /^(.*)$/ or do {
1035 $dbh->rollback if $oldAutoCommit;
1036 return "Illegal recur for pkgpart ". $part_pkg->pkgpart.
1040 $recur_prog = '0' if $recur_prog =~ /^\s*$/;
1042 # shared with $recur_prog
1043 $sdate = $cust_pkg->bill || $cust_pkg->setup || $time;
1045 #my $cpt = new Safe;
1046 ##$cpt->permit(); #what is necessary?
1047 #$cpt->share(qw( $cust_pkg )); #can $cpt now use $cust_pkg methods?
1048 #$recur = $cpt->reval($recur_prog);
1049 $recur = eval $recur_prog;
1050 unless ( defined($recur) ) {
1051 $dbh->rollback if $oldAutoCommit;
1052 return "Error eval-ing part_pkg->recur pkgpart ". $part_pkg->pkgpart.
1053 "(expression $recur_prog): $@";
1055 #change this bit to use Date::Manip? CAREFUL with timezones (see
1056 # mailing list archive)
1057 my ($sec,$min,$hour,$mday,$mon,$year) =
1058 (localtime($sdate) )[0,1,2,3,4,5];
1060 #pro-rating magic - if $recur_prog fiddles $sdate, want to use that
1061 # only for figuring next bill date, nothing else, so, reset $sdate again
1063 $sdate = $cust_pkg->bill || $cust_pkg->setup || $time;
1064 $cust_pkg->last_bill($sdate)
1065 if $cust_pkg->dbdef_table->column('last_bill');
1067 if ( $part_pkg->freq =~ /^\d+$/ ) {
1068 $mon += $part_pkg->freq;
1069 until ( $mon < 12 ) { $mon -= 12; $year++; }
1070 } elsif ( $part_pkg->freq =~ /^(\d+)w$/ ) {
1072 $mday += $weeks * 7;
1073 } elsif ( $part_pkg->freq =~ /^(\d+)d$/ ) {
1077 $dbh->rollback if $oldAutoCommit;
1078 return "unparsable frequency: ". $part_pkg->freq;
1080 $cust_pkg->setfield('bill',
1081 timelocal_nocheck($sec,$min,$hour,$mday,$mon,$year));
1082 $cust_pkg_mod_flag = 1;
1085 warn "\$setup is undefined" unless defined($setup);
1086 warn "\$recur is undefined" unless defined($recur);
1087 warn "\$cust_pkg->bill is undefined" unless defined($cust_pkg->bill);
1089 if ( $cust_pkg_mod_flag ) {
1090 $error=$cust_pkg->replace($old_cust_pkg);
1091 if ( $error ) { #just in case
1092 $dbh->rollback if $oldAutoCommit;
1093 return "Error modifying pkgnum ". $cust_pkg->pkgnum. ": $error";
1095 $setup = sprintf( "%.2f", $setup );
1096 $recur = sprintf( "%.2f", $recur );
1098 $dbh->rollback if $oldAutoCommit;
1099 return "negative setup $setup for pkgnum ". $cust_pkg->pkgnum;
1102 $dbh->rollback if $oldAutoCommit;
1103 return "negative recur $recur for pkgnum ". $cust_pkg->pkgnum;
1105 if ( $setup > 0 || $recur > 0 ) {
1106 my $cust_bill_pkg = new FS::cust_bill_pkg ({
1107 'pkgnum' => $cust_pkg->pkgnum,
1111 'edate' => $cust_pkg->bill,
1113 push @cust_bill_pkg, $cust_bill_pkg;
1114 $total_setup += $setup;
1115 $total_recur += $recur;
1117 unless ( $self->tax =~ /Y/i || $self->payby eq 'COMP' ) {
1119 my @taxes = qsearch( 'cust_main_county', {
1120 'state' => $self->state,
1121 'county' => $self->county,
1122 'country' => $self->country,
1123 'taxclass' => $part_pkg->taxclass,
1126 @taxes = qsearch( 'cust_main_county', {
1127 'state' => $self->state,
1128 'county' => $self->county,
1129 'country' => $self->country,
1134 # maybe eliminate this entirely, along with all the 0% records
1136 $dbh->rollback if $oldAutoCommit;
1138 "fatal: can't find tax rate for state/county/country/taxclass ".
1139 join('/', ( map $self->$_(), qw(state county country) ),
1140 $part_pkg->taxclass ). "\n";
1143 foreach my $tax ( @taxes ) {
1145 my $taxable_charged = 0;
1146 $taxable_charged += $setup
1147 unless $part_pkg->setuptax =~ /^Y$/i
1148 || $tax->setuptax =~ /^Y$/i;
1149 $taxable_charged += $recur
1150 unless $part_pkg->recurtax =~ /^Y$/i
1151 || $tax->recurtax =~ /^Y$/i;
1152 next unless $taxable_charged;
1154 if ( $tax->exempt_amount > 0 ) {
1155 my ($mon,$year) = (localtime($sdate) )[4,5];
1157 my $freq = $part_pkg->freq || 1;
1158 if ( $freq !~ /(\d+)$/ ) {
1159 $dbh->rollback if $oldAutoCommit;
1160 return "daily/weekly package definitions not (yet?)".
1161 " compatible with monthly tax exemptions";
1163 my $taxable_per_month = sprintf("%.2f", $taxable_charged / $freq );
1164 foreach my $which_month ( 1 .. $freq ) {
1166 'custnum' => $self->custnum,
1167 'taxnum' => $tax->taxnum,
1168 'year' => 1900+$year,
1171 #until ( $mon < 12 ) { $mon -= 12; $year++; }
1172 until ( $mon < 13 ) { $mon -= 12; $year++; }
1173 my $cust_tax_exempt =
1174 qsearchs('cust_tax_exempt', \%hash)
1175 || new FS::cust_tax_exempt( { %hash, 'amount' => 0 } );
1176 my $remaining_exemption = sprintf("%.2f",
1177 $tax->exempt_amount - $cust_tax_exempt->amount );
1178 if ( $remaining_exemption > 0 ) {
1179 my $addl = $remaining_exemption > $taxable_per_month
1180 ? $taxable_per_month
1181 : $remaining_exemption;
1182 $taxable_charged -= $addl;
1183 my $new_cust_tax_exempt = new FS::cust_tax_exempt ( {
1184 $cust_tax_exempt->hash,
1186 sprintf("%.2f", $cust_tax_exempt->amount + $addl),
1188 $error = $new_cust_tax_exempt->exemptnum
1189 ? $new_cust_tax_exempt->replace($cust_tax_exempt)
1190 : $new_cust_tax_exempt->insert;
1192 $dbh->rollback if $oldAutoCommit;
1193 return "fatal: can't update cust_tax_exempt: $error";
1196 } # if $remaining_exemption > 0
1198 } #foreach $which_month
1200 } #if $tax->exempt_amount
1202 $taxable_charged = sprintf( "%.2f", $taxable_charged);
1204 #$tax += $taxable_charged * $cust_main_county->tax / 100
1205 $tax{ $tax->taxname || 'Tax' } +=
1206 $taxable_charged * $tax->tax / 100
1208 } #foreach my $tax ( @taxes )
1210 } #unless $self->tax =~ /Y/i || $self->payby eq 'COMP'
1212 } #if $setup > 0 || $recur > 0
1214 } #if $cust_pkg_mod_flag
1216 } #foreach my $cust_pkg
1218 my $charged = sprintf( "%.2f", $total_setup + $total_recur );
1219 # my $taxable_charged = sprintf( "%.2f", $taxable_setup + $taxable_recur );
1221 unless ( @cust_bill_pkg ) { #don't create invoices with no line items
1222 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1226 # unless ( $self->tax =~ /Y/i
1227 # || $self->payby eq 'COMP'
1228 # || $taxable_charged == 0 ) {
1229 # my $cust_main_county = qsearchs('cust_main_county',{
1230 # 'state' => $self->state,
1231 # 'county' => $self->county,
1232 # 'country' => $self->country,
1233 # } ) or die "fatal: can't find tax rate for state/county/country ".
1234 # $self->state. "/". $self->county. "/". $self->country. "\n";
1235 # my $tax = sprintf( "%.2f",
1236 # $taxable_charged * ( $cust_main_county->getfield('tax') / 100 )
1239 if ( dbdef->table('cust_bill_pkg')->column('itemdesc') ) { #1.5 schema
1241 foreach my $taxname ( grep { $tax{$_} > 0 } keys %tax ) {
1242 my $tax = sprintf("%.2f", $tax{$taxname} );
1243 $charged = sprintf( "%.2f", $charged+$tax );
1245 my $cust_bill_pkg = new FS::cust_bill_pkg ({
1251 'itemdesc' => $taxname,
1253 push @cust_bill_pkg, $cust_bill_pkg;
1256 } else { #1.4 schema
1259 foreach ( values %tax ) { $tax += $_ };
1260 $tax = sprintf("%.2f", $tax);
1262 $charged = sprintf( "%.2f", $charged+$tax );
1264 my $cust_bill_pkg = new FS::cust_bill_pkg ({
1271 push @cust_bill_pkg, $cust_bill_pkg;
1276 my $cust_bill = new FS::cust_bill ( {
1277 'custnum' => $self->custnum,
1279 'charged' => $charged,
1281 $error = $cust_bill->insert;
1283 $dbh->rollback if $oldAutoCommit;
1284 return "can't create invoice for customer #". $self->custnum. ": $error";
1287 my $invnum = $cust_bill->invnum;
1289 foreach $cust_bill_pkg ( @cust_bill_pkg ) {
1291 $cust_bill_pkg->invnum($invnum);
1292 $error = $cust_bill_pkg->insert;
1294 $dbh->rollback if $oldAutoCommit;
1295 return "can't create invoice line item for customer #". $self->custnum.
1300 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1306 document me. Re-schedules all exports by calling the B<reexport> method
1307 of all associated packages (see L<FS::cust_pkg>). If there is an error,
1308 returns the error; otherwise returns false.
1315 local $SIG{HUP} = 'IGNORE';
1316 local $SIG{INT} = 'IGNORE';
1317 local $SIG{QUIT} = 'IGNORE';
1318 local $SIG{TERM} = 'IGNORE';
1319 local $SIG{TSTP} = 'IGNORE';
1320 local $SIG{PIPE} = 'IGNORE';
1322 my $oldAutoCommit = $FS::UID::AutoCommit;
1323 local $FS::UID::AutoCommit = 0;
1326 foreach my $cust_pkg ( $self->ncancelled_pkgs ) {
1327 my $error = $cust_pkg->reexport;
1329 $dbh->rollback if $oldAutoCommit;
1334 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1339 =item collect OPTIONS
1341 (Attempt to) collect money for this customer's outstanding invoices (see
1342 L<FS::cust_bill>). Usually used after the bill method.
1344 Depending on the value of `payby', this may print an invoice (`BILL'), charge
1345 a credit card (`CARD'), or just add any necessary (pseudo-)payment (`COMP').
1347 Most actions are now triggered by invoice events; see L<FS::part_bill_event>
1348 and the invoice events web interface.
1350 If there is an error, returns the error, otherwise returns false.
1352 Options are passed as name-value pairs.
1354 Currently available options are:
1356 invoice_time - Use this time when deciding when to print invoices and
1357 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>
1358 for conversion functions.
1360 retry - Retry card/echeck/LEC transactions even when not scheduled by invoice
1363 retry_card - Deprecated alias for 'retry'
1365 batch_card - This option is deprecated. See the invoice events web interface
1366 to control whether cards are batched or run against a realtime gateway.
1368 report_badcard - This option is deprecated.
1370 force_print - This option is deprecated; see the invoice events web interface.
1372 quiet - set true to surpress email card/ACH decline notices.
1377 my( $self, %options ) = @_;
1378 my $invoice_time = $options{'invoice_time'} || time;
1381 local $SIG{HUP} = 'IGNORE';
1382 local $SIG{INT} = 'IGNORE';
1383 local $SIG{QUIT} = 'IGNORE';
1384 local $SIG{TERM} = 'IGNORE';
1385 local $SIG{TSTP} = 'IGNORE';
1386 local $SIG{PIPE} = 'IGNORE';
1388 my $oldAutoCommit = $FS::UID::AutoCommit;
1389 local $FS::UID::AutoCommit = 0;
1392 my $balance = $self->balance;
1393 warn "collect customer". $self->custnum. ": balance $balance" if $Debug;
1394 unless ( $balance > 0 ) { #redundant?????
1395 $dbh->rollback if $oldAutoCommit; #hmm
1399 if ( exists($options{'retry_card'}) ) {
1400 carp 'retry_card option passed to collect is deprecated; use retry';
1401 $options{'retry'} ||= $options{'retry_card'};
1403 if ( exists($options{'retry'}) && $options{'retry'} ) {
1404 my $error = $self->retry_realtime;
1406 $dbh->rollback if $oldAutoCommit;
1411 foreach my $cust_bill ( $self->cust_bill ) {
1413 #this has to be before next's
1414 my $amount = sprintf( "%.2f", $balance < $cust_bill->owed
1418 $balance = sprintf( "%.2f", $balance - $amount );
1420 next unless $cust_bill->owed > 0;
1422 # don't try to charge for the same invoice if it's already in a batch
1423 #next if qsearchs( 'cust_pay_batch', { 'invnum' => $cust_bill->invnum } );
1425 warn "invnum ". $cust_bill->invnum. " (owed ". $cust_bill->owed. ", amount $amount, balance $balance)" if $Debug;
1427 next unless $amount > 0;
1430 foreach my $part_bill_event (
1431 sort { $a->seconds <=> $b->seconds
1432 || $a->weight <=> $b->weight
1433 || $a->eventpart <=> $b->eventpart }
1434 grep { $_->seconds <= ( $invoice_time - $cust_bill->_date )
1435 && ! qsearchs( 'cust_bill_event', {
1436 'invnum' => $cust_bill->invnum,
1437 'eventpart' => $_->eventpart,
1441 qsearch('part_bill_event', { 'payby' => $self->payby,
1442 'disabled' => '', } )
1445 last unless $cust_bill->owed > 0; #don't run subsequent events if owed=0
1447 warn "calling invoice event (". $part_bill_event->eventcode. ")\n"
1449 my $cust_main = $self; #for callback
1453 #supress "used only once" warning
1454 $FS::cust_bill::realtime_bop_decline_quiet += 0;
1455 local $FS::cust_bill::realtime_bop_decline_quiet = 1
1456 if $options{'quiet'};
1457 $error = eval $part_bill_event->eventcode;
1461 my $statustext = '';
1465 } elsif ( $error ) {
1467 $statustext = $error;
1472 #add cust_bill_event
1473 my $cust_bill_event = new FS::cust_bill_event {
1474 'invnum' => $cust_bill->invnum,
1475 'eventpart' => $part_bill_event->eventpart,
1476 #'_date' => $invoice_time,
1478 'status' => $status,
1479 'statustext' => $statustext,
1481 $error = $cust_bill_event->insert;
1483 #$dbh->rollback if $oldAutoCommit;
1484 #return "error: $error";
1486 # gah, even with transactions.
1487 $dbh->commit if $oldAutoCommit; #well.
1488 my $e = 'WARNING: Event run but database not updated - '.
1489 'error inserting cust_bill_event, invnum #'. $cust_bill->invnum.
1490 ', eventpart '. $part_bill_event->eventpart.
1501 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1506 =item retry_realtime
1508 Schedules realtime credit card / electronic check / LEC billing events for
1509 for retry. Useful if card information has changed or manual retry is desired.
1510 The 'collect' method must be called to actually retry the transaction.
1512 Implementation details: For each of this customer's open invoices, changes
1513 the status of the first "done" (with statustext error) realtime processing
1518 sub retry_realtime {
1521 local $SIG{HUP} = 'IGNORE';
1522 local $SIG{INT} = 'IGNORE';
1523 local $SIG{QUIT} = 'IGNORE';
1524 local $SIG{TERM} = 'IGNORE';
1525 local $SIG{TSTP} = 'IGNORE';
1526 local $SIG{PIPE} = 'IGNORE';
1528 my $oldAutoCommit = $FS::UID::AutoCommit;
1529 local $FS::UID::AutoCommit = 0;
1532 foreach my $cust_bill (
1533 grep { $_->cust_bill_event }
1534 $self->open_cust_bill
1536 my @cust_bill_event =
1537 sort { $a->part_bill_event->seconds <=> $b->part_bill_event->seconds }
1539 #$_->part_bill_event->plan eq 'realtime-card'
1540 $_->part_bill_event->eventcode =~
1541 /\$cust_bill\->realtime_(card|ach|lec)/
1542 && $_->status eq 'done'
1545 $cust_bill->cust_bill_event;
1546 next unless @cust_bill_event;
1547 my $error = $cust_bill_event[0]->retry;
1549 $dbh->rollback if $oldAutoCommit;
1550 return "error scheduling invoice event for retry: $error";
1555 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1562 Returns the total owed for this customer on all invoices
1563 (see L<FS::cust_bill/owed>).
1569 $self->total_owed_date(2145859200); #12/31/2037
1572 =item total_owed_date TIME
1574 Returns the total owed for this customer on all invoices with date earlier than
1575 TIME. TIME is specified as a UNIX timestamp; see L<perlfunc/"time">). Also
1576 see L<Time::Local> and L<Date::Parse> for conversion functions.
1580 sub total_owed_date {
1584 foreach my $cust_bill (
1585 grep { $_->_date <= $time }
1586 qsearch('cust_bill', { 'custnum' => $self->custnum, } )
1588 $total_bill += $cust_bill->owed;
1590 sprintf( "%.2f", $total_bill );
1595 Applies (see L<FS::cust_credit_bill>) unapplied credits (see L<FS::cust_credit>)
1596 to outstanding invoice balances in chronological order and returns the value
1597 of any remaining unapplied credits available for refund
1598 (see L<FS::cust_refund>).
1605 return 0 unless $self->total_credited;
1607 my @credits = sort { $b->_date <=> $a->_date} (grep { $_->credited > 0 }
1608 qsearch('cust_credit', { 'custnum' => $self->custnum } ) );
1610 my @invoices = sort { $a->_date <=> $b->_date} (grep { $_->owed > 0 }
1611 qsearch('cust_bill', { 'custnum' => $self->custnum } ) );
1615 foreach my $cust_bill ( @invoices ) {
1618 if ( !defined($credit) || $credit->credited == 0) {
1619 $credit = pop @credits or last;
1622 if ($cust_bill->owed >= $credit->credited) {
1623 $amount=$credit->credited;
1625 $amount=$cust_bill->owed;
1628 my $cust_credit_bill = new FS::cust_credit_bill ( {
1629 'crednum' => $credit->crednum,
1630 'invnum' => $cust_bill->invnum,
1631 'amount' => $amount,
1633 my $error = $cust_credit_bill->insert;
1634 die $error if $error;
1636 redo if ($cust_bill->owed > 0);
1640 return $self->total_credited;
1643 =item apply_payments
1645 Applies (see L<FS::cust_bill_pay>) unapplied payments (see L<FS::cust_pay>)
1646 to outstanding invoice balances in chronological order.
1648 #and returns the value of any remaining unapplied payments.
1652 sub apply_payments {
1657 my @payments = sort { $b->_date <=> $a->_date } ( grep { $_->unapplied > 0 }
1658 qsearch('cust_pay', { 'custnum' => $self->custnum } ) );
1660 my @invoices = sort { $a->_date <=> $b->_date} (grep { $_->owed > 0 }
1661 qsearch('cust_bill', { 'custnum' => $self->custnum } ) );
1665 foreach my $cust_bill ( @invoices ) {
1668 if ( !defined($payment) || $payment->unapplied == 0 ) {
1669 $payment = pop @payments or last;
1672 if ( $cust_bill->owed >= $payment->unapplied ) {
1673 $amount = $payment->unapplied;
1675 $amount = $cust_bill->owed;
1678 my $cust_bill_pay = new FS::cust_bill_pay ( {
1679 'paynum' => $payment->paynum,
1680 'invnum' => $cust_bill->invnum,
1681 'amount' => $amount,
1683 my $error = $cust_bill_pay->insert;
1684 die $error if $error;
1686 redo if ( $cust_bill->owed > 0);
1690 return $self->total_unapplied_payments;
1693 =item total_credited
1695 Returns the total outstanding credit (see L<FS::cust_credit>) for this
1696 customer. See L<FS::cust_credit/credited>.
1700 sub total_credited {
1702 my $total_credit = 0;
1703 foreach my $cust_credit ( qsearch('cust_credit', {
1704 'custnum' => $self->custnum,
1706 $total_credit += $cust_credit->credited;
1708 sprintf( "%.2f", $total_credit );
1711 =item total_unapplied_payments
1713 Returns the total unapplied payments (see L<FS::cust_pay>) for this customer.
1714 See L<FS::cust_pay/unapplied>.
1718 sub total_unapplied_payments {
1720 my $total_unapplied = 0;
1721 foreach my $cust_pay ( qsearch('cust_pay', {
1722 'custnum' => $self->custnum,
1724 $total_unapplied += $cust_pay->unapplied;
1726 sprintf( "%.2f", $total_unapplied );
1731 Returns the balance for this customer (total_owed minus total_credited
1732 minus total_unapplied_payments).
1739 $self->total_owed - $self->total_credited - $self->total_unapplied_payments
1743 =item balance_date TIME
1745 Returns the balance for this customer, only considering invoices with date
1746 earlier than TIME (total_owed_date minus total_credited minus
1747 total_unapplied_payments). TIME is specified as a UNIX timestamp; see
1748 L<perlfunc/"time">). Also see L<Time::Local> and L<Date::Parse> for conversion
1757 $self->total_owed_date($time)
1758 - $self->total_credited
1759 - $self->total_unapplied_payments
1763 =item invoicing_list [ ARRAYREF ]
1765 If an arguement is given, sets these email addresses as invoice recipients
1766 (see L<FS::cust_main_invoice>). Errors are not fatal and are not reported
1767 (except as warnings), so use check_invoicing_list first.
1769 Returns a list of email addresses (with svcnum entries expanded).
1771 Note: You can clear the invoicing list by passing an empty ARRAYREF. You can
1772 check it without disturbing anything by passing nothing.
1774 This interface may change in the future.
1778 sub invoicing_list {
1779 my( $self, $arrayref ) = @_;
1781 my @cust_main_invoice;
1782 if ( $self->custnum ) {
1783 @cust_main_invoice =
1784 qsearch( 'cust_main_invoice', { 'custnum' => $self->custnum } );
1786 @cust_main_invoice = ();
1788 foreach my $cust_main_invoice ( @cust_main_invoice ) {
1789 #warn $cust_main_invoice->destnum;
1790 unless ( grep { $cust_main_invoice->address eq $_ } @{$arrayref} ) {
1791 #warn $cust_main_invoice->destnum;
1792 my $error = $cust_main_invoice->delete;
1793 warn $error if $error;
1796 if ( $self->custnum ) {
1797 @cust_main_invoice =
1798 qsearch( 'cust_main_invoice', { 'custnum' => $self->custnum } );
1800 @cust_main_invoice = ();
1802 my %seen = map { $_->address => 1 } @cust_main_invoice;
1803 foreach my $address ( @{$arrayref} ) {
1804 next if exists $seen{$address} && $seen{$address};
1805 $seen{$address} = 1;
1806 my $cust_main_invoice = new FS::cust_main_invoice ( {
1807 'custnum' => $self->custnum,
1810 my $error = $cust_main_invoice->insert;
1811 warn $error if $error;
1814 if ( $self->custnum ) {
1816 qsearch( 'cust_main_invoice', { 'custnum' => $self->custnum } );
1822 =item check_invoicing_list ARRAYREF
1824 Checks these arguements as valid input for the invoicing_list method. If there
1825 is an error, returns the error, otherwise returns false.
1829 sub check_invoicing_list {
1830 my( $self, $arrayref ) = @_;
1831 foreach my $address ( @{$arrayref} ) {
1832 my $cust_main_invoice = new FS::cust_main_invoice ( {
1833 'custnum' => $self->custnum,
1836 my $error = $self->custnum
1837 ? $cust_main_invoice->check
1838 : $cust_main_invoice->checkdest
1840 return $error if $error;
1845 =item set_default_invoicing_list
1847 Sets the invoicing list to all accounts associated with this customer,
1848 overwriting any previous invoicing list.
1852 sub set_default_invoicing_list {
1854 $self->invoicing_list($self->all_emails);
1859 Returns the email addresses of all accounts provisioned for this customer.
1866 foreach my $cust_pkg ( $self->all_pkgs ) {
1867 my @cust_svc = qsearch('cust_svc', { 'pkgnum' => $cust_pkg->pkgnum } );
1869 map { qsearchs('svc_acct', { 'svcnum' => $_->svcnum } ) }
1870 grep { qsearchs('svc_acct', { 'svcnum' => $_->svcnum } ) }
1872 $list{$_}=1 foreach map { $_->email } @svc_acct;
1877 =item invoicing_list_addpost
1879 Adds postal invoicing to this customer. If this customer is already configured
1880 to receive postal invoices, does nothing.
1884 sub invoicing_list_addpost {
1886 return if grep { $_ eq 'POST' } $self->invoicing_list;
1887 my @invoicing_list = $self->invoicing_list;
1888 push @invoicing_list, 'POST';
1889 $self->invoicing_list(\@invoicing_list);
1892 =item referral_cust_main [ DEPTH [ EXCLUDE_HASHREF ] ]
1894 Returns an array of customers referred by this customer (referral_custnum set
1895 to this custnum). If DEPTH is given, recurses up to the given depth, returning
1896 customers referred by customers referred by this customer and so on, inclusive.
1897 The default behavior is DEPTH 1 (no recursion).
1901 sub referral_cust_main {
1903 my $depth = @_ ? shift : 1;
1904 my $exclude = @_ ? shift : {};
1907 map { $exclude->{$_->custnum}++; $_; }
1908 grep { ! $exclude->{ $_->custnum } }
1909 qsearch( 'cust_main', { 'referral_custnum' => $self->custnum } );
1913 map { $_->referral_cust_main($depth-1, $exclude) }
1920 =item referral_cust_main_ncancelled
1922 Same as referral_cust_main, except only returns customers with uncancelled
1927 sub referral_cust_main_ncancelled {
1929 grep { scalar($_->ncancelled_pkgs) } $self->referral_cust_main;
1932 =item referral_cust_pkg [ DEPTH ]
1934 Like referral_cust_main, except returns a flat list of all unsuspended (and
1935 uncancelled) packages for each customer. The number of items in this list may
1936 be useful for comission calculations (perhaps after a C<grep { my $pkgpart = $_->pkgpart; grep { $_ == $pkgpart } @commission_worthy_pkgparts> } $cust_main-> ).
1940 sub referral_cust_pkg {
1942 my $depth = @_ ? shift : 1;
1944 map { $_->unsuspended_pkgs }
1945 grep { $_->unsuspended_pkgs }
1946 $self->referral_cust_main($depth);
1949 =item credit AMOUNT, REASON
1951 Applies a credit to this customer. If there is an error, returns the error,
1952 otherwise returns false.
1957 my( $self, $amount, $reason ) = @_;
1958 my $cust_credit = new FS::cust_credit {
1959 'custnum' => $self->custnum,
1960 'amount' => $amount,
1961 'reason' => $reason,
1963 $cust_credit->insert;
1966 =item charge AMOUNT [ PKG [ COMMENT [ TAXCLASS ] ] ]
1968 Creates a one-time charge for this customer. If there is an error, returns
1969 the error, otherwise returns false.
1974 my ( $self, $amount ) = ( shift, shift );
1975 my $pkg = @_ ? shift : 'One-time charge';
1976 my $comment = @_ ? shift : '$'. sprintf("%.2f",$amount);
1977 my $taxclass = @_ ? shift : '';
1979 local $SIG{HUP} = 'IGNORE';
1980 local $SIG{INT} = 'IGNORE';
1981 local $SIG{QUIT} = 'IGNORE';
1982 local $SIG{TERM} = 'IGNORE';
1983 local $SIG{TSTP} = 'IGNORE';
1984 local $SIG{PIPE} = 'IGNORE';
1986 my $oldAutoCommit = $FS::UID::AutoCommit;
1987 local $FS::UID::AutoCommit = 0;
1990 my $part_pkg = new FS::part_pkg ( {
1992 'comment' => $comment,
1997 'taxclass' => $taxclass,
2000 my $error = $part_pkg->insert;
2002 $dbh->rollback if $oldAutoCommit;
2006 my $pkgpart = $part_pkg->pkgpart;
2007 my %type_pkgs = ( 'typenum' => $self->agent->typenum, 'pkgpart' => $pkgpart );
2008 unless ( qsearchs('type_pkgs', \%type_pkgs ) ) {
2009 my $type_pkgs = new FS::type_pkgs \%type_pkgs;
2010 $error = $type_pkgs->insert;
2012 $dbh->rollback if $oldAutoCommit;
2017 my $cust_pkg = new FS::cust_pkg ( {
2018 'custnum' => $self->custnum,
2019 'pkgpart' => $pkgpart,
2022 $error = $cust_pkg->insert;
2024 $dbh->rollback if $oldAutoCommit;
2028 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2035 Returns all the invoices (see L<FS::cust_bill>) for this customer.
2041 sort { $a->_date <=> $b->_date }
2042 qsearch('cust_bill', { 'custnum' => $self->custnum, } )
2045 =item open_cust_bill
2047 Returns all the open (owed > 0) invoices (see L<FS::cust_bill>) for this
2052 sub open_cust_bill {
2054 grep { $_->owed > 0 } $self->cust_bill;
2063 =item check_and_rebuild_fuzzyfiles
2067 sub check_and_rebuild_fuzzyfiles {
2068 my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
2069 -e "$dir/cust_main.last" && -e "$dir/cust_main.company"
2070 or &rebuild_fuzzyfiles;
2073 =item rebuild_fuzzyfiles
2077 sub rebuild_fuzzyfiles {
2079 use Fcntl qw(:flock);
2081 my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
2085 open(LASTLOCK,">>$dir/cust_main.last")
2086 or die "can't open $dir/cust_main.last: $!";
2087 flock(LASTLOCK,LOCK_EX)
2088 or die "can't lock $dir/cust_main.last: $!";
2090 my @all_last = map $_->getfield('last'), qsearch('cust_main', {});
2092 grep $_, map $_->getfield('ship_last'), qsearch('cust_main',{})
2093 if defined dbdef->table('cust_main')->column('ship_last');
2095 open (LASTCACHE,">$dir/cust_main.last.tmp")
2096 or die "can't open $dir/cust_main.last.tmp: $!";
2097 print LASTCACHE join("\n", @all_last), "\n";
2098 close LASTCACHE or die "can't close $dir/cust_main.last.tmp: $!";
2100 rename "$dir/cust_main.last.tmp", "$dir/cust_main.last";
2105 open(COMPANYLOCK,">>$dir/cust_main.company")
2106 or die "can't open $dir/cust_main.company: $!";
2107 flock(COMPANYLOCK,LOCK_EX)
2108 or die "can't lock $dir/cust_main.company: $!";
2110 my @all_company = grep $_ ne '', map $_->company, qsearch('cust_main',{});
2112 grep $_ ne '', map $_->ship_company, qsearch('cust_main', {})
2113 if defined dbdef->table('cust_main')->column('ship_last');
2115 open (COMPANYCACHE,">$dir/cust_main.company.tmp")
2116 or die "can't open $dir/cust_main.company.tmp: $!";
2117 print COMPANYCACHE join("\n", @all_company), "\n";
2118 close COMPANYCACHE or die "can't close $dir/cust_main.company.tmp: $!";
2120 rename "$dir/cust_main.company.tmp", "$dir/cust_main.company";
2130 my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
2131 open(LASTCACHE,"<$dir/cust_main.last")
2132 or die "can't open $dir/cust_main.last: $!";
2133 my @array = map { chomp; $_; } <LASTCACHE>;
2143 my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
2144 open(COMPANYCACHE,"<$dir/cust_main.company")
2145 or die "can't open $dir/cust_main.last: $!";
2146 my @array = map { chomp; $_; } <COMPANYCACHE>;
2151 =item append_fuzzyfiles LASTNAME COMPANY
2155 sub append_fuzzyfiles {
2156 my( $last, $company ) = @_;
2158 &check_and_rebuild_fuzzyfiles;
2160 use Fcntl qw(:flock);
2162 my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
2166 open(LAST,">>$dir/cust_main.last")
2167 or die "can't open $dir/cust_main.last: $!";
2169 or die "can't lock $dir/cust_main.last: $!";
2171 print LAST "$last\n";
2174 or die "can't unlock $dir/cust_main.last: $!";
2180 open(COMPANY,">>$dir/cust_main.company")
2181 or die "can't open $dir/cust_main.company: $!";
2182 flock(COMPANY,LOCK_EX)
2183 or die "can't lock $dir/cust_main.company: $!";
2185 print COMPANY "$company\n";
2187 flock(COMPANY,LOCK_UN)
2188 or die "can't unlock $dir/cust_main.company: $!";
2202 #warn join('-',keys %$param);
2203 my $fh = $param->{filehandle};
2204 my $agentnum = $param->{agentnum};
2205 my $refnum = $param->{refnum};
2206 my $pkgpart = $param->{pkgpart};
2207 my @fields = @{$param->{fields}};
2209 eval "use Date::Parse;";
2211 eval "use Text::CSV_XS;";
2214 my $csv = new Text::CSV_XS;
2221 local $SIG{HUP} = 'IGNORE';
2222 local $SIG{INT} = 'IGNORE';
2223 local $SIG{QUIT} = 'IGNORE';
2224 local $SIG{TERM} = 'IGNORE';
2225 local $SIG{TSTP} = 'IGNORE';
2226 local $SIG{PIPE} = 'IGNORE';
2228 my $oldAutoCommit = $FS::UID::AutoCommit;
2229 local $FS::UID::AutoCommit = 0;
2232 #while ( $columns = $csv->getline($fh) ) {
2234 while ( defined($line=<$fh>) ) {
2236 $csv->parse($line) or do {
2237 $dbh->rollback if $oldAutoCommit;
2238 return "can't parse: ". $csv->error_input();
2241 my @columns = $csv->fields();
2242 #warn join('-',@columns);
2245 agentnum => $agentnum,
2247 country => 'US', #default
2248 payby => 'BILL', #default
2249 paydate => '12/2037', #default
2251 my $billtime = time;
2252 my %cust_pkg = ( pkgpart => $pkgpart );
2253 foreach my $field ( @fields ) {
2254 if ( $field =~ /^cust_pkg\.(setup|bill|susp|expire|cancel)$/ ) {
2255 #$cust_pkg{$1} = str2time( shift @$columns );
2256 if ( $1 eq 'setup' ) {
2257 $billtime = str2time(shift @columns);
2259 $cust_pkg{$1} = str2time( shift @columns );
2262 #$cust_main{$field} = shift @$columns;
2263 $cust_main{$field} = shift @columns;
2267 my $cust_pkg = new FS::cust_pkg ( \%cust_pkg ) if $pkgpart;
2268 my $cust_main = new FS::cust_main ( \%cust_main );
2270 tie my %hash, 'Tie::RefHash'; #this part is important
2271 $hash{$cust_pkg} = [] if $pkgpart;
2272 my $error = $cust_main->insert( \%hash );
2275 $dbh->rollback if $oldAutoCommit;
2276 return "can't insert customer for $line: $error";
2279 #false laziness w/bill.cgi
2280 $error = $cust_main->bill( 'time' => $billtime );
2282 $dbh->rollback if $oldAutoCommit;
2283 return "can't bill customer for $line: $error";
2286 $cust_main->apply_payments;
2287 $cust_main->apply_credits;
2289 $error = $cust_main->collect();
2291 $dbh->rollback if $oldAutoCommit;
2292 return "can't collect customer for $line: $error";
2298 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2300 return "Empty file!" unless $imported;
2312 #warn join('-',keys %$param);
2313 my $fh = $param->{filehandle};
2314 my @fields = @{$param->{fields}};
2316 eval "use Date::Parse;";
2318 eval "use Text::CSV_XS;";
2321 my $csv = new Text::CSV_XS;
2328 local $SIG{HUP} = 'IGNORE';
2329 local $SIG{INT} = 'IGNORE';
2330 local $SIG{QUIT} = 'IGNORE';
2331 local $SIG{TERM} = 'IGNORE';
2332 local $SIG{TSTP} = 'IGNORE';
2333 local $SIG{PIPE} = 'IGNORE';
2335 my $oldAutoCommit = $FS::UID::AutoCommit;
2336 local $FS::UID::AutoCommit = 0;
2339 #while ( $columns = $csv->getline($fh) ) {
2341 while ( defined($line=<$fh>) ) {
2343 $csv->parse($line) or do {
2344 $dbh->rollback if $oldAutoCommit;
2345 return "can't parse: ". $csv->error_input();
2348 my @columns = $csv->fields();
2349 #warn join('-',@columns);
2352 foreach my $field ( @fields ) {
2353 $row{$field} = shift @columns;
2356 my $cust_main = qsearchs('cust_main', { 'custnum' => $row{'custnum'} } );
2357 unless ( $cust_main ) {
2358 $dbh->rollback if $oldAutoCommit;
2359 return "unknown custnum $row{'custnum'}";
2362 if ( $row{'amount'} > 0 ) {
2363 my $error = $cust_main->charge($row{'amount'}, $row{'pkg'});
2365 $dbh->rollback if $oldAutoCommit;
2369 } elsif ( $row{'amount'} < 0 ) {
2370 my $error = $cust_main->credit( sprintf( "%.2f", 0-$row{'amount'} ),
2373 $dbh->rollback if $oldAutoCommit;
2383 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2385 return "Empty file!" unless $imported;
2397 The delete method should possibly take an FS::cust_main object reference
2398 instead of a scalar customer number.
2400 Bill and collect options should probably be passed as references instead of a
2403 There should probably be a configuration file with a list of allowed credit
2406 No multiple currency support (probably a larger project than just this module).
2410 L<FS::Record>, L<FS::cust_pkg>, L<FS::cust_bill>, L<FS::cust_credit>
2411 L<FS::agent>, L<FS::part_referral>, L<FS::cust_main_county>,
2412 L<FS::cust_main_invoice>, L<FS::UID>, schema.html from the base documentation.