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";
703 } elsif ( $self->payby eq 'CHEK' ) {
705 my $payinfo = $self->payinfo;
706 $payinfo =~ s/[^\d\@]//g;
707 $payinfo =~ /^(\d+)\@(\d{9})$/ or return 'invalid echeck account@aba';
709 $self->payinfo($payinfo);
711 } elsif ( $self->payby eq 'LECB' ) {
713 my $payinfo = $self->payinfo;
715 $payinfo =~ /^1?(\d{10})$/ or return 'invalid btn billing telephone number';
717 $self->payinfo($payinfo);
719 } elsif ( $self->payby eq 'BILL' ) {
721 $error = $self->ut_textn('payinfo');
722 return "Illegal P.O. number: ". $self->payinfo if $error;
724 } elsif ( $self->payby eq 'COMP' ) {
726 if ( !$self->custnum && $conf->config('users-allow_comp') ) {
727 return "You are not permitted to create complimentary accounts."
728 unless grep { $_ eq getotaker } $conf->config('users-allow_comp');
731 $error = $self->ut_textn('payinfo');
732 return "Illegal comp account issuer: ". $self->payinfo if $error;
734 } elsif ( $self->payby eq 'PREPAY' ) {
736 my $payinfo = $self->payinfo;
737 $payinfo =~ s/\W//g; #anything else would just confuse things
738 $self->payinfo($payinfo);
739 $error = $self->ut_alpha('payinfo');
740 return "Illegal prepayment identifier: ". $self->payinfo if $error;
741 return "Unknown prepayment identifier"
742 unless qsearchs('prepay_credit', { 'identifier' => $self->payinfo } );
746 if ( $self->paydate eq '' || $self->paydate eq '-' ) {
747 return "Expriation date required"
748 unless $self->payby =~ /^(BILL|PREPAY|CHEK|LECB)$/;
751 $self->paydate =~ /^(\d{1,2})[\/\-](\d{2}(\d{2})?)$/
752 or return "Illegal expiration date: ". $self->paydate;
753 my $y = length($2) == 4 ? $2 : "20$2";
754 $self->paydate("$y-$1-01");
755 my($nowm,$nowy)=(localtime(time))[4,5]; $nowm++; $nowy+=1900;
756 return gettext('expired_card')
757 if !$import && ( $y<$nowy || ( $y==$nowy && $1<$nowm ) );
760 if ( $self->payname eq '' && $self->payby ne 'CHEK' &&
761 ( ! $conf->exists('require_cardname') || $self->payby ne 'CARD' ) ) {
762 $self->payname( $self->first. " ". $self->getfield('last') );
764 $self->payname =~ /^([\w \,\.\-\']+)$/
765 or return gettext('illegal_name'). " payname: ". $self->payname;
769 $self->tax =~ /^(Y?)$/ or return "Illegal tax: ". $self->tax;
772 $self->otaker(getotaker);
774 #warn "AFTER: \n". $self->_dump;
781 Returns all packages (see L<FS::cust_pkg>) for this customer.
787 if ( $self->{'_pkgnum'} ) {
788 values %{ $self->{'_pkgnum'}->cache };
790 qsearch( 'cust_pkg', { 'custnum' => $self->custnum });
794 =item ncancelled_pkgs
796 Returns all non-cancelled packages (see L<FS::cust_pkg>) for this customer.
800 sub ncancelled_pkgs {
802 if ( $self->{'_pkgnum'} ) {
803 grep { ! $_->getfield('cancel') } values %{ $self->{'_pkgnum'}->cache };
805 @{ [ # force list context
806 qsearch( 'cust_pkg', {
807 'custnum' => $self->custnum,
810 qsearch( 'cust_pkg', {
811 'custnum' => $self->custnum,
820 Returns all suspended packages (see L<FS::cust_pkg>) for this customer.
826 grep { $_->susp } $self->ncancelled_pkgs;
829 =item unflagged_suspended_pkgs
831 Returns all unflagged suspended packages (see L<FS::cust_pkg>) for this
832 customer (thouse packages without the `manual_flag' set).
836 sub unflagged_suspended_pkgs {
838 return $self->suspended_pkgs
839 unless dbdef->table('cust_pkg')->column('manual_flag');
840 grep { ! $_->manual_flag } $self->suspended_pkgs;
843 =item unsuspended_pkgs
845 Returns all unsuspended (and uncancelled) packages (see L<FS::cust_pkg>) for
850 sub unsuspended_pkgs {
852 grep { ! $_->susp } $self->ncancelled_pkgs;
857 Unsuspends all unflagged suspended packages (see L</unflagged_suspended_pkgs>
858 and L<FS::cust_pkg>) for this customer. Always returns a list: an empty list
859 on success or a list of errors.
865 grep { $_->unsuspend } $self->suspended_pkgs;
870 Suspends all unsuspended packages (see L<FS::cust_pkg>) for this customer.
871 Always returns a list: an empty list on success or a list of errors.
877 grep { $_->suspend } $self->unsuspended_pkgs;
880 =item cancel [ OPTION => VALUE ... ]
882 Cancels all uncancelled packages (see L<FS::cust_pkg>) for this customer.
884 Available options are: I<quiet>
886 I<quiet> can be set true to supress email cancellation notices.
888 Always returns a list: an empty list on success or a list of errors.
894 grep { $_->cancel(@_) } $self->ncancelled_pkgs;
899 Returns the agent (see L<FS::agent>) for this customer.
905 qsearchs( 'agent', { 'agentnum' => $self->agentnum } );
910 Generates invoices (see L<FS::cust_bill>) for this customer. Usually used in
911 conjunction with the collect method.
913 Options are passed as name-value pairs.
915 Currently available options are:
917 resetup - if set true, re-charges setup fees.
919 time - bills the customer as if it were that time. Specified as a UNIX
920 timestamp; see L<perlfunc/"time">). Also see L<Time::Local> and
921 L<Date::Parse> for conversion functions. For example:
925 $cust_main->bill( 'time' => str2time('April 20th, 2001') );
928 If there is an error, returns the error, otherwise returns false.
933 my( $self, %options ) = @_;
934 my $time = $options{'time'} || time;
939 local $SIG{HUP} = 'IGNORE';
940 local $SIG{INT} = 'IGNORE';
941 local $SIG{QUIT} = 'IGNORE';
942 local $SIG{TERM} = 'IGNORE';
943 local $SIG{TSTP} = 'IGNORE';
944 local $SIG{PIPE} = 'IGNORE';
946 my $oldAutoCommit = $FS::UID::AutoCommit;
947 local $FS::UID::AutoCommit = 0;
950 # find the packages which are due for billing, find out how much they are
951 # & generate invoice database.
953 my( $total_setup, $total_recur ) = ( 0, 0 );
954 #my( $taxable_setup, $taxable_recur ) = ( 0, 0 );
955 my @cust_bill_pkg = ();
957 #my $taxable_charged = 0;##
962 foreach my $cust_pkg (
963 qsearch('cust_pkg', { 'custnum' => $self->custnum } )
966 #NO!! next if $cust_pkg->cancel;
967 next if $cust_pkg->getfield('cancel');
969 #? to avoid use of uninitialized value errors... ?
970 $cust_pkg->setfield('bill', '')
971 unless defined($cust_pkg->bill);
973 my $part_pkg = $cust_pkg->part_pkg;
975 #so we don't modify cust_pkg record unnecessarily
976 my $cust_pkg_mod_flag = 0;
977 my %hash = $cust_pkg->hash;
978 my $old_cust_pkg = new FS::cust_pkg \%hash;
982 if ( !$cust_pkg->setup || $options{'resetup'} ) {
983 my $setup_prog = $part_pkg->getfield('setup');
984 $setup_prog =~ /^(.*)$/ or do {
985 $dbh->rollback if $oldAutoCommit;
986 return "Illegal setup for pkgpart ". $part_pkg->pkgpart.
990 $setup_prog = '0' if $setup_prog =~ /^\s*$/;
993 ##$cpt->permit(); #what is necessary?
994 #$cpt->share(qw( $cust_pkg )); #can $cpt now use $cust_pkg methods?
995 #$setup = $cpt->reval($setup_prog);
996 $setup = eval $setup_prog;
997 unless ( defined($setup) ) {
998 $dbh->rollback if $oldAutoCommit;
999 return "Error eval-ing part_pkg->setup pkgpart ". $part_pkg->pkgpart.
1000 "(expression $setup_prog): $@";
1002 $cust_pkg->setfield('setup', $time) unless $cust_pkg->setup;
1003 $cust_pkg_mod_flag=1;
1009 if ( $part_pkg->getfield('freq') > 0 &&
1010 ! $cust_pkg->getfield('susp') &&
1011 ( $cust_pkg->getfield('bill') || 0 ) <= $time
1013 my $recur_prog = $part_pkg->getfield('recur');
1014 $recur_prog =~ /^(.*)$/ or do {
1015 $dbh->rollback if $oldAutoCommit;
1016 return "Illegal recur for pkgpart ". $part_pkg->pkgpart.
1020 $recur_prog = '0' if $recur_prog =~ /^\s*$/;
1022 # shared with $recur_prog
1023 $sdate = $cust_pkg->bill || $cust_pkg->setup || $time;
1025 #my $cpt = new Safe;
1026 ##$cpt->permit(); #what is necessary?
1027 #$cpt->share(qw( $cust_pkg )); #can $cpt now use $cust_pkg methods?
1028 #$recur = $cpt->reval($recur_prog);
1029 $recur = eval $recur_prog;
1030 unless ( defined($recur) ) {
1031 $dbh->rollback if $oldAutoCommit;
1032 return "Error eval-ing part_pkg->recur pkgpart ". $part_pkg->pkgpart.
1033 "(expression $recur_prog): $@";
1035 #change this bit to use Date::Manip? CAREFUL with timezones (see
1036 # mailing list archive)
1037 my ($sec,$min,$hour,$mday,$mon,$year) =
1038 (localtime($sdate) )[0,1,2,3,4,5];
1040 #pro-rating magic - if $recur_prog fiddles $sdate, want to use that
1041 # only for figuring next bill date, nothing else, so, reset $sdate again
1043 $sdate = $cust_pkg->bill || $cust_pkg->setup || $time;
1044 $cust_pkg->last_bill($sdate)
1045 if $cust_pkg->dbdef_table->column('last_bill');
1047 $mon += $part_pkg->freq;
1048 until ( $mon < 12 ) { $mon -= 12; $year++; }
1049 $cust_pkg->setfield('bill',
1050 timelocal_nocheck($sec,$min,$hour,$mday,$mon,$year));
1051 $cust_pkg_mod_flag = 1;
1054 warn "\$setup is undefined" unless defined($setup);
1055 warn "\$recur is undefined" unless defined($recur);
1056 warn "\$cust_pkg->bill is undefined" unless defined($cust_pkg->bill);
1058 if ( $cust_pkg_mod_flag ) {
1059 $error=$cust_pkg->replace($old_cust_pkg);
1060 if ( $error ) { #just in case
1061 $dbh->rollback if $oldAutoCommit;
1062 return "Error modifying pkgnum ". $cust_pkg->pkgnum. ": $error";
1064 $setup = sprintf( "%.2f", $setup );
1065 $recur = sprintf( "%.2f", $recur );
1067 $dbh->rollback if $oldAutoCommit;
1068 return "negative setup $setup for pkgnum ". $cust_pkg->pkgnum;
1071 $dbh->rollback if $oldAutoCommit;
1072 return "negative recur $recur for pkgnum ". $cust_pkg->pkgnum;
1074 if ( $setup > 0 || $recur > 0 ) {
1075 my $cust_bill_pkg = new FS::cust_bill_pkg ({
1076 'pkgnum' => $cust_pkg->pkgnum,
1080 'edate' => $cust_pkg->bill,
1082 push @cust_bill_pkg, $cust_bill_pkg;
1083 $total_setup += $setup;
1084 $total_recur += $recur;
1086 unless ( $self->tax =~ /Y/i || $self->payby eq 'COMP' ) {
1088 my @taxes = qsearch( 'cust_main_county', {
1089 'state' => $self->state,
1090 'county' => $self->county,
1091 'country' => $self->country,
1092 'taxclass' => $part_pkg->taxclass,
1095 @taxes = qsearch( 'cust_main_county', {
1096 'state' => $self->state,
1097 'county' => $self->county,
1098 'country' => $self->country,
1103 # maybe eliminate this entirely, along with all the 0% records
1105 $dbh->rollback if $oldAutoCommit;
1107 "fatal: can't find tax rate for state/county/country/taxclass ".
1108 join('/', ( map $self->$_(), qw(state county country) ),
1109 $part_pkg->taxclass ). "\n";
1112 foreach my $tax ( @taxes ) {
1114 my $taxable_charged = 0;
1115 $taxable_charged += $setup
1116 unless $part_pkg->setuptax =~ /^Y$/i
1117 || $tax->setuptax =~ /^Y$/i;
1118 $taxable_charged += $recur
1119 unless $part_pkg->recurtax =~ /^Y$/i
1120 || $tax->recurtax =~ /^Y$/i;
1121 next unless $taxable_charged;
1123 if ( $tax->exempt_amount ) {
1124 my ($mon,$year) = (localtime($sdate) )[4,5];
1126 my $freq = $part_pkg->freq || 1;
1127 my $taxable_per_month = sprintf("%.2f", $taxable_charged / $freq );
1128 foreach my $which_month ( 1 .. $freq ) {
1130 'custnum' => $self->custnum,
1131 'taxnum' => $tax->taxnum,
1132 'year' => 1900+$year,
1135 #until ( $mon < 12 ) { $mon -= 12; $year++; }
1136 until ( $mon < 13 ) { $mon -= 12; $year++; }
1137 my $cust_tax_exempt =
1138 qsearchs('cust_tax_exempt', \%hash)
1139 || new FS::cust_tax_exempt( { %hash, 'amount' => 0 } );
1140 my $remaining_exemption = sprintf("%.2f",
1141 $tax->exempt_amount - $cust_tax_exempt->amount );
1142 if ( $remaining_exemption > 0 ) {
1143 my $addl = $remaining_exemption > $taxable_per_month
1144 ? $taxable_per_month
1145 : $remaining_exemption;
1146 $taxable_charged -= $addl;
1147 my $new_cust_tax_exempt = new FS::cust_tax_exempt ( {
1148 $cust_tax_exempt->hash,
1150 sprintf("%.2f", $cust_tax_exempt->amount + $addl),
1152 $error = $new_cust_tax_exempt->exemptnum
1153 ? $new_cust_tax_exempt->replace($cust_tax_exempt)
1154 : $new_cust_tax_exempt->insert;
1156 $dbh->rollback if $oldAutoCommit;
1157 return "fatal: can't update cust_tax_exempt: $error";
1160 } # if $remaining_exemption > 0
1162 } #foreach $which_month
1164 } #if $tax->exempt_amount
1166 $taxable_charged = sprintf( "%.2f", $taxable_charged);
1168 #$tax += $taxable_charged * $cust_main_county->tax / 100
1169 $tax{ $tax->taxname || 'Tax' } +=
1170 $taxable_charged * $tax->tax / 100
1172 } #foreach my $tax ( @taxes )
1174 } #unless $self->tax =~ /Y/i || $self->payby eq 'COMP'
1176 } #if $setup > 0 || $recur > 0
1178 } #if $cust_pkg_mod_flag
1180 } #foreach my $cust_pkg
1182 my $charged = sprintf( "%.2f", $total_setup + $total_recur );
1183 # my $taxable_charged = sprintf( "%.2f", $taxable_setup + $taxable_recur );
1185 unless ( @cust_bill_pkg ) { #don't create invoices with no line items
1186 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1190 # unless ( $self->tax =~ /Y/i
1191 # || $self->payby eq 'COMP'
1192 # || $taxable_charged == 0 ) {
1193 # my $cust_main_county = qsearchs('cust_main_county',{
1194 # 'state' => $self->state,
1195 # 'county' => $self->county,
1196 # 'country' => $self->country,
1197 # } ) or die "fatal: can't find tax rate for state/county/country ".
1198 # $self->state. "/". $self->county. "/". $self->country. "\n";
1199 # my $tax = sprintf( "%.2f",
1200 # $taxable_charged * ( $cust_main_county->getfield('tax') / 100 )
1203 if ( dbdef->table('cust_bill_pkg')->column('itemdesc') ) { #1.5 schema
1205 foreach my $taxname ( grep { $tax{$_} > 0 } keys %tax ) {
1206 my $tax = sprintf("%.2f", $tax{$taxname} );
1207 $charged = sprintf( "%.2f", $charged+$tax );
1209 my $cust_bill_pkg = new FS::cust_bill_pkg ({
1215 'itemdesc' => $taxname,
1217 push @cust_bill_pkg, $cust_bill_pkg;
1220 } else { #1.4 schema
1223 foreach ( values %tax ) { $tax += $_ };
1224 $tax = sprintf("%.2f", $tax);
1226 $charged = sprintf( "%.2f", $charged+$tax );
1228 my $cust_bill_pkg = new FS::cust_bill_pkg ({
1235 push @cust_bill_pkg, $cust_bill_pkg;
1240 my $cust_bill = new FS::cust_bill ( {
1241 'custnum' => $self->custnum,
1243 'charged' => $charged,
1245 $error = $cust_bill->insert;
1247 $dbh->rollback if $oldAutoCommit;
1248 return "can't create invoice for customer #". $self->custnum. ": $error";
1251 my $invnum = $cust_bill->invnum;
1253 foreach $cust_bill_pkg ( @cust_bill_pkg ) {
1255 $cust_bill_pkg->invnum($invnum);
1256 $error = $cust_bill_pkg->insert;
1258 $dbh->rollback if $oldAutoCommit;
1259 return "can't create invoice line item for customer #". $self->custnum.
1264 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1270 document me. Re-schedules all exports by calling the B<reexport> method
1271 of all associated packages (see L<FS::cust_pkg>). If there is an error,
1272 returns the error; otherwise returns false.
1279 local $SIG{HUP} = 'IGNORE';
1280 local $SIG{INT} = 'IGNORE';
1281 local $SIG{QUIT} = 'IGNORE';
1282 local $SIG{TERM} = 'IGNORE';
1283 local $SIG{TSTP} = 'IGNORE';
1284 local $SIG{PIPE} = 'IGNORE';
1286 my $oldAutoCommit = $FS::UID::AutoCommit;
1287 local $FS::UID::AutoCommit = 0;
1290 foreach my $cust_pkg ( $self->ncancelled_pkgs ) {
1291 my $error = $cust_pkg->reexport;
1293 $dbh->rollback if $oldAutoCommit;
1298 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1303 =item collect OPTIONS
1305 (Attempt to) collect money for this customer's outstanding invoices (see
1306 L<FS::cust_bill>). Usually used after the bill method.
1308 Depending on the value of `payby', this may print an invoice (`BILL'), charge
1309 a credit card (`CARD'), or just add any necessary (pseudo-)payment (`COMP').
1311 Most actions are now triggered by invoice events; see L<FS::part_bill_event>
1312 and the invoice events web interface.
1314 If there is an error, returns the error, otherwise returns false.
1316 Options are passed as name-value pairs.
1318 Currently available options are:
1320 invoice_time - Use this time when deciding when to print invoices and
1321 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>
1322 for conversion functions.
1324 retry - Retry card/echeck/LEC transactions even when not scheduled by invoice
1327 retry_card - Deprecated alias for 'retry'
1329 batch_card - This option is deprecated. See the invoice events web interface
1330 to control whether cards are batched or run against a realtime gateway.
1332 report_badcard - This option is deprecated.
1334 force_print - This option is deprecated; see the invoice events web interface.
1336 quiet - set true to surpress email card/ACH decline notices.
1341 my( $self, %options ) = @_;
1342 my $invoice_time = $options{'invoice_time'} || time;
1345 local $SIG{HUP} = 'IGNORE';
1346 local $SIG{INT} = 'IGNORE';
1347 local $SIG{QUIT} = 'IGNORE';
1348 local $SIG{TERM} = 'IGNORE';
1349 local $SIG{TSTP} = 'IGNORE';
1350 local $SIG{PIPE} = 'IGNORE';
1352 my $oldAutoCommit = $FS::UID::AutoCommit;
1353 local $FS::UID::AutoCommit = 0;
1356 my $balance = $self->balance;
1357 warn "collect customer". $self->custnum. ": balance $balance" if $Debug;
1358 unless ( $balance > 0 ) { #redundant?????
1359 $dbh->rollback if $oldAutoCommit; #hmm
1363 if ( exists($options{'retry_card'}) ) {
1364 carp 'retry_card option passed to collect is deprecated; use retry';
1365 $options{'retry'} ||= $options{'retry_card'};
1367 if ( exists($options{'retry'}) && $options{'retry'} ) {
1368 my $error = $self->retry_realtime;
1370 $dbh->rollback if $oldAutoCommit;
1375 foreach my $cust_bill ( $self->cust_bill ) {
1377 #this has to be before next's
1378 my $amount = sprintf( "%.2f", $balance < $cust_bill->owed
1382 $balance = sprintf( "%.2f", $balance - $amount );
1384 next unless $cust_bill->owed > 0;
1386 # don't try to charge for the same invoice if it's already in a batch
1387 #next if qsearchs( 'cust_pay_batch', { 'invnum' => $cust_bill->invnum } );
1389 warn "invnum ". $cust_bill->invnum. " (owed ". $cust_bill->owed. ", amount $amount, balance $balance)" if $Debug;
1391 next unless $amount > 0;
1394 foreach my $part_bill_event (
1395 sort { $a->seconds <=> $b->seconds
1396 || $a->weight <=> $b->weight
1397 || $a->eventpart <=> $b->eventpart }
1398 grep { $_->seconds <= ( $invoice_time - $cust_bill->_date )
1399 && ! qsearchs( 'cust_bill_event', {
1400 'invnum' => $cust_bill->invnum,
1401 'eventpart' => $_->eventpart,
1405 qsearch('part_bill_event', { 'payby' => $self->payby,
1406 'disabled' => '', } )
1409 last unless $cust_bill->owed > 0; #don't run subsequent events if owed=0
1411 warn "calling invoice event (". $part_bill_event->eventcode. ")\n"
1413 my $cust_main = $self; #for callback
1417 #supress "used only once" warning
1418 $FS::cust_bill::realtime_bop_decline_quiet += 0;
1419 local $FS::cust_bill::realtime_bop_decline_quiet = 1
1420 if $options{'quiet'};
1421 $error = eval $part_bill_event->eventcode;
1425 my $statustext = '';
1429 } elsif ( $error ) {
1431 $statustext = $error;
1436 #add cust_bill_event
1437 my $cust_bill_event = new FS::cust_bill_event {
1438 'invnum' => $cust_bill->invnum,
1439 'eventpart' => $part_bill_event->eventpart,
1440 #'_date' => $invoice_time,
1442 'status' => $status,
1443 'statustext' => $statustext,
1445 $error = $cust_bill_event->insert;
1447 #$dbh->rollback if $oldAutoCommit;
1448 #return "error: $error";
1450 # gah, even with transactions.
1451 $dbh->commit if $oldAutoCommit; #well.
1452 my $e = 'WARNING: Event run but database not updated - '.
1453 'error inserting cust_bill_event, invnum #'. $cust_bill->invnum.
1454 ', eventpart '. $part_bill_event->eventpart.
1465 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1470 =item retry_realtime
1472 Schedules realtime credit card / electronic check / LEC billing events for
1473 for retry. Useful if card information has changed or manual retry is desired.
1474 The 'collect' method must be called to actually retry the transaction.
1476 Implementation details: For each of this customer's open invoices, changes
1477 the status of the first "done" (with statustext error) realtime processing
1482 sub retry_realtime {
1485 local $SIG{HUP} = 'IGNORE';
1486 local $SIG{INT} = 'IGNORE';
1487 local $SIG{QUIT} = 'IGNORE';
1488 local $SIG{TERM} = 'IGNORE';
1489 local $SIG{TSTP} = 'IGNORE';
1490 local $SIG{PIPE} = 'IGNORE';
1492 my $oldAutoCommit = $FS::UID::AutoCommit;
1493 local $FS::UID::AutoCommit = 0;
1496 foreach my $cust_bill (
1497 grep { $_->cust_bill_event }
1498 $self->open_cust_bill
1500 my @cust_bill_event =
1501 sort { $a->part_bill_event->seconds <=> $b->part_bill_event->seconds }
1503 #$_->part_bill_event->plan eq 'realtime-card'
1504 $_->part_bill_event->eventcode =~
1505 /\$cust_bill\->realtime_(card|ach|lec)/
1506 && $_->status eq 'done'
1509 $cust_bill->cust_bill_event;
1510 next unless @cust_bill_event;
1511 my $error = $cust_bill_event[0]->retry;
1513 $dbh->rollback if $oldAutoCommit;
1514 return "error scheduling invoice event for retry: $error";
1519 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1526 Returns the total owed for this customer on all invoices
1527 (see L<FS::cust_bill/owed>).
1533 $self->total_owed_date(2145859200); #12/31/2037
1536 =item total_owed_date TIME
1538 Returns the total owed for this customer on all invoices with date earlier than
1539 TIME. TIME is specified as a UNIX timestamp; see L<perlfunc/"time">). Also
1540 see L<Time::Local> and L<Date::Parse> for conversion functions.
1544 sub total_owed_date {
1548 foreach my $cust_bill (
1549 grep { $_->_date <= $time }
1550 qsearch('cust_bill', { 'custnum' => $self->custnum, } )
1552 $total_bill += $cust_bill->owed;
1554 sprintf( "%.2f", $total_bill );
1559 Applies (see L<FS::cust_credit_bill>) unapplied credits (see L<FS::cust_credit>)
1560 to outstanding invoice balances in chronological order and returns the value
1561 of any remaining unapplied credits available for refund
1562 (see L<FS::cust_refund>).
1569 return 0 unless $self->total_credited;
1571 my @credits = sort { $b->_date <=> $a->_date} (grep { $_->credited > 0 }
1572 qsearch('cust_credit', { 'custnum' => $self->custnum } ) );
1574 my @invoices = sort { $a->_date <=> $b->_date} (grep { $_->owed > 0 }
1575 qsearch('cust_bill', { 'custnum' => $self->custnum } ) );
1579 foreach my $cust_bill ( @invoices ) {
1582 if ( !defined($credit) || $credit->credited == 0) {
1583 $credit = pop @credits or last;
1586 if ($cust_bill->owed >= $credit->credited) {
1587 $amount=$credit->credited;
1589 $amount=$cust_bill->owed;
1592 my $cust_credit_bill = new FS::cust_credit_bill ( {
1593 'crednum' => $credit->crednum,
1594 'invnum' => $cust_bill->invnum,
1595 'amount' => $amount,
1597 my $error = $cust_credit_bill->insert;
1598 die $error if $error;
1600 redo if ($cust_bill->owed > 0);
1604 return $self->total_credited;
1607 =item apply_payments
1609 Applies (see L<FS::cust_bill_pay>) unapplied payments (see L<FS::cust_pay>)
1610 to outstanding invoice balances in chronological order.
1612 #and returns the value of any remaining unapplied payments.
1616 sub apply_payments {
1621 my @payments = sort { $b->_date <=> $a->_date } ( grep { $_->unapplied > 0 }
1622 qsearch('cust_pay', { 'custnum' => $self->custnum } ) );
1624 my @invoices = sort { $a->_date <=> $b->_date} (grep { $_->owed > 0 }
1625 qsearch('cust_bill', { 'custnum' => $self->custnum } ) );
1629 foreach my $cust_bill ( @invoices ) {
1632 if ( !defined($payment) || $payment->unapplied == 0 ) {
1633 $payment = pop @payments or last;
1636 if ( $cust_bill->owed >= $payment->unapplied ) {
1637 $amount = $payment->unapplied;
1639 $amount = $cust_bill->owed;
1642 my $cust_bill_pay = new FS::cust_bill_pay ( {
1643 'paynum' => $payment->paynum,
1644 'invnum' => $cust_bill->invnum,
1645 'amount' => $amount,
1647 my $error = $cust_bill_pay->insert;
1648 die $error if $error;
1650 redo if ( $cust_bill->owed > 0);
1654 return $self->total_unapplied_payments;
1657 =item total_credited
1659 Returns the total outstanding credit (see L<FS::cust_credit>) for this
1660 customer. See L<FS::cust_credit/credited>.
1664 sub total_credited {
1666 my $total_credit = 0;
1667 foreach my $cust_credit ( qsearch('cust_credit', {
1668 'custnum' => $self->custnum,
1670 $total_credit += $cust_credit->credited;
1672 sprintf( "%.2f", $total_credit );
1675 =item total_unapplied_payments
1677 Returns the total unapplied payments (see L<FS::cust_pay>) for this customer.
1678 See L<FS::cust_pay/unapplied>.
1682 sub total_unapplied_payments {
1684 my $total_unapplied = 0;
1685 foreach my $cust_pay ( qsearch('cust_pay', {
1686 'custnum' => $self->custnum,
1688 $total_unapplied += $cust_pay->unapplied;
1690 sprintf( "%.2f", $total_unapplied );
1695 Returns the balance for this customer (total_owed minus total_credited
1696 minus total_unapplied_payments).
1703 $self->total_owed - $self->total_credited - $self->total_unapplied_payments
1707 =item balance_date TIME
1709 Returns the balance for this customer, only considering invoices with date
1710 earlier than TIME (total_owed_date minus total_credited minus
1711 total_unapplied_payments). TIME is specified as a UNIX timestamp; see
1712 L<perlfunc/"time">). Also see L<Time::Local> and L<Date::Parse> for conversion
1721 $self->total_owed_date($time)
1722 - $self->total_credited
1723 - $self->total_unapplied_payments
1727 =item invoicing_list [ ARRAYREF ]
1729 If an arguement is given, sets these email addresses as invoice recipients
1730 (see L<FS::cust_main_invoice>). Errors are not fatal and are not reported
1731 (except as warnings), so use check_invoicing_list first.
1733 Returns a list of email addresses (with svcnum entries expanded).
1735 Note: You can clear the invoicing list by passing an empty ARRAYREF. You can
1736 check it without disturbing anything by passing nothing.
1738 This interface may change in the future.
1742 sub invoicing_list {
1743 my( $self, $arrayref ) = @_;
1745 my @cust_main_invoice;
1746 if ( $self->custnum ) {
1747 @cust_main_invoice =
1748 qsearch( 'cust_main_invoice', { 'custnum' => $self->custnum } );
1750 @cust_main_invoice = ();
1752 foreach my $cust_main_invoice ( @cust_main_invoice ) {
1753 #warn $cust_main_invoice->destnum;
1754 unless ( grep { $cust_main_invoice->address eq $_ } @{$arrayref} ) {
1755 #warn $cust_main_invoice->destnum;
1756 my $error = $cust_main_invoice->delete;
1757 warn $error if $error;
1760 if ( $self->custnum ) {
1761 @cust_main_invoice =
1762 qsearch( 'cust_main_invoice', { 'custnum' => $self->custnum } );
1764 @cust_main_invoice = ();
1766 my %seen = map { $_->address => 1 } @cust_main_invoice;
1767 foreach my $address ( @{$arrayref} ) {
1768 next if exists $seen{$address} && $seen{$address};
1769 $seen{$address} = 1;
1770 my $cust_main_invoice = new FS::cust_main_invoice ( {
1771 'custnum' => $self->custnum,
1774 my $error = $cust_main_invoice->insert;
1775 warn $error if $error;
1778 if ( $self->custnum ) {
1780 qsearch( 'cust_main_invoice', { 'custnum' => $self->custnum } );
1786 =item check_invoicing_list ARRAYREF
1788 Checks these arguements as valid input for the invoicing_list method. If there
1789 is an error, returns the error, otherwise returns false.
1793 sub check_invoicing_list {
1794 my( $self, $arrayref ) = @_;
1795 foreach my $address ( @{$arrayref} ) {
1796 my $cust_main_invoice = new FS::cust_main_invoice ( {
1797 'custnum' => $self->custnum,
1800 my $error = $self->custnum
1801 ? $cust_main_invoice->check
1802 : $cust_main_invoice->checkdest
1804 return $error if $error;
1809 =item set_default_invoicing_list
1811 Sets the invoicing list to all accounts associated with this customer,
1812 overwriting any previous invoicing list.
1816 sub set_default_invoicing_list {
1818 $self->invoicing_list($self->all_emails);
1823 Returns the email addresses of all accounts provisioned for this customer.
1830 foreach my $cust_pkg ( $self->all_pkgs ) {
1831 my @cust_svc = qsearch('cust_svc', { 'pkgnum' => $cust_pkg->pkgnum } );
1833 map { qsearchs('svc_acct', { 'svcnum' => $_->svcnum } ) }
1834 grep { qsearchs('svc_acct', { 'svcnum' => $_->svcnum } ) }
1836 $list{$_}=1 foreach map { $_->email } @svc_acct;
1841 =item invoicing_list_addpost
1843 Adds postal invoicing to this customer. If this customer is already configured
1844 to receive postal invoices, does nothing.
1848 sub invoicing_list_addpost {
1850 return if grep { $_ eq 'POST' } $self->invoicing_list;
1851 my @invoicing_list = $self->invoicing_list;
1852 push @invoicing_list, 'POST';
1853 $self->invoicing_list(\@invoicing_list);
1856 =item referral_cust_main [ DEPTH [ EXCLUDE_HASHREF ] ]
1858 Returns an array of customers referred by this customer (referral_custnum set
1859 to this custnum). If DEPTH is given, recurses up to the given depth, returning
1860 customers referred by customers referred by this customer and so on, inclusive.
1861 The default behavior is DEPTH 1 (no recursion).
1865 sub referral_cust_main {
1867 my $depth = @_ ? shift : 1;
1868 my $exclude = @_ ? shift : {};
1871 map { $exclude->{$_->custnum}++; $_; }
1872 grep { ! $exclude->{ $_->custnum } }
1873 qsearch( 'cust_main', { 'referral_custnum' => $self->custnum } );
1877 map { $_->referral_cust_main($depth-1, $exclude) }
1884 =item referral_cust_main_ncancelled
1886 Same as referral_cust_main, except only returns customers with uncancelled
1891 sub referral_cust_main_ncancelled {
1893 grep { scalar($_->ncancelled_pkgs) } $self->referral_cust_main;
1896 =item referral_cust_pkg [ DEPTH ]
1898 Like referral_cust_main, except returns a flat list of all unsuspended (and
1899 uncancelled) packages for each customer. The number of items in this list may
1900 be useful for comission calculations (perhaps after a C<grep { my $pkgpart = $_->pkgpart; grep { $_ == $pkgpart } @commission_worthy_pkgparts> } $cust_main-> ).
1904 sub referral_cust_pkg {
1906 my $depth = @_ ? shift : 1;
1908 map { $_->unsuspended_pkgs }
1909 grep { $_->unsuspended_pkgs }
1910 $self->referral_cust_main($depth);
1913 =item credit AMOUNT, REASON
1915 Applies a credit to this customer. If there is an error, returns the error,
1916 otherwise returns false.
1921 my( $self, $amount, $reason ) = @_;
1922 my $cust_credit = new FS::cust_credit {
1923 'custnum' => $self->custnum,
1924 'amount' => $amount,
1925 'reason' => $reason,
1927 $cust_credit->insert;
1930 =item charge AMOUNT [ PKG [ COMMENT [ TAXCLASS ] ] ]
1932 Creates a one-time charge for this customer. If there is an error, returns
1933 the error, otherwise returns false.
1938 my ( $self, $amount ) = ( shift, shift );
1939 my $pkg = @_ ? shift : 'One-time charge';
1940 my $comment = @_ ? shift : '$'. sprintf("%.2f",$amount);
1941 my $taxclass = @_ ? shift : '';
1943 local $SIG{HUP} = 'IGNORE';
1944 local $SIG{INT} = 'IGNORE';
1945 local $SIG{QUIT} = 'IGNORE';
1946 local $SIG{TERM} = 'IGNORE';
1947 local $SIG{TSTP} = 'IGNORE';
1948 local $SIG{PIPE} = 'IGNORE';
1950 my $oldAutoCommit = $FS::UID::AutoCommit;
1951 local $FS::UID::AutoCommit = 0;
1954 my $part_pkg = new FS::part_pkg ( {
1956 'comment' => $comment,
1961 'taxclass' => $taxclass,
1964 my $error = $part_pkg->insert;
1966 $dbh->rollback if $oldAutoCommit;
1970 my $pkgpart = $part_pkg->pkgpart;
1971 my %type_pkgs = ( 'typenum' => $self->agent->typenum, 'pkgpart' => $pkgpart );
1972 unless ( qsearchs('type_pkgs', \%type_pkgs ) ) {
1973 my $type_pkgs = new FS::type_pkgs \%type_pkgs;
1974 $error = $type_pkgs->insert;
1976 $dbh->rollback if $oldAutoCommit;
1981 my $cust_pkg = new FS::cust_pkg ( {
1982 'custnum' => $self->custnum,
1983 'pkgpart' => $pkgpart,
1986 $error = $cust_pkg->insert;
1988 $dbh->rollback if $oldAutoCommit;
1992 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1999 Returns all the invoices (see L<FS::cust_bill>) for this customer.
2005 sort { $a->_date <=> $b->_date }
2006 qsearch('cust_bill', { 'custnum' => $self->custnum, } )
2009 =item open_cust_bill
2011 Returns all the open (owed > 0) invoices (see L<FS::cust_bill>) for this
2016 sub open_cust_bill {
2018 grep { $_->owed > 0 } $self->cust_bill;
2027 =item check_and_rebuild_fuzzyfiles
2031 sub check_and_rebuild_fuzzyfiles {
2032 my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
2033 -e "$dir/cust_main.last" && -e "$dir/cust_main.company"
2034 or &rebuild_fuzzyfiles;
2037 =item rebuild_fuzzyfiles
2041 sub rebuild_fuzzyfiles {
2043 use Fcntl qw(:flock);
2045 my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
2049 open(LASTLOCK,">>$dir/cust_main.last")
2050 or die "can't open $dir/cust_main.last: $!";
2051 flock(LASTLOCK,LOCK_EX)
2052 or die "can't lock $dir/cust_main.last: $!";
2054 my @all_last = map $_->getfield('last'), qsearch('cust_main', {});
2056 grep $_, map $_->getfield('ship_last'), qsearch('cust_main',{})
2057 if defined dbdef->table('cust_main')->column('ship_last');
2059 open (LASTCACHE,">$dir/cust_main.last.tmp")
2060 or die "can't open $dir/cust_main.last.tmp: $!";
2061 print LASTCACHE join("\n", @all_last), "\n";
2062 close LASTCACHE or die "can't close $dir/cust_main.last.tmp: $!";
2064 rename "$dir/cust_main.last.tmp", "$dir/cust_main.last";
2069 open(COMPANYLOCK,">>$dir/cust_main.company")
2070 or die "can't open $dir/cust_main.company: $!";
2071 flock(COMPANYLOCK,LOCK_EX)
2072 or die "can't lock $dir/cust_main.company: $!";
2074 my @all_company = grep $_ ne '', map $_->company, qsearch('cust_main',{});
2076 grep $_ ne '', map $_->ship_company, qsearch('cust_main', {})
2077 if defined dbdef->table('cust_main')->column('ship_last');
2079 open (COMPANYCACHE,">$dir/cust_main.company.tmp")
2080 or die "can't open $dir/cust_main.company.tmp: $!";
2081 print COMPANYCACHE join("\n", @all_company), "\n";
2082 close COMPANYCACHE or die "can't close $dir/cust_main.company.tmp: $!";
2084 rename "$dir/cust_main.company.tmp", "$dir/cust_main.company";
2094 my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
2095 open(LASTCACHE,"<$dir/cust_main.last")
2096 or die "can't open $dir/cust_main.last: $!";
2097 my @array = map { chomp; $_; } <LASTCACHE>;
2107 my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
2108 open(COMPANYCACHE,"<$dir/cust_main.company")
2109 or die "can't open $dir/cust_main.last: $!";
2110 my @array = map { chomp; $_; } <COMPANYCACHE>;
2115 =item append_fuzzyfiles LASTNAME COMPANY
2119 sub append_fuzzyfiles {
2120 my( $last, $company ) = @_;
2122 &check_and_rebuild_fuzzyfiles;
2124 use Fcntl qw(:flock);
2126 my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
2130 open(LAST,">>$dir/cust_main.last")
2131 or die "can't open $dir/cust_main.last: $!";
2133 or die "can't lock $dir/cust_main.last: $!";
2135 print LAST "$last\n";
2138 or die "can't unlock $dir/cust_main.last: $!";
2144 open(COMPANY,">>$dir/cust_main.company")
2145 or die "can't open $dir/cust_main.company: $!";
2146 flock(COMPANY,LOCK_EX)
2147 or die "can't lock $dir/cust_main.company: $!";
2149 print COMPANY "$company\n";
2151 flock(COMPANY,LOCK_UN)
2152 or die "can't unlock $dir/cust_main.company: $!";
2166 #warn join('-',keys %$param);
2167 my $fh = $param->{filehandle};
2168 my $agentnum = $param->{agentnum};
2169 my $refnum = $param->{refnum};
2170 my $pkgpart = $param->{pkgpart};
2171 my @fields = @{$param->{fields}};
2173 eval "use Date::Parse;";
2175 eval "use Text::CSV_XS;";
2178 my $csv = new Text::CSV_XS;
2185 local $SIG{HUP} = 'IGNORE';
2186 local $SIG{INT} = 'IGNORE';
2187 local $SIG{QUIT} = 'IGNORE';
2188 local $SIG{TERM} = 'IGNORE';
2189 local $SIG{TSTP} = 'IGNORE';
2190 local $SIG{PIPE} = 'IGNORE';
2192 my $oldAutoCommit = $FS::UID::AutoCommit;
2193 local $FS::UID::AutoCommit = 0;
2196 #while ( $columns = $csv->getline($fh) ) {
2198 while ( defined($line=<$fh>) ) {
2200 $csv->parse($line) or do {
2201 $dbh->rollback if $oldAutoCommit;
2202 return "can't parse: ". $csv->error_input();
2205 my @columns = $csv->fields();
2206 #warn join('-',@columns);
2209 agentnum => $agentnum,
2211 country => 'US', #default
2212 payby => 'BILL', #default
2213 paydate => '12/2037', #default
2215 my $billtime = time;
2216 my %cust_pkg = ( pkgpart => $pkgpart );
2217 foreach my $field ( @fields ) {
2218 if ( $field =~ /^cust_pkg\.(setup|bill|susp|expire|cancel)$/ ) {
2219 #$cust_pkg{$1} = str2time( shift @$columns );
2220 if ( $1 eq 'setup' ) {
2221 $billtime = str2time(shift @columns);
2223 $cust_pkg{$1} = str2time( shift @columns );
2226 #$cust_main{$field} = shift @$columns;
2227 $cust_main{$field} = shift @columns;
2231 my $cust_pkg = new FS::cust_pkg ( \%cust_pkg ) if $pkgpart;
2232 my $cust_main = new FS::cust_main ( \%cust_main );
2234 tie my %hash, 'Tie::RefHash'; #this part is important
2235 $hash{$cust_pkg} = [] if $pkgpart;
2236 my $error = $cust_main->insert( \%hash );
2239 $dbh->rollback if $oldAutoCommit;
2240 return "can't insert customer for $line: $error";
2243 #false laziness w/bill.cgi
2244 $error = $cust_main->bill( 'time' => $billtime );
2246 $dbh->rollback if $oldAutoCommit;
2247 return "can't bill customer for $line: $error";
2250 $cust_main->apply_payments;
2251 $cust_main->apply_credits;
2253 $error = $cust_main->collect();
2255 $dbh->rollback if $oldAutoCommit;
2256 return "can't collect customer for $line: $error";
2262 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2264 return "Empty file!" unless $imported;
2276 #warn join('-',keys %$param);
2277 my $fh = $param->{filehandle};
2278 my @fields = @{$param->{fields}};
2280 eval "use Date::Parse;";
2282 eval "use Text::CSV_XS;";
2285 my $csv = new Text::CSV_XS;
2292 local $SIG{HUP} = 'IGNORE';
2293 local $SIG{INT} = 'IGNORE';
2294 local $SIG{QUIT} = 'IGNORE';
2295 local $SIG{TERM} = 'IGNORE';
2296 local $SIG{TSTP} = 'IGNORE';
2297 local $SIG{PIPE} = 'IGNORE';
2299 my $oldAutoCommit = $FS::UID::AutoCommit;
2300 local $FS::UID::AutoCommit = 0;
2303 #while ( $columns = $csv->getline($fh) ) {
2305 while ( defined($line=<$fh>) ) {
2307 $csv->parse($line) or do {
2308 $dbh->rollback if $oldAutoCommit;
2309 return "can't parse: ". $csv->error_input();
2312 my @columns = $csv->fields();
2313 #warn join('-',@columns);
2316 foreach my $field ( @fields ) {
2317 $row{$field} = shift @columns;
2320 my $cust_main = qsearchs('cust_main', { 'custnum' => $row{'custnum'} } );
2321 unless ( $cust_main ) {
2322 $dbh->rollback if $oldAutoCommit;
2323 return "unknown custnum $row{'custnum'}";
2326 if ( $row{'amount'} > 0 ) {
2327 my $error = $cust_main->charge($row{'amount'}, $row{'pkg'});
2329 $dbh->rollback if $oldAutoCommit;
2333 } elsif ( $row{'amount'} < 0 ) {
2334 my $error = $cust_main->credit( sprintf( "%.2f", 0-$row{'amount'} ),
2337 $dbh->rollback if $oldAutoCommit;
2347 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2349 return "Empty file!" unless $imported;
2361 The delete method should possibly take an FS::cust_main object reference
2362 instead of a scalar customer number.
2364 Bill and collect options should probably be passed as references instead of a
2367 There should probably be a configuration file with a list of allowed credit
2370 No multiple currency support (probably a larger project than just this module).
2374 L<FS::Record>, L<FS::cust_pkg>, L<FS::cust_bill>, L<FS::cust_credit>
2375 L<FS::agent>, L<FS::part_referral>, L<FS::cust_main_county>,
2376 L<FS::cust_main_invoice>, L<FS::UID>, schema.html from the base documentation.