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_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<FS::cust_pkg/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 my $oldAutoCommit = $FS::UID::AutoCommit;
458 local $FS::UID::AutoCommit = 0;
461 my $error = $self->SUPER::replace($old);
464 $dbh->rollback if $oldAutoCommit;
468 if ( @param ) { # INVOICING_LIST_ARYREF
469 my $invoicing_list = shift @param;
470 $error = $self->check_invoicing_list( $invoicing_list );
472 $dbh->rollback if $oldAutoCommit;
475 $self->invoicing_list( $invoicing_list );
478 if ( $self->payby =~ /^(CARD|CHEK|LECB)$/ &&
479 grep { $self->get($_) ne $old->get($_) } qw(payinfo paydate payname) ) {
480 # card/check/lec info has changed, want to retry realtime_ invoice events
481 my $error = $self->retry_realtime;
483 $dbh->rollback if $oldAutoCommit;
488 $error = $self->queue_fuzzyfiles_update;
490 $dbh->rollback if $oldAutoCommit;
491 return "updating fuzzy search cache: $error";
494 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
499 =item queue_fuzzyfiles_update
501 Used by insert & replace to update the fuzzy search cache
505 sub queue_fuzzyfiles_update {
508 local $SIG{HUP} = 'IGNORE';
509 local $SIG{INT} = 'IGNORE';
510 local $SIG{QUIT} = 'IGNORE';
511 local $SIG{TERM} = 'IGNORE';
512 local $SIG{TSTP} = 'IGNORE';
513 local $SIG{PIPE} = 'IGNORE';
515 my $oldAutoCommit = $FS::UID::AutoCommit;
516 local $FS::UID::AutoCommit = 0;
519 my $queue = new FS::queue { 'job' => 'FS::cust_main::append_fuzzyfiles' };
520 my $error = $queue->insert($self->getfield('last'), $self->company);
522 $dbh->rollback if $oldAutoCommit;
523 return "queueing job (transaction rolled back): $error";
526 if ( defined $self->dbdef_table->column('ship_last') && $self->ship_last ) {
527 $queue = new FS::queue { 'job' => 'FS::cust_main::append_fuzzyfiles' };
528 $error = $queue->insert($self->getfield('ship_last'), $self->ship_company);
530 $dbh->rollback if $oldAutoCommit;
531 return "queueing job (transaction rolled back): $error";
535 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
542 Checks all fields to make sure this is a valid customer record. If there is
543 an error, returns the error, otherwise returns false. Called by the insert
551 #warn "BEFORE: \n". $self->_dump;
554 $self->ut_numbern('custnum')
555 || $self->ut_number('agentnum')
556 || $self->ut_number('refnum')
557 || $self->ut_name('last')
558 || $self->ut_name('first')
559 || $self->ut_textn('company')
560 || $self->ut_text('address1')
561 || $self->ut_textn('address2')
562 || $self->ut_text('city')
563 || $self->ut_textn('county')
564 || $self->ut_textn('state')
565 || $self->ut_country('country')
566 || $self->ut_anything('comments')
567 || $self->ut_numbern('referral_custnum')
569 #barf. need message catalogs. i18n. etc.
570 $error .= "Please select a advertising source."
571 if $error =~ /^Illegal or empty \(numeric\) refnum: /;
572 return $error if $error;
574 return "Unknown agent"
575 unless qsearchs( 'agent', { 'agentnum' => $self->agentnum } );
577 return "Unknown refnum"
578 unless qsearchs( 'part_referral', { 'refnum' => $self->refnum } );
580 return "Unknown referring custnum ". $self->referral_custnum
581 unless ! $self->referral_custnum
582 || qsearchs( 'cust_main', { 'custnum' => $self->referral_custnum } );
584 if ( $self->ss eq '' ) {
589 $ss =~ /^(\d{3})(\d{2})(\d{4})$/
590 or return "Illegal social security number: ". $self->ss;
591 $self->ss("$1-$2-$3");
595 # bad idea to disable, causes billing to fail because of no tax rates later
596 # unless ( $import ) {
597 unless ( qsearch('cust_main_county', {
598 'country' => $self->country,
601 return "Unknown state/county/country: ".
602 $self->state. "/". $self->county. "/". $self->country
603 unless qsearch('cust_main_county',{
604 'state' => $self->state,
605 'county' => $self->county,
606 'country' => $self->country,
612 $self->ut_phonen('daytime', $self->country)
613 || $self->ut_phonen('night', $self->country)
614 || $self->ut_phonen('fax', $self->country)
615 || $self->ut_zip('zip', $self->country)
617 return $error if $error;
620 last first company address1 address2 city county state zip
621 country daytime night fax
624 if ( defined $self->dbdef_table->column('ship_last') ) {
625 if ( scalar ( grep { $self->getfield($_) ne $self->getfield("ship_$_") }
627 && scalar ( grep { $self->getfield("ship_$_") ne '' } @addfields )
631 $self->ut_name('ship_last')
632 || $self->ut_name('ship_first')
633 || $self->ut_textn('ship_company')
634 || $self->ut_text('ship_address1')
635 || $self->ut_textn('ship_address2')
636 || $self->ut_text('ship_city')
637 || $self->ut_textn('ship_county')
638 || $self->ut_textn('ship_state')
639 || $self->ut_country('ship_country')
641 return $error if $error;
643 #false laziness with above
644 unless ( qsearchs('cust_main_county', {
645 'country' => $self->ship_country,
648 return "Unknown ship_state/ship_county/ship_country: ".
649 $self->ship_state. "/". $self->ship_county. "/". $self->ship_country
650 unless qsearchs('cust_main_county',{
651 'state' => $self->ship_state,
652 'county' => $self->ship_county,
653 'country' => $self->ship_country,
659 $self->ut_phonen('ship_daytime', $self->ship_country)
660 || $self->ut_phonen('ship_night', $self->ship_country)
661 || $self->ut_phonen('ship_fax', $self->ship_country)
662 || $self->ut_zip('ship_zip', $self->ship_country)
664 return $error if $error;
666 } else { # ship_ info eq billing info, so don't store dup info in database
667 $self->setfield("ship_$_", '')
668 foreach qw( last first company address1 address2 city county state zip
669 country daytime night fax );
673 $self->payby =~ /^(CARD|CHEK|LECB|BILL|COMP|PREPAY)$/
674 or return "Illegal payby: ". $self->payby;
677 if ( $self->payby eq 'CARD' ) {
679 my $payinfo = $self->payinfo;
681 $payinfo =~ /^(\d{13,16})$/
682 or return gettext('invalid_card'); # . ": ". $self->payinfo;
684 $self->payinfo($payinfo);
686 or return gettext('invalid_card'); # . ": ". $self->payinfo;
687 return gettext('unknown_card_type')
688 if cardtype($self->payinfo) eq "Unknown";
690 } elsif ( $self->payby eq 'CHEK' ) {
692 my $payinfo = $self->payinfo;
693 $payinfo =~ s/[^\d\@]//g;
694 $payinfo =~ /^(\d+)\@(\d{9})$/ or return 'invalid echeck account@aba';
696 $self->payinfo($payinfo);
698 } elsif ( $self->payby eq 'LECB' ) {
700 my $payinfo = $self->payinfo;
702 $payinfo =~ /^1?(\d{10})$/ or return 'invalid btn billing telephone number';
704 $self->payinfo($payinfo);
706 } elsif ( $self->payby eq 'BILL' ) {
708 $error = $self->ut_textn('payinfo');
709 return "Illegal P.O. number: ". $self->payinfo if $error;
711 } elsif ( $self->payby eq 'COMP' ) {
713 $error = $self->ut_textn('payinfo');
714 return "Illegal comp account issuer: ". $self->payinfo if $error;
716 } elsif ( $self->payby eq 'PREPAY' ) {
718 my $payinfo = $self->payinfo;
719 $payinfo =~ s/\W//g; #anything else would just confuse things
720 $self->payinfo($payinfo);
721 $error = $self->ut_alpha('payinfo');
722 return "Illegal prepayment identifier: ". $self->payinfo if $error;
723 return "Unknown prepayment identifier"
724 unless qsearchs('prepay_credit', { 'identifier' => $self->payinfo } );
728 if ( $self->paydate eq '' || $self->paydate eq '-' ) {
729 return "Expriation date required"
730 unless $self->payby =~ /^(BILL|PREPAY|CHEK|LECB)$/;
733 $self->paydate =~ /^(\d{1,2})[\/\-](\d{2}(\d{2})?)$/
734 or return "Illegal expiration date: ". $self->paydate;
735 my $y = length($2) == 4 ? $2 : "20$2";
736 $self->paydate("$y-$1-01");
737 my($nowm,$nowy)=(localtime(time))[4,5]; $nowm++; $nowy+=1900;
738 return gettext('expired_card')
739 if !$import && ( $y<$nowy || ( $y==$nowy && $1<$nowm ) );
742 if ( $self->payname eq '' && $self->payby ne 'CHEK' &&
743 ( ! $conf->exists('require_cardname') || $self->payby ne 'CARD' ) ) {
744 $self->payname( $self->first. " ". $self->getfield('last') );
746 $self->payname =~ /^([\w \,\.\-\']+)$/
747 or return gettext('illegal_name'). " payname: ". $self->payname;
751 $self->tax =~ /^(Y?)$/ or return "Illegal tax: ". $self->tax;
754 $self->otaker(getotaker);
756 #warn "AFTER: \n". $self->_dump;
763 Returns all packages (see L<FS::cust_pkg>) for this customer.
769 if ( $self->{'_pkgnum'} ) {
770 values %{ $self->{'_pkgnum'}->cache };
772 qsearch( 'cust_pkg', { 'custnum' => $self->custnum });
776 =item ncancelled_pkgs
778 Returns all non-cancelled packages (see L<FS::cust_pkg>) for this customer.
782 sub ncancelled_pkgs {
784 if ( $self->{'_pkgnum'} ) {
785 grep { ! $_->getfield('cancel') } values %{ $self->{'_pkgnum'}->cache };
787 @{ [ # force list context
788 qsearch( 'cust_pkg', {
789 'custnum' => $self->custnum,
792 qsearch( 'cust_pkg', {
793 'custnum' => $self->custnum,
802 Returns all suspended packages (see L<FS::cust_pkg>) for this customer.
808 grep { $_->susp } $self->ncancelled_pkgs;
811 =item unflagged_suspended_pkgs
813 Returns all unflagged suspended packages (see L<FS::cust_pkg>) for this
814 customer (thouse packages without the `manual_flag' set).
818 sub unflagged_suspended_pkgs {
820 return $self->suspended_pkgs
821 unless dbdef->table('cust_pkg')->column('manual_flag');
822 grep { ! $_->manual_flag } $self->suspended_pkgs;
825 =item unsuspended_pkgs
827 Returns all unsuspended (and uncancelled) packages (see L<FS::cust_pkg>) for
832 sub unsuspended_pkgs {
834 grep { ! $_->susp } $self->ncancelled_pkgs;
839 Unsuspends all unflagged suspended packages (see L</unflagged_suspended_pkgs>
840 and L<FS::cust_pkg>) for this customer. Always returns a list: an empty list
841 on success or a list of errors.
847 grep { $_->unsuspend } $self->suspended_pkgs;
852 Suspends all unsuspended packages (see L<FS::cust_pkg>) for this customer.
853 Always returns a list: an empty list on success or a list of errors.
859 grep { $_->suspend } $self->unsuspended_pkgs;
864 Cancels all uncancelled packages (see L<FS::cust_pkg>) for this customer.
865 Always returns a list: an empty list on success or a list of errors.
871 grep { $_->cancel } $self->ncancelled_pkgs;
876 Returns the agent (see L<FS::agent>) for this customer.
882 qsearchs( 'agent', { 'agentnum' => $self->agentnum } );
887 Generates invoices (see L<FS::cust_bill>) for this customer. Usually used in
888 conjunction with the collect method.
890 Options are passed as name-value pairs.
892 The only currently available option is `time', which bills the customer as if
893 it were that time. It is specified as a UNIX timestamp; see
894 L<perlfunc/"time">). Also see L<Time::Local> and L<Date::Parse> for conversion
895 functions. For example:
899 $cust_main->bill( 'time' => str2time('April 20th, 2001') );
901 If there is an error, returns the error, otherwise returns false.
906 my( $self, %options ) = @_;
907 my $time = $options{'time'} || time;
912 local $SIG{HUP} = 'IGNORE';
913 local $SIG{INT} = 'IGNORE';
914 local $SIG{QUIT} = 'IGNORE';
915 local $SIG{TERM} = 'IGNORE';
916 local $SIG{TSTP} = 'IGNORE';
917 local $SIG{PIPE} = 'IGNORE';
919 my $oldAutoCommit = $FS::UID::AutoCommit;
920 local $FS::UID::AutoCommit = 0;
923 # find the packages which are due for billing, find out how much they are
924 # & generate invoice database.
926 my( $total_setup, $total_recur ) = ( 0, 0 );
927 #my( $taxable_setup, $taxable_recur ) = ( 0, 0 );
928 my @cust_bill_pkg = ();
930 #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;
953 unless ( $cust_pkg->setup ) {
954 my $setup_prog = $part_pkg->getfield('setup');
955 $setup_prog =~ /^(.*)$/ or do {
956 $dbh->rollback if $oldAutoCommit;
957 return "Illegal setup for pkgpart ". $part_pkg->pkgpart.
961 $setup_prog = '0' if $setup_prog =~ /^\s*$/;
964 ##$cpt->permit(); #what is necessary?
965 #$cpt->share(qw( $cust_pkg )); #can $cpt now use $cust_pkg methods?
966 #$setup = $cpt->reval($setup_prog);
967 $setup = eval $setup_prog;
968 unless ( defined($setup) ) {
969 $dbh->rollback if $oldAutoCommit;
970 return "Error eval-ing part_pkg->setup pkgpart ". $part_pkg->pkgpart.
971 "(expression $setup_prog): $@";
973 $cust_pkg->setfield('setup',$time);
974 $cust_pkg_mod_flag=1;
980 if ( $part_pkg->getfield('freq') > 0 &&
981 ! $cust_pkg->getfield('susp') &&
982 ( $cust_pkg->getfield('bill') || 0 ) <= $time
984 my $recur_prog = $part_pkg->getfield('recur');
985 $recur_prog =~ /^(.*)$/ or do {
986 $dbh->rollback if $oldAutoCommit;
987 return "Illegal recur for pkgpart ". $part_pkg->pkgpart.
991 $recur_prog = '0' if $recur_prog =~ /^\s*$/;
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_nocheck($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,
1054 push @cust_bill_pkg, $cust_bill_pkg;
1055 $total_setup += $setup;
1056 $total_recur += $recur;
1057 $taxable_charged += $setup
1058 unless $part_pkg->setuptax =~ /^Y$/i;
1059 $taxable_charged += $recur
1060 unless $part_pkg->recurtax =~ /^Y$/i;
1062 unless ( $self->tax =~ /Y/i
1063 || $self->payby eq 'COMP'
1064 || $taxable_charged == 0 ) {
1066 my $cust_main_county = qsearchs('cust_main_county',{
1067 'state' => $self->state,
1068 'county' => $self->county,
1069 'country' => $self->country,
1070 'taxclass' => $part_pkg->taxclass,
1072 $cust_main_county ||= qsearchs('cust_main_county',{
1073 'state' => $self->state,
1074 'county' => $self->county,
1075 'country' => $self->country,
1078 unless ( $cust_main_county ) {
1079 $dbh->rollback if $oldAutoCommit;
1081 "fatal: can't find tax rate for state/county/country/taxclass ".
1082 join('/', ( map $self->$_(), qw(state county country) ),
1083 $part_pkg->taxclass ). "\n";
1086 if ( $cust_main_county->exempt_amount ) {
1087 my ($mon,$year) = (localtime($sdate) )[4,5];
1089 my $freq = $part_pkg->freq || 1;
1090 my $taxable_per_month = sprintf("%.2f", $taxable_charged / $freq );
1091 foreach my $which_month ( 1 .. $freq ) {
1093 'custnum' => $self->custnum,
1094 'taxnum' => $cust_main_county->taxnum,
1095 'year' => 1900+$year,
1098 #until ( $mon < 12 ) { $mon -= 12; $year++; }
1099 until ( $mon < 13 ) { $mon -= 12; $year++; }
1100 my $cust_tax_exempt =
1101 qsearchs('cust_tax_exempt', \%hash)
1102 || new FS::cust_tax_exempt( { %hash, 'amount' => 0 } );
1103 my $remaining_exemption = sprintf("%.2f",
1104 $cust_main_county->exempt_amount - $cust_tax_exempt->amount );
1105 if ( $remaining_exemption > 0 ) {
1106 my $addl = $remaining_exemption > $taxable_per_month
1107 ? $taxable_per_month
1108 : $remaining_exemption;
1109 $taxable_charged -= $addl;
1110 my $new_cust_tax_exempt = new FS::cust_tax_exempt ( {
1111 $cust_tax_exempt->hash,
1112 'amount' => sprintf("%.2f", $cust_tax_exempt->amount + $addl),
1114 $error = $new_cust_tax_exempt->exemptnum
1115 ? $new_cust_tax_exempt->replace($cust_tax_exempt)
1116 : $new_cust_tax_exempt->insert;
1118 $dbh->rollback if $oldAutoCommit;
1119 return "fatal: can't update cust_tax_exempt: $error";
1122 } # if $remaining_exemption > 0
1124 } #foreach $which_month
1126 } #if $cust_main_county->exempt_amount
1128 $taxable_charged = sprintf( "%.2f", $taxable_charged);
1129 $tax += $taxable_charged * $cust_main_county->tax / 100
1131 } #unless $self->tax =~ /Y/i
1132 # || $self->payby eq 'COMP'
1133 # || $taxable_charged == 0
1135 } #if $setup > 0 || $recur > 0
1137 } #if $cust_pkg_mod_flag
1139 } #foreach my $cust_pkg
1141 my $charged = sprintf( "%.2f", $total_setup + $total_recur );
1142 # my $taxable_charged = sprintf( "%.2f", $taxable_setup + $taxable_recur );
1144 unless ( @cust_bill_pkg ) { #don't create invoices with no line items
1145 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1149 # unless ( $self->tax =~ /Y/i
1150 # || $self->payby eq 'COMP'
1151 # || $taxable_charged == 0 ) {
1152 # my $cust_main_county = qsearchs('cust_main_county',{
1153 # 'state' => $self->state,
1154 # 'county' => $self->county,
1155 # 'country' => $self->country,
1156 # } ) or die "fatal: can't find tax rate for state/county/country ".
1157 # $self->state. "/". $self->county. "/". $self->country. "\n";
1158 # my $tax = sprintf( "%.2f",
1159 # $taxable_charged * ( $cust_main_county->getfield('tax') / 100 )
1162 $tax = sprintf("%.2f", $tax);
1164 $charged = sprintf( "%.2f", $charged+$tax );
1166 my $cust_bill_pkg = new FS::cust_bill_pkg ({
1173 push @cust_bill_pkg, $cust_bill_pkg;
1177 my $cust_bill = new FS::cust_bill ( {
1178 'custnum' => $self->custnum,
1180 'charged' => $charged,
1182 $error = $cust_bill->insert;
1184 $dbh->rollback if $oldAutoCommit;
1185 return "can't create invoice for customer #". $self->custnum. ": $error";
1188 my $invnum = $cust_bill->invnum;
1190 foreach $cust_bill_pkg ( @cust_bill_pkg ) {
1192 $cust_bill_pkg->invnum($invnum);
1193 $error = $cust_bill_pkg->insert;
1195 $dbh->rollback if $oldAutoCommit;
1196 return "can't create invoice line item for customer #". $self->custnum.
1201 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1205 =item collect OPTIONS
1207 (Attempt to) collect money for this customer's outstanding invoices (see
1208 L<FS::cust_bill>). Usually used after the bill method.
1210 Depending on the value of `payby', this may print an invoice (`BILL'), charge
1211 a credit card (`CARD'), or just add any necessary (pseudo-)payment (`COMP').
1213 Most actions are now triggered by invoice events; see L<FS::part_bill_event>
1214 and the invoice events web interface.
1216 If there is an error, returns the error, otherwise returns false.
1218 Options are passed as name-value pairs.
1220 Currently available options are:
1222 invoice_time - Use this time when deciding when to print invoices and
1223 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>
1224 for conversion functions.
1226 retry - Retry card/echeck/LEC transactions even when not scheduled by invoice
1229 retry_card - Deprecated alias for 'retry'
1231 batch_card - This option is deprecated. See the invoice events web interface
1232 to control whether cards are batched or run against a realtime gateway.
1234 report_badcard - This option is deprecated.
1236 force_print - This option is deprecated; see the invoice events web interface.
1241 my( $self, %options ) = @_;
1242 my $invoice_time = $options{'invoice_time'} || time;
1245 local $SIG{HUP} = 'IGNORE';
1246 local $SIG{INT} = 'IGNORE';
1247 local $SIG{QUIT} = 'IGNORE';
1248 local $SIG{TERM} = 'IGNORE';
1249 local $SIG{TSTP} = 'IGNORE';
1250 local $SIG{PIPE} = 'IGNORE';
1252 my $oldAutoCommit = $FS::UID::AutoCommit;
1253 local $FS::UID::AutoCommit = 0;
1256 my $balance = $self->balance;
1257 warn "collect customer". $self->custnum. ": balance $balance" if $Debug;
1258 unless ( $balance > 0 ) { #redundant?????
1259 $dbh->rollback if $oldAutoCommit; #hmm
1263 if ( exists($options{'retry_card'}) ) {
1264 carp 'retry_card option passed to collect is deprecated; use retry';
1265 $options{'retry'} ||= $options{'retry_card'};
1267 if ( exists($options{'retry'}) && $options{'retry'} ) {
1268 my $error = $self->retry_realtime;
1270 $dbh->rollback if $oldAutoCommit;
1275 foreach my $cust_bill ( $self->cust_bill ) {
1277 #this has to be before next's
1278 my $amount = sprintf( "%.2f", $balance < $cust_bill->owed
1282 $balance = sprintf( "%.2f", $balance - $amount );
1284 next unless $cust_bill->owed > 0;
1286 # don't try to charge for the same invoice if it's already in a batch
1287 #next if qsearchs( 'cust_pay_batch', { 'invnum' => $cust_bill->invnum } );
1289 warn "invnum ". $cust_bill->invnum. " (owed ". $cust_bill->owed. ", amount $amount, balance $balance)" if $Debug;
1291 next unless $amount > 0;
1294 foreach my $part_bill_event (
1295 sort { $a->seconds <=> $b->seconds
1296 || $a->weight <=> $b->weight
1297 || $a->eventpart <=> $b->eventpart }
1298 grep { $_->seconds <= ( $invoice_time - $cust_bill->_date )
1299 && ! qsearchs( 'cust_bill_event', {
1300 'invnum' => $cust_bill->invnum,
1301 'eventpart' => $_->eventpart,
1305 qsearch('part_bill_event', { 'payby' => $self->payby,
1306 'disabled' => '', } )
1309 last unless $cust_bill->owed > 0; #don't run subsequent events if owed=0
1311 warn "calling invoice event (". $part_bill_event->eventcode. ")\n"
1313 my $cust_main = $self; #for callback
1314 my $error = eval $part_bill_event->eventcode;
1317 my $statustext = '';
1321 } elsif ( $error ) {
1323 $statustext = $error;
1328 #add cust_bill_event
1329 my $cust_bill_event = new FS::cust_bill_event {
1330 'invnum' => $cust_bill->invnum,
1331 'eventpart' => $part_bill_event->eventpart,
1332 #'_date' => $invoice_time,
1334 'status' => $status,
1335 'statustext' => $statustext,
1337 $error = $cust_bill_event->insert;
1339 #$dbh->rollback if $oldAutoCommit;
1340 #return "error: $error";
1342 # gah, even with transactions.
1343 $dbh->commit if $oldAutoCommit; #well.
1344 my $e = 'WARNING: Event run but database not updated - '.
1345 'error inserting cust_bill_event, invnum #'. $cust_bill->invnum.
1346 ', eventpart '. $part_bill_event->eventpart.
1357 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1362 =item retry_realtime
1364 Schedules realtime credit card / electronic check / LEC billing events for
1365 for retry. Useful if card information has changed or manual retry is desired.
1366 The 'collect' method must be called to actually retry the transaction.
1368 Implementation details: For each of this customer's open invoices, changes
1369 the status of the first "done" (with statustext error) realtime processing
1374 sub retry_realtime {
1377 local $SIG{HUP} = 'IGNORE';
1378 local $SIG{INT} = 'IGNORE';
1379 local $SIG{QUIT} = 'IGNORE';
1380 local $SIG{TERM} = 'IGNORE';
1381 local $SIG{TSTP} = 'IGNORE';
1382 local $SIG{PIPE} = 'IGNORE';
1384 my $oldAutoCommit = $FS::UID::AutoCommit;
1385 local $FS::UID::AutoCommit = 0;
1388 foreach my $cust_bill (
1389 grep { $_->cust_bill_event }
1390 $self->open_cust_bill
1392 my @cust_bill_event =
1393 sort { $a->part_bill_event->seconds <=> $b->part_bill_event->seconds }
1395 #$_->part_bill_event->plan eq 'realtime-card'
1396 $_->part_bill_event->eventcode =~
1397 /\$cust_bill\->realtime_(card|ach|lec)$/
1398 && $_->status eq 'done'
1401 $_->cust_bill_event;
1402 next unless @cust_bill_event;
1403 my $error = $cust_bill_event[0]->retry;
1405 $dbh->rollback if $oldAutoCommit;
1406 return "error scheduling invoice event for retry: $error";
1411 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1418 Returns the total owed for this customer on all invoices
1419 (see L<FS::cust_bill/owed>).
1425 $self->total_owed_date(2145859200); #12/31/2037
1428 =item total_owed_date TIME
1430 Returns the total owed for this customer on all invoices with date earlier than
1431 TIME. TIME is specified as a UNIX timestamp; see L<perlfunc/"time">). Also
1432 see L<Time::Local> and L<Date::Parse> for conversion functions.
1436 sub total_owed_date {
1440 foreach my $cust_bill (
1441 grep { $_->_date <= $time }
1442 qsearch('cust_bill', { 'custnum' => $self->custnum, } )
1444 $total_bill += $cust_bill->owed;
1446 sprintf( "%.2f", $total_bill );
1451 Applies (see L<FS::cust_credit_bill>) unapplied credits (see L<FS::cust_credit>)
1452 to outstanding invoice balances in chronological order and returns the value
1453 of any remaining unapplied credits available for refund
1454 (see L<FS::cust_refund>).
1461 return 0 unless $self->total_credited;
1463 my @credits = sort { $b->_date <=> $a->_date} (grep { $_->credited > 0 }
1464 qsearch('cust_credit', { 'custnum' => $self->custnum } ) );
1466 my @invoices = sort { $a->_date <=> $b->_date} (grep { $_->owed > 0 }
1467 qsearch('cust_bill', { 'custnum' => $self->custnum } ) );
1471 foreach my $cust_bill ( @invoices ) {
1474 if ( !defined($credit) || $credit->credited == 0) {
1475 $credit = pop @credits or last;
1478 if ($cust_bill->owed >= $credit->credited) {
1479 $amount=$credit->credited;
1481 $amount=$cust_bill->owed;
1484 my $cust_credit_bill = new FS::cust_credit_bill ( {
1485 'crednum' => $credit->crednum,
1486 'invnum' => $cust_bill->invnum,
1487 'amount' => $amount,
1489 my $error = $cust_credit_bill->insert;
1490 die $error if $error;
1492 redo if ($cust_bill->owed > 0);
1496 return $self->total_credited;
1499 =item apply_payments
1501 Applies (see L<FS::cust_bill_pay>) unapplied payments (see L<FS::cust_pay>)
1502 to outstanding invoice balances in chronological order.
1504 #and returns the value of any remaining unapplied payments.
1508 sub apply_payments {
1513 my @payments = sort { $b->_date <=> $a->_date } ( grep { $_->unapplied > 0 }
1514 qsearch('cust_pay', { 'custnum' => $self->custnum } ) );
1516 my @invoices = sort { $a->_date <=> $b->_date} (grep { $_->owed > 0 }
1517 qsearch('cust_bill', { 'custnum' => $self->custnum } ) );
1521 foreach my $cust_bill ( @invoices ) {
1524 if ( !defined($payment) || $payment->unapplied == 0 ) {
1525 $payment = pop @payments or last;
1528 if ( $cust_bill->owed >= $payment->unapplied ) {
1529 $amount = $payment->unapplied;
1531 $amount = $cust_bill->owed;
1534 my $cust_bill_pay = new FS::cust_bill_pay ( {
1535 'paynum' => $payment->paynum,
1536 'invnum' => $cust_bill->invnum,
1537 'amount' => $amount,
1539 my $error = $cust_bill_pay->insert;
1540 die $error if $error;
1542 redo if ( $cust_bill->owed > 0);
1546 return $self->total_unapplied_payments;
1549 =item total_credited
1551 Returns the total outstanding credit (see L<FS::cust_credit>) for this
1552 customer. See L<FS::cust_credit/credited>.
1556 sub total_credited {
1558 my $total_credit = 0;
1559 foreach my $cust_credit ( qsearch('cust_credit', {
1560 'custnum' => $self->custnum,
1562 $total_credit += $cust_credit->credited;
1564 sprintf( "%.2f", $total_credit );
1567 =item total_unapplied_payments
1569 Returns the total unapplied payments (see L<FS::cust_pay>) for this customer.
1570 See L<FS::cust_pay/unapplied>.
1574 sub total_unapplied_payments {
1576 my $total_unapplied = 0;
1577 foreach my $cust_pay ( qsearch('cust_pay', {
1578 'custnum' => $self->custnum,
1580 $total_unapplied += $cust_pay->unapplied;
1582 sprintf( "%.2f", $total_unapplied );
1587 Returns the balance for this customer (total_owed minus total_credited
1588 minus total_unapplied_payments).
1595 $self->total_owed - $self->total_credited - $self->total_unapplied_payments
1599 =item balance_date TIME
1601 Returns the balance for this customer, only considering invoices with date
1602 earlier than TIME (total_owed_date minus total_credited minus
1603 total_unapplied_payments). TIME is specified as a UNIX timestamp; see
1604 L<perlfunc/"time">). Also see L<Time::Local> and L<Date::Parse> for conversion
1613 $self->total_owed_date($time)
1614 - $self->total_credited
1615 - $self->total_unapplied_payments
1619 =item invoicing_list [ ARRAYREF ]
1621 If an arguement is given, sets these email addresses as invoice recipients
1622 (see L<FS::cust_main_invoice>). Errors are not fatal and are not reported
1623 (except as warnings), so use check_invoicing_list first.
1625 Returns a list of email addresses (with svcnum entries expanded).
1627 Note: You can clear the invoicing list by passing an empty ARRAYREF. You can
1628 check it without disturbing anything by passing nothing.
1630 This interface may change in the future.
1634 sub invoicing_list {
1635 my( $self, $arrayref ) = @_;
1637 my @cust_main_invoice;
1638 if ( $self->custnum ) {
1639 @cust_main_invoice =
1640 qsearch( 'cust_main_invoice', { 'custnum' => $self->custnum } );
1642 @cust_main_invoice = ();
1644 foreach my $cust_main_invoice ( @cust_main_invoice ) {
1645 #warn $cust_main_invoice->destnum;
1646 unless ( grep { $cust_main_invoice->address eq $_ } @{$arrayref} ) {
1647 #warn $cust_main_invoice->destnum;
1648 my $error = $cust_main_invoice->delete;
1649 warn $error if $error;
1652 if ( $self->custnum ) {
1653 @cust_main_invoice =
1654 qsearch( 'cust_main_invoice', { 'custnum' => $self->custnum } );
1656 @cust_main_invoice = ();
1658 my %seen = map { $_->address => 1 } @cust_main_invoice;
1659 foreach my $address ( @{$arrayref} ) {
1660 next if exists $seen{$address} && $seen{$address};
1661 $seen{$address} = 1;
1662 my $cust_main_invoice = new FS::cust_main_invoice ( {
1663 'custnum' => $self->custnum,
1666 my $error = $cust_main_invoice->insert;
1667 warn $error if $error;
1670 if ( $self->custnum ) {
1672 qsearch( 'cust_main_invoice', { 'custnum' => $self->custnum } );
1678 =item check_invoicing_list ARRAYREF
1680 Checks these arguements as valid input for the invoicing_list method. If there
1681 is an error, returns the error, otherwise returns false.
1685 sub check_invoicing_list {
1686 my( $self, $arrayref ) = @_;
1687 foreach my $address ( @{$arrayref} ) {
1688 my $cust_main_invoice = new FS::cust_main_invoice ( {
1689 'custnum' => $self->custnum,
1692 my $error = $self->custnum
1693 ? $cust_main_invoice->check
1694 : $cust_main_invoice->checkdest
1696 return $error if $error;
1701 =item set_default_invoicing_list
1703 Sets the invoicing list to all accounts associated with this customer,
1704 overwriting any previous invoicing list.
1708 sub set_default_invoicing_list {
1710 $self->invoicing_list($self->all_emails);
1715 Returns the email addresses of all accounts provisioned for this customer.
1722 foreach my $cust_pkg ( $self->all_pkgs ) {
1723 my @cust_svc = qsearch('cust_svc', { 'pkgnum' => $cust_pkg->pkgnum } );
1725 map { qsearchs('svc_acct', { 'svcnum' => $_->svcnum } ) }
1726 grep { qsearchs('svc_acct', { 'svcnum' => $_->svcnum } ) }
1728 $list{$_}=1 foreach map { $_->email } @svc_acct;
1733 =item invoicing_list_addpost
1735 Adds postal invoicing to this customer. If this customer is already configured
1736 to receive postal invoices, does nothing.
1740 sub invoicing_list_addpost {
1742 return if grep { $_ eq 'POST' } $self->invoicing_list;
1743 my @invoicing_list = $self->invoicing_list;
1744 push @invoicing_list, 'POST';
1745 $self->invoicing_list(\@invoicing_list);
1748 =item referral_cust_main [ DEPTH [ EXCLUDE_HASHREF ] ]
1750 Returns an array of customers referred by this customer (referral_custnum set
1751 to this custnum). If DEPTH is given, recurses up to the given depth, returning
1752 customers referred by customers referred by this customer and so on, inclusive.
1753 The default behavior is DEPTH 1 (no recursion).
1757 sub referral_cust_main {
1759 my $depth = @_ ? shift : 1;
1760 my $exclude = @_ ? shift : {};
1763 map { $exclude->{$_->custnum}++; $_; }
1764 grep { ! $exclude->{ $_->custnum } }
1765 qsearch( 'cust_main', { 'referral_custnum' => $self->custnum } );
1769 map { $_->referral_cust_main($depth-1, $exclude) }
1776 =item referral_cust_main_ncancelled
1778 Same as referral_cust_main, except only returns customers with uncancelled
1783 sub referral_cust_main_ncancelled {
1785 grep { scalar($_->ncancelled_pkgs) } $self->referral_cust_main;
1788 =item referral_cust_pkg [ DEPTH ]
1790 Like referral_cust_main, except returns a flat list of all unsuspended (and
1791 uncancelled) packages for each customer. The number of items in this list may
1792 be useful for comission calculations (perhaps after a C<grep { my $pkgpart = $_->pkgpart; grep { $_ == $pkgpart } @commission_worthy_pkgparts> } $cust_main-> ).
1796 sub referral_cust_pkg {
1798 my $depth = @_ ? shift : 1;
1800 map { $_->unsuspended_pkgs }
1801 grep { $_->unsuspended_pkgs }
1802 $self->referral_cust_main($depth);
1805 =item credit AMOUNT, REASON
1807 Applies a credit to this customer. If there is an error, returns the error,
1808 otherwise returns false.
1813 my( $self, $amount, $reason ) = @_;
1814 my $cust_credit = new FS::cust_credit {
1815 'custnum' => $self->custnum,
1816 'amount' => $amount,
1817 'reason' => $reason,
1819 $cust_credit->insert;
1822 =item charge AMOUNT [ PKG [ COMMENT [ TAXCLASS ] ] ]
1824 Creates a one-time charge for this customer. If there is an error, returns
1825 the error, otherwise returns false.
1830 my ( $self, $amount ) = ( shift, shift );
1831 my $pkg = @_ ? shift : 'One-time charge';
1832 my $comment = @_ ? shift : '$'. sprintf("%.2f",$amount);
1833 my $taxclass = @_ ? shift : '';
1835 local $SIG{HUP} = 'IGNORE';
1836 local $SIG{INT} = 'IGNORE';
1837 local $SIG{QUIT} = 'IGNORE';
1838 local $SIG{TERM} = 'IGNORE';
1839 local $SIG{TSTP} = 'IGNORE';
1840 local $SIG{PIPE} = 'IGNORE';
1842 my $oldAutoCommit = $FS::UID::AutoCommit;
1843 local $FS::UID::AutoCommit = 0;
1846 my $part_pkg = new FS::part_pkg ( {
1848 'comment' => $comment,
1853 'taxclass' => $taxclass,
1856 my $error = $part_pkg->insert;
1858 $dbh->rollback if $oldAutoCommit;
1862 my $pkgpart = $part_pkg->pkgpart;
1863 my %type_pkgs = ( 'typenum' => $self->agent->typenum, 'pkgpart' => $pkgpart );
1864 unless ( qsearchs('type_pkgs', \%type_pkgs ) ) {
1865 my $type_pkgs = new FS::type_pkgs \%type_pkgs;
1866 $error = $type_pkgs->insert;
1868 $dbh->rollback if $oldAutoCommit;
1873 my $cust_pkg = new FS::cust_pkg ( {
1874 'custnum' => $self->custnum,
1875 'pkgpart' => $pkgpart,
1878 $error = $cust_pkg->insert;
1880 $dbh->rollback if $oldAutoCommit;
1884 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1891 Returns all the invoices (see L<FS::cust_bill>) for this customer.
1897 sort { $a->_date <=> $b->_date }
1898 qsearch('cust_bill', { 'custnum' => $self->custnum, } )
1901 =item open_cust_bill
1903 Returns all the open (owed > 0) invoices (see L<FS::cust_bill>) for this
1908 sub open_cust_bill {
1910 grep { $_->owed > 0 } $self->cust_bill;
1919 =item check_and_rebuild_fuzzyfiles
1923 sub check_and_rebuild_fuzzyfiles {
1924 my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
1925 -e "$dir/cust_main.last" && -e "$dir/cust_main.company"
1926 or &rebuild_fuzzyfiles;
1929 =item rebuild_fuzzyfiles
1933 sub rebuild_fuzzyfiles {
1935 use Fcntl qw(:flock);
1937 my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
1941 open(LASTLOCK,">>$dir/cust_main.last")
1942 or die "can't open $dir/cust_main.last: $!";
1943 flock(LASTLOCK,LOCK_EX)
1944 or die "can't lock $dir/cust_main.last: $!";
1946 my @all_last = map $_->getfield('last'), qsearch('cust_main', {});
1948 grep $_, map $_->getfield('ship_last'), qsearch('cust_main',{})
1949 if defined dbdef->table('cust_main')->column('ship_last');
1951 open (LASTCACHE,">$dir/cust_main.last.tmp")
1952 or die "can't open $dir/cust_main.last.tmp: $!";
1953 print LASTCACHE join("\n", @all_last), "\n";
1954 close LASTCACHE or die "can't close $dir/cust_main.last.tmp: $!";
1956 rename "$dir/cust_main.last.tmp", "$dir/cust_main.last";
1961 open(COMPANYLOCK,">>$dir/cust_main.company")
1962 or die "can't open $dir/cust_main.company: $!";
1963 flock(COMPANYLOCK,LOCK_EX)
1964 or die "can't lock $dir/cust_main.company: $!";
1966 my @all_company = grep $_ ne '', map $_->company, qsearch('cust_main',{});
1968 grep $_ ne '', map $_->ship_company, qsearch('cust_main', {})
1969 if defined dbdef->table('cust_main')->column('ship_last');
1971 open (COMPANYCACHE,">$dir/cust_main.company.tmp")
1972 or die "can't open $dir/cust_main.company.tmp: $!";
1973 print COMPANYCACHE join("\n", @all_company), "\n";
1974 close COMPANYCACHE or die "can't close $dir/cust_main.company.tmp: $!";
1976 rename "$dir/cust_main.company.tmp", "$dir/cust_main.company";
1986 my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
1987 open(LASTCACHE,"<$dir/cust_main.last")
1988 or die "can't open $dir/cust_main.last: $!";
1989 my @array = map { chomp; $_; } <LASTCACHE>;
1999 my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
2000 open(COMPANYCACHE,"<$dir/cust_main.company")
2001 or die "can't open $dir/cust_main.last: $!";
2002 my @array = map { chomp; $_; } <COMPANYCACHE>;
2007 =item append_fuzzyfiles LASTNAME COMPANY
2011 sub append_fuzzyfiles {
2012 my( $last, $company ) = @_;
2014 &check_and_rebuild_fuzzyfiles;
2016 use Fcntl qw(:flock);
2018 my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
2022 open(LAST,">>$dir/cust_main.last")
2023 or die "can't open $dir/cust_main.last: $!";
2025 or die "can't lock $dir/cust_main.last: $!";
2027 print LAST "$last\n";
2030 or die "can't unlock $dir/cust_main.last: $!";
2036 open(COMPANY,">>$dir/cust_main.company")
2037 or die "can't open $dir/cust_main.company: $!";
2038 flock(COMPANY,LOCK_EX)
2039 or die "can't lock $dir/cust_main.company: $!";
2041 print COMPANY "$company\n";
2043 flock(COMPANY,LOCK_UN)
2044 or die "can't unlock $dir/cust_main.company: $!";
2058 #warn join('-',keys %$param);
2059 my $fh = $param->{filehandle};
2060 my $agentnum = $param->{agentnum};
2061 my $refnum = $param->{refnum};
2062 my $pkgpart = $param->{pkgpart};
2063 my @fields = @{$param->{fields}};
2065 eval "use Date::Parse;";
2067 eval "use Text::CSV_XS;";
2070 my $csv = new Text::CSV_XS;
2077 local $SIG{HUP} = 'IGNORE';
2078 local $SIG{INT} = 'IGNORE';
2079 local $SIG{QUIT} = 'IGNORE';
2080 local $SIG{TERM} = 'IGNORE';
2081 local $SIG{TSTP} = 'IGNORE';
2082 local $SIG{PIPE} = 'IGNORE';
2084 my $oldAutoCommit = $FS::UID::AutoCommit;
2085 local $FS::UID::AutoCommit = 0;
2088 #while ( $columns = $csv->getline($fh) ) {
2090 while ( defined($line=<$fh>) ) {
2092 $csv->parse($line) or do {
2093 $dbh->rollback if $oldAutoCommit;
2094 return "can't parse: ". $csv->error_input();
2097 my @columns = $csv->fields();
2098 #warn join('-',@columns);
2101 agentnum => $agentnum,
2103 country => 'US', #default
2104 payby => 'BILL', #default
2105 paydate => '12/2037', #default
2107 my $billtime = time;
2108 my %cust_pkg = ( pkgpart => $pkgpart );
2109 foreach my $field ( @fields ) {
2110 if ( $field =~ /^cust_pkg\.(setup|bill|susp|expire|cancel)$/ ) {
2111 #$cust_pkg{$1} = str2time( shift @$columns );
2112 if ( $1 eq 'setup' ) {
2113 $billtime = str2time(shift @columns);
2115 $cust_pkg{$1} = str2time( shift @columns );
2118 #$cust_main{$field} = shift @$columns;
2119 $cust_main{$field} = shift @columns;
2123 my $cust_pkg = new FS::cust_pkg ( \%cust_pkg ) if $pkgpart;
2124 my $cust_main = new FS::cust_main ( \%cust_main );
2126 tie my %hash, 'Tie::RefHash'; #this part is important
2127 $hash{$cust_pkg} = [] if $pkgpart;
2128 my $error = $cust_main->insert( \%hash );
2131 $dbh->rollback if $oldAutoCommit;
2132 return "can't insert customer for $line: $error";
2135 #false laziness w/bill.cgi
2136 $error = $cust_main->bill( 'time' => $billtime );
2138 $dbh->rollback if $oldAutoCommit;
2139 return "can't bill customer for $line: $error";
2142 $cust_main->apply_payments;
2143 $cust_main->apply_credits;
2145 $error = $cust_main->collect();
2147 $dbh->rollback if $oldAutoCommit;
2148 return "can't collect customer for $line: $error";
2154 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2156 return "Empty file!" unless $imported;
2168 #warn join('-',keys %$param);
2169 my $fh = $param->{filehandle};
2170 my @fields = @{$param->{fields}};
2172 eval "use Date::Parse;";
2174 eval "use Text::CSV_XS;";
2177 my $csv = new Text::CSV_XS;
2184 local $SIG{HUP} = 'IGNORE';
2185 local $SIG{INT} = 'IGNORE';
2186 local $SIG{QUIT} = 'IGNORE';
2187 local $SIG{TERM} = 'IGNORE';
2188 local $SIG{TSTP} = 'IGNORE';
2189 local $SIG{PIPE} = 'IGNORE';
2191 my $oldAutoCommit = $FS::UID::AutoCommit;
2192 local $FS::UID::AutoCommit = 0;
2195 #while ( $columns = $csv->getline($fh) ) {
2197 while ( defined($line=<$fh>) ) {
2199 $csv->parse($line) or do {
2200 $dbh->rollback if $oldAutoCommit;
2201 return "can't parse: ". $csv->error_input();
2204 my @columns = $csv->fields();
2205 #warn join('-',@columns);
2208 foreach my $field ( @fields ) {
2209 $row{$field} = shift @columns;
2212 my $cust_main = qsearchs('cust_main', { 'custnum' => $row{'custnum'} } );
2213 unless ( $cust_main ) {
2214 $dbh->rollback if $oldAutoCommit;
2215 return "unknown custnum $row{'custnum'}";
2218 if ( $row{'amount'} > 0 ) {
2219 my $error = $cust_main->charge($row{'amount'}, $row{'pkg'});
2221 $dbh->rollback if $oldAutoCommit;
2225 } elsif ( $row{'amount'} < 0 ) {
2226 my $error = $cust_main->credit( sprintf( "%.2f", 0-$row{'amount'} ),
2229 $dbh->rollback if $oldAutoCommit;
2239 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2241 return "Empty file!" unless $imported;
2253 The delete method should possibly take an FS::cust_main object reference
2254 instead of a scalar customer number.
2256 Bill and collect options should probably be passed as references instead of a
2259 There should probably be a configuration file with a list of allowed credit
2262 No multiple currency support (probably a larger project than just this module).
2266 L<FS::Record>, L<FS::cust_pkg>, L<FS::cust_bill>, L<FS::cust_credit>
2267 L<FS::agent>, L<FS::part_referral>, L<FS::cust_main_county>,
2268 L<FS::cust_main_invoice>, L<FS::UID>, schema.html from the base documentation.