4 use vars qw( @ISA $conf $Debug $import );
10 use Business::CreditCard;
11 use FS::UID qw( getotaker dbh );
12 use FS::Record qw( qsearchs qsearch dbdef );
15 use FS::cust_bill_pkg;
18 use FS::part_referral;
19 use FS::cust_main_county;
21 use FS::cust_main_invoice;
22 use FS::cust_credit_bill;
23 use FS::cust_bill_pay;
24 use FS::prepay_credit;
27 use FS::part_bill_event;
28 use FS::cust_bill_event;
29 use FS::cust_tax_exempt;
31 use FS::Msgcat qw(gettext);
33 @ISA = qw( FS::Record );
40 #ask FS::UID to run this stuff for us later
41 #$FS::UID::callback{'FS::cust_main'} = sub {
42 install_callback FS::UID sub {
44 #yes, need it for stuff below (prolly should be cached)
49 my ( $hashref, $cache ) = @_;
50 if ( exists $hashref->{'pkgnum'} ) {
51 # #@{ $self->{'_pkgnum'} } = ();
52 my $subcache = $cache->subcache( 'pkgnum', 'cust_pkg', $hashref->{custnum});
53 $self->{'_pkgnum'} = $subcache;
54 #push @{ $self->{'_pkgnum'} },
55 FS::cust_pkg->new_or_cached($hashref, $subcache) if $hashref->{pkgnum};
61 FS::cust_main - Object methods for cust_main records
67 $record = new FS::cust_main \%hash;
68 $record = new FS::cust_main { 'column' => 'value' };
70 $error = $record->insert;
72 $error = $new_record->replace($old_record);
74 $error = $record->delete;
76 $error = $record->check;
78 @cust_pkg = $record->all_pkgs;
80 @cust_pkg = $record->ncancelled_pkgs;
82 @cust_pkg = $record->suspended_pkgs;
84 $error = $record->bill;
85 $error = $record->bill %options;
86 $error = $record->bill 'time' => $time;
88 $error = $record->collect;
89 $error = $record->collect %options;
90 $error = $record->collect 'invoice_time' => $time,
91 'batch_card' => 'yes',
92 'report_badcard' => 'yes',
97 An FS::cust_main object represents a customer. FS::cust_main inherits from
98 FS::Record. The following fields are currently supported:
102 =item custnum - primary key (assigned automatically for new customers)
104 =item agentnum - agent (see L<FS::agent>)
106 =item refnum - Advertising source (see L<FS::part_referral>)
112 =item ss - social security number (optional)
114 =item company - (optional)
118 =item address2 - (optional)
122 =item county - (optional, see L<FS::cust_main_county>)
124 =item state - (see L<FS::cust_main_county>)
128 =item country - (see L<FS::cust_main_county>)
130 =item daytime - phone (optional)
132 =item night - phone (optional)
134 =item fax - phone (optional)
136 =item ship_first - name
138 =item ship_last - name
140 =item ship_company - (optional)
144 =item ship_address2 - (optional)
148 =item ship_county - (optional, see L<FS::cust_main_county>)
150 =item ship_state - (see L<FS::cust_main_county>)
154 =item ship_country - (see L<FS::cust_main_county>)
156 =item ship_daytime - phone (optional)
158 =item ship_night - phone (optional)
160 =item ship_fax - phone (optional)
162 =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)
164 =item payinfo - card number, P.O., comp issuer (4-8 lowercase alphanumerics; think username) or prepayment identifier (see L<FS::prepay_credit>)
166 =item paydate - expiration date, mm/yyyy, m/yyyy, mm/yy or m/yy
168 =item payname - name on card or billing name
170 =item tax - tax exempt, empty or `Y'
172 =item otaker - order taker (assigned automatically, see L<FS::UID>)
174 =item comments - comments (optional)
176 =item referral_custnum - referring customer number
186 Creates a new customer. To add the customer to the database, see L<"insert">.
188 Note that this stores the hash reference, not a distinct copy of the hash it
189 points to. You can ask the object for a copy with the I<hash> method.
193 sub table { 'cust_main'; }
195 =item insert [ CUST_PKG_HASHREF [ , INVOICING_LIST_ARYREF ] ]
197 Adds this customer to the database. If there is an error, returns the error,
198 otherwise returns false.
200 CUST_PKG_HASHREF: If you pass a Tie::RefHash data structure to the insert
201 method containing FS::cust_pkg and FS::svc_I<tablename> objects, all records
202 are inserted atomicly, or the transaction is rolled back. Passing an empty
203 hash reference is equivalent to not supplying this parameter. There should be
204 a better explanation of this, but until then, here's an example:
207 tie %hash, 'Tie::RefHash'; #this part is important
209 $cust_pkg => [ $svc_acct ],
212 $cust_main->insert( \%hash );
214 INVOICING_LIST_ARYREF: If you pass an arrarref to the insert method, it will
215 be set as the invoicing list (see L<"invoicing_list">). Errors return as
216 expected and rollback the entire transaction; it is not necessary to call
217 check_invoicing_list first. The invoicing_list is set after the records in the
218 CUST_PKG_HASHREF above are inserted, so it is now possible to set an
219 invoicing_list destination to the newly-created svc_acct. Here's an example:
221 $cust_main->insert( {}, [ $email, 'POST' ] );
227 my $cust_pkgs = @_ ? shift : {};
228 my $invoicing_list = @_ ? shift : '';
230 local $SIG{HUP} = 'IGNORE';
231 local $SIG{INT} = 'IGNORE';
232 local $SIG{QUIT} = 'IGNORE';
233 local $SIG{TERM} = 'IGNORE';
234 local $SIG{TSTP} = 'IGNORE';
235 local $SIG{PIPE} = 'IGNORE';
237 my $oldAutoCommit = $FS::UID::AutoCommit;
238 local $FS::UID::AutoCommit = 0;
243 if ( $self->payby eq 'PREPAY' ) {
244 $self->payby('BILL');
245 my $prepay_credit = qsearchs(
247 { 'identifier' => $self->payinfo },
251 warn "WARNING: can't find pre-found prepay_credit: ". $self->payinfo
252 unless $prepay_credit;
253 $amount = $prepay_credit->amount;
254 $seconds = $prepay_credit->seconds;
255 my $error = $prepay_credit->delete;
257 $dbh->rollback if $oldAutoCommit;
258 return "removing prepay_credit (transaction rolled back): $error";
262 my $error = $self->SUPER::insert;
264 $dbh->rollback if $oldAutoCommit;
265 #return "inserting cust_main record (transaction rolled back): $error";
270 if ( $invoicing_list ) {
271 $error = $self->check_invoicing_list( $invoicing_list );
273 $dbh->rollback if $oldAutoCommit;
274 return "checking invoicing_list (transaction rolled back): $error";
276 $self->invoicing_list( $invoicing_list );
280 foreach my $cust_pkg ( keys %$cust_pkgs ) {
281 $cust_pkg->custnum( $self->custnum );
282 $error = $cust_pkg->insert;
284 $dbh->rollback if $oldAutoCommit;
285 return "inserting cust_pkg (transaction rolled back): $error";
287 foreach my $svc_something ( @{$cust_pkgs->{$cust_pkg}} ) {
288 $svc_something->pkgnum( $cust_pkg->pkgnum );
289 if ( $seconds && $svc_something->isa('FS::svc_acct') ) {
290 $svc_something->seconds( $svc_something->seconds + $seconds );
293 $error = $svc_something->insert;
295 $dbh->rollback if $oldAutoCommit;
296 #return "inserting svc_ (transaction rolled back): $error";
303 $dbh->rollback if $oldAutoCommit;
304 return "No svc_acct record to apply pre-paid time";
308 my $cust_credit = new FS::cust_credit {
309 'custnum' => $self->custnum,
312 $error = $cust_credit->insert;
314 $dbh->rollback if $oldAutoCommit;
315 return "inserting credit (transaction rolled back): $error";
319 #false laziness with sub replace
320 my $queue = new FS::queue { 'job' => 'FS::cust_main::append_fuzzyfiles' };
321 $error = $queue->insert($self->getfield('last'), $self->company);
323 $dbh->rollback if $oldAutoCommit;
324 return "queueing job (transaction rolled back): $error";
327 if ( defined $self->dbdef_table->column('ship_last') && $self->ship_last ) {
328 $queue = new FS::queue { 'job' => 'FS::cust_main::append_fuzzyfiles' };
329 $error = $queue->insert($self->getfield('last'), $self->company);
331 $dbh->rollback if $oldAutoCommit;
332 return "queueing job (transaction rolled back): $error";
337 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
342 =item delete NEW_CUSTNUM
344 This deletes the customer. If there is an error, returns the error, otherwise
347 This will completely remove all traces of the customer record. This is not
348 what you want when a customer cancels service; for that, cancel all of the
349 customer's packages (see L<FS::cust_pkg/cancel>).
351 If the customer has any uncancelled packages, you need to pass a new (valid)
352 customer number for those packages to be transferred to. Cancelled packages
353 will be deleted. Did I mention that this is NOT what you want when a customer
354 cancels service and that you really should be looking see L<FS::cust_pkg/cancel>?
356 You can't delete a customer with invoices (see L<FS::cust_bill>),
357 or credits (see L<FS::cust_credit>), payments (see L<FS::cust_pay>) or
358 refunds (see L<FS::cust_refund>).
365 local $SIG{HUP} = 'IGNORE';
366 local $SIG{INT} = 'IGNORE';
367 local $SIG{QUIT} = 'IGNORE';
368 local $SIG{TERM} = 'IGNORE';
369 local $SIG{TSTP} = 'IGNORE';
370 local $SIG{PIPE} = 'IGNORE';
372 my $oldAutoCommit = $FS::UID::AutoCommit;
373 local $FS::UID::AutoCommit = 0;
376 if ( qsearch( 'cust_bill', { 'custnum' => $self->custnum } ) ) {
377 $dbh->rollback if $oldAutoCommit;
378 return "Can't delete a customer with invoices";
380 if ( qsearch( 'cust_credit', { 'custnum' => $self->custnum } ) ) {
381 $dbh->rollback if $oldAutoCommit;
382 return "Can't delete a customer with credits";
384 if ( qsearch( 'cust_pay', { 'custnum' => $self->custnum } ) ) {
385 $dbh->rollback if $oldAutoCommit;
386 return "Can't delete a customer with payments";
388 if ( qsearch( 'cust_refund', { 'custnum' => $self->custnum } ) ) {
389 $dbh->rollback if $oldAutoCommit;
390 return "Can't delete a customer with refunds";
393 my @cust_pkg = $self->ncancelled_pkgs;
395 my $new_custnum = shift;
396 unless ( qsearchs( 'cust_main', { 'custnum' => $new_custnum } ) ) {
397 $dbh->rollback if $oldAutoCommit;
398 return "Invalid new customer number: $new_custnum";
400 foreach my $cust_pkg ( @cust_pkg ) {
401 my %hash = $cust_pkg->hash;
402 $hash{'custnum'} = $new_custnum;
403 my $new_cust_pkg = new FS::cust_pkg ( \%hash );
404 my $error = $new_cust_pkg->replace($cust_pkg);
406 $dbh->rollback if $oldAutoCommit;
411 my @cancelled_cust_pkg = $self->all_pkgs;
412 foreach my $cust_pkg ( @cancelled_cust_pkg ) {
413 my $error = $cust_pkg->delete;
415 $dbh->rollback if $oldAutoCommit;
420 foreach my $cust_main_invoice ( #(email invoice destinations, not invoices)
421 qsearch( 'cust_main_invoice', { 'custnum' => $self->custnum } )
423 my $error = $cust_main_invoice->delete;
425 $dbh->rollback if $oldAutoCommit;
430 my $error = $self->SUPER::delete;
432 $dbh->rollback if $oldAutoCommit;
436 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
441 =item replace OLD_RECORD [ INVOICING_LIST_ARYREF ]
443 Replaces the OLD_RECORD with this one in the database. If there is an error,
444 returns the error, otherwise returns false.
446 INVOICING_LIST_ARYREF: If you pass an arrarref to the insert method, it will
447 be set as the invoicing list (see L<"invoicing_list">). Errors return as
448 expected and rollback the entire transaction; it is not necessary to call
449 check_invoicing_list first. Here's an example:
451 $new_cust_main->replace( $old_cust_main, [ $email, 'POST' ] );
460 local $SIG{HUP} = 'IGNORE';
461 local $SIG{INT} = 'IGNORE';
462 local $SIG{QUIT} = 'IGNORE';
463 local $SIG{TERM} = 'IGNORE';
464 local $SIG{TSTP} = 'IGNORE';
465 local $SIG{PIPE} = 'IGNORE';
467 my $oldAutoCommit = $FS::UID::AutoCommit;
468 local $FS::UID::AutoCommit = 0;
471 my $error = $self->SUPER::replace($old);
474 $dbh->rollback if $oldAutoCommit;
478 if ( @param ) { # INVOICING_LIST_ARYREF
479 my $invoicing_list = shift @param;
480 $error = $self->check_invoicing_list( $invoicing_list );
482 $dbh->rollback if $oldAutoCommit;
485 $self->invoicing_list( $invoicing_list );
488 if ( $self->payby =~ /^(CARD|CHEK|LECB)$/ &&
489 grep { $self->get($_) ne $old->get($_) } qw(payinfo paydate payname) ) {
490 # card/check info has changed, want to retry realtime_card invoice events
491 #false laziness w/collect
492 foreach my $cust_bill_event (
494 #$_->part_bill_event->plan eq 'realtime-card'
495 $_->part_bill_event->eventcode =~
496 /^\$cust_bill\->realtime_(card|ach|lec)\(\);$/
497 && $_->status eq 'done'
500 map { $_->cust_bill_event }
501 grep { $_->cust_bill_event }
502 $self->open_cust_bill
505 my $error = $cust_bill_event->retry;
507 $dbh->rollback if $oldAutoCommit;
508 return "error scheduling invoice events for retry: $error";
515 #false laziness with sub insert
516 my $queue = new FS::queue { 'job' => 'FS::cust_main::append_fuzzyfiles' };
517 $error = $queue->insert($self->getfield('last'), $self->company);
519 $dbh->rollback if $oldAutoCommit;
520 return "queueing job (transaction rolled back): $error";
523 if ( defined $self->dbdef_table->column('ship_last') && $self->ship_last ) {
524 $queue = new FS::queue { 'job' => 'FS::cust_main::append_fuzzyfiles' };
525 $error = $queue->insert($self->getfield('last'), $self->company);
527 $dbh->rollback if $oldAutoCommit;
528 return "queueing job (transaction rolled back): $error";
533 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
540 Checks all fields to make sure this is a valid customer record. If there is
541 an error, returns the error, otherwise returns false. Called by the insert
549 #warn "BEFORE: \n". $self->_dump;
552 $self->ut_numbern('custnum')
553 || $self->ut_number('agentnum')
554 || $self->ut_number('refnum')
555 || $self->ut_name('last')
556 || $self->ut_name('first')
557 || $self->ut_textn('company')
558 || $self->ut_text('address1')
559 || $self->ut_textn('address2')
560 || $self->ut_text('city')
561 || $self->ut_textn('county')
562 || $self->ut_textn('state')
563 || $self->ut_country('country')
564 || $self->ut_anything('comments')
565 || $self->ut_numbern('referral_custnum')
567 #barf. need message catalogs. i18n. etc.
568 $error .= "Please select a advertising source."
569 if $error =~ /^Illegal or empty \(numeric\) refnum: /;
570 return $error if $error;
572 return "Unknown agent"
573 unless qsearchs( 'agent', { 'agentnum' => $self->agentnum } );
575 return "Unknown refnum"
576 unless qsearchs( 'part_referral', { 'refnum' => $self->refnum } );
578 return "Unknown referring custnum ". $self->referral_custnum
579 unless ! $self->referral_custnum
580 || qsearchs( 'cust_main', { 'custnum' => $self->referral_custnum } );
582 if ( $self->ss eq '' ) {
587 $ss =~ /^(\d{3})(\d{2})(\d{4})$/
588 or return "Illegal social security number: ". $self->ss;
589 $self->ss("$1-$2-$3");
593 # bad idea to disable, causes billing to fail because of no tax rates later
594 # unless ( $import ) {
595 unless ( qsearch('cust_main_county', {
596 'country' => $self->country,
599 return "Unknown state/county/country: ".
600 $self->state. "/". $self->county. "/". $self->country
601 unless qsearch('cust_main_county',{
602 'state' => $self->state,
603 'county' => $self->county,
604 'country' => $self->country,
610 $self->ut_phonen('daytime', $self->country)
611 || $self->ut_phonen('night', $self->country)
612 || $self->ut_phonen('fax', $self->country)
613 || $self->ut_zip('zip', $self->country)
615 return $error if $error;
618 last first company address1 address2 city county state zip
619 country daytime night fax
622 if ( defined $self->dbdef_table->column('ship_last') ) {
623 if ( scalar ( grep { $self->getfield($_) ne $self->getfield("ship_$_") }
625 && scalar ( grep { $self->getfield("ship_$_") ne '' } @addfields )
629 $self->ut_name('ship_last')
630 || $self->ut_name('ship_first')
631 || $self->ut_textn('ship_company')
632 || $self->ut_text('ship_address1')
633 || $self->ut_textn('ship_address2')
634 || $self->ut_text('ship_city')
635 || $self->ut_textn('ship_county')
636 || $self->ut_textn('ship_state')
637 || $self->ut_country('ship_country')
639 return $error if $error;
641 #false laziness with above
642 unless ( qsearchs('cust_main_county', {
643 'country' => $self->ship_country,
646 return "Unknown ship_state/ship_county/ship_country: ".
647 $self->ship_state. "/". $self->ship_county. "/". $self->ship_country
648 unless qsearchs('cust_main_county',{
649 'state' => $self->ship_state,
650 'county' => $self->ship_county,
651 'country' => $self->ship_country,
657 $self->ut_phonen('ship_daytime', $self->ship_country)
658 || $self->ut_phonen('ship_night', $self->ship_country)
659 || $self->ut_phonen('ship_fax', $self->ship_country)
660 || $self->ut_zip('ship_zip', $self->ship_country)
662 return $error if $error;
664 } else { # ship_ info eq billing info, so don't store dup info in database
665 $self->setfield("ship_$_", '')
666 foreach qw( last first company address1 address2 city county state zip
667 country daytime night fax );
671 $self->payby =~ /^(CARD|CHEK|LECB|BILL|COMP|PREPAY)$/
672 or return "Illegal payby: ". $self->payby;
675 if ( $self->payby eq 'CARD' ) {
677 my $payinfo = $self->payinfo;
679 $payinfo =~ /^(\d{13,16})$/
680 or return gettext('invalid_card'); # . ": ". $self->payinfo;
682 $self->payinfo($payinfo);
684 or return gettext('invalid_card'); # . ": ". $self->payinfo;
685 return gettext('unknown_card_type')
686 if cardtype($self->payinfo) eq "Unknown";
688 } elsif ( $self->payby eq 'CHEK' ) {
690 my $payinfo = $self->payinfo;
691 $payinfo =~ s/[^\d\@]//g;
692 $payinfo =~ /^(\d+)\@(\d{9})$/ or return 'invalid echeck account@aba';
694 $self->payinfo($payinfo);
696 } elsif ( $self->payby eq 'LECB' ) {
698 my $payinfo = $self->payinfo;
700 $payinfo =~ /^1?(\d{10})$/ or return 'invalid btn billing telephone number';
702 $self->payinfo($payinfo);
704 } elsif ( $self->payby eq 'BILL' ) {
706 $error = $self->ut_textn('payinfo');
707 return "Illegal P.O. number: ". $self->payinfo if $error;
709 } elsif ( $self->payby eq 'COMP' ) {
711 $error = $self->ut_textn('payinfo');
712 return "Illegal comp account issuer: ". $self->payinfo if $error;
714 } elsif ( $self->payby eq 'PREPAY' ) {
716 my $payinfo = $self->payinfo;
717 $payinfo =~ s/\W//g; #anything else would just confuse things
718 $self->payinfo($payinfo);
719 $error = $self->ut_alpha('payinfo');
720 return "Illegal prepayment identifier: ". $self->payinfo if $error;
721 return "Unknown prepayment identifier"
722 unless qsearchs('prepay_credit', { 'identifier' => $self->payinfo } );
726 if ( $self->paydate eq '' || $self->paydate eq '-' ) {
727 return "Expriation date required"
728 unless $self->payby =~ /^(BILL|PREPAY|CHEK|LECB)$/;
731 $self->paydate =~ /^(\d{1,2})[\/\-](\d{2}(\d{2})?)$/
732 or return "Illegal expiration date: ". $self->paydate;
733 my $y = length($2) == 4 ? $2 : "20$2";
734 $self->paydate("$y-$1-01");
735 my($nowm,$nowy)=(localtime(time))[4,5]; $nowm++; $nowy+=1900;
736 return gettext('expired_card')
737 if !$import && ( $y<$nowy || ( $y==$nowy && $1<$nowm ) );
740 if ( $self->payname eq '' && $self->payby ne 'CHEK' &&
741 ( ! $conf->exists('require_cardname') || $self->payby ne 'CARD' ) ) {
742 $self->payname( $self->first. " ". $self->getfield('last') );
744 $self->payname =~ /^([\w \,\.\-\']+)$/
745 or return gettext('illegal_name'). " payname: ". $self->payname;
749 $self->tax =~ /^(Y?)$/ or return "Illegal tax: ". $self->tax;
752 $self->otaker(getotaker);
754 #warn "AFTER: \n". $self->_dump;
761 Returns all packages (see L<FS::cust_pkg>) for this customer.
767 if ( $self->{'_pkgnum'} ) {
768 values %{ $self->{'_pkgnum'}->cache };
770 qsearch( 'cust_pkg', { 'custnum' => $self->custnum });
774 =item ncancelled_pkgs
776 Returns all non-cancelled packages (see L<FS::cust_pkg>) for this customer.
780 sub ncancelled_pkgs {
782 if ( $self->{'_pkgnum'} ) {
783 grep { ! $_->getfield('cancel') } values %{ $self->{'_pkgnum'}->cache };
785 @{ [ # force list context
786 qsearch( 'cust_pkg', {
787 'custnum' => $self->custnum,
790 qsearch( 'cust_pkg', {
791 'custnum' => $self->custnum,
800 Returns all suspended packages (see L<FS::cust_pkg>) for this customer.
806 grep { $_->susp } $self->ncancelled_pkgs;
809 =item unflagged_suspended_pkgs
811 Returns all unflagged suspended packages (see L<FS::cust_pkg>) for this
812 customer (thouse packages without the `manual_flag' set).
816 sub unflagged_suspended_pkgs {
818 return $self->suspended_pkgs
819 unless dbdef->table('cust_pkg')->column('manual_flag');
820 grep { ! $_->manual_flag } $self->suspended_pkgs;
823 =item unsuspended_pkgs
825 Returns all unsuspended (and uncancelled) packages (see L<FS::cust_pkg>) for
830 sub unsuspended_pkgs {
832 grep { ! $_->susp } $self->ncancelled_pkgs;
837 Unsuspends all unflagged suspended packages (see L</unflagged_suspended_pkgs>
838 and L<FS::cust_pkg>) for this customer. Always returns a list: an empty list
839 on success or a list of errors.
845 grep { $_->unsuspend } $self->suspended_pkgs;
850 Suspends all unsuspended packages (see L<FS::cust_pkg>) for this customer.
851 Always returns a list: an empty list on success or a list of errors.
857 grep { $_->suspend } $self->unsuspended_pkgs;
862 Cancels all uncancelled packages (see L<FS::cust_pkg>) for this customer.
863 Always returns a list: an empty list on success or a list of errors.
869 grep { $_->cancel } $self->ncancelled_pkgs;
874 Returns the agent (see L<FS::agent>) for this customer.
880 qsearchs( 'agent', { 'agentnum' => $self->agentnum } );
885 Generates invoices (see L<FS::cust_bill>) for this customer. Usually used in
886 conjunction with the collect method.
888 Options are passed as name-value pairs.
890 The only currently available option is `time', which bills the customer as if
891 it were that time. It is specified as a UNIX timestamp; see
892 L<perlfunc/"time">). Also see L<Time::Local> and L<Date::Parse> for conversion
893 functions. For example:
897 $cust_main->bill( 'time' => str2time('April 20th, 2001') );
899 If there is an error, returns the error, otherwise returns false.
904 my( $self, %options ) = @_;
905 my $time = $options{'time'} || time;
910 local $SIG{HUP} = 'IGNORE';
911 local $SIG{INT} = 'IGNORE';
912 local $SIG{QUIT} = 'IGNORE';
913 local $SIG{TERM} = 'IGNORE';
914 local $SIG{TSTP} = 'IGNORE';
915 local $SIG{PIPE} = 'IGNORE';
917 my $oldAutoCommit = $FS::UID::AutoCommit;
918 local $FS::UID::AutoCommit = 0;
921 # find the packages which are due for billing, find out how much they are
922 # & generate invoice database.
924 my( $total_setup, $total_recur ) = ( 0, 0 );
925 #my( $taxable_setup, $taxable_recur ) = ( 0, 0 );
926 my @cust_bill_pkg = ();
928 #my $taxable_charged = 0;##
933 foreach my $cust_pkg (
934 qsearch('cust_pkg', { 'custnum' => $self->custnum } )
937 #NO!! next if $cust_pkg->cancel;
938 next if $cust_pkg->getfield('cancel');
940 #? to avoid use of uninitialized value errors... ?
941 $cust_pkg->setfield('bill', '')
942 unless defined($cust_pkg->bill);
944 my $part_pkg = $cust_pkg->part_pkg;
946 #so we don't modify cust_pkg record unnecessarily
947 my $cust_pkg_mod_flag = 0;
948 my %hash = $cust_pkg->hash;
949 my $old_cust_pkg = new FS::cust_pkg \%hash;
955 unless ( $cust_pkg->setup ) {
956 my $setup_prog = $part_pkg->getfield('setup');
957 $setup_prog =~ /^(.*)$/ or do {
958 $dbh->rollback if $oldAutoCommit;
959 return "Illegal setup for pkgpart ". $part_pkg->pkgpart.
965 ##$cpt->permit(); #what is necessary?
966 #$cpt->share(qw( $cust_pkg )); #can $cpt now use $cust_pkg methods?
967 #$setup = $cpt->reval($setup_prog);
968 $setup = eval $setup_prog;
969 unless ( defined($setup) ) {
970 $dbh->rollback if $oldAutoCommit;
971 return "Error eval-ing part_pkg->setup pkgpart ". $part_pkg->pkgpart.
972 "(expression $setup_prog): $@";
974 $cust_pkg->setfield('setup',$time);
975 $cust_pkg_mod_flag=1;
981 if ( $part_pkg->getfield('freq') > 0 &&
982 ! $cust_pkg->getfield('susp') &&
983 ( $cust_pkg->getfield('bill') || 0 ) <= $time
985 my $recur_prog = $part_pkg->getfield('recur');
986 $recur_prog =~ /^(.*)$/ or do {
987 $dbh->rollback if $oldAutoCommit;
988 return "Illegal recur for pkgpart ". $part_pkg->pkgpart.
993 # shared with $recur_prog
994 $sdate = $cust_pkg->bill || $cust_pkg->setup || $time;
997 ##$cpt->permit(); #what is necessary?
998 #$cpt->share(qw( $cust_pkg )); #can $cpt now use $cust_pkg methods?
999 #$recur = $cpt->reval($recur_prog);
1000 $recur = eval $recur_prog;
1001 unless ( defined($recur) ) {
1002 $dbh->rollback if $oldAutoCommit;
1003 return "Error eval-ing part_pkg->recur pkgpart ". $part_pkg->pkgpart.
1004 "(expression $recur_prog): $@";
1006 #change this bit to use Date::Manip? CAREFUL with timezones (see
1007 # mailing list archive)
1008 my ($sec,$min,$hour,$mday,$mon,$year) =
1009 (localtime($sdate) )[0,1,2,3,4,5];
1011 #pro-rating magic - if $recur_prog fiddles $sdate, want to use that
1012 # only for figuring next bill date, nothing else, so, reset $sdate again
1014 $sdate = $cust_pkg->bill || $cust_pkg->setup || $time;
1015 $cust_pkg->last_bill($sdate)
1016 if $cust_pkg->dbdef_table->column('last_bill');
1018 $mon += $part_pkg->freq;
1019 until ( $mon < 12 ) { $mon -= 12; $year++; }
1020 $cust_pkg->setfield('bill',
1021 timelocal($sec,$min,$hour,$mday,$mon,$year));
1022 $cust_pkg_mod_flag = 1;
1025 warn "\$setup is undefined" unless defined($setup);
1026 warn "\$recur is undefined" unless defined($recur);
1027 warn "\$cust_pkg->bill is undefined" unless defined($cust_pkg->bill);
1029 my $taxable_charged = 0;
1030 if ( $cust_pkg_mod_flag ) {
1031 $error=$cust_pkg->replace($old_cust_pkg);
1032 if ( $error ) { #just in case
1033 $dbh->rollback if $oldAutoCommit;
1034 return "Error modifying pkgnum ". $cust_pkg->pkgnum. ": $error";
1036 $setup = sprintf( "%.2f", $setup );
1037 $recur = sprintf( "%.2f", $recur );
1039 $dbh->rollback if $oldAutoCommit;
1040 return "negative setup $setup for pkgnum ". $cust_pkg->pkgnum;
1043 $dbh->rollback if $oldAutoCommit;
1044 return "negative recur $recur for pkgnum ". $cust_pkg->pkgnum;
1046 if ( $setup > 0 || $recur > 0 ) {
1047 my $cust_bill_pkg = new FS::cust_bill_pkg ({
1048 'pkgnum' => $cust_pkg->pkgnum,
1052 'edate' => $cust_pkg->bill,
1053 'details' => \@details,
1055 push @cust_bill_pkg, $cust_bill_pkg;
1056 $total_setup += $setup;
1057 $total_recur += $recur;
1058 $taxable_charged += $setup
1059 unless $part_pkg->setuptax =~ /^Y$/i;
1060 $taxable_charged += $recur
1061 unless $part_pkg->recurtax =~ /^Y$/i;
1063 unless ( $self->tax =~ /Y/i
1064 || $self->payby eq 'COMP'
1065 || $taxable_charged == 0 ) {
1067 my $cust_main_county = qsearchs('cust_main_county',{
1068 'state' => $self->state,
1069 'county' => $self->county,
1070 'country' => $self->country,
1071 'taxclass' => $part_pkg->taxclass,
1073 $cust_main_county ||= qsearchs('cust_main_county',{
1074 'state' => $self->state,
1075 'county' => $self->county,
1076 'country' => $self->country,
1079 unless ( $cust_main_county ) {
1080 $dbh->rollback if $oldAutoCommit;
1082 "fatal: can't find tax rate for state/county/country/taxclass ".
1083 join('/', ( map $self->$_(), qw(state county country) ),
1084 $part_pkg->taxclass ). "\n";
1087 if ( $cust_main_county->exempt_amount ) {
1088 my ($mon,$year) = (localtime($sdate) )[4,5];
1090 my $freq = $part_pkg->freq || 1;
1091 my $taxable_per_month = sprintf("%.2f", $taxable_charged / $freq );
1092 foreach my $which_month ( 1 .. $freq ) {
1094 'custnum' => $self->custnum,
1095 'taxnum' => $cust_main_county->taxnum,
1096 'year' => 1900+$year,
1099 #until ( $mon < 12 ) { $mon -= 12; $year++; }
1100 until ( $mon < 13 ) { $mon -= 12; $year++; }
1101 my $cust_tax_exempt =
1102 qsearchs('cust_tax_exempt', \%hash)
1103 || new FS::cust_tax_exempt( { %hash, 'amount' => 0 } );
1104 my $remaining_exemption = sprintf("%.2f",
1105 $cust_main_county->exempt_amount - $cust_tax_exempt->amount );
1106 if ( $remaining_exemption > 0 ) {
1107 my $addl = $remaining_exemption > $taxable_per_month
1108 ? $taxable_per_month
1109 : $remaining_exemption;
1110 $taxable_charged -= $addl;
1111 my $new_cust_tax_exempt = new FS::cust_tax_exempt ( {
1112 $cust_tax_exempt->hash,
1113 'amount' => sprintf("%.2f", $cust_tax_exempt->amount + $addl),
1115 $error = $new_cust_tax_exempt->exemptnum
1116 ? $new_cust_tax_exempt->replace($cust_tax_exempt)
1117 : $new_cust_tax_exempt->insert;
1119 $dbh->rollback if $oldAutoCommit;
1120 return "fatal: can't update cust_tax_exempt: $error";
1123 } # if $remaining_exemption > 0
1125 } #foreach $which_month
1127 } #if $cust_main_county->exempt_amount
1129 $taxable_charged = sprintf( "%.2f", $taxable_charged);
1131 #$tax += $taxable_charged * $cust_main_county->tax / 100
1132 $tax{ $cust_main_county->taxname || 'Tax' } +=
1133 $taxable_charged * $cust_main_county->tax / 100
1135 } #unless $self->tax =~ /Y/i
1136 # || $self->payby eq 'COMP'
1137 # || $taxable_charged == 0
1139 } #if $setup > 0 || $recur > 0
1141 } #if $cust_pkg_mod_flag
1143 } #foreach my $cust_pkg
1145 my $charged = sprintf( "%.2f", $total_setup + $total_recur );
1146 # my $taxable_charged = sprintf( "%.2f", $taxable_setup + $taxable_recur );
1148 unless ( @cust_bill_pkg ) { #don't create invoices with no line items
1149 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1153 # unless ( $self->tax =~ /Y/i
1154 # || $self->payby eq 'COMP'
1155 # || $taxable_charged == 0 ) {
1156 # my $cust_main_county = qsearchs('cust_main_county',{
1157 # 'state' => $self->state,
1158 # 'county' => $self->county,
1159 # 'country' => $self->country,
1160 # } ) or die "fatal: can't find tax rate for state/county/country ".
1161 # $self->state. "/". $self->county. "/". $self->country. "\n";
1162 # my $tax = sprintf( "%.2f",
1163 # $taxable_charged * ( $cust_main_county->getfield('tax') / 100 )
1166 foreach my $taxname ( grep { $tax{$_} > 0 } keys %tax ) {
1167 my $tax = sprintf("%.2f", $tax{$taxname} );
1168 $charged = sprintf( "%.2f", $charged+$tax );
1170 my $cust_bill_pkg = new FS::cust_bill_pkg ({
1176 'itemdesc' => $taxname,
1178 push @cust_bill_pkg, $cust_bill_pkg;
1182 my $cust_bill = new FS::cust_bill ( {
1183 'custnum' => $self->custnum,
1185 'charged' => $charged,
1187 $error = $cust_bill->insert;
1189 $dbh->rollback if $oldAutoCommit;
1190 return "can't create invoice for customer #". $self->custnum. ": $error";
1193 my $invnum = $cust_bill->invnum;
1195 foreach $cust_bill_pkg ( @cust_bill_pkg ) {
1197 $cust_bill_pkg->invnum($invnum);
1198 $error = $cust_bill_pkg->insert;
1200 $dbh->rollback if $oldAutoCommit;
1201 return "can't create invoice line item for customer #". $self->custnum.
1206 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1210 =item collect OPTIONS
1212 (Attempt to) collect money for this customer's outstanding invoices (see
1213 L<FS::cust_bill>). Usually used after the bill method.
1215 Depending on the value of `payby', this may print an invoice (`BILL'), charge
1216 a credit card (`CARD'), or just add any necessary (pseudo-)payment (`COMP').
1218 Most actions are now triggered by invoice events; see L<FS::part_bill_event>
1219 and the invoice events web interface.
1221 If there is an error, returns the error, otherwise returns false.
1223 Options are passed as name-value pairs.
1225 Currently available options are:
1227 invoice_time - Use this time when deciding when to print invoices and
1228 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>
1229 for conversion functions.
1231 retry_card - Retry cards even when not scheduled by invoice events.
1233 batch_card - This option is deprecated. See the invoice events web interface
1234 to control whether cards are batched or run against a realtime gateway.
1236 report_badcard - This option is deprecated.
1238 force_print - This option is deprecated; see the invoice events web interface.
1243 my( $self, %options ) = @_;
1244 my $invoice_time = $options{'invoice_time'} || time;
1247 local $SIG{HUP} = 'IGNORE';
1248 local $SIG{INT} = 'IGNORE';
1249 local $SIG{QUIT} = 'IGNORE';
1250 local $SIG{TERM} = 'IGNORE';
1251 local $SIG{TSTP} = 'IGNORE';
1252 local $SIG{PIPE} = 'IGNORE';
1254 my $oldAutoCommit = $FS::UID::AutoCommit;
1255 local $FS::UID::AutoCommit = 0;
1258 my $balance = $self->balance;
1259 warn "collect customer". $self->custnum. ": balance $balance" if $Debug;
1260 unless ( $balance > 0 ) { #redundant?????
1261 $dbh->rollback if $oldAutoCommit; #hmm
1265 if ( exists($options{'retry_card'}) && $options{'retry_card'} ) {
1266 #false laziness w/replace
1267 foreach my $cust_bill_event (
1269 #$_->part_bill_event->plan eq 'realtime-card'
1270 $_->part_bill_event->eventcode eq '$cust_bill->realtime_card();'
1271 && $_->status eq 'done'
1274 map { $_->cust_bill_event }
1275 grep { $_->cust_bill_event }
1276 $self->open_cust_bill
1278 my $error = $cust_bill_event->retry;
1280 $dbh->rollback if $oldAutoCommit;
1281 return "error scheduling invoice events for retry: $error";
1287 foreach my $cust_bill ( $self->cust_bill ) {
1289 #this has to be before next's
1290 my $amount = sprintf( "%.2f", $balance < $cust_bill->owed
1294 $balance = sprintf( "%.2f", $balance - $amount );
1296 next unless $cust_bill->owed > 0;
1298 # don't try to charge for the same invoice if it's already in a batch
1299 #next if qsearchs( 'cust_pay_batch', { 'invnum' => $cust_bill->invnum } );
1301 warn "invnum ". $cust_bill->invnum. " (owed ". $cust_bill->owed. ", amount $amount, balance $balance)" if $Debug;
1303 next unless $amount > 0;
1306 foreach my $part_bill_event (
1307 sort { $a->seconds <=> $b->seconds
1308 || $a->weight <=> $b->weight
1309 || $a->eventpart <=> $b->eventpart }
1310 grep { $_->seconds <= ( $invoice_time - $cust_bill->_date )
1311 && ! qsearchs( 'cust_bill_event', {
1312 'invnum' => $cust_bill->invnum,
1313 'eventpart' => $_->eventpart,
1317 qsearch('part_bill_event', { 'payby' => $self->payby,
1318 'disabled' => '', } )
1321 last unless $cust_bill->owed > 0; #don't run subsequent events if owed=0
1323 warn "calling invoice event (". $part_bill_event->eventcode. ")\n"
1325 my $cust_main = $self; #for callback
1326 my $error = eval $part_bill_event->eventcode;
1329 my $statustext = '';
1333 } elsif ( $error ) {
1335 $statustext = $error;
1340 #add cust_bill_event
1341 my $cust_bill_event = new FS::cust_bill_event {
1342 'invnum' => $cust_bill->invnum,
1343 'eventpart' => $part_bill_event->eventpart,
1344 #'_date' => $invoice_time,
1346 'status' => $status,
1347 'statustext' => $statustext,
1349 $error = $cust_bill_event->insert;
1351 #$dbh->rollback if $oldAutoCommit;
1352 #return "error: $error";
1354 # gah, even with transactions.
1355 $dbh->commit if $oldAutoCommit; #well.
1356 my $e = 'WARNING: Event run but database not updated - '.
1357 'error inserting cust_bill_event, invnum #'. $cust_bill->invnum.
1358 ', eventpart '. $part_bill_event->eventpart.
1369 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1376 Returns the total owed for this customer on all invoices
1377 (see L<FS::cust_bill/owed>).
1383 $self->total_owed_date(2145859200); #12/31/2037
1386 =item total_owed_date TIME
1388 Returns the total owed for this customer on all invoices with date earlier than
1389 TIME. TIME is specified as a UNIX timestamp; see L<perlfunc/"time">). Also
1390 see L<Time::Local> and L<Date::Parse> for conversion functions.
1394 sub total_owed_date {
1398 foreach my $cust_bill (
1399 grep { $_->_date <= $time }
1400 qsearch('cust_bill', { 'custnum' => $self->custnum, } )
1402 $total_bill += $cust_bill->owed;
1404 sprintf( "%.2f", $total_bill );
1409 Applies (see L<FS::cust_credit_bill>) unapplied credits (see L<FS::cust_credit>)
1410 to outstanding invoice balances in chronological order and returns the value
1411 of any remaining unapplied credits available for refund
1412 (see L<FS::cust_refund>).
1419 return 0 unless $self->total_credited;
1421 my @credits = sort { $b->_date <=> $a->_date} (grep { $_->credited > 0 }
1422 qsearch('cust_credit', { 'custnum' => $self->custnum } ) );
1424 my @invoices = sort { $a->_date <=> $b->_date} (grep { $_->owed > 0 }
1425 qsearch('cust_bill', { 'custnum' => $self->custnum } ) );
1429 foreach my $cust_bill ( @invoices ) {
1432 if ( !defined($credit) || $credit->credited == 0) {
1433 $credit = pop @credits or last;
1436 if ($cust_bill->owed >= $credit->credited) {
1437 $amount=$credit->credited;
1439 $amount=$cust_bill->owed;
1442 my $cust_credit_bill = new FS::cust_credit_bill ( {
1443 'crednum' => $credit->crednum,
1444 'invnum' => $cust_bill->invnum,
1445 'amount' => $amount,
1447 my $error = $cust_credit_bill->insert;
1448 die $error if $error;
1450 redo if ($cust_bill->owed > 0);
1454 return $self->total_credited;
1457 =item apply_payments
1459 Applies (see L<FS::cust_bill_pay>) unapplied payments (see L<FS::cust_pay>)
1460 to outstanding invoice balances in chronological order.
1462 #and returns the value of any remaining unapplied payments.
1466 sub apply_payments {
1471 my @payments = sort { $b->_date <=> $a->_date } ( grep { $_->unapplied > 0 }
1472 qsearch('cust_pay', { 'custnum' => $self->custnum } ) );
1474 my @invoices = sort { $a->_date <=> $b->_date} (grep { $_->owed > 0 }
1475 qsearch('cust_bill', { 'custnum' => $self->custnum } ) );
1479 foreach my $cust_bill ( @invoices ) {
1482 if ( !defined($payment) || $payment->unapplied == 0 ) {
1483 $payment = pop @payments or last;
1486 if ( $cust_bill->owed >= $payment->unapplied ) {
1487 $amount = $payment->unapplied;
1489 $amount = $cust_bill->owed;
1492 my $cust_bill_pay = new FS::cust_bill_pay ( {
1493 'paynum' => $payment->paynum,
1494 'invnum' => $cust_bill->invnum,
1495 'amount' => $amount,
1497 my $error = $cust_bill_pay->insert;
1498 die $error if $error;
1500 redo if ( $cust_bill->owed > 0);
1504 return $self->total_unapplied_payments;
1507 =item total_credited
1509 Returns the total outstanding credit (see L<FS::cust_credit>) for this
1510 customer. See L<FS::cust_credit/credited>.
1514 sub total_credited {
1516 my $total_credit = 0;
1517 foreach my $cust_credit ( qsearch('cust_credit', {
1518 'custnum' => $self->custnum,
1520 $total_credit += $cust_credit->credited;
1522 sprintf( "%.2f", $total_credit );
1525 =item total_unapplied_payments
1527 Returns the total unapplied payments (see L<FS::cust_pay>) for this customer.
1528 See L<FS::cust_pay/unapplied>.
1532 sub total_unapplied_payments {
1534 my $total_unapplied = 0;
1535 foreach my $cust_pay ( qsearch('cust_pay', {
1536 'custnum' => $self->custnum,
1538 $total_unapplied += $cust_pay->unapplied;
1540 sprintf( "%.2f", $total_unapplied );
1545 Returns the balance for this customer (total_owed minus total_credited
1546 minus total_unapplied_payments).
1553 $self->total_owed - $self->total_credited - $self->total_unapplied_payments
1557 =item balance_date TIME
1559 Returns the balance for this customer, only considering invoices with date
1560 earlier than TIME (total_owed_date minus total_credited minus
1561 total_unapplied_payments). TIME is specified as a UNIX timestamp; see
1562 L<perlfunc/"time">). Also see L<Time::Local> and L<Date::Parse> for conversion
1571 $self->total_owed_date($time)
1572 - $self->total_credited
1573 - $self->total_unapplied_payments
1577 =item invoicing_list [ ARRAYREF ]
1579 If an arguement is given, sets these email addresses as invoice recipients
1580 (see L<FS::cust_main_invoice>). Errors are not fatal and are not reported
1581 (except as warnings), so use check_invoicing_list first.
1583 Returns a list of email addresses (with svcnum entries expanded).
1585 Note: You can clear the invoicing list by passing an empty ARRAYREF. You can
1586 check it without disturbing anything by passing nothing.
1588 This interface may change in the future.
1592 sub invoicing_list {
1593 my( $self, $arrayref ) = @_;
1595 my @cust_main_invoice;
1596 if ( $self->custnum ) {
1597 @cust_main_invoice =
1598 qsearch( 'cust_main_invoice', { 'custnum' => $self->custnum } );
1600 @cust_main_invoice = ();
1602 foreach my $cust_main_invoice ( @cust_main_invoice ) {
1603 #warn $cust_main_invoice->destnum;
1604 unless ( grep { $cust_main_invoice->address eq $_ } @{$arrayref} ) {
1605 #warn $cust_main_invoice->destnum;
1606 my $error = $cust_main_invoice->delete;
1607 warn $error if $error;
1610 if ( $self->custnum ) {
1611 @cust_main_invoice =
1612 qsearch( 'cust_main_invoice', { 'custnum' => $self->custnum } );
1614 @cust_main_invoice = ();
1616 my %seen = map { $_->address => 1 } @cust_main_invoice;
1617 foreach my $address ( @{$arrayref} ) {
1618 next if exists $seen{$address} && $seen{$address};
1619 $seen{$address} = 1;
1620 my $cust_main_invoice = new FS::cust_main_invoice ( {
1621 'custnum' => $self->custnum,
1624 my $error = $cust_main_invoice->insert;
1625 warn $error if $error;
1628 if ( $self->custnum ) {
1630 qsearch( 'cust_main_invoice', { 'custnum' => $self->custnum } );
1636 =item check_invoicing_list ARRAYREF
1638 Checks these arguements as valid input for the invoicing_list method. If there
1639 is an error, returns the error, otherwise returns false.
1643 sub check_invoicing_list {
1644 my( $self, $arrayref ) = @_;
1645 foreach my $address ( @{$arrayref} ) {
1646 my $cust_main_invoice = new FS::cust_main_invoice ( {
1647 'custnum' => $self->custnum,
1650 my $error = $self->custnum
1651 ? $cust_main_invoice->check
1652 : $cust_main_invoice->checkdest
1654 return $error if $error;
1659 =item set_default_invoicing_list
1661 Sets the invoicing list to all accounts associated with this customer,
1662 overwriting any previous invoicing list.
1666 sub set_default_invoicing_list {
1668 $self->invoicing_list($self->all_emails);
1673 Returns the email addresses of all accounts provisioned for this customer.
1680 foreach my $cust_pkg ( $self->all_pkgs ) {
1681 my @cust_svc = qsearch('cust_svc', { 'pkgnum' => $cust_pkg->pkgnum } );
1683 map { qsearchs('svc_acct', { 'svcnum' => $_->svcnum } ) }
1684 grep { qsearchs('svc_acct', { 'svcnum' => $_->svcnum } ) }
1686 $list{$_}=1 foreach map { $_->email } @svc_acct;
1691 =item invoicing_list_addpost
1693 Adds postal invoicing to this customer. If this customer is already configured
1694 to receive postal invoices, does nothing.
1698 sub invoicing_list_addpost {
1700 return if grep { $_ eq 'POST' } $self->invoicing_list;
1701 my @invoicing_list = $self->invoicing_list;
1702 push @invoicing_list, 'POST';
1703 $self->invoicing_list(\@invoicing_list);
1706 =item referral_cust_main [ DEPTH [ EXCLUDE_HASHREF ] ]
1708 Returns an array of customers referred by this customer (referral_custnum set
1709 to this custnum). If DEPTH is given, recurses up to the given depth, returning
1710 customers referred by customers referred by this customer and so on, inclusive.
1711 The default behavior is DEPTH 1 (no recursion).
1715 sub referral_cust_main {
1717 my $depth = @_ ? shift : 1;
1718 my $exclude = @_ ? shift : {};
1721 map { $exclude->{$_->custnum}++; $_; }
1722 grep { ! $exclude->{ $_->custnum } }
1723 qsearch( 'cust_main', { 'referral_custnum' => $self->custnum } );
1727 map { $_->referral_cust_main($depth-1, $exclude) }
1734 =item referral_cust_main_ncancelled
1736 Same as referral_cust_main, except only returns customers with uncancelled
1741 sub referral_cust_main_ncancelled {
1743 grep { scalar($_->ncancelled_pkgs) } $self->referral_cust_main;
1746 =item referral_cust_pkg [ DEPTH ]
1748 Like referral_cust_main, except returns a flat list of all unsuspended (and
1749 uncancelled) packages for each customer. The number of items in this list may
1750 be useful for comission calculations (perhaps after a C<grep { my $pkgpart = $_->pkgpart; grep { $_ == $pkgpart } @commission_worthy_pkgparts> } $cust_main-> ).
1754 sub referral_cust_pkg {
1756 my $depth = @_ ? shift : 1;
1758 map { $_->unsuspended_pkgs }
1759 grep { $_->unsuspended_pkgs }
1760 $self->referral_cust_main($depth);
1763 =item credit AMOUNT, REASON
1765 Applies a credit to this customer. If there is an error, returns the error,
1766 otherwise returns false.
1771 my( $self, $amount, $reason ) = @_;
1772 my $cust_credit = new FS::cust_credit {
1773 'custnum' => $self->custnum,
1774 'amount' => $amount,
1775 'reason' => $reason,
1777 $cust_credit->insert;
1780 =item charge AMOUNT [ PKG [ COMMENT [ TAXCLASS ] ] ]
1782 Creates a one-time charge for this customer. If there is an error, returns
1783 the error, otherwise returns false.
1788 my ( $self, $amount ) = ( shift, shift );
1789 my $pkg = @_ ? shift : 'One-time charge';
1790 my $comment = @_ ? shift : '$'. sprintf("%.2f",$amount);
1791 my $taxclass = @_ ? shift : '';
1793 local $SIG{HUP} = 'IGNORE';
1794 local $SIG{INT} = 'IGNORE';
1795 local $SIG{QUIT} = 'IGNORE';
1796 local $SIG{TERM} = 'IGNORE';
1797 local $SIG{TSTP} = 'IGNORE';
1798 local $SIG{PIPE} = 'IGNORE';
1800 my $oldAutoCommit = $FS::UID::AutoCommit;
1801 local $FS::UID::AutoCommit = 0;
1804 my $part_pkg = new FS::part_pkg ( {
1806 'comment' => $comment,
1811 'taxclass' => $taxclass,
1814 my $error = $part_pkg->insert;
1816 $dbh->rollback if $oldAutoCommit;
1820 my $pkgpart = $part_pkg->pkgpart;
1821 my %type_pkgs = ( 'typenum' => $self->agent->typenum, 'pkgpart' => $pkgpart );
1822 unless ( qsearchs('type_pkgs', \%type_pkgs ) ) {
1823 my $type_pkgs = new FS::type_pkgs \%type_pkgs;
1824 $error = $type_pkgs->insert;
1826 $dbh->rollback if $oldAutoCommit;
1831 my $cust_pkg = new FS::cust_pkg ( {
1832 'custnum' => $self->custnum,
1833 'pkgpart' => $pkgpart,
1836 $error = $cust_pkg->insert;
1838 $dbh->rollback if $oldAutoCommit;
1842 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1849 Returns all the invoices (see L<FS::cust_bill>) for this customer.
1855 sort { $a->_date <=> $b->_date }
1856 qsearch('cust_bill', { 'custnum' => $self->custnum, } )
1859 =item open_cust_bill
1861 Returns all the open (owed > 0) invoices (see L<FS::cust_bill>) for this
1866 sub open_cust_bill {
1868 grep { $_->owed > 0 } $self->cust_bill;
1877 =item check_and_rebuild_fuzzyfiles
1881 sub check_and_rebuild_fuzzyfiles {
1882 my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
1883 -e "$dir/cust_main.last" && -e "$dir/cust_main.company"
1884 or &rebuild_fuzzyfiles;
1887 =item rebuild_fuzzyfiles
1891 sub rebuild_fuzzyfiles {
1893 use Fcntl qw(:flock);
1895 my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
1899 open(LASTLOCK,">>$dir/cust_main.last")
1900 or die "can't open $dir/cust_main.last: $!";
1901 flock(LASTLOCK,LOCK_EX)
1902 or die "can't lock $dir/cust_main.last: $!";
1904 my @all_last = map $_->getfield('last'), qsearch('cust_main', {});
1906 grep $_, map $_->getfield('ship_last'), qsearch('cust_main',{})
1907 if defined dbdef->table('cust_main')->column('ship_last');
1909 open (LASTCACHE,">$dir/cust_main.last.tmp")
1910 or die "can't open $dir/cust_main.last.tmp: $!";
1911 print LASTCACHE join("\n", @all_last), "\n";
1912 close LASTCACHE or die "can't close $dir/cust_main.last.tmp: $!";
1914 rename "$dir/cust_main.last.tmp", "$dir/cust_main.last";
1919 open(COMPANYLOCK,">>$dir/cust_main.company")
1920 or die "can't open $dir/cust_main.company: $!";
1921 flock(COMPANYLOCK,LOCK_EX)
1922 or die "can't lock $dir/cust_main.company: $!";
1924 my @all_company = grep $_ ne '', map $_->company, qsearch('cust_main',{});
1926 grep $_ ne '', map $_->ship_company, qsearch('cust_main', {})
1927 if defined dbdef->table('cust_main')->column('ship_last');
1929 open (COMPANYCACHE,">$dir/cust_main.company.tmp")
1930 or die "can't open $dir/cust_main.company.tmp: $!";
1931 print COMPANYCACHE join("\n", @all_company), "\n";
1932 close COMPANYCACHE or die "can't close $dir/cust_main.company.tmp: $!";
1934 rename "$dir/cust_main.company.tmp", "$dir/cust_main.company";
1944 my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
1945 open(LASTCACHE,"<$dir/cust_main.last")
1946 or die "can't open $dir/cust_main.last: $!";
1947 my @array = map { chomp; $_; } <LASTCACHE>;
1957 my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
1958 open(COMPANYCACHE,"<$dir/cust_main.company")
1959 or die "can't open $dir/cust_main.last: $!";
1960 my @array = map { chomp; $_; } <COMPANYCACHE>;
1965 =item append_fuzzyfiles LASTNAME COMPANY
1969 sub append_fuzzyfiles {
1970 my( $last, $company ) = @_;
1972 &check_and_rebuild_fuzzyfiles;
1974 use Fcntl qw(:flock);
1976 my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
1980 open(LAST,">>$dir/cust_main.last")
1981 or die "can't open $dir/cust_main.last: $!";
1983 or die "can't lock $dir/cust_main.last: $!";
1985 print LAST "$last\n";
1988 or die "can't unlock $dir/cust_main.last: $!";
1994 open(COMPANY,">>$dir/cust_main.company")
1995 or die "can't open $dir/cust_main.company: $!";
1996 flock(COMPANY,LOCK_EX)
1997 or die "can't lock $dir/cust_main.company: $!";
1999 print COMPANY "$company\n";
2001 flock(COMPANY,LOCK_UN)
2002 or die "can't unlock $dir/cust_main.company: $!";
2016 #warn join('-',keys %$param);
2017 my $fh = $param->{filehandle};
2018 my $agentnum = $param->{agentnum};
2019 my $refnum = $param->{refnum};
2020 my $pkgpart = $param->{pkgpart};
2021 my @fields = @{$param->{fields}};
2023 eval "use Date::Parse;";
2025 eval "use Text::CSV_XS;";
2028 my $csv = new Text::CSV_XS;
2035 local $SIG{HUP} = 'IGNORE';
2036 local $SIG{INT} = 'IGNORE';
2037 local $SIG{QUIT} = 'IGNORE';
2038 local $SIG{TERM} = 'IGNORE';
2039 local $SIG{TSTP} = 'IGNORE';
2040 local $SIG{PIPE} = 'IGNORE';
2042 my $oldAutoCommit = $FS::UID::AutoCommit;
2043 local $FS::UID::AutoCommit = 0;
2046 #while ( $columns = $csv->getline($fh) ) {
2048 while ( defined($line=<$fh>) ) {
2050 $csv->parse($line) or do {
2051 $dbh->rollback if $oldAutoCommit;
2052 return "can't parse: ". $csv->error_input();
2055 my @columns = $csv->fields();
2056 #warn join('-',@columns);
2059 agentnum => $agentnum,
2061 country => 'US', #default
2062 payby => 'BILL', #default
2063 paydate => '12/2037', #default
2065 my $billtime = time;
2066 my %cust_pkg = ( pkgpart => $pkgpart );
2067 foreach my $field ( @fields ) {
2068 if ( $field =~ /^cust_pkg\.(setup|bill|susp|expire|cancel)$/ ) {
2069 #$cust_pkg{$1} = str2time( shift @$columns );
2070 if ( $1 eq 'setup' ) {
2071 $billtime = str2time(shift @columns);
2073 $cust_pkg{$1} = str2time( shift @columns );
2076 #$cust_main{$field} = shift @$columns;
2077 $cust_main{$field} = shift @columns;
2081 my $cust_pkg = new FS::cust_pkg ( \%cust_pkg ) if $pkgpart;
2082 my $cust_main = new FS::cust_main ( \%cust_main );
2084 tie my %hash, 'Tie::RefHash'; #this part is important
2085 $hash{$cust_pkg} = [] if $pkgpart;
2086 my $error = $cust_main->insert( \%hash );
2089 $dbh->rollback if $oldAutoCommit;
2090 return "can't insert customer for $line: $error";
2093 #false laziness w/bill.cgi
2094 $error = $cust_main->bill( 'time' => $billtime );
2096 $dbh->rollback if $oldAutoCommit;
2097 return "can't bill customer for $line: $error";
2100 $cust_main->apply_payments;
2101 $cust_main->apply_credits;
2103 $error = $cust_main->collect();
2105 $dbh->rollback if $oldAutoCommit;
2106 return "can't collect customer for $line: $error";
2112 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2114 return "Empty file!" unless $imported;
2126 #warn join('-',keys %$param);
2127 my $fh = $param->{filehandle};
2128 my @fields = @{$param->{fields}};
2130 eval "use Date::Parse;";
2132 eval "use Text::CSV_XS;";
2135 my $csv = new Text::CSV_XS;
2142 local $SIG{HUP} = 'IGNORE';
2143 local $SIG{INT} = 'IGNORE';
2144 local $SIG{QUIT} = 'IGNORE';
2145 local $SIG{TERM} = 'IGNORE';
2146 local $SIG{TSTP} = 'IGNORE';
2147 local $SIG{PIPE} = 'IGNORE';
2149 my $oldAutoCommit = $FS::UID::AutoCommit;
2150 local $FS::UID::AutoCommit = 0;
2153 #while ( $columns = $csv->getline($fh) ) {
2155 while ( defined($line=<$fh>) ) {
2157 $csv->parse($line) or do {
2158 $dbh->rollback if $oldAutoCommit;
2159 return "can't parse: ". $csv->error_input();
2162 my @columns = $csv->fields();
2163 #warn join('-',@columns);
2166 foreach my $field ( @fields ) {
2167 $row{$field} = shift @columns;
2170 my $cust_main = qsearchs('cust_main', { 'custnum' => $row{'custnum'} } );
2171 unless ( $cust_main ) {
2172 $dbh->rollback if $oldAutoCommit;
2173 return "unknown custnum $row{'custnum'}";
2176 if ( $row{'amount'} > 0 ) {
2177 my $error = $cust_main->charge($row{'amount'}, $row{'pkg'});
2179 $dbh->rollback if $oldAutoCommit;
2183 } elsif ( $row{'amount'} < 0 ) {
2184 my $error = $cust_main->credit( sprintf( "%.2f", 0-$row{'amount'} ),
2187 $dbh->rollback if $oldAutoCommit;
2197 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2199 return "Empty file!" unless $imported;
2211 The delete method should possibly take an FS::cust_main object reference
2212 instead of a scalar customer number.
2214 Bill and collect options should probably be passed as references instead of a
2217 There should probably be a configuration file with a list of allowed credit
2220 No multiple currency support (probably a larger project than just this module).
2224 L<FS::Record>, L<FS::cust_pkg>, L<FS::cust_bill>, L<FS::cust_credit>
2225 L<FS::agent>, L<FS::part_referral>, L<FS::cust_main_county>,
2226 L<FS::cust_main_invoice>, L<FS::UID>, schema.html from the base documentation.