4 use vars qw( @ISA $conf $Debug $import );
8 eval "use Time::Local;";
9 die "Time::Local 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 ] ]
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' ] );
229 my $cust_pkgs = @_ ? shift : {};
230 my $invoicing_list = @_ ? shift : '';
232 local $SIG{HUP} = 'IGNORE';
233 local $SIG{INT} = 'IGNORE';
234 local $SIG{QUIT} = 'IGNORE';
235 local $SIG{TERM} = 'IGNORE';
236 local $SIG{TSTP} = 'IGNORE';
237 local $SIG{PIPE} = 'IGNORE';
239 my $oldAutoCommit = $FS::UID::AutoCommit;
240 local $FS::UID::AutoCommit = 0;
245 if ( $self->payby eq 'PREPAY' ) {
246 $self->payby('BILL');
247 my $prepay_credit = qsearchs(
249 { 'identifier' => $self->payinfo },
253 warn "WARNING: can't find pre-found prepay_credit: ". $self->payinfo
254 unless $prepay_credit;
255 $amount = $prepay_credit->amount;
256 $seconds = $prepay_credit->seconds;
257 my $error = $prepay_credit->delete;
259 $dbh->rollback if $oldAutoCommit;
260 return "removing prepay_credit (transaction rolled back): $error";
264 my $error = $self->SUPER::insert;
266 $dbh->rollback if $oldAutoCommit;
267 #return "inserting cust_main record (transaction rolled back): $error";
272 if ( $invoicing_list ) {
273 $error = $self->check_invoicing_list( $invoicing_list );
275 $dbh->rollback if $oldAutoCommit;
276 return "checking invoicing_list (transaction rolled back): $error";
278 $self->invoicing_list( $invoicing_list );
282 foreach my $cust_pkg ( keys %$cust_pkgs ) {
283 $cust_pkg->custnum( $self->custnum );
284 $error = $cust_pkg->insert;
286 $dbh->rollback if $oldAutoCommit;
287 return "inserting cust_pkg (transaction rolled back): $error";
289 foreach my $svc_something ( @{$cust_pkgs->{$cust_pkg}} ) {
290 $svc_something->pkgnum( $cust_pkg->pkgnum );
291 if ( $seconds && $svc_something->isa('FS::svc_acct') ) {
292 $svc_something->seconds( $svc_something->seconds + $seconds );
295 $error = $svc_something->insert;
297 $dbh->rollback if $oldAutoCommit;
298 #return "inserting svc_ (transaction rolled back): $error";
305 $dbh->rollback if $oldAutoCommit;
306 return "No svc_acct record to apply pre-paid time";
310 my $cust_credit = new FS::cust_credit {
311 'custnum' => $self->custnum,
314 $error = $cust_credit->insert;
316 $dbh->rollback if $oldAutoCommit;
317 return "inserting credit (transaction rolled back): $error";
321 $error = $self->queue_fuzzyfiles_update;
323 $dbh->rollback if $oldAutoCommit;
324 return "updating fuzzy search cache: $error";
327 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
332 =item delete NEW_CUSTNUM
334 This deletes the customer. If there is an error, returns the error, otherwise
337 This will completely remove all traces of the customer record. This is not
338 what you want when a customer cancels service; for that, cancel all of the
339 customer's packages (see L</cancel>).
341 If the customer has any uncancelled packages, you need to pass a new (valid)
342 customer number for those packages to be transferred to. Cancelled packages
343 will be deleted. Did I mention that this is NOT what you want when a customer
344 cancels service and that you really should be looking see L<FS::cust_pkg/cancel>?
346 You can't delete a customer with invoices (see L<FS::cust_bill>),
347 or credits (see L<FS::cust_credit>), payments (see L<FS::cust_pay>) or
348 refunds (see L<FS::cust_refund>).
355 local $SIG{HUP} = 'IGNORE';
356 local $SIG{INT} = 'IGNORE';
357 local $SIG{QUIT} = 'IGNORE';
358 local $SIG{TERM} = 'IGNORE';
359 local $SIG{TSTP} = 'IGNORE';
360 local $SIG{PIPE} = 'IGNORE';
362 my $oldAutoCommit = $FS::UID::AutoCommit;
363 local $FS::UID::AutoCommit = 0;
366 if ( qsearch( 'cust_bill', { 'custnum' => $self->custnum } ) ) {
367 $dbh->rollback if $oldAutoCommit;
368 return "Can't delete a customer with invoices";
370 if ( qsearch( 'cust_credit', { 'custnum' => $self->custnum } ) ) {
371 $dbh->rollback if $oldAutoCommit;
372 return "Can't delete a customer with credits";
374 if ( qsearch( 'cust_pay', { 'custnum' => $self->custnum } ) ) {
375 $dbh->rollback if $oldAutoCommit;
376 return "Can't delete a customer with payments";
378 if ( qsearch( 'cust_refund', { 'custnum' => $self->custnum } ) ) {
379 $dbh->rollback if $oldAutoCommit;
380 return "Can't delete a customer with refunds";
383 my @cust_pkg = $self->ncancelled_pkgs;
385 my $new_custnum = shift;
386 unless ( qsearchs( 'cust_main', { 'custnum' => $new_custnum } ) ) {
387 $dbh->rollback if $oldAutoCommit;
388 return "Invalid new customer number: $new_custnum";
390 foreach my $cust_pkg ( @cust_pkg ) {
391 my %hash = $cust_pkg->hash;
392 $hash{'custnum'} = $new_custnum;
393 my $new_cust_pkg = new FS::cust_pkg ( \%hash );
394 my $error = $new_cust_pkg->replace($cust_pkg);
396 $dbh->rollback if $oldAutoCommit;
401 my @cancelled_cust_pkg = $self->all_pkgs;
402 foreach my $cust_pkg ( @cancelled_cust_pkg ) {
403 my $error = $cust_pkg->delete;
405 $dbh->rollback if $oldAutoCommit;
410 foreach my $cust_main_invoice ( #(email invoice destinations, not invoices)
411 qsearch( 'cust_main_invoice', { 'custnum' => $self->custnum } )
413 my $error = $cust_main_invoice->delete;
415 $dbh->rollback if $oldAutoCommit;
420 my $error = $self->SUPER::delete;
422 $dbh->rollback if $oldAutoCommit;
426 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
431 =item replace OLD_RECORD [ INVOICING_LIST_ARYREF ]
433 Replaces the OLD_RECORD with this one in the database. If there is an error,
434 returns the error, otherwise returns false.
436 INVOICING_LIST_ARYREF: If you pass an arrarref to the insert method, it will
437 be set as the invoicing list (see L<"invoicing_list">). Errors return as
438 expected and rollback the entire transaction; it is not necessary to call
439 check_invoicing_list first. Here's an example:
441 $new_cust_main->replace( $old_cust_main, [ $email, 'POST' ] );
450 local $SIG{HUP} = 'IGNORE';
451 local $SIG{INT} = 'IGNORE';
452 local $SIG{QUIT} = 'IGNORE';
453 local $SIG{TERM} = 'IGNORE';
454 local $SIG{TSTP} = 'IGNORE';
455 local $SIG{PIPE} = 'IGNORE';
457 if ( $self->payby eq 'COMP' && $self->payby ne $old->payby
458 && $conf->config('users-allow_comp') ) {
459 return "You are not permitted to create complimentary accounts."
460 unless grep { $_ eq getotaker } $conf->config('users-allow_comp');
463 my $oldAutoCommit = $FS::UID::AutoCommit;
464 local $FS::UID::AutoCommit = 0;
467 my $error = $self->SUPER::replace($old);
470 $dbh->rollback if $oldAutoCommit;
474 if ( @param ) { # INVOICING_LIST_ARYREF
475 my $invoicing_list = shift @param;
476 $error = $self->check_invoicing_list( $invoicing_list );
478 $dbh->rollback if $oldAutoCommit;
481 $self->invoicing_list( $invoicing_list );
484 if ( $self->payby =~ /^(CARD|CHEK|LECB)$/ &&
485 grep { $self->get($_) ne $old->get($_) } qw(payinfo paydate payname) ) {
486 # card/check/lec info has changed, want to retry realtime_ invoice events
487 my $error = $self->retry_realtime;
489 $dbh->rollback if $oldAutoCommit;
494 $error = $self->queue_fuzzyfiles_update;
496 $dbh->rollback if $oldAutoCommit;
497 return "updating fuzzy search cache: $error";
500 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
505 =item queue_fuzzyfiles_update
507 Used by insert & replace to update the fuzzy search cache
511 sub queue_fuzzyfiles_update {
514 local $SIG{HUP} = 'IGNORE';
515 local $SIG{INT} = 'IGNORE';
516 local $SIG{QUIT} = 'IGNORE';
517 local $SIG{TERM} = 'IGNORE';
518 local $SIG{TSTP} = 'IGNORE';
519 local $SIG{PIPE} = 'IGNORE';
521 my $oldAutoCommit = $FS::UID::AutoCommit;
522 local $FS::UID::AutoCommit = 0;
525 my $queue = new FS::queue { 'job' => 'FS::cust_main::append_fuzzyfiles' };
526 my $error = $queue->insert($self->getfield('last'), $self->company);
528 $dbh->rollback if $oldAutoCommit;
529 return "queueing job (transaction rolled back): $error";
532 if ( defined $self->dbdef_table->column('ship_last') && $self->ship_last ) {
533 $queue = new FS::queue { 'job' => 'FS::cust_main::append_fuzzyfiles' };
534 $error = $queue->insert($self->getfield('ship_last'), $self->ship_company);
536 $dbh->rollback if $oldAutoCommit;
537 return "queueing job (transaction rolled back): $error";
541 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
548 Checks all fields to make sure this is a valid customer record. If there is
549 an error, returns the error, otherwise returns false. Called by the insert
557 #warn "BEFORE: \n". $self->_dump;
560 $self->ut_numbern('custnum')
561 || $self->ut_number('agentnum')
562 || $self->ut_number('refnum')
563 || $self->ut_name('last')
564 || $self->ut_name('first')
565 || $self->ut_textn('company')
566 || $self->ut_text('address1')
567 || $self->ut_textn('address2')
568 || $self->ut_text('city')
569 || $self->ut_textn('county')
570 || $self->ut_textn('state')
571 || $self->ut_country('country')
572 || $self->ut_anything('comments')
573 || $self->ut_numbern('referral_custnum')
575 #barf. need message catalogs. i18n. etc.
576 $error .= "Please select an advertising source."
577 if $error =~ /^Illegal or empty \(numeric\) refnum: /;
578 return $error if $error;
580 return "Unknown agent"
581 unless qsearchs( 'agent', { 'agentnum' => $self->agentnum } );
583 return "Unknown refnum"
584 unless qsearchs( 'part_referral', { 'refnum' => $self->refnum } );
586 return "Unknown referring custnum ". $self->referral_custnum
587 unless ! $self->referral_custnum
588 || qsearchs( 'cust_main', { 'custnum' => $self->referral_custnum } );
590 if ( $self->ss eq '' ) {
595 $ss =~ /^(\d{3})(\d{2})(\d{4})$/
596 or return "Illegal social security number: ". $self->ss;
597 $self->ss("$1-$2-$3");
601 # bad idea to disable, causes billing to fail because of no tax rates later
602 # unless ( $import ) {
603 unless ( qsearch('cust_main_county', {
604 'country' => $self->country,
607 return "Unknown state/county/country: ".
608 $self->state. "/". $self->county. "/". $self->country
609 unless qsearch('cust_main_county',{
610 'state' => $self->state,
611 'county' => $self->county,
612 'country' => $self->country,
618 $self->ut_phonen('daytime', $self->country)
619 || $self->ut_phonen('night', $self->country)
620 || $self->ut_phonen('fax', $self->country)
621 || $self->ut_zip('zip', $self->country)
623 return $error if $error;
626 last first company address1 address2 city county state zip
627 country daytime night fax
630 if ( defined $self->dbdef_table->column('ship_last') ) {
631 if ( scalar ( grep { $self->getfield($_) ne $self->getfield("ship_$_") }
633 && scalar ( grep { $self->getfield("ship_$_") ne '' } @addfields )
637 $self->ut_name('ship_last')
638 || $self->ut_name('ship_first')
639 || $self->ut_textn('ship_company')
640 || $self->ut_text('ship_address1')
641 || $self->ut_textn('ship_address2')
642 || $self->ut_text('ship_city')
643 || $self->ut_textn('ship_county')
644 || $self->ut_textn('ship_state')
645 || $self->ut_country('ship_country')
647 return $error if $error;
649 #false laziness with above
650 unless ( qsearchs('cust_main_county', {
651 'country' => $self->ship_country,
654 return "Unknown ship_state/ship_county/ship_country: ".
655 $self->ship_state. "/". $self->ship_county. "/". $self->ship_country
656 unless qsearchs('cust_main_county',{
657 'state' => $self->ship_state,
658 'county' => $self->ship_county,
659 'country' => $self->ship_country,
665 $self->ut_phonen('ship_daytime', $self->ship_country)
666 || $self->ut_phonen('ship_night', $self->ship_country)
667 || $self->ut_phonen('ship_fax', $self->ship_country)
668 || $self->ut_zip('ship_zip', $self->ship_country)
670 return $error if $error;
672 } else { # ship_ info eq billing info, so don't store dup info in database
673 $self->setfield("ship_$_", '')
674 foreach qw( last first company address1 address2 city county state zip
675 country daytime night fax );
679 $self->payby =~ /^(CARD|CHEK|LECB|BILL|COMP|PREPAY)$/
680 or return "Illegal payby: ". $self->payby;
683 if ( $self->payby eq 'CARD' ) {
685 my $payinfo = $self->payinfo;
687 $payinfo =~ /^(\d{13,16})$/
688 or return gettext('invalid_card'); # . ": ". $self->payinfo;
690 $self->payinfo($payinfo);
692 or return gettext('invalid_card'); # . ": ". $self->payinfo;
693 return gettext('unknown_card_type')
694 if cardtype($self->payinfo) eq "Unknown";
696 } elsif ( $self->payby eq 'CHEK' ) {
698 my $payinfo = $self->payinfo;
699 $payinfo =~ s/[^\d\@]//g;
700 $payinfo =~ /^(\d+)\@(\d{9})$/ or return 'invalid echeck account@aba';
702 $self->payinfo($payinfo);
704 } elsif ( $self->payby eq 'LECB' ) {
706 my $payinfo = $self->payinfo;
708 $payinfo =~ /^1?(\d{10})$/ or return 'invalid btn billing telephone number';
710 $self->payinfo($payinfo);
712 } elsif ( $self->payby eq 'BILL' ) {
714 $error = $self->ut_textn('payinfo');
715 return "Illegal P.O. number: ". $self->payinfo if $error;
717 } elsif ( $self->payby eq 'COMP' ) {
719 if ( !$self->custnum && $conf->config('users-allow_comp') ) {
720 return "You are not permitted to create complimentary accounts."
721 unless grep { $_ eq getotaker } $conf->config('users-allow_comp');
724 $error = $self->ut_textn('payinfo');
725 return "Illegal comp account issuer: ". $self->payinfo if $error;
727 } elsif ( $self->payby eq 'PREPAY' ) {
729 my $payinfo = $self->payinfo;
730 $payinfo =~ s/\W//g; #anything else would just confuse things
731 $self->payinfo($payinfo);
732 $error = $self->ut_alpha('payinfo');
733 return "Illegal prepayment identifier: ". $self->payinfo if $error;
734 return "Unknown prepayment identifier"
735 unless qsearchs('prepay_credit', { 'identifier' => $self->payinfo } );
739 if ( $self->paydate eq '' || $self->paydate eq '-' ) {
740 return "Expriation date required"
741 unless $self->payby =~ /^(BILL|PREPAY|CHEK|LECB)$/;
744 $self->paydate =~ /^(\d{1,2})[\/\-](\d{2}(\d{2})?)$/
745 or return "Illegal expiration date: ". $self->paydate;
746 my $y = length($2) == 4 ? $2 : "20$2";
747 $self->paydate("$y-$1-01");
748 my($nowm,$nowy)=(localtime(time))[4,5]; $nowm++; $nowy+=1900;
749 return gettext('expired_card')
750 if !$import && ( $y<$nowy || ( $y==$nowy && $1<$nowm ) );
753 if ( $self->payname eq '' && $self->payby ne 'CHEK' &&
754 ( ! $conf->exists('require_cardname') || $self->payby ne 'CARD' ) ) {
755 $self->payname( $self->first. " ". $self->getfield('last') );
757 $self->payname =~ /^([\w \,\.\-\']+)$/
758 or return gettext('illegal_name'). " payname: ". $self->payname;
762 $self->tax =~ /^(Y?)$/ or return "Illegal tax: ". $self->tax;
765 $self->otaker(getotaker);
767 #warn "AFTER: \n". $self->_dump;
774 Returns all packages (see L<FS::cust_pkg>) for this customer.
780 if ( $self->{'_pkgnum'} ) {
781 values %{ $self->{'_pkgnum'}->cache };
783 qsearch( 'cust_pkg', { 'custnum' => $self->custnum });
787 =item ncancelled_pkgs
789 Returns all non-cancelled packages (see L<FS::cust_pkg>) for this customer.
793 sub ncancelled_pkgs {
795 if ( $self->{'_pkgnum'} ) {
796 grep { ! $_->getfield('cancel') } values %{ $self->{'_pkgnum'}->cache };
798 @{ [ # force list context
799 qsearch( 'cust_pkg', {
800 'custnum' => $self->custnum,
803 qsearch( 'cust_pkg', {
804 'custnum' => $self->custnum,
813 Returns all suspended packages (see L<FS::cust_pkg>) for this customer.
819 grep { $_->susp } $self->ncancelled_pkgs;
822 =item unflagged_suspended_pkgs
824 Returns all unflagged suspended packages (see L<FS::cust_pkg>) for this
825 customer (thouse packages without the `manual_flag' set).
829 sub unflagged_suspended_pkgs {
831 return $self->suspended_pkgs
832 unless dbdef->table('cust_pkg')->column('manual_flag');
833 grep { ! $_->manual_flag } $self->suspended_pkgs;
836 =item unsuspended_pkgs
838 Returns all unsuspended (and uncancelled) packages (see L<FS::cust_pkg>) for
843 sub unsuspended_pkgs {
845 grep { ! $_->susp } $self->ncancelled_pkgs;
850 Unsuspends all unflagged suspended packages (see L</unflagged_suspended_pkgs>
851 and L<FS::cust_pkg>) for this customer. Always returns a list: an empty list
852 on success or a list of errors.
858 grep { $_->unsuspend } $self->suspended_pkgs;
863 Suspends all unsuspended packages (see L<FS::cust_pkg>) for this customer.
864 Always returns a list: an empty list on success or a list of errors.
870 grep { $_->suspend } $self->unsuspended_pkgs;
875 Cancels all uncancelled packages (see L<FS::cust_pkg>) for this customer.
876 Always returns a list: an empty list on success or a list of errors.
882 grep { $_->cancel } $self->ncancelled_pkgs;
887 Returns the agent (see L<FS::agent>) for this customer.
893 qsearchs( 'agent', { 'agentnum' => $self->agentnum } );
898 Generates invoices (see L<FS::cust_bill>) for this customer. Usually used in
899 conjunction with the collect method.
901 Options are passed as name-value pairs.
903 The only currently available option is `time', which bills the customer as if
904 it were that time. It is specified as a UNIX timestamp; see
905 L<perlfunc/"time">). Also see L<Time::Local> and L<Date::Parse> for conversion
906 functions. For example:
910 $cust_main->bill( 'time' => str2time('April 20th, 2001') );
912 If there is an error, returns the error, otherwise returns false.
917 my( $self, %options ) = @_;
918 my $time = $options{'time'} || time;
923 local $SIG{HUP} = 'IGNORE';
924 local $SIG{INT} = 'IGNORE';
925 local $SIG{QUIT} = 'IGNORE';
926 local $SIG{TERM} = 'IGNORE';
927 local $SIG{TSTP} = 'IGNORE';
928 local $SIG{PIPE} = 'IGNORE';
930 my $oldAutoCommit = $FS::UID::AutoCommit;
931 local $FS::UID::AutoCommit = 0;
934 # find the packages which are due for billing, find out how much they are
935 # & generate invoice database.
937 my( $total_setup, $total_recur ) = ( 0, 0 );
938 #my( $taxable_setup, $taxable_recur ) = ( 0, 0 );
939 my @cust_bill_pkg = ();
941 #my $taxable_charged = 0;##
944 foreach my $cust_pkg (
945 qsearch('cust_pkg', { 'custnum' => $self->custnum } )
948 #NO!! next if $cust_pkg->cancel;
949 next if $cust_pkg->getfield('cancel');
951 #? to avoid use of uninitialized value errors... ?
952 $cust_pkg->setfield('bill', '')
953 unless defined($cust_pkg->bill);
955 my $part_pkg = $cust_pkg->part_pkg;
957 #so we don't modify cust_pkg record unnecessarily
958 my $cust_pkg_mod_flag = 0;
959 my %hash = $cust_pkg->hash;
960 my $old_cust_pkg = new FS::cust_pkg \%hash;
964 unless ( $cust_pkg->setup ) {
965 my $setup_prog = $part_pkg->getfield('setup');
966 $setup_prog =~ /^(.*)$/ or do {
967 $dbh->rollback if $oldAutoCommit;
968 return "Illegal setup for pkgpart ". $part_pkg->pkgpart.
972 $setup_prog = '0' if $setup_prog =~ /^\s*$/;
975 ##$cpt->permit(); #what is necessary?
976 #$cpt->share(qw( $cust_pkg )); #can $cpt now use $cust_pkg methods?
977 #$setup = $cpt->reval($setup_prog);
978 $setup = eval $setup_prog;
979 unless ( defined($setup) ) {
980 $dbh->rollback if $oldAutoCommit;
981 return "Error eval-ing part_pkg->setup pkgpart ". $part_pkg->pkgpart.
982 "(expression $setup_prog): $@";
984 $cust_pkg->setfield('setup',$time);
985 $cust_pkg_mod_flag=1;
991 if ( $part_pkg->getfield('freq') > 0 &&
992 ! $cust_pkg->getfield('susp') &&
993 ( $cust_pkg->getfield('bill') || 0 ) <= $time
995 my $recur_prog = $part_pkg->getfield('recur');
996 $recur_prog =~ /^(.*)$/ or do {
997 $dbh->rollback if $oldAutoCommit;
998 return "Illegal recur for pkgpart ". $part_pkg->pkgpart.
1002 $recur_prog = '0' if $recur_prog =~ /^\s*$/;
1004 # shared with $recur_prog
1005 $sdate = $cust_pkg->bill || $cust_pkg->setup || $time;
1007 #my $cpt = new Safe;
1008 ##$cpt->permit(); #what is necessary?
1009 #$cpt->share(qw( $cust_pkg )); #can $cpt now use $cust_pkg methods?
1010 #$recur = $cpt->reval($recur_prog);
1011 $recur = eval $recur_prog;
1012 unless ( defined($recur) ) {
1013 $dbh->rollback if $oldAutoCommit;
1014 return "Error eval-ing part_pkg->recur pkgpart ". $part_pkg->pkgpart.
1015 "(expression $recur_prog): $@";
1017 #change this bit to use Date::Manip? CAREFUL with timezones (see
1018 # mailing list archive)
1019 my ($sec,$min,$hour,$mday,$mon,$year) =
1020 (localtime($sdate) )[0,1,2,3,4,5];
1022 #pro-rating magic - if $recur_prog fiddles $sdate, want to use that
1023 # only for figuring next bill date, nothing else, so, reset $sdate again
1025 $sdate = $cust_pkg->bill || $cust_pkg->setup || $time;
1026 $cust_pkg->last_bill($sdate)
1027 if $cust_pkg->dbdef_table->column('last_bill');
1029 $mon += $part_pkg->freq;
1030 until ( $mon < 12 ) { $mon -= 12; $year++; }
1031 $cust_pkg->setfield('bill',
1032 timelocal_nocheck($sec,$min,$hour,$mday,$mon,$year));
1033 $cust_pkg_mod_flag = 1;
1036 warn "\$setup is undefined" unless defined($setup);
1037 warn "\$recur is undefined" unless defined($recur);
1038 warn "\$cust_pkg->bill is undefined" unless defined($cust_pkg->bill);
1040 my $taxable_charged = 0;
1041 if ( $cust_pkg_mod_flag ) {
1042 $error=$cust_pkg->replace($old_cust_pkg);
1043 if ( $error ) { #just in case
1044 $dbh->rollback if $oldAutoCommit;
1045 return "Error modifying pkgnum ". $cust_pkg->pkgnum. ": $error";
1047 $setup = sprintf( "%.2f", $setup );
1048 $recur = sprintf( "%.2f", $recur );
1050 $dbh->rollback if $oldAutoCommit;
1051 return "negative setup $setup for pkgnum ". $cust_pkg->pkgnum;
1054 $dbh->rollback if $oldAutoCommit;
1055 return "negative recur $recur for pkgnum ". $cust_pkg->pkgnum;
1057 if ( $setup > 0 || $recur > 0 ) {
1058 my $cust_bill_pkg = new FS::cust_bill_pkg ({
1059 'pkgnum' => $cust_pkg->pkgnum,
1063 'edate' => $cust_pkg->bill,
1065 push @cust_bill_pkg, $cust_bill_pkg;
1066 $total_setup += $setup;
1067 $total_recur += $recur;
1068 $taxable_charged += $setup
1069 unless $part_pkg->setuptax =~ /^Y$/i;
1070 $taxable_charged += $recur
1071 unless $part_pkg->recurtax =~ /^Y$/i;
1073 unless ( $self->tax =~ /Y/i
1074 || $self->payby eq 'COMP'
1075 || $taxable_charged == 0 ) {
1077 my $cust_main_county = qsearchs('cust_main_county',{
1078 'state' => $self->state,
1079 'county' => $self->county,
1080 'country' => $self->country,
1081 'taxclass' => $part_pkg->taxclass,
1083 $cust_main_county ||= qsearchs('cust_main_county',{
1084 'state' => $self->state,
1085 'county' => $self->county,
1086 'country' => $self->country,
1089 unless ( $cust_main_county ) {
1090 $dbh->rollback if $oldAutoCommit;
1092 "fatal: can't find tax rate for state/county/country/taxclass ".
1093 join('/', ( map $self->$_(), qw(state county country) ),
1094 $part_pkg->taxclass ). "\n";
1097 if ( $cust_main_county->exempt_amount ) {
1098 my ($mon,$year) = (localtime($sdate) )[4,5];
1100 my $freq = $part_pkg->freq || 1;
1101 my $taxable_per_month = sprintf("%.2f", $taxable_charged / $freq );
1102 foreach my $which_month ( 1 .. $freq ) {
1104 'custnum' => $self->custnum,
1105 'taxnum' => $cust_main_county->taxnum,
1106 'year' => 1900+$year,
1109 #until ( $mon < 12 ) { $mon -= 12; $year++; }
1110 until ( $mon < 13 ) { $mon -= 12; $year++; }
1111 my $cust_tax_exempt =
1112 qsearchs('cust_tax_exempt', \%hash)
1113 || new FS::cust_tax_exempt( { %hash, 'amount' => 0 } );
1114 my $remaining_exemption = sprintf("%.2f",
1115 $cust_main_county->exempt_amount - $cust_tax_exempt->amount );
1116 if ( $remaining_exemption > 0 ) {
1117 my $addl = $remaining_exemption > $taxable_per_month
1118 ? $taxable_per_month
1119 : $remaining_exemption;
1120 $taxable_charged -= $addl;
1121 my $new_cust_tax_exempt = new FS::cust_tax_exempt ( {
1122 $cust_tax_exempt->hash,
1123 'amount' => sprintf("%.2f", $cust_tax_exempt->amount + $addl),
1125 $error = $new_cust_tax_exempt->exemptnum
1126 ? $new_cust_tax_exempt->replace($cust_tax_exempt)
1127 : $new_cust_tax_exempt->insert;
1129 $dbh->rollback if $oldAutoCommit;
1130 return "fatal: can't update cust_tax_exempt: $error";
1133 } # if $remaining_exemption > 0
1135 } #foreach $which_month
1137 } #if $cust_main_county->exempt_amount
1139 $taxable_charged = sprintf( "%.2f", $taxable_charged);
1140 $tax += $taxable_charged * $cust_main_county->tax / 100
1142 } #unless $self->tax =~ /Y/i
1143 # || $self->payby eq 'COMP'
1144 # || $taxable_charged == 0
1146 } #if $setup > 0 || $recur > 0
1148 } #if $cust_pkg_mod_flag
1150 } #foreach my $cust_pkg
1152 my $charged = sprintf( "%.2f", $total_setup + $total_recur );
1153 # my $taxable_charged = sprintf( "%.2f", $taxable_setup + $taxable_recur );
1155 unless ( @cust_bill_pkg ) { #don't create invoices with no line items
1156 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1160 # unless ( $self->tax =~ /Y/i
1161 # || $self->payby eq 'COMP'
1162 # || $taxable_charged == 0 ) {
1163 # my $cust_main_county = qsearchs('cust_main_county',{
1164 # 'state' => $self->state,
1165 # 'county' => $self->county,
1166 # 'country' => $self->country,
1167 # } ) or die "fatal: can't find tax rate for state/county/country ".
1168 # $self->state. "/". $self->county. "/". $self->country. "\n";
1169 # my $tax = sprintf( "%.2f",
1170 # $taxable_charged * ( $cust_main_county->getfield('tax') / 100 )
1173 $tax = sprintf("%.2f", $tax);
1175 $charged = sprintf( "%.2f", $charged+$tax );
1177 my $cust_bill_pkg = new FS::cust_bill_pkg ({
1184 push @cust_bill_pkg, $cust_bill_pkg;
1188 my $cust_bill = new FS::cust_bill ( {
1189 'custnum' => $self->custnum,
1191 'charged' => $charged,
1193 $error = $cust_bill->insert;
1195 $dbh->rollback if $oldAutoCommit;
1196 return "can't create invoice for customer #". $self->custnum. ": $error";
1199 my $invnum = $cust_bill->invnum;
1201 foreach $cust_bill_pkg ( @cust_bill_pkg ) {
1203 $cust_bill_pkg->invnum($invnum);
1204 $error = $cust_bill_pkg->insert;
1206 $dbh->rollback if $oldAutoCommit;
1207 return "can't create invoice line item for customer #". $self->custnum.
1212 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1216 =item collect OPTIONS
1218 (Attempt to) collect money for this customer's outstanding invoices (see
1219 L<FS::cust_bill>). Usually used after the bill method.
1221 Depending on the value of `payby', this may print an invoice (`BILL'), charge
1222 a credit card (`CARD'), or just add any necessary (pseudo-)payment (`COMP').
1224 Most actions are now triggered by invoice events; see L<FS::part_bill_event>
1225 and the invoice events web interface.
1227 If there is an error, returns the error, otherwise returns false.
1229 Options are passed as name-value pairs.
1231 Currently available options are:
1233 invoice_time - Use this time when deciding when to print invoices and
1234 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>
1235 for conversion functions.
1237 retry - Retry card/echeck/LEC transactions even when not scheduled by invoice
1240 retry_card - Deprecated alias for 'retry'
1242 batch_card - This option is deprecated. See the invoice events web interface
1243 to control whether cards are batched or run against a realtime gateway.
1245 report_badcard - This option is deprecated.
1247 force_print - This option is deprecated; see the invoice events web interface.
1249 quiet - set true to surpress email card/ACH decline notices.
1254 my( $self, %options ) = @_;
1255 my $invoice_time = $options{'invoice_time'} || time;
1258 local $SIG{HUP} = 'IGNORE';
1259 local $SIG{INT} = 'IGNORE';
1260 local $SIG{QUIT} = 'IGNORE';
1261 local $SIG{TERM} = 'IGNORE';
1262 local $SIG{TSTP} = 'IGNORE';
1263 local $SIG{PIPE} = 'IGNORE';
1265 my $oldAutoCommit = $FS::UID::AutoCommit;
1266 local $FS::UID::AutoCommit = 0;
1269 my $balance = $self->balance;
1270 warn "collect customer". $self->custnum. ": balance $balance" if $Debug;
1271 unless ( $balance > 0 ) { #redundant?????
1272 $dbh->rollback if $oldAutoCommit; #hmm
1276 if ( exists($options{'retry_card'}) ) {
1277 carp 'retry_card option passed to collect is deprecated; use retry';
1278 $options{'retry'} ||= $options{'retry_card'};
1280 if ( exists($options{'retry'}) && $options{'retry'} ) {
1281 my $error = $self->retry_realtime;
1283 $dbh->rollback if $oldAutoCommit;
1288 foreach my $cust_bill ( $self->cust_bill ) {
1290 #this has to be before next's
1291 my $amount = sprintf( "%.2f", $balance < $cust_bill->owed
1295 $balance = sprintf( "%.2f", $balance - $amount );
1297 next unless $cust_bill->owed > 0;
1299 # don't try to charge for the same invoice if it's already in a batch
1300 #next if qsearchs( 'cust_pay_batch', { 'invnum' => $cust_bill->invnum } );
1302 warn "invnum ". $cust_bill->invnum. " (owed ". $cust_bill->owed. ", amount $amount, balance $balance)" if $Debug;
1304 next unless $amount > 0;
1307 foreach my $part_bill_event (
1308 sort { $a->seconds <=> $b->seconds
1309 || $a->weight <=> $b->weight
1310 || $a->eventpart <=> $b->eventpart }
1311 grep { $_->seconds <= ( $invoice_time - $cust_bill->_date )
1312 && ! qsearchs( 'cust_bill_event', {
1313 'invnum' => $cust_bill->invnum,
1314 'eventpart' => $_->eventpart,
1318 qsearch('part_bill_event', { 'payby' => $self->payby,
1319 'disabled' => '', } )
1322 last unless $cust_bill->owed > 0; #don't run subsequent events if owed=0
1324 warn "calling invoice event (". $part_bill_event->eventcode. ")\n"
1326 my $cust_main = $self; #for callback
1330 $FS::cust_bill::realtime_bop_decline_quiet; #supress "used only once"
1332 local $FS::cust_bill::realtime_bop_decline_quiet = 1
1333 if $options{'quiet'};
1334 $error = eval $part_bill_event->eventcode;
1338 my $statustext = '';
1342 } elsif ( $error ) {
1344 $statustext = $error;
1349 #add cust_bill_event
1350 my $cust_bill_event = new FS::cust_bill_event {
1351 'invnum' => $cust_bill->invnum,
1352 'eventpart' => $part_bill_event->eventpart,
1353 #'_date' => $invoice_time,
1355 'status' => $status,
1356 'statustext' => $statustext,
1358 $error = $cust_bill_event->insert;
1360 #$dbh->rollback if $oldAutoCommit;
1361 #return "error: $error";
1363 # gah, even with transactions.
1364 $dbh->commit if $oldAutoCommit; #well.
1365 my $e = 'WARNING: Event run but database not updated - '.
1366 'error inserting cust_bill_event, invnum #'. $cust_bill->invnum.
1367 ', eventpart '. $part_bill_event->eventpart.
1378 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1383 =item retry_realtime
1385 Schedules realtime credit card / electronic check / LEC billing events for
1386 for retry. Useful if card information has changed or manual retry is desired.
1387 The 'collect' method must be called to actually retry the transaction.
1389 Implementation details: For each of this customer's open invoices, changes
1390 the status of the first "done" (with statustext error) realtime processing
1395 sub retry_realtime {
1398 local $SIG{HUP} = 'IGNORE';
1399 local $SIG{INT} = 'IGNORE';
1400 local $SIG{QUIT} = 'IGNORE';
1401 local $SIG{TERM} = 'IGNORE';
1402 local $SIG{TSTP} = 'IGNORE';
1403 local $SIG{PIPE} = 'IGNORE';
1405 my $oldAutoCommit = $FS::UID::AutoCommit;
1406 local $FS::UID::AutoCommit = 0;
1409 foreach my $cust_bill (
1410 grep { $_->cust_bill_event }
1411 $self->open_cust_bill
1413 my @cust_bill_event =
1414 sort { $a->part_bill_event->seconds <=> $b->part_bill_event->seconds }
1416 #$_->part_bill_event->plan eq 'realtime-card'
1417 $_->part_bill_event->eventcode =~
1418 /\$cust_bill\->realtime_(card|ach|lec)/
1419 && $_->status eq 'done'
1422 $cust_bill->cust_bill_event;
1423 next unless @cust_bill_event;
1424 my $error = $cust_bill_event[0]->retry;
1426 $dbh->rollback if $oldAutoCommit;
1427 return "error scheduling invoice event for retry: $error";
1432 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1439 Returns the total owed for this customer on all invoices
1440 (see L<FS::cust_bill/owed>).
1446 $self->total_owed_date(2145859200); #12/31/2037
1449 =item total_owed_date TIME
1451 Returns the total owed for this customer on all invoices with date earlier than
1452 TIME. TIME is specified as a UNIX timestamp; see L<perlfunc/"time">). Also
1453 see L<Time::Local> and L<Date::Parse> for conversion functions.
1457 sub total_owed_date {
1461 foreach my $cust_bill (
1462 grep { $_->_date <= $time }
1463 qsearch('cust_bill', { 'custnum' => $self->custnum, } )
1465 $total_bill += $cust_bill->owed;
1467 sprintf( "%.2f", $total_bill );
1472 Applies (see L<FS::cust_credit_bill>) unapplied credits (see L<FS::cust_credit>)
1473 to outstanding invoice balances in chronological order and returns the value
1474 of any remaining unapplied credits available for refund
1475 (see L<FS::cust_refund>).
1482 return 0 unless $self->total_credited;
1484 my @credits = sort { $b->_date <=> $a->_date} (grep { $_->credited > 0 }
1485 qsearch('cust_credit', { 'custnum' => $self->custnum } ) );
1487 my @invoices = sort { $a->_date <=> $b->_date} (grep { $_->owed > 0 }
1488 qsearch('cust_bill', { 'custnum' => $self->custnum } ) );
1492 foreach my $cust_bill ( @invoices ) {
1495 if ( !defined($credit) || $credit->credited == 0) {
1496 $credit = pop @credits or last;
1499 if ($cust_bill->owed >= $credit->credited) {
1500 $amount=$credit->credited;
1502 $amount=$cust_bill->owed;
1505 my $cust_credit_bill = new FS::cust_credit_bill ( {
1506 'crednum' => $credit->crednum,
1507 'invnum' => $cust_bill->invnum,
1508 'amount' => $amount,
1510 my $error = $cust_credit_bill->insert;
1511 die $error if $error;
1513 redo if ($cust_bill->owed > 0);
1517 return $self->total_credited;
1520 =item apply_payments
1522 Applies (see L<FS::cust_bill_pay>) unapplied payments (see L<FS::cust_pay>)
1523 to outstanding invoice balances in chronological order.
1525 #and returns the value of any remaining unapplied payments.
1529 sub apply_payments {
1534 my @payments = sort { $b->_date <=> $a->_date } ( grep { $_->unapplied > 0 }
1535 qsearch('cust_pay', { 'custnum' => $self->custnum } ) );
1537 my @invoices = sort { $a->_date <=> $b->_date} (grep { $_->owed > 0 }
1538 qsearch('cust_bill', { 'custnum' => $self->custnum } ) );
1542 foreach my $cust_bill ( @invoices ) {
1545 if ( !defined($payment) || $payment->unapplied == 0 ) {
1546 $payment = pop @payments or last;
1549 if ( $cust_bill->owed >= $payment->unapplied ) {
1550 $amount = $payment->unapplied;
1552 $amount = $cust_bill->owed;
1555 my $cust_bill_pay = new FS::cust_bill_pay ( {
1556 'paynum' => $payment->paynum,
1557 'invnum' => $cust_bill->invnum,
1558 'amount' => $amount,
1560 my $error = $cust_bill_pay->insert;
1561 die $error if $error;
1563 redo if ( $cust_bill->owed > 0);
1567 return $self->total_unapplied_payments;
1570 =item total_credited
1572 Returns the total outstanding credit (see L<FS::cust_credit>) for this
1573 customer. See L<FS::cust_credit/credited>.
1577 sub total_credited {
1579 my $total_credit = 0;
1580 foreach my $cust_credit ( qsearch('cust_credit', {
1581 'custnum' => $self->custnum,
1583 $total_credit += $cust_credit->credited;
1585 sprintf( "%.2f", $total_credit );
1588 =item total_unapplied_payments
1590 Returns the total unapplied payments (see L<FS::cust_pay>) for this customer.
1591 See L<FS::cust_pay/unapplied>.
1595 sub total_unapplied_payments {
1597 my $total_unapplied = 0;
1598 foreach my $cust_pay ( qsearch('cust_pay', {
1599 'custnum' => $self->custnum,
1601 $total_unapplied += $cust_pay->unapplied;
1603 sprintf( "%.2f", $total_unapplied );
1608 Returns the balance for this customer (total_owed minus total_credited
1609 minus total_unapplied_payments).
1616 $self->total_owed - $self->total_credited - $self->total_unapplied_payments
1620 =item balance_date TIME
1622 Returns the balance for this customer, only considering invoices with date
1623 earlier than TIME (total_owed_date minus total_credited minus
1624 total_unapplied_payments). TIME is specified as a UNIX timestamp; see
1625 L<perlfunc/"time">). Also see L<Time::Local> and L<Date::Parse> for conversion
1634 $self->total_owed_date($time)
1635 - $self->total_credited
1636 - $self->total_unapplied_payments
1640 =item invoicing_list [ ARRAYREF ]
1642 If an arguement is given, sets these email addresses as invoice recipients
1643 (see L<FS::cust_main_invoice>). Errors are not fatal and are not reported
1644 (except as warnings), so use check_invoicing_list first.
1646 Returns a list of email addresses (with svcnum entries expanded).
1648 Note: You can clear the invoicing list by passing an empty ARRAYREF. You can
1649 check it without disturbing anything by passing nothing.
1651 This interface may change in the future.
1655 sub invoicing_list {
1656 my( $self, $arrayref ) = @_;
1658 my @cust_main_invoice;
1659 if ( $self->custnum ) {
1660 @cust_main_invoice =
1661 qsearch( 'cust_main_invoice', { 'custnum' => $self->custnum } );
1663 @cust_main_invoice = ();
1665 foreach my $cust_main_invoice ( @cust_main_invoice ) {
1666 #warn $cust_main_invoice->destnum;
1667 unless ( grep { $cust_main_invoice->address eq $_ } @{$arrayref} ) {
1668 #warn $cust_main_invoice->destnum;
1669 my $error = $cust_main_invoice->delete;
1670 warn $error if $error;
1673 if ( $self->custnum ) {
1674 @cust_main_invoice =
1675 qsearch( 'cust_main_invoice', { 'custnum' => $self->custnum } );
1677 @cust_main_invoice = ();
1679 my %seen = map { $_->address => 1 } @cust_main_invoice;
1680 foreach my $address ( @{$arrayref} ) {
1681 next if exists $seen{$address} && $seen{$address};
1682 $seen{$address} = 1;
1683 my $cust_main_invoice = new FS::cust_main_invoice ( {
1684 'custnum' => $self->custnum,
1687 my $error = $cust_main_invoice->insert;
1688 warn $error if $error;
1691 if ( $self->custnum ) {
1693 qsearch( 'cust_main_invoice', { 'custnum' => $self->custnum } );
1699 =item check_invoicing_list ARRAYREF
1701 Checks these arguements as valid input for the invoicing_list method. If there
1702 is an error, returns the error, otherwise returns false.
1706 sub check_invoicing_list {
1707 my( $self, $arrayref ) = @_;
1708 foreach my $address ( @{$arrayref} ) {
1709 my $cust_main_invoice = new FS::cust_main_invoice ( {
1710 'custnum' => $self->custnum,
1713 my $error = $self->custnum
1714 ? $cust_main_invoice->check
1715 : $cust_main_invoice->checkdest
1717 return $error if $error;
1722 =item set_default_invoicing_list
1724 Sets the invoicing list to all accounts associated with this customer,
1725 overwriting any previous invoicing list.
1729 sub set_default_invoicing_list {
1731 $self->invoicing_list($self->all_emails);
1736 Returns the email addresses of all accounts provisioned for this customer.
1743 foreach my $cust_pkg ( $self->all_pkgs ) {
1744 my @cust_svc = qsearch('cust_svc', { 'pkgnum' => $cust_pkg->pkgnum } );
1746 map { qsearchs('svc_acct', { 'svcnum' => $_->svcnum } ) }
1747 grep { qsearchs('svc_acct', { 'svcnum' => $_->svcnum } ) }
1749 $list{$_}=1 foreach map { $_->email } @svc_acct;
1754 =item invoicing_list_addpost
1756 Adds postal invoicing to this customer. If this customer is already configured
1757 to receive postal invoices, does nothing.
1761 sub invoicing_list_addpost {
1763 return if grep { $_ eq 'POST' } $self->invoicing_list;
1764 my @invoicing_list = $self->invoicing_list;
1765 push @invoicing_list, 'POST';
1766 $self->invoicing_list(\@invoicing_list);
1769 =item referral_cust_main [ DEPTH [ EXCLUDE_HASHREF ] ]
1771 Returns an array of customers referred by this customer (referral_custnum set
1772 to this custnum). If DEPTH is given, recurses up to the given depth, returning
1773 customers referred by customers referred by this customer and so on, inclusive.
1774 The default behavior is DEPTH 1 (no recursion).
1778 sub referral_cust_main {
1780 my $depth = @_ ? shift : 1;
1781 my $exclude = @_ ? shift : {};
1784 map { $exclude->{$_->custnum}++; $_; }
1785 grep { ! $exclude->{ $_->custnum } }
1786 qsearch( 'cust_main', { 'referral_custnum' => $self->custnum } );
1790 map { $_->referral_cust_main($depth-1, $exclude) }
1797 =item referral_cust_main_ncancelled
1799 Same as referral_cust_main, except only returns customers with uncancelled
1804 sub referral_cust_main_ncancelled {
1806 grep { scalar($_->ncancelled_pkgs) } $self->referral_cust_main;
1809 =item referral_cust_pkg [ DEPTH ]
1811 Like referral_cust_main, except returns a flat list of all unsuspended (and
1812 uncancelled) packages for each customer. The number of items in this list may
1813 be useful for comission calculations (perhaps after a C<grep { my $pkgpart = $_->pkgpart; grep { $_ == $pkgpart } @commission_worthy_pkgparts> } $cust_main-> ).
1817 sub referral_cust_pkg {
1819 my $depth = @_ ? shift : 1;
1821 map { $_->unsuspended_pkgs }
1822 grep { $_->unsuspended_pkgs }
1823 $self->referral_cust_main($depth);
1826 =item credit AMOUNT, REASON
1828 Applies a credit to this customer. If there is an error, returns the error,
1829 otherwise returns false.
1834 my( $self, $amount, $reason ) = @_;
1835 my $cust_credit = new FS::cust_credit {
1836 'custnum' => $self->custnum,
1837 'amount' => $amount,
1838 'reason' => $reason,
1840 $cust_credit->insert;
1843 =item charge AMOUNT [ PKG [ COMMENT [ TAXCLASS ] ] ]
1845 Creates a one-time charge for this customer. If there is an error, returns
1846 the error, otherwise returns false.
1851 my ( $self, $amount ) = ( shift, shift );
1852 my $pkg = @_ ? shift : 'One-time charge';
1853 my $comment = @_ ? shift : '$'. sprintf("%.2f",$amount);
1854 my $taxclass = @_ ? shift : '';
1856 local $SIG{HUP} = 'IGNORE';
1857 local $SIG{INT} = 'IGNORE';
1858 local $SIG{QUIT} = 'IGNORE';
1859 local $SIG{TERM} = 'IGNORE';
1860 local $SIG{TSTP} = 'IGNORE';
1861 local $SIG{PIPE} = 'IGNORE';
1863 my $oldAutoCommit = $FS::UID::AutoCommit;
1864 local $FS::UID::AutoCommit = 0;
1867 my $part_pkg = new FS::part_pkg ( {
1869 'comment' => $comment,
1874 'taxclass' => $taxclass,
1877 my $error = $part_pkg->insert;
1879 $dbh->rollback if $oldAutoCommit;
1883 my $pkgpart = $part_pkg->pkgpart;
1884 my %type_pkgs = ( 'typenum' => $self->agent->typenum, 'pkgpart' => $pkgpart );
1885 unless ( qsearchs('type_pkgs', \%type_pkgs ) ) {
1886 my $type_pkgs = new FS::type_pkgs \%type_pkgs;
1887 $error = $type_pkgs->insert;
1889 $dbh->rollback if $oldAutoCommit;
1894 my $cust_pkg = new FS::cust_pkg ( {
1895 'custnum' => $self->custnum,
1896 'pkgpart' => $pkgpart,
1899 $error = $cust_pkg->insert;
1901 $dbh->rollback if $oldAutoCommit;
1905 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1912 Returns all the invoices (see L<FS::cust_bill>) for this customer.
1918 sort { $a->_date <=> $b->_date }
1919 qsearch('cust_bill', { 'custnum' => $self->custnum, } )
1922 =item open_cust_bill
1924 Returns all the open (owed > 0) invoices (see L<FS::cust_bill>) for this
1929 sub open_cust_bill {
1931 grep { $_->owed > 0 } $self->cust_bill;
1940 =item check_and_rebuild_fuzzyfiles
1944 sub check_and_rebuild_fuzzyfiles {
1945 my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
1946 -e "$dir/cust_main.last" && -e "$dir/cust_main.company"
1947 or &rebuild_fuzzyfiles;
1950 =item rebuild_fuzzyfiles
1954 sub rebuild_fuzzyfiles {
1956 use Fcntl qw(:flock);
1958 my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
1962 open(LASTLOCK,">>$dir/cust_main.last")
1963 or die "can't open $dir/cust_main.last: $!";
1964 flock(LASTLOCK,LOCK_EX)
1965 or die "can't lock $dir/cust_main.last: $!";
1967 my @all_last = map $_->getfield('last'), qsearch('cust_main', {});
1969 grep $_, map $_->getfield('ship_last'), qsearch('cust_main',{})
1970 if defined dbdef->table('cust_main')->column('ship_last');
1972 open (LASTCACHE,">$dir/cust_main.last.tmp")
1973 or die "can't open $dir/cust_main.last.tmp: $!";
1974 print LASTCACHE join("\n", @all_last), "\n";
1975 close LASTCACHE or die "can't close $dir/cust_main.last.tmp: $!";
1977 rename "$dir/cust_main.last.tmp", "$dir/cust_main.last";
1982 open(COMPANYLOCK,">>$dir/cust_main.company")
1983 or die "can't open $dir/cust_main.company: $!";
1984 flock(COMPANYLOCK,LOCK_EX)
1985 or die "can't lock $dir/cust_main.company: $!";
1987 my @all_company = grep $_ ne '', map $_->company, qsearch('cust_main',{});
1989 grep $_ ne '', map $_->ship_company, qsearch('cust_main', {})
1990 if defined dbdef->table('cust_main')->column('ship_last');
1992 open (COMPANYCACHE,">$dir/cust_main.company.tmp")
1993 or die "can't open $dir/cust_main.company.tmp: $!";
1994 print COMPANYCACHE join("\n", @all_company), "\n";
1995 close COMPANYCACHE or die "can't close $dir/cust_main.company.tmp: $!";
1997 rename "$dir/cust_main.company.tmp", "$dir/cust_main.company";
2007 my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
2008 open(LASTCACHE,"<$dir/cust_main.last")
2009 or die "can't open $dir/cust_main.last: $!";
2010 my @array = map { chomp; $_; } <LASTCACHE>;
2020 my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
2021 open(COMPANYCACHE,"<$dir/cust_main.company")
2022 or die "can't open $dir/cust_main.last: $!";
2023 my @array = map { chomp; $_; } <COMPANYCACHE>;
2028 =item append_fuzzyfiles LASTNAME COMPANY
2032 sub append_fuzzyfiles {
2033 my( $last, $company ) = @_;
2035 &check_and_rebuild_fuzzyfiles;
2037 use Fcntl qw(:flock);
2039 my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
2043 open(LAST,">>$dir/cust_main.last")
2044 or die "can't open $dir/cust_main.last: $!";
2046 or die "can't lock $dir/cust_main.last: $!";
2048 print LAST "$last\n";
2051 or die "can't unlock $dir/cust_main.last: $!";
2057 open(COMPANY,">>$dir/cust_main.company")
2058 or die "can't open $dir/cust_main.company: $!";
2059 flock(COMPANY,LOCK_EX)
2060 or die "can't lock $dir/cust_main.company: $!";
2062 print COMPANY "$company\n";
2064 flock(COMPANY,LOCK_UN)
2065 or die "can't unlock $dir/cust_main.company: $!";
2079 #warn join('-',keys %$param);
2080 my $fh = $param->{filehandle};
2081 my $agentnum = $param->{agentnum};
2082 my $refnum = $param->{refnum};
2083 my $pkgpart = $param->{pkgpart};
2084 my @fields = @{$param->{fields}};
2086 eval "use Date::Parse;";
2088 eval "use Text::CSV_XS;";
2091 my $csv = new Text::CSV_XS;
2098 local $SIG{HUP} = 'IGNORE';
2099 local $SIG{INT} = 'IGNORE';
2100 local $SIG{QUIT} = 'IGNORE';
2101 local $SIG{TERM} = 'IGNORE';
2102 local $SIG{TSTP} = 'IGNORE';
2103 local $SIG{PIPE} = 'IGNORE';
2105 my $oldAutoCommit = $FS::UID::AutoCommit;
2106 local $FS::UID::AutoCommit = 0;
2109 #while ( $columns = $csv->getline($fh) ) {
2111 while ( defined($line=<$fh>) ) {
2113 $csv->parse($line) or do {
2114 $dbh->rollback if $oldAutoCommit;
2115 return "can't parse: ". $csv->error_input();
2118 my @columns = $csv->fields();
2119 #warn join('-',@columns);
2122 agentnum => $agentnum,
2124 country => 'US', #default
2125 payby => 'BILL', #default
2126 paydate => '12/2037', #default
2128 my $billtime = time;
2129 my %cust_pkg = ( pkgpart => $pkgpart );
2130 foreach my $field ( @fields ) {
2131 if ( $field =~ /^cust_pkg\.(setup|bill|susp|expire|cancel)$/ ) {
2132 #$cust_pkg{$1} = str2time( shift @$columns );
2133 if ( $1 eq 'setup' ) {
2134 $billtime = str2time(shift @columns);
2136 $cust_pkg{$1} = str2time( shift @columns );
2139 #$cust_main{$field} = shift @$columns;
2140 $cust_main{$field} = shift @columns;
2144 my $cust_pkg = new FS::cust_pkg ( \%cust_pkg ) if $pkgpart;
2145 my $cust_main = new FS::cust_main ( \%cust_main );
2147 tie my %hash, 'Tie::RefHash'; #this part is important
2148 $hash{$cust_pkg} = [] if $pkgpart;
2149 my $error = $cust_main->insert( \%hash );
2152 $dbh->rollback if $oldAutoCommit;
2153 return "can't insert customer for $line: $error";
2156 #false laziness w/bill.cgi
2157 $error = $cust_main->bill( 'time' => $billtime );
2159 $dbh->rollback if $oldAutoCommit;
2160 return "can't bill customer for $line: $error";
2163 $cust_main->apply_payments;
2164 $cust_main->apply_credits;
2166 $error = $cust_main->collect();
2168 $dbh->rollback if $oldAutoCommit;
2169 return "can't collect customer for $line: $error";
2175 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2177 return "Empty file!" unless $imported;
2189 #warn join('-',keys %$param);
2190 my $fh = $param->{filehandle};
2191 my @fields = @{$param->{fields}};
2193 eval "use Date::Parse;";
2195 eval "use Text::CSV_XS;";
2198 my $csv = new Text::CSV_XS;
2205 local $SIG{HUP} = 'IGNORE';
2206 local $SIG{INT} = 'IGNORE';
2207 local $SIG{QUIT} = 'IGNORE';
2208 local $SIG{TERM} = 'IGNORE';
2209 local $SIG{TSTP} = 'IGNORE';
2210 local $SIG{PIPE} = 'IGNORE';
2212 my $oldAutoCommit = $FS::UID::AutoCommit;
2213 local $FS::UID::AutoCommit = 0;
2216 #while ( $columns = $csv->getline($fh) ) {
2218 while ( defined($line=<$fh>) ) {
2220 $csv->parse($line) or do {
2221 $dbh->rollback if $oldAutoCommit;
2222 return "can't parse: ". $csv->error_input();
2225 my @columns = $csv->fields();
2226 #warn join('-',@columns);
2229 foreach my $field ( @fields ) {
2230 $row{$field} = shift @columns;
2233 my $cust_main = qsearchs('cust_main', { 'custnum' => $row{'custnum'} } );
2234 unless ( $cust_main ) {
2235 $dbh->rollback if $oldAutoCommit;
2236 return "unknown custnum $row{'custnum'}";
2239 if ( $row{'amount'} > 0 ) {
2240 my $error = $cust_main->charge($row{'amount'}, $row{'pkg'});
2242 $dbh->rollback if $oldAutoCommit;
2246 } elsif ( $row{'amount'} < 0 ) {
2247 my $error = $cust_main->credit( sprintf( "%.2f", 0-$row{'amount'} ),
2250 $dbh->rollback if $oldAutoCommit;
2260 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2262 return "Empty file!" unless $imported;
2274 The delete method should possibly take an FS::cust_main object reference
2275 instead of a scalar customer number.
2277 Bill and collect options should probably be passed as references instead of a
2280 There should probably be a configuration file with a list of allowed credit
2283 No multiple currency support (probably a larger project than just this module).
2287 L<FS::Record>, L<FS::cust_pkg>, L<FS::cust_bill>, L<FS::cust_credit>
2288 L<FS::agent>, L<FS::part_referral>, L<FS::cust_main_county>,
2289 L<FS::cust_main_invoice>, L<FS::UID>, schema.html from the base documentation.