4 use vars qw( @ISA @EXPORT_OK $conf $DEBUG $import );
5 use vars qw( $realtime_bop_decline_quiet ); #ugh
10 eval "use Time::Local;";
11 die "Time::Local minimum version 1.05 required with Perl versions before 5.6"
12 if $] < 5.006 && !defined($Time::Local::VERSION);
13 #eval "use Time::Local qw(timelocal timelocal_nocheck);";
14 eval "use Time::Local qw(timelocal_nocheck);";
18 use String::Approx qw(amatch);
19 use Business::CreditCard;
20 use FS::UID qw( getotaker dbh );
21 use FS::Record qw( qsearchs qsearch dbdef );
22 use FS::Misc qw( send_email );
25 use FS::cust_bill_pkg;
27 use FS::cust_pay_void;
30 use FS::part_referral;
31 use FS::cust_main_county;
33 use FS::cust_main_invoice;
34 use FS::cust_credit_bill;
35 use FS::cust_bill_pay;
36 use FS::prepay_credit;
39 use FS::part_bill_event;
40 use FS::cust_bill_event;
41 use FS::cust_tax_exempt;
43 use FS::Msgcat qw(gettext);
45 @ISA = qw( FS::Record );
47 @EXPORT_OK = qw( smart_search );
49 $realtime_bop_decline_quiet = 0;
56 #ask FS::UID to run this stuff for us later
57 #$FS::UID::callback{'FS::cust_main'} = sub {
58 install_callback FS::UID sub {
60 #yes, need it for stuff below (prolly should be cached)
65 my ( $hashref, $cache ) = @_;
66 if ( exists $hashref->{'pkgnum'} ) {
67 # #@{ $self->{'_pkgnum'} } = ();
68 my $subcache = $cache->subcache( 'pkgnum', 'cust_pkg', $hashref->{custnum});
69 $self->{'_pkgnum'} = $subcache;
70 #push @{ $self->{'_pkgnum'} },
71 FS::cust_pkg->new_or_cached($hashref, $subcache) if $hashref->{pkgnum};
77 FS::cust_main - Object methods for cust_main records
83 $record = new FS::cust_main \%hash;
84 $record = new FS::cust_main { 'column' => 'value' };
86 $error = $record->insert;
88 $error = $new_record->replace($old_record);
90 $error = $record->delete;
92 $error = $record->check;
94 @cust_pkg = $record->all_pkgs;
96 @cust_pkg = $record->ncancelled_pkgs;
98 @cust_pkg = $record->suspended_pkgs;
100 $error = $record->bill;
101 $error = $record->bill %options;
102 $error = $record->bill 'time' => $time;
104 $error = $record->collect;
105 $error = $record->collect %options;
106 $error = $record->collect 'invoice_time' => $time,
107 'batch_card' => 'yes',
108 'report_badcard' => 'yes',
113 An FS::cust_main object represents a customer. FS::cust_main inherits from
114 FS::Record. The following fields are currently supported:
118 =item custnum - primary key (assigned automatically for new customers)
120 =item agentnum - agent (see L<FS::agent>)
122 =item refnum - Advertising source (see L<FS::part_referral>)
128 =item ss - social security number (optional)
130 =item company - (optional)
134 =item address2 - (optional)
138 =item county - (optional, see L<FS::cust_main_county>)
140 =item state - (see L<FS::cust_main_county>)
144 =item country - (see L<FS::cust_main_county>)
146 =item daytime - phone (optional)
148 =item night - phone (optional)
150 =item fax - phone (optional)
152 =item ship_first - name
154 =item ship_last - name
156 =item ship_company - (optional)
160 =item ship_address2 - (optional)
164 =item ship_county - (optional, see L<FS::cust_main_county>)
166 =item ship_state - (see L<FS::cust_main_county>)
170 =item ship_country - (see L<FS::cust_main_county>)
172 =item ship_daytime - phone (optional)
174 =item ship_night - phone (optional)
176 =item ship_fax - phone (optional)
178 =item payby - I<CARD> (credit card - automatic), I<DCRD> (credit card - on-demand), I<CHEK> (electronic check - automatic), I<DCHK> (electronic check - on-demand), I<LECB> (Phone bill billing), I<BILL> (billing), I<COMP> (free), or I<PREPAY> (special billing type: applies a credit - see L<FS::prepay_credit> and sets billing type to I<BILL>)
180 =item payinfo - card number, P.O., comp issuer (4-8 lowercase alphanumerics; think username) or prepayment identifier (see L<FS::prepay_credit>)
182 =item paycvv - Card Verification Value, "CVV2" (also known as CVC2 or CID), the 3 or 4 digit number on the back (or front, for American Express) of the credit card
184 =item paydate - expiration date, mm/yyyy, m/yyyy, mm/yy or m/yy
186 =item payname - name on card or billing name
188 =item tax - tax exempt, empty or `Y'
190 =item otaker - order taker (assigned automatically, see L<FS::UID>)
192 =item comments - comments (optional)
194 =item referral_custnum - referring customer number
204 Creates a new customer. To add the customer to the database, see L<"insert">.
206 Note that this stores the hash reference, not a distinct copy of the hash it
207 points to. You can ask the object for a copy with the I<hash> method.
211 sub table { 'cust_main'; }
213 =item insert [ CUST_PKG_HASHREF [ , INVOICING_LIST_ARYREF ] [ , OPTION => VALUE ... ] ]
215 Adds this customer to the database. If there is an error, returns the error,
216 otherwise returns false.
218 CUST_PKG_HASHREF: If you pass a Tie::RefHash data structure to the insert
219 method containing FS::cust_pkg and FS::svc_I<tablename> objects, all records
220 are inserted atomicly, or the transaction is rolled back. Passing an empty
221 hash reference is equivalent to not supplying this parameter. There should be
222 a better explanation of this, but until then, here's an example:
225 tie %hash, 'Tie::RefHash'; #this part is important
227 $cust_pkg => [ $svc_acct ],
230 $cust_main->insert( \%hash );
232 INVOICING_LIST_ARYREF: If you pass an arrarref to the insert method, it will
233 be set as the invoicing list (see L<"invoicing_list">). Errors return as
234 expected and rollback the entire transaction; it is not necessary to call
235 check_invoicing_list first. The invoicing_list is set after the records in the
236 CUST_PKG_HASHREF above are inserted, so it is now possible to set an
237 invoicing_list destination to the newly-created svc_acct. Here's an example:
239 $cust_main->insert( {}, [ $email, 'POST' ] );
241 Currently available options are: I<depend_jobnum> and I<noexport>.
243 If I<depend_jobnum> is set, all provisioning jobs will have a dependancy
244 on the supplied jobnum (they will not run until the specific job completes).
245 This can be used to defer provisioning until some action completes (such
246 as running the customer's credit card sucessfully).
248 The I<noexport> option is deprecated. If I<noexport> is set true, no
249 provisioning jobs (exports) are scheduled. (You can schedule them later with
250 the B<reexport> method.)
256 my $cust_pkgs = @_ ? shift : {};
257 my $invoicing_list = @_ ? shift : '';
259 warn "FS::cust_main::insert called with options ".
260 join(', ', map { "$_: $options{$_}" } keys %options ). "\n"
263 local $SIG{HUP} = 'IGNORE';
264 local $SIG{INT} = 'IGNORE';
265 local $SIG{QUIT} = 'IGNORE';
266 local $SIG{TERM} = 'IGNORE';
267 local $SIG{TSTP} = 'IGNORE';
268 local $SIG{PIPE} = 'IGNORE';
270 my $oldAutoCommit = $FS::UID::AutoCommit;
271 local $FS::UID::AutoCommit = 0;
276 if ( $self->payby eq 'PREPAY' ) {
277 $self->payby('BILL');
278 my $prepay_credit = qsearchs(
280 { 'identifier' => $self->payinfo },
284 warn "WARNING: can't find pre-found prepay_credit: ". $self->payinfo
285 unless $prepay_credit;
286 $amount = $prepay_credit->amount;
287 $seconds = $prepay_credit->seconds;
288 my $error = $prepay_credit->delete;
290 $dbh->rollback if $oldAutoCommit;
291 return "removing prepay_credit (transaction rolled back): $error";
295 my $error = $self->SUPER::insert;
297 $dbh->rollback if $oldAutoCommit;
298 #return "inserting cust_main record (transaction rolled back): $error";
303 if ( $invoicing_list ) {
304 $error = $self->check_invoicing_list( $invoicing_list );
306 $dbh->rollback if $oldAutoCommit;
307 return "checking invoicing_list (transaction rolled back): $error";
309 $self->invoicing_list( $invoicing_list );
313 $error = $self->order_pkgs($cust_pkgs, \$seconds, %options);
315 $dbh->rollback if $oldAutoCommit;
320 $dbh->rollback if $oldAutoCommit;
321 return "No svc_acct record to apply pre-paid time";
325 my $cust_credit = new FS::cust_credit {
326 'custnum' => $self->custnum,
329 $error = $cust_credit->insert;
331 $dbh->rollback if $oldAutoCommit;
332 return "inserting credit (transaction rolled back): $error";
336 $error = $self->queue_fuzzyfiles_update;
338 $dbh->rollback if $oldAutoCommit;
339 return "updating fuzzy search cache: $error";
342 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
347 =item order_pkgs HASHREF, [ SECONDSREF, [ , OPTION => VALUE ... ] ]
349 Like the insert method on an existing record, this method orders a package
350 and included services atomicaly. Pass a Tie::RefHash data structure to this
351 method containing FS::cust_pkg and FS::svc_I<tablename> objects. There should
352 be a better explanation of this, but until then, here's an example:
355 tie %hash, 'Tie::RefHash'; #this part is important
357 $cust_pkg => [ $svc_acct ],
360 $cust_main->order_pkgs( \%hash, \'0', 'noexport'=>1 );
362 Currently available options are: I<depend_jobnum> and I<noexport>.
364 If I<depend_jobnum> is set, all provisioning jobs will have a dependancy
365 on the supplied jobnum (they will not run until the specific job completes).
366 This can be used to defer provisioning until some action completes (such
367 as running the customer's credit card sucessfully).
369 The I<noexport> option is deprecated. If I<noexport> is set true, no
370 provisioning jobs (exports) are scheduled. (You can schedule them later with
371 the B<reexport> method for each cust_pkg object. Using the B<reexport> method
372 on the cust_main object is not recommended, as existing services will also be
379 my $cust_pkgs = shift;
382 my %svc_options = ();
383 $svc_options{'depend_jobnum'} = $options{'depend_jobnum'}
384 if exists $options{'depend_jobnum'};
385 warn "FS::cust_main::order_pkgs called with options ".
386 join(', ', map { "$_: $options{$_}" } keys %options ). "\n"
389 local $SIG{HUP} = 'IGNORE';
390 local $SIG{INT} = 'IGNORE';
391 local $SIG{QUIT} = 'IGNORE';
392 local $SIG{TERM} = 'IGNORE';
393 local $SIG{TSTP} = 'IGNORE';
394 local $SIG{PIPE} = 'IGNORE';
396 my $oldAutoCommit = $FS::UID::AutoCommit;
397 local $FS::UID::AutoCommit = 0;
400 local $FS::svc_Common::noexport_hack = 1 if $options{'noexport'};
402 foreach my $cust_pkg ( keys %$cust_pkgs ) {
403 $cust_pkg->custnum( $self->custnum );
404 my $error = $cust_pkg->insert;
406 $dbh->rollback if $oldAutoCommit;
407 return "inserting cust_pkg (transaction rolled back): $error";
409 foreach my $svc_something ( @{$cust_pkgs->{$cust_pkg}} ) {
410 $svc_something->pkgnum( $cust_pkg->pkgnum );
411 if ( $seconds && $$seconds && $svc_something->isa('FS::svc_acct') ) {
412 $svc_something->seconds( $svc_something->seconds + $$seconds );
415 $error = $svc_something->insert(%svc_options);
417 $dbh->rollback if $oldAutoCommit;
418 #return "inserting svc_ (transaction rolled back): $error";
424 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
430 This method is deprecated. See the I<depend_jobnum> option to the insert and
431 order_pkgs methods for a better way to defer provisioning.
433 Re-schedules all exports by calling the B<reexport> method of all associated
434 packages (see L<FS::cust_pkg>). If there is an error, returns the error;
435 otherwise returns false.
442 carp "warning: FS::cust_main::reexport is deprectated; ".
443 "use the depend_jobnum option to insert or order_pkgs to delay export";
445 local $SIG{HUP} = 'IGNORE';
446 local $SIG{INT} = 'IGNORE';
447 local $SIG{QUIT} = 'IGNORE';
448 local $SIG{TERM} = 'IGNORE';
449 local $SIG{TSTP} = 'IGNORE';
450 local $SIG{PIPE} = 'IGNORE';
452 my $oldAutoCommit = $FS::UID::AutoCommit;
453 local $FS::UID::AutoCommit = 0;
456 foreach my $cust_pkg ( $self->ncancelled_pkgs ) {
457 my $error = $cust_pkg->reexport;
459 $dbh->rollback if $oldAutoCommit;
464 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
469 =item delete NEW_CUSTNUM
471 This deletes the customer. If there is an error, returns the error, otherwise
474 This will completely remove all traces of the customer record. This is not
475 what you want when a customer cancels service; for that, cancel all of the
476 customer's packages (see L</cancel>).
478 If the customer has any uncancelled packages, you need to pass a new (valid)
479 customer number for those packages to be transferred to. Cancelled packages
480 will be deleted. Did I mention that this is NOT what you want when a customer
481 cancels service and that you really should be looking see L<FS::cust_pkg/cancel>?
483 You can't delete a customer with invoices (see L<FS::cust_bill>),
484 or credits (see L<FS::cust_credit>), payments (see L<FS::cust_pay>) or
485 refunds (see L<FS::cust_refund>).
492 local $SIG{HUP} = 'IGNORE';
493 local $SIG{INT} = 'IGNORE';
494 local $SIG{QUIT} = 'IGNORE';
495 local $SIG{TERM} = 'IGNORE';
496 local $SIG{TSTP} = 'IGNORE';
497 local $SIG{PIPE} = 'IGNORE';
499 my $oldAutoCommit = $FS::UID::AutoCommit;
500 local $FS::UID::AutoCommit = 0;
503 if ( $self->cust_bill ) {
504 $dbh->rollback if $oldAutoCommit;
505 return "Can't delete a customer with invoices";
507 if ( $self->cust_credit ) {
508 $dbh->rollback if $oldAutoCommit;
509 return "Can't delete a customer with credits";
511 if ( $self->cust_pay ) {
512 $dbh->rollback if $oldAutoCommit;
513 return "Can't delete a customer with payments";
515 if ( $self->cust_refund ) {
516 $dbh->rollback if $oldAutoCommit;
517 return "Can't delete a customer with refunds";
520 my @cust_pkg = $self->ncancelled_pkgs;
522 my $new_custnum = shift;
523 unless ( qsearchs( 'cust_main', { 'custnum' => $new_custnum } ) ) {
524 $dbh->rollback if $oldAutoCommit;
525 return "Invalid new customer number: $new_custnum";
527 foreach my $cust_pkg ( @cust_pkg ) {
528 my %hash = $cust_pkg->hash;
529 $hash{'custnum'} = $new_custnum;
530 my $new_cust_pkg = new FS::cust_pkg ( \%hash );
531 my $error = $new_cust_pkg->replace($cust_pkg);
533 $dbh->rollback if $oldAutoCommit;
538 my @cancelled_cust_pkg = $self->all_pkgs;
539 foreach my $cust_pkg ( @cancelled_cust_pkg ) {
540 my $error = $cust_pkg->delete;
542 $dbh->rollback if $oldAutoCommit;
547 foreach my $cust_main_invoice ( #(email invoice destinations, not invoices)
548 qsearch( 'cust_main_invoice', { 'custnum' => $self->custnum } )
550 my $error = $cust_main_invoice->delete;
552 $dbh->rollback if $oldAutoCommit;
557 my $error = $self->SUPER::delete;
559 $dbh->rollback if $oldAutoCommit;
563 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
568 =item replace OLD_RECORD [ INVOICING_LIST_ARYREF ]
570 Replaces the OLD_RECORD with this one in the database. If there is an error,
571 returns the error, otherwise returns false.
573 INVOICING_LIST_ARYREF: If you pass an arrarref to the insert method, it will
574 be set as the invoicing list (see L<"invoicing_list">). Errors return as
575 expected and rollback the entire transaction; it is not necessary to call
576 check_invoicing_list first. Here's an example:
578 $new_cust_main->replace( $old_cust_main, [ $email, 'POST' ] );
587 local $SIG{HUP} = 'IGNORE';
588 local $SIG{INT} = 'IGNORE';
589 local $SIG{QUIT} = 'IGNORE';
590 local $SIG{TERM} = 'IGNORE';
591 local $SIG{TSTP} = 'IGNORE';
592 local $SIG{PIPE} = 'IGNORE';
594 if ( $self->payby eq 'COMP' && $self->payby ne $old->payby
595 && $conf->config('users-allow_comp') ) {
596 return "You are not permitted to create complimentary accounts."
597 unless grep { $_ eq getotaker } $conf->config('users-allow_comp');
600 my $oldAutoCommit = $FS::UID::AutoCommit;
601 local $FS::UID::AutoCommit = 0;
604 my $error = $self->SUPER::replace($old);
607 $dbh->rollback if $oldAutoCommit;
611 if ( @param ) { # INVOICING_LIST_ARYREF
612 my $invoicing_list = shift @param;
613 $error = $self->check_invoicing_list( $invoicing_list );
615 $dbh->rollback if $oldAutoCommit;
618 $self->invoicing_list( $invoicing_list );
621 if ( $self->payby =~ /^(CARD|CHEK|LECB)$/ &&
622 grep { $self->get($_) ne $old->get($_) } qw(payinfo paydate payname) ) {
623 # card/check/lec info has changed, want to retry realtime_ invoice events
624 my $error = $self->retry_realtime;
626 $dbh->rollback if $oldAutoCommit;
631 $error = $self->queue_fuzzyfiles_update;
633 $dbh->rollback if $oldAutoCommit;
634 return "updating fuzzy search cache: $error";
637 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
642 =item queue_fuzzyfiles_update
644 Used by insert & replace to update the fuzzy search cache
648 sub queue_fuzzyfiles_update {
651 local $SIG{HUP} = 'IGNORE';
652 local $SIG{INT} = 'IGNORE';
653 local $SIG{QUIT} = 'IGNORE';
654 local $SIG{TERM} = 'IGNORE';
655 local $SIG{TSTP} = 'IGNORE';
656 local $SIG{PIPE} = 'IGNORE';
658 my $oldAutoCommit = $FS::UID::AutoCommit;
659 local $FS::UID::AutoCommit = 0;
662 my $queue = new FS::queue { 'job' => 'FS::cust_main::append_fuzzyfiles' };
663 my $error = $queue->insert($self->getfield('last'), $self->company);
665 $dbh->rollback if $oldAutoCommit;
666 return "queueing job (transaction rolled back): $error";
669 if ( defined $self->dbdef_table->column('ship_last') && $self->ship_last ) {
670 $queue = new FS::queue { 'job' => 'FS::cust_main::append_fuzzyfiles' };
671 $error = $queue->insert($self->getfield('ship_last'), $self->ship_company);
673 $dbh->rollback if $oldAutoCommit;
674 return "queueing job (transaction rolled back): $error";
678 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
685 Checks all fields to make sure this is a valid customer record. If there is
686 an error, returns the error, otherwise returns false. Called by the insert
694 #warn "BEFORE: \n". $self->_dump;
697 $self->ut_numbern('custnum')
698 || $self->ut_number('agentnum')
699 || $self->ut_number('refnum')
700 || $self->ut_name('last')
701 || $self->ut_name('first')
702 || $self->ut_textn('company')
703 || $self->ut_text('address1')
704 || $self->ut_textn('address2')
705 || $self->ut_text('city')
706 || $self->ut_textn('county')
707 || $self->ut_textn('state')
708 || $self->ut_country('country')
709 || $self->ut_anything('comments')
710 || $self->ut_numbern('referral_custnum')
712 #barf. need message catalogs. i18n. etc.
713 $error .= "Please select an advertising source."
714 if $error =~ /^Illegal or empty \(numeric\) refnum: /;
715 return $error if $error;
717 return "Unknown agent"
718 unless qsearchs( 'agent', { 'agentnum' => $self->agentnum } );
720 return "Unknown refnum"
721 unless qsearchs( 'part_referral', { 'refnum' => $self->refnum } );
723 return "Unknown referring custnum ". $self->referral_custnum
724 unless ! $self->referral_custnum
725 || qsearchs( 'cust_main', { 'custnum' => $self->referral_custnum } );
727 if ( $self->ss eq '' ) {
732 $ss =~ /^(\d{3})(\d{2})(\d{4})$/
733 or return "Illegal social security number: ". $self->ss;
734 $self->ss("$1-$2-$3");
738 # bad idea to disable, causes billing to fail because of no tax rates later
739 # unless ( $import ) {
740 unless ( qsearch('cust_main_county', {
741 'country' => $self->country,
744 return "Unknown state/county/country: ".
745 $self->state. "/". $self->county. "/". $self->country
746 unless qsearch('cust_main_county',{
747 'state' => $self->state,
748 'county' => $self->county,
749 'country' => $self->country,
755 $self->ut_phonen('daytime', $self->country)
756 || $self->ut_phonen('night', $self->country)
757 || $self->ut_phonen('fax', $self->country)
758 || $self->ut_zip('zip', $self->country)
760 return $error if $error;
763 last first company address1 address2 city county state zip
764 country daytime night fax
767 if ( defined $self->dbdef_table->column('ship_last') ) {
768 if ( scalar ( grep { $self->getfield($_) ne $self->getfield("ship_$_") }
770 && scalar ( grep { $self->getfield("ship_$_") ne '' } @addfields )
774 $self->ut_name('ship_last')
775 || $self->ut_name('ship_first')
776 || $self->ut_textn('ship_company')
777 || $self->ut_text('ship_address1')
778 || $self->ut_textn('ship_address2')
779 || $self->ut_text('ship_city')
780 || $self->ut_textn('ship_county')
781 || $self->ut_textn('ship_state')
782 || $self->ut_country('ship_country')
784 return $error if $error;
786 #false laziness with above
787 unless ( qsearchs('cust_main_county', {
788 'country' => $self->ship_country,
791 return "Unknown ship_state/ship_county/ship_country: ".
792 $self->ship_state. "/". $self->ship_county. "/". $self->ship_country
793 unless qsearchs('cust_main_county',{
794 'state' => $self->ship_state,
795 'county' => $self->ship_county,
796 'country' => $self->ship_country,
802 $self->ut_phonen('ship_daytime', $self->ship_country)
803 || $self->ut_phonen('ship_night', $self->ship_country)
804 || $self->ut_phonen('ship_fax', $self->ship_country)
805 || $self->ut_zip('ship_zip', $self->ship_country)
807 return $error if $error;
809 } else { # ship_ info eq billing info, so don't store dup info in database
810 $self->setfield("ship_$_", '')
811 foreach qw( last first company address1 address2 city county state zip
812 country daytime night fax );
816 $self->payby =~ /^(CARD|DCRD|CHEK|DCHK|LECB|BILL|COMP|PREPAY)$/
817 or return "Illegal payby: ". $self->payby;
820 if ( $self->payby eq 'CARD' || $self->payby eq 'DCRD' ) {
822 my $payinfo = $self->payinfo;
824 $payinfo =~ /^(\d{13,16})$/
825 or return gettext('invalid_card'); # . ": ". $self->payinfo;
827 $self->payinfo($payinfo);
829 or return gettext('invalid_card'); # . ": ". $self->payinfo;
830 return gettext('unknown_card_type')
831 if cardtype($self->payinfo) eq "Unknown";
832 if ( defined $self->dbdef_table->column('paycvv') ) {
833 if ( length($self->paycvv) ) {
834 if ( cardtype($self->payinfo) eq 'American Express card' ) {
835 $self->paycvv =~ /^(\d{4})$/
836 or return "CVV2 (CID) for American Express cards is four digits.";
839 $self->paycvv =~ /^(\d{3})$/
840 or return "CVV2 (CVC2/CID) is three digits.";
848 } elsif ( $self->payby eq 'CHEK' || $self->payby eq 'DCHK' ) {
850 my $payinfo = $self->payinfo;
851 $payinfo =~ s/[^\d\@]//g;
852 $payinfo =~ /^(\d+)\@(\d{9})$/ or return 'invalid echeck account@aba';
854 $self->payinfo($payinfo);
855 $self->paycvv('') if $self->dbdef_table->column('paycvv');
857 } elsif ( $self->payby eq 'LECB' ) {
859 my $payinfo = $self->payinfo;
861 $payinfo =~ /^1?(\d{10})$/ or return 'invalid btn billing telephone number';
863 $self->payinfo($payinfo);
864 $self->paycvv('') if $self->dbdef_table->column('paycvv');
866 } elsif ( $self->payby eq 'BILL' ) {
868 $error = $self->ut_textn('payinfo');
869 return "Illegal P.O. number: ". $self->payinfo if $error;
870 $self->paycvv('') if $self->dbdef_table->column('paycvv');
872 } elsif ( $self->payby eq 'COMP' ) {
874 if ( !$self->custnum && $conf->config('users-allow_comp') ) {
875 return "You are not permitted to create complimentary accounts."
876 unless grep { $_ eq getotaker } $conf->config('users-allow_comp');
879 $error = $self->ut_textn('payinfo');
880 return "Illegal comp account issuer: ". $self->payinfo if $error;
881 $self->paycvv('') if $self->dbdef_table->column('paycvv');
883 } elsif ( $self->payby eq 'PREPAY' ) {
885 my $payinfo = $self->payinfo;
886 $payinfo =~ s/\W//g; #anything else would just confuse things
887 $self->payinfo($payinfo);
888 $error = $self->ut_alpha('payinfo');
889 return "Illegal prepayment identifier: ". $self->payinfo if $error;
890 return "Unknown prepayment identifier"
891 unless qsearchs('prepay_credit', { 'identifier' => $self->payinfo } );
892 $self->paycvv('') if $self->dbdef_table->column('paycvv');
896 if ( $self->paydate eq '' || $self->paydate eq '-' ) {
897 return "Expriation date required"
898 unless $self->payby =~ /^(BILL|PREPAY|CHEK|LECB)$/;
902 if ( $self->paydate =~ /^(\d{1,2})[\/\-](\d{2}(\d{2})?)$/ ) {
903 ( $m, $y ) = ( $1, length($2) == 4 ? $2 : "20$2" );
904 } elsif ( $self->paydate =~ /^(20)?(\d{2})[\/\-](\d{1,2})[\/\-]\d+$/ ) {
905 ( $m, $y ) = ( $3, "20$2" );
907 return "Illegal expiration date: ". $self->paydate;
909 $self->paydate("$y-$m-01");
910 my($nowm,$nowy)=(localtime(time))[4,5]; $nowm++; $nowy+=1900;
911 return gettext('expired_card')
912 if !$import && ( $y<$nowy || ( $y==$nowy && $1<$nowm ) );
915 if ( $self->payname eq '' && $self->payby !~ /^(CHEK|DCHK)$/ &&
916 ( ! $conf->exists('require_cardname')
917 || $self->payby !~ /^(CARD|DCRD)$/ )
919 $self->payname( $self->first. " ". $self->getfield('last') );
921 $self->payname =~ /^([\w \,\.\-\']+)$/
922 or return gettext('illegal_name'). " payname: ". $self->payname;
926 $self->tax =~ /^(Y?)$/ or return "Illegal tax: ". $self->tax;
929 $self->otaker(getotaker) unless $self->otaker;
931 #warn "AFTER: \n". $self->_dump;
938 Returns all packages (see L<FS::cust_pkg>) for this customer.
944 if ( $self->{'_pkgnum'} ) {
945 values %{ $self->{'_pkgnum'}->cache };
947 qsearch( 'cust_pkg', { 'custnum' => $self->custnum });
951 =item ncancelled_pkgs
953 Returns all non-cancelled packages (see L<FS::cust_pkg>) for this customer.
957 sub ncancelled_pkgs {
959 if ( $self->{'_pkgnum'} ) {
960 grep { ! $_->getfield('cancel') } values %{ $self->{'_pkgnum'}->cache };
962 @{ [ # force list context
963 qsearch( 'cust_pkg', {
964 'custnum' => $self->custnum,
967 qsearch( 'cust_pkg', {
968 'custnum' => $self->custnum,
977 Returns all suspended packages (see L<FS::cust_pkg>) for this customer.
983 grep { $_->susp } $self->ncancelled_pkgs;
986 =item unflagged_suspended_pkgs
988 Returns all unflagged suspended packages (see L<FS::cust_pkg>) for this
989 customer (thouse packages without the `manual_flag' set).
993 sub unflagged_suspended_pkgs {
995 return $self->suspended_pkgs
996 unless dbdef->table('cust_pkg')->column('manual_flag');
997 grep { ! $_->manual_flag } $self->suspended_pkgs;
1000 =item unsuspended_pkgs
1002 Returns all unsuspended (and uncancelled) packages (see L<FS::cust_pkg>) for
1007 sub unsuspended_pkgs {
1009 grep { ! $_->susp } $self->ncancelled_pkgs;
1014 Unsuspends all unflagged suspended packages (see L</unflagged_suspended_pkgs>
1015 and L<FS::cust_pkg>) for this customer. Always returns a list: an empty list
1016 on success or a list of errors.
1022 grep { $_->unsuspend } $self->suspended_pkgs;
1027 Suspends all unsuspended packages (see L<FS::cust_pkg>) for this customer.
1028 Always returns a list: an empty list on success or a list of errors.
1034 grep { $_->suspend } $self->unsuspended_pkgs;
1037 =item suspend_if_pkgpart PKGPART [ , PKGPART ... ]
1039 Suspends all unsuspended packages (see L<FS::cust_pkg>) matching the listed
1040 PKGPARTs (see L<FS::part_pkg>). Always returns a list: an empty list on
1041 success or a list of errors.
1045 sub suspend_if_pkgpart {
1048 grep { $_->suspend }
1049 grep { my $pkgpart = $_->pkgpart; grep { $pkgpart eq $_ } @pkgparts }
1050 $self->unsuspended_pkgs;
1053 =item suspend_unless_pkgpart PKGPART [ , PKGPART ... ]
1055 Suspends all unsuspended packages (see L<FS::cust_pkg>) unless they match the
1056 listed PKGPARTs (see L<FS::part_pkg>). Always returns a list: an empty list
1057 on success or a list of errors.
1061 sub suspend_unless_pkgpart {
1064 grep { $_->suspend }
1065 grep { my $pkgpart = $_->pkgpart; ! grep { $pkgpart eq $_ } @pkgparts }
1066 $self->unsuspended_pkgs;
1069 =item cancel [ OPTION => VALUE ... ]
1071 Cancels all uncancelled packages (see L<FS::cust_pkg>) for this customer.
1073 Available options are: I<quiet>
1075 I<quiet> can be set true to supress email cancellation notices.
1077 Always returns a list: an empty list on success or a list of errors.
1083 grep { $_ } map { $_->cancel(@_) } $self->ncancelled_pkgs;
1088 Returns the agent (see L<FS::agent>) for this customer.
1094 qsearchs( 'agent', { 'agentnum' => $self->agentnum } );
1099 Generates invoices (see L<FS::cust_bill>) for this customer. Usually used in
1100 conjunction with the collect method.
1102 Options are passed as name-value pairs.
1104 Currently available options are:
1106 resetup - if set true, re-charges setup fees.
1108 time - bills the customer as if it were that time. Specified as a UNIX
1109 timestamp; see L<perlfunc/"time">). Also see L<Time::Local> and
1110 L<Date::Parse> for conversion functions. For example:
1114 $cust_main->bill( 'time' => str2time('April 20th, 2001') );
1117 If there is an error, returns the error, otherwise returns false.
1122 my( $self, %options ) = @_;
1123 return '' if $self->payby eq 'COMP';
1124 warn "bill customer ". $self->custnum if $DEBUG;
1126 my $time = $options{'time'} || time;
1131 local $SIG{HUP} = 'IGNORE';
1132 local $SIG{INT} = 'IGNORE';
1133 local $SIG{QUIT} = 'IGNORE';
1134 local $SIG{TERM} = 'IGNORE';
1135 local $SIG{TSTP} = 'IGNORE';
1136 local $SIG{PIPE} = 'IGNORE';
1138 my $oldAutoCommit = $FS::UID::AutoCommit;
1139 local $FS::UID::AutoCommit = 0;
1142 $self->select_for_update; #mutex
1144 # find the packages which are due for billing, find out how much they are
1145 # & generate invoice database.
1147 my( $total_setup, $total_recur ) = ( 0, 0 );
1148 #my( $taxable_setup, $taxable_recur ) = ( 0, 0 );
1149 my @cust_bill_pkg = ();
1151 #my $taxable_charged = 0;##
1156 foreach my $cust_pkg (
1157 qsearch('cust_pkg', { 'custnum' => $self->custnum } )
1160 #NO!! next if $cust_pkg->cancel;
1161 next if $cust_pkg->getfield('cancel');
1163 warn " bill package ". $cust_pkg->pkgnum if $DEBUG;
1165 #? to avoid use of uninitialized value errors... ?
1166 $cust_pkg->setfield('bill', '')
1167 unless defined($cust_pkg->bill);
1169 my $part_pkg = $cust_pkg->part_pkg;
1171 my %hash = $cust_pkg->hash;
1172 my $old_cust_pkg = new FS::cust_pkg \%hash;
1178 if ( !$cust_pkg->setup || $options{'resetup'} ) {
1180 warn " bill setup" if $DEBUG;
1182 $setup = eval { $cust_pkg->calc_setup( $time ) };
1184 $dbh->rollback if $oldAutoCommit;
1188 $cust_pkg->setfield('setup', $time) unless $cust_pkg->setup;
1194 if ( $part_pkg->getfield('freq') ne '0' &&
1195 ! $cust_pkg->getfield('susp') &&
1196 ( $cust_pkg->getfield('bill') || 0 ) <= $time
1199 warn " bill recur" if $DEBUG;
1201 # XXX shared with $recur_prog
1202 $sdate = $cust_pkg->bill || $cust_pkg->setup || $time;
1204 $recur = eval { $cust_pkg->calc_recur( \$sdate, \@details ) };
1206 $dbh->rollback if $oldAutoCommit;
1210 #change this bit to use Date::Manip? CAREFUL with timezones (see
1211 # mailing list archive)
1212 my ($sec,$min,$hour,$mday,$mon,$year) =
1213 (localtime($sdate) )[0,1,2,3,4,5];
1215 #pro-rating magic - if $recur_prog fiddles $sdate, want to use that
1216 # only for figuring next bill date, nothing else, so, reset $sdate again
1218 $sdate = $cust_pkg->bill || $cust_pkg->setup || $time;
1219 $cust_pkg->last_bill($sdate)
1220 if $cust_pkg->dbdef_table->column('last_bill');
1222 if ( $part_pkg->freq =~ /^\d+$/ ) {
1223 $mon += $part_pkg->freq;
1224 until ( $mon < 12 ) { $mon -= 12; $year++; }
1225 } elsif ( $part_pkg->freq =~ /^(\d+)w$/ ) {
1227 $mday += $weeks * 7;
1228 } elsif ( $part_pkg->freq =~ /^(\d+)d$/ ) {
1232 $dbh->rollback if $oldAutoCommit;
1233 return "unparsable frequency: ". $part_pkg->freq;
1235 $cust_pkg->setfield('bill',
1236 timelocal_nocheck($sec,$min,$hour,$mday,$mon,$year));
1239 warn "\$setup is undefined" unless defined($setup);
1240 warn "\$recur is undefined" unless defined($recur);
1241 warn "\$cust_pkg->bill is undefined" unless defined($cust_pkg->bill);
1243 if ( $cust_pkg->modified ) {
1245 warn " package ". $cust_pkg->pkgnum. " modified; updating\n" if $DEBUG;
1247 $error=$cust_pkg->replace($old_cust_pkg);
1248 if ( $error ) { #just in case
1249 $dbh->rollback if $oldAutoCommit;
1250 return "Error modifying pkgnum ". $cust_pkg->pkgnum. ": $error";
1253 $setup = sprintf( "%.2f", $setup );
1254 $recur = sprintf( "%.2f", $recur );
1255 if ( $setup < 0 && ! $conf->exists('allow_negative_charges') ) {
1256 $dbh->rollback if $oldAutoCommit;
1257 return "negative setup $setup for pkgnum ". $cust_pkg->pkgnum;
1259 if ( $recur < 0 && ! $conf->exists('allow_negative_charges') ) {
1260 $dbh->rollback if $oldAutoCommit;
1261 return "negative recur $recur for pkgnum ". $cust_pkg->pkgnum;
1263 if ( $setup != 0 || $recur != 0 ) {
1264 warn " charges (setup=$setup, recur=$recur); queueing line items\n"
1266 my $cust_bill_pkg = new FS::cust_bill_pkg ({
1267 'pkgnum' => $cust_pkg->pkgnum,
1271 'edate' => $cust_pkg->bill,
1272 'details' => \@details,
1274 push @cust_bill_pkg, $cust_bill_pkg;
1275 $total_setup += $setup;
1276 $total_recur += $recur;
1278 unless ( $self->tax =~ /Y/i || $self->payby eq 'COMP' ) {
1280 my @taxes = qsearch( 'cust_main_county', {
1281 'state' => $self->state,
1282 'county' => $self->county,
1283 'country' => $self->country,
1284 'taxclass' => $part_pkg->taxclass,
1287 @taxes = qsearch( 'cust_main_county', {
1288 'state' => $self->state,
1289 'county' => $self->county,
1290 'country' => $self->country,
1295 #one more try at a whole-country tax rate
1297 @taxes = qsearch( 'cust_main_county', {
1300 'country' => $self->country,
1305 # maybe eliminate this entirely, along with all the 0% records
1307 $dbh->rollback if $oldAutoCommit;
1309 "fatal: can't find tax rate for state/county/country/taxclass ".
1310 join('/', ( map $self->$_(), qw(state county country) ),
1311 $part_pkg->taxclass ). "\n";
1314 foreach my $tax ( @taxes ) {
1316 my $taxable_charged = 0;
1317 $taxable_charged += $setup
1318 unless $part_pkg->setuptax =~ /^Y$/i
1319 || $tax->setuptax =~ /^Y$/i;
1320 $taxable_charged += $recur
1321 unless $part_pkg->recurtax =~ /^Y$/i
1322 || $tax->recurtax =~ /^Y$/i;
1323 next unless $taxable_charged;
1325 if ( $tax->exempt_amount > 0 ) {
1326 my ($mon,$year) = (localtime($sdate) )[4,5];
1328 my $freq = $part_pkg->freq || 1;
1329 if ( $freq !~ /(\d+)$/ ) {
1330 $dbh->rollback if $oldAutoCommit;
1331 return "daily/weekly package definitions not (yet?)".
1332 " compatible with monthly tax exemptions";
1334 my $taxable_per_month = sprintf("%.2f", $taxable_charged / $freq );
1335 foreach my $which_month ( 1 .. $freq ) {
1337 'custnum' => $self->custnum,
1338 'taxnum' => $tax->taxnum,
1339 'year' => 1900+$year,
1342 #until ( $mon < 12 ) { $mon -= 12; $year++; }
1343 until ( $mon < 13 ) { $mon -= 12; $year++; }
1344 my $cust_tax_exempt =
1345 qsearchs('cust_tax_exempt', \%hash)
1346 || new FS::cust_tax_exempt( { %hash, 'amount' => 0 } );
1347 my $remaining_exemption = sprintf("%.2f",
1348 $tax->exempt_amount - $cust_tax_exempt->amount );
1349 if ( $remaining_exemption > 0 ) {
1350 my $addl = $remaining_exemption > $taxable_per_month
1351 ? $taxable_per_month
1352 : $remaining_exemption;
1353 $taxable_charged -= $addl;
1354 my $new_cust_tax_exempt = new FS::cust_tax_exempt ( {
1355 $cust_tax_exempt->hash,
1357 sprintf("%.2f", $cust_tax_exempt->amount + $addl),
1359 $error = $new_cust_tax_exempt->exemptnum
1360 ? $new_cust_tax_exempt->replace($cust_tax_exempt)
1361 : $new_cust_tax_exempt->insert;
1363 $dbh->rollback if $oldAutoCommit;
1364 return "fatal: can't update cust_tax_exempt: $error";
1367 } # if $remaining_exemption > 0
1369 } #foreach $which_month
1371 } #if $tax->exempt_amount
1373 $taxable_charged = sprintf( "%.2f", $taxable_charged);
1375 #$tax += $taxable_charged * $cust_main_county->tax / 100
1376 $tax{ $tax->taxname || 'Tax' } +=
1377 $taxable_charged * $tax->tax / 100
1379 } #foreach my $tax ( @taxes )
1381 } #unless $self->tax =~ /Y/i || $self->payby eq 'COMP'
1383 } #if $setup != 0 || $recur != 0
1385 } #if $cust_pkg->modified
1387 } #foreach my $cust_pkg
1389 my $charged = sprintf( "%.2f", $total_setup + $total_recur );
1390 # my $taxable_charged = sprintf( "%.2f", $taxable_setup + $taxable_recur );
1392 unless ( @cust_bill_pkg ) { #don't create invoices with no line items
1393 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1397 # unless ( $self->tax =~ /Y/i
1398 # || $self->payby eq 'COMP'
1399 # || $taxable_charged == 0 ) {
1400 # my $cust_main_county = qsearchs('cust_main_county',{
1401 # 'state' => $self->state,
1402 # 'county' => $self->county,
1403 # 'country' => $self->country,
1404 # } ) or die "fatal: can't find tax rate for state/county/country ".
1405 # $self->state. "/". $self->county. "/". $self->country. "\n";
1406 # my $tax = sprintf( "%.2f",
1407 # $taxable_charged * ( $cust_main_county->getfield('tax') / 100 )
1410 if ( dbdef->table('cust_bill_pkg')->column('itemdesc') ) { #1.5 schema
1412 foreach my $taxname ( grep { $tax{$_} > 0 } keys %tax ) {
1413 my $tax = sprintf("%.2f", $tax{$taxname} );
1414 $charged = sprintf( "%.2f", $charged+$tax );
1416 my $cust_bill_pkg = new FS::cust_bill_pkg ({
1422 'itemdesc' => $taxname,
1424 push @cust_bill_pkg, $cust_bill_pkg;
1427 } else { #1.4 schema
1430 foreach ( values %tax ) { $tax += $_ };
1431 $tax = sprintf("%.2f", $tax);
1433 $charged = sprintf( "%.2f", $charged+$tax );
1435 my $cust_bill_pkg = new FS::cust_bill_pkg ({
1442 push @cust_bill_pkg, $cust_bill_pkg;
1447 my $cust_bill = new FS::cust_bill ( {
1448 'custnum' => $self->custnum,
1450 'charged' => $charged,
1452 $error = $cust_bill->insert;
1454 $dbh->rollback if $oldAutoCommit;
1455 return "can't create invoice for customer #". $self->custnum. ": $error";
1458 my $invnum = $cust_bill->invnum;
1460 foreach $cust_bill_pkg ( @cust_bill_pkg ) {
1462 $cust_bill_pkg->invnum($invnum);
1463 $error = $cust_bill_pkg->insert;
1465 $dbh->rollback if $oldAutoCommit;
1466 return "can't create invoice line item for customer #". $self->custnum.
1471 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1475 =item collect OPTIONS
1477 (Attempt to) collect money for this customer's outstanding invoices (see
1478 L<FS::cust_bill>). Usually used after the bill method.
1480 Depending on the value of `payby', this may print or email an invoice (I<BILL>,
1481 I<DCRD>, or I<DCHK>), charge a credit card (I<CARD>), charge via electronic
1482 check/ACH (I<CHEK>), or just add any necessary (pseudo-)payment (I<COMP>).
1484 Most actions are now triggered by invoice events; see L<FS::part_bill_event>
1485 and the invoice events web interface.
1487 If there is an error, returns the error, otherwise returns false.
1489 Options are passed as name-value pairs.
1491 Currently available options are:
1493 invoice_time - Use this time when deciding when to print invoices and
1494 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>
1495 for conversion functions.
1497 retry - Retry card/echeck/LEC transactions even when not scheduled by invoice
1500 retry_card - Deprecated alias for 'retry'
1502 batch_card - This option is deprecated. See the invoice events web interface
1503 to control whether cards are batched or run against a realtime gateway.
1505 report_badcard - This option is deprecated.
1507 force_print - This option is deprecated; see the invoice events web interface.
1509 quiet - set true to surpress email card/ACH decline notices.
1514 my( $self, %options ) = @_;
1515 my $invoice_time = $options{'invoice_time'} || time;
1518 local $SIG{HUP} = 'IGNORE';
1519 local $SIG{INT} = 'IGNORE';
1520 local $SIG{QUIT} = 'IGNORE';
1521 local $SIG{TERM} = 'IGNORE';
1522 local $SIG{TSTP} = 'IGNORE';
1523 local $SIG{PIPE} = 'IGNORE';
1525 my $oldAutoCommit = $FS::UID::AutoCommit;
1526 local $FS::UID::AutoCommit = 0;
1529 $self->select_for_update; #mutex
1531 my $balance = $self->balance;
1532 warn "collect customer ". $self->custnum. ": balance $balance" if $DEBUG;
1533 unless ( $balance > 0 ) { #redundant?????
1534 $dbh->rollback if $oldAutoCommit; #hmm
1538 if ( exists($options{'retry_card'}) ) {
1539 carp 'retry_card option passed to collect is deprecated; use retry';
1540 $options{'retry'} ||= $options{'retry_card'};
1542 if ( exists($options{'retry'}) && $options{'retry'} ) {
1543 my $error = $self->retry_realtime;
1545 $dbh->rollback if $oldAutoCommit;
1550 foreach my $cust_bill ( $self->open_cust_bill ) {
1552 # don't try to charge for the same invoice if it's already in a batch
1553 #next if qsearchs( 'cust_pay_batch', { 'invnum' => $cust_bill->invnum } );
1555 last if $self->balance <= 0;
1557 warn "invnum ". $cust_bill->invnum. " (owed ". $cust_bill->owed. ")"
1560 foreach my $part_bill_event (
1561 sort { $a->seconds <=> $b->seconds
1562 || $a->weight <=> $b->weight
1563 || $a->eventpart <=> $b->eventpart }
1564 grep { $_->seconds <= ( $invoice_time - $cust_bill->_date )
1565 && ! qsearch( 'cust_bill_event', {
1566 'invnum' => $cust_bill->invnum,
1567 'eventpart' => $_->eventpart,
1571 qsearch('part_bill_event', { 'payby' => $self->payby,
1572 'disabled' => '', } )
1575 last if $cust_bill->owed <= 0 # don't run subsequent events if owed<=0
1576 || $self->balance <= 0; # or if balance<=0
1578 warn "calling invoice event (". $part_bill_event->eventcode. ")\n"
1580 my $cust_main = $self; #for callback
1584 local $realtime_bop_decline_quiet = 1 if $options{'quiet'};
1585 local $SIG{__DIE__}; # don't want Mason __DIE__ handler active
1586 $error = eval $part_bill_event->eventcode;
1590 my $statustext = '';
1594 } elsif ( $error ) {
1596 $statustext = $error;
1601 #add cust_bill_event
1602 my $cust_bill_event = new FS::cust_bill_event {
1603 'invnum' => $cust_bill->invnum,
1604 'eventpart' => $part_bill_event->eventpart,
1605 #'_date' => $invoice_time,
1607 'status' => $status,
1608 'statustext' => $statustext,
1610 $error = $cust_bill_event->insert;
1612 #$dbh->rollback if $oldAutoCommit;
1613 #return "error: $error";
1615 # gah, even with transactions.
1616 $dbh->commit if $oldAutoCommit; #well.
1617 my $e = 'WARNING: Event run but database not updated - '.
1618 'error inserting cust_bill_event, invnum #'. $cust_bill->invnum.
1619 ', eventpart '. $part_bill_event->eventpart.
1630 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1635 =item retry_realtime
1637 Schedules realtime credit card / electronic check / LEC billing events for
1638 for retry. Useful if card information has changed or manual retry is desired.
1639 The 'collect' method must be called to actually retry the transaction.
1641 Implementation details: For each of this customer's open invoices, changes
1642 the status of the first "done" (with statustext error) realtime processing
1647 sub retry_realtime {
1650 local $SIG{HUP} = 'IGNORE';
1651 local $SIG{INT} = 'IGNORE';
1652 local $SIG{QUIT} = 'IGNORE';
1653 local $SIG{TERM} = 'IGNORE';
1654 local $SIG{TSTP} = 'IGNORE';
1655 local $SIG{PIPE} = 'IGNORE';
1657 my $oldAutoCommit = $FS::UID::AutoCommit;
1658 local $FS::UID::AutoCommit = 0;
1661 foreach my $cust_bill (
1662 grep { $_->cust_bill_event }
1663 $self->open_cust_bill
1665 my @cust_bill_event =
1666 sort { $a->part_bill_event->seconds <=> $b->part_bill_event->seconds }
1668 #$_->part_bill_event->plan eq 'realtime-card'
1669 $_->part_bill_event->eventcode =~
1670 /\$cust_bill\->realtime_(card|ach|lec)/
1671 && $_->status eq 'done'
1674 $cust_bill->cust_bill_event;
1675 next unless @cust_bill_event;
1676 my $error = $cust_bill_event[0]->retry;
1678 $dbh->rollback if $oldAutoCommit;
1679 return "error scheduling invoice event for retry: $error";
1684 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1689 =item realtime_bop METHOD AMOUNT [ OPTION => VALUE ... ]
1691 Runs a realtime credit card, ACH (electronic check) or phone bill transaction
1692 via a Business::OnlinePayment realtime gateway. See
1693 L<http://420.am/business-onlinepayment> for supported gateways.
1695 Available methods are: I<CC>, I<ECHECK> and I<LEC>
1697 Available options are: I<description>, I<invnum>, I<quiet>
1699 The additional options I<payname>, I<address1>, I<address2>, I<city>, I<state>,
1700 I<zip>, I<payinfo> and I<paydate> are also available. Any of these options,
1701 if set, will override the value from the customer record.
1703 I<description> is a free-text field passed to the gateway. It defaults to
1704 "Internet services".
1706 If an I<invnum> is specified, this payment (if sucessful) is applied to the
1707 specified invoice. If you don't specify an I<invnum> you might want to
1708 call the B<apply_payments> method.
1710 I<quiet> can be set true to surpress email decline notices.
1712 (moved from cust_bill) (probably should get realtime_{card,ach,lec} here too)
1717 my( $self, $method, $amount, %options ) = @_;
1719 warn "$self $method $amount\n";
1720 warn " $_ => $options{$_}\n" foreach keys %options;
1723 $options{'description'} ||= 'Internet services';
1726 die "Real-time processing not enabled\n"
1727 unless $conf->exists('business-onlinepayment');
1728 eval "use Business::OnlinePayment";
1732 my $bop_config = 'business-onlinepayment';
1733 $bop_config .= '-ach'
1734 if $method eq 'ECHECK' && $conf->exists($bop_config. '-ach');
1735 my ( $processor, $login, $password, $action, @bop_options ) =
1736 $conf->config($bop_config);
1737 $action ||= 'normal authorization';
1738 pop @bop_options if scalar(@bop_options) % 2 && $bop_options[-1] =~ /^\s*$/;
1739 die "No real-time processor is enabled - ".
1740 "did you set the business-onlinepayment configuration value?\n"
1745 my $address = exists($options{'address1'})
1746 ? $options{'address1'}
1748 my $address2 = exists($options{'address2'})
1749 ? $options{'address2'}
1751 $address .= ", ". $address2 if length($address2);
1753 my $o_payname = exists($options{'payname'})
1754 ? $options{'payname'}
1756 my($payname, $payfirst, $paylast);
1757 if ( $o_payname && $method ne 'ECHECK' ) {
1758 ($payname = $o_payname) =~ /^\s*([\w \,\.\-\']*)?\s+([\w\,\.\-\']+)\s*$/
1759 or return "Illegal payname $payname";
1760 ($payfirst, $paylast) = ($1, $2);
1762 $payfirst = $self->getfield('first');
1763 $paylast = $self->getfield('last');
1764 $payname = "$payfirst $paylast";
1767 my @invoicing_list = grep { $_ ne 'POST' } $self->invoicing_list;
1768 if ( $conf->exists('emailinvoiceauto')
1769 || ( $conf->exists('emailinvoiceonly') && ! @invoicing_list ) ) {
1770 push @invoicing_list, $self->all_emails;
1772 my $email = $invoicing_list[0];
1774 my $payinfo = exists($options{'payinfo'})
1775 ? $options{'payinfo'}
1779 if ( $method eq 'CC' ) {
1781 $content{card_number} = $payinfo;
1782 my $paydate = exists($options{'paydate'})
1783 ? $options{'paydate'}
1785 $paydate =~ /^\d{2}(\d{2})[\/\-](\d+)[\/\-]\d+$/;
1786 $content{expiration} = "$2/$1";
1788 if ( defined $self->dbdef_table->column('paycvv') ) {
1789 my $paycvv = exists($options{'paycvv'})
1790 ? $options{'paycvv'}
1792 $content{cvv2} = $self->paycvv
1796 $content{recurring_billing} = 'YES'
1797 if qsearch('cust_pay', { 'custnum' => $self->custnum,
1799 'payinfo' => $payinfo,
1802 } elsif ( $method eq 'ECHECK' ) {
1803 ( $content{account_number}, $content{routing_code} ) =
1804 split('@', $payinfo);
1805 $content{bank_name} = $o_payname;
1806 $content{account_type} = 'CHECKING';
1807 $content{account_name} = $payname;
1808 $content{customer_org} = $self->company ? 'B' : 'I';
1809 $content{customer_ssn} = exists($options{'ss'})
1812 } elsif ( $method eq 'LEC' ) {
1813 $content{phone} = $payinfo;
1818 my( $action1, $action2 ) = split(/\s*\,\s*/, $action );
1820 my $transaction = new Business::OnlinePayment( $processor, @bop_options );
1821 $transaction->content(
1824 'password' => $password,
1825 'action' => $action1,
1826 'description' => $options{'description'},
1827 'amount' => $amount,
1828 'invoice_number' => $options{'invnum'},
1829 'customer_id' => $self->custnum,
1830 'last_name' => $paylast,
1831 'first_name' => $payfirst,
1833 'address' => $address,
1834 'city' => ( exists($options{'city'})
1837 'state' => ( exists($options{'state'})
1840 'zip' => ( exists($options{'zip'})
1843 'country' => ( exists($options{'country'})
1844 ? $options{'country'}
1846 'referer' => 'http://cleanwhisker.420.am/',
1848 'phone' => $self->daytime || $self->night,
1851 $transaction->submit();
1853 if ( $transaction->is_success() && $action2 ) {
1854 my $auth = $transaction->authorization;
1855 my $ordernum = $transaction->can('order_number')
1856 ? $transaction->order_number
1860 new Business::OnlinePayment( $processor, @bop_options );
1867 password => $password,
1868 order_number => $ordernum,
1870 authorization => $auth,
1871 description => $options{'description'},
1874 foreach my $field (qw( authorization_source_code returned_ACI transaction_identifier validation_code
1875 transaction_sequence_num local_transaction_date
1876 local_transaction_time AVS_result_code )) {
1877 $capture{$field} = $transaction->$field() if $transaction->can($field);
1880 $capture->content( %capture );
1884 unless ( $capture->is_success ) {
1885 my $e = "Authorization sucessful but capture failed, custnum #".
1886 $self->custnum. ': '. $capture->result_code.
1887 ": ". $capture->error_message;
1894 #remove paycvv after initial transaction
1895 #false laziness w/misc/process/payment.cgi - check both to make sure working
1897 if ( defined $self->dbdef_table->column('paycvv')
1898 && length($self->paycvv)
1899 && ! grep { $_ eq cardtype($payinfo) } $conf->config('cvv-save')
1901 my $error = $self->remove_cvv;
1903 warn "error removing cvv: $error\n";
1908 if ( $transaction->is_success() ) {
1910 my %method2payby = (
1916 my $paybatch = "$processor:". $transaction->authorization;
1917 $paybatch .= ':'. $transaction->order_number
1918 if $transaction->can('order_number')
1919 && length($transaction->order_number);
1921 my $cust_pay = new FS::cust_pay ( {
1922 'custnum' => $self->custnum,
1923 'invnum' => $options{'invnum'},
1926 'payby' => $method2payby{$method},
1927 'payinfo' => $payinfo,
1928 'paybatch' => $paybatch,
1930 my $error = $cust_pay->insert;
1932 $cust_pay->invnum(''); #try again with no specific invnum
1933 my $error2 = $cust_pay->insert;
1935 # gah, even with transactions.
1936 my $e = 'WARNING: Card/ACH debited but database not updated - '.
1937 "error inserting payment ($processor): $error2".
1938 " (previously tried insert with invnum #$options{'invnum'}" .
1944 return ''; #no error
1948 my $perror = "$processor error: ". $transaction->error_message;
1950 if ( !$options{'quiet'} && !$realtime_bop_decline_quiet
1951 && $conf->exists('emaildecline')
1952 && grep { $_ ne 'POST' } $self->invoicing_list
1953 && ! grep { $transaction->error_message =~ /$_/ }
1954 $conf->config('emaildecline-exclude')
1956 my @templ = $conf->config('declinetemplate');
1957 my $template = new Text::Template (
1959 SOURCE => [ map "$_\n", @templ ],
1960 ) or return "($perror) can't create template: $Text::Template::ERROR";
1961 $template->compile()
1962 or return "($perror) can't compile template: $Text::Template::ERROR";
1964 my $templ_hash = { error => $transaction->error_message };
1966 my $error = send_email(
1967 'from' => $conf->config('invoice_from'),
1968 'to' => [ grep { $_ ne 'POST' } $self->invoicing_list ],
1969 'subject' => 'Your payment could not be processed',
1970 'body' => [ $template->fill_in(HASH => $templ_hash) ],
1973 $perror .= " (also received error sending decline notification: $error)"
1985 Removes the I<paycvv> field from the database directly.
1987 If there is an error, returns the error, otherwise returns false.
1993 my $sth = dbh->prepare("UPDATE cust_main SET paycvv = '' WHERE custnum = ?")
1994 or return dbh->errstr;
1995 $sth->execute($self->custnum)
1996 or return $sth->errstr;
2001 =item realtime_refund_bop METHOD [ OPTION => VALUE ... ]
2003 Refunds a realtime credit card, ACH (electronic check) or phone bill transaction
2004 via a Business::OnlinePayment realtime gateway. See
2005 L<http://420.am/business-onlinepayment> for supported gateways.
2007 Available methods are: I<CC>, I<ECHECK> and I<LEC>
2009 Available options are: I<amount>, I<reason>, I<paynum>
2011 Most gateways require a reference to an original payment transaction to refund,
2012 so you probably need to specify a I<paynum>.
2014 I<amount> defaults to the original amount of the payment if not specified.
2016 I<reason> specifies a reason for the refund.
2018 Implementation note: If I<amount> is unspecified or equal to the amount of the
2019 orignal payment, first an attempt is made to "void" the transaction via
2020 the gateway (to cancel a not-yet settled transaction) and then if that fails,
2021 the normal attempt is made to "refund" ("credit") the transaction via the
2022 gateway is attempted.
2024 #The additional options I<payname>, I<address1>, I<address2>, I<city>, I<state>,
2025 #I<zip>, I<payinfo> and I<paydate> are also available. Any of these options,
2026 #if set, will override the value from the customer record.
2028 #If an I<invnum> is specified, this payment (if sucessful) is applied to the
2029 #specified invoice. If you don't specify an I<invnum> you might want to
2030 #call the B<apply_payments> method.
2034 #some false laziness w/realtime_bop, not enough to make it worth merging
2035 #but some useful small subs should be pulled out
2036 sub realtime_refund_bop {
2037 my( $self, $method, %options ) = @_;
2039 warn "$self $method refund\n";
2040 warn " $_ => $options{$_}\n" foreach keys %options;
2044 die "Real-time processing not enabled\n"
2045 unless $conf->exists('business-onlinepayment');
2046 eval "use Business::OnlinePayment";
2050 my $bop_config = 'business-onlinepayment';
2051 $bop_config .= '-ach'
2052 if $method eq 'ECHECK' && $conf->exists($bop_config. '-ach');
2053 my ( $processor, $login, $password, $unused_action, @bop_options ) =
2054 $conf->config($bop_config);
2055 #$action ||= 'normal authorization';
2056 pop @bop_options if scalar(@bop_options) % 2 && $bop_options[-1] =~ /^\s*$/;
2057 die "No real-time processor is enabled - ".
2058 "did you set the business-onlinepayment configuration value?\n"
2062 my $amount = $options{'amount'};
2063 my( $pay_processor, $auth, $order_number ) = ( '', '', '' );
2064 if ( $options{'paynum'} ) {
2065 warn "FS::cust_main::realtime_bop: paynum: $options{paynum}\n" if $DEBUG;
2066 $cust_pay = qsearchs('cust_pay', { paynum=>$options{'paynum'} } )
2067 or return "Unknown paynum $options{'paynum'}";
2068 $amount ||= $cust_pay->paid;
2069 $cust_pay->paybatch =~ /^(\w+):(\w*)(:(\w+))?$/
2070 or return "Can't parse paybatch for paynum $options{'paynum'}: ".
2071 $cust_pay->paybatch;
2072 ( $pay_processor, $auth, $order_number ) = ( $1, $2, $4 );
2073 return "processor of payment $options{'paynum'} $pay_processor does not".
2074 " match current processor $processor"
2075 unless $pay_processor eq $processor;
2077 return "neither amount nor paynum specified" unless $amount;
2082 'password' => $password,
2083 'order_number' => $order_number,
2084 'amount' => $amount,
2085 'referer' => 'http://cleanwhisker.420.am/',
2087 $content{authorization} = $auth
2088 if length($auth); #echeck/ACH transactions have an order # but no auth
2089 #(at least with authorize.net)
2091 #first try void if applicable
2092 if ( $cust_pay && $cust_pay->paid == $amount ) { #and check dates?
2093 my $void = new Business::OnlinePayment( $processor, @bop_options );
2094 $void->content( 'action' => 'void', %content );
2096 if ( $void->is_success ) {
2097 my $error = $cust_pay->void($options{'reason'});
2099 # gah, even with transactions.
2100 my $e = 'WARNING: Card/ACH voided but database not updated - '.
2101 "error voiding payment: $error";
2110 my $address = $self->address1;
2111 $address .= ", ". $self->address2 if $self->address2;
2113 my($payname, $payfirst, $paylast);
2114 if ( $self->payname && $method ne 'ECHECK' ) {
2115 $payname = $self->payname;
2116 $payname =~ /^\s*([\w \,\.\-\']*)?\s+([\w\,\.\-\']+)\s*$/
2117 or return "Illegal payname $payname";
2118 ($payfirst, $paylast) = ($1, $2);
2120 $payfirst = $self->getfield('first');
2121 $paylast = $self->getfield('last');
2122 $payname = "$payfirst $paylast";
2125 if ( $method eq 'CC' ) {
2127 $content{card_number} = $self->payinfo;
2128 $self->paydate =~ /^\d{2}(\d{2})[\/\-](\d+)[\/\-]\d+$/;
2129 $content{expiration} = "$2/$1";
2131 #$content{cvv2} = $self->paycvv
2132 # if defined $self->dbdef_table->column('paycvv')
2133 # && length($self->paycvv);
2135 #$content{recurring_billing} = 'YES'
2136 # if qsearch('cust_pay', { 'custnum' => $self->custnum,
2137 # 'payby' => 'CARD',
2138 # 'payinfo' => $self->payinfo, } );
2140 } elsif ( $method eq 'ECHECK' ) {
2141 ( $content{account_number}, $content{routing_code} ) =
2142 split('@', $self->payinfo);
2143 $content{bank_name} = $self->payname;
2144 $content{account_type} = 'CHECKING';
2145 $content{account_name} = $payname;
2146 $content{customer_org} = $self->company ? 'B' : 'I';
2147 $content{customer_ssn} = $self->ss;
2148 } elsif ( $method eq 'LEC' ) {
2149 $content{phone} = $self->payinfo;
2153 my $refund = new Business::OnlinePayment( $processor, @bop_options );
2155 'action' => 'credit',
2156 'customer_id' => $self->custnum,
2157 'last_name' => $paylast,
2158 'first_name' => $payfirst,
2160 'address' => $address,
2161 'city' => $self->city,
2162 'state' => $self->state,
2163 'zip' => $self->zip,
2164 'country' => $self->country,
2169 return "$processor error: ". $refund->error_message
2170 unless $refund->is_success();
2172 my %method2payby = (
2178 my $paybatch = "$processor:". $refund->authorization;
2179 $paybatch .= ':'. $refund->order_number
2180 if $refund->can('order_number') && $refund->order_number;
2182 while ( $cust_pay && $cust_pay->unappled < $amount ) {
2183 my @cust_bill_pay = $cust_pay->cust_bill_pay;
2184 last unless @cust_bill_pay;
2185 my $cust_bill_pay = pop @cust_bill_pay;
2186 my $error = $cust_bill_pay->delete;
2190 my $cust_refund = new FS::cust_refund ( {
2191 'custnum' => $self->custnum,
2192 'paynum' => $options{'paynum'},
2193 'refund' => $amount,
2195 'payby' => $method2payby{$method},
2196 'payinfo' => $self->payinfo,
2197 'paybatch' => $paybatch,
2198 'reason' => $options{'reason'} || 'card or ACH refund',
2200 my $error = $cust_refund->insert;
2202 $cust_refund->paynum(''); #try again with no specific paynum
2203 my $error2 = $cust_refund->insert;
2205 # gah, even with transactions.
2206 my $e = 'WARNING: Card/ACH refunded but database not updated - '.
2207 "error inserting refund ($processor): $error2".
2208 " (previously tried insert with paynum #$options{'paynum'}" .
2221 Returns the total owed for this customer on all invoices
2222 (see L<FS::cust_bill/owed>).
2228 $self->total_owed_date(2145859200); #12/31/2037
2231 =item total_owed_date TIME
2233 Returns the total owed for this customer on all invoices with date earlier than
2234 TIME. TIME is specified as a UNIX timestamp; see L<perlfunc/"time">). Also
2235 see L<Time::Local> and L<Date::Parse> for conversion functions.
2239 sub total_owed_date {
2243 foreach my $cust_bill (
2244 grep { $_->_date <= $time }
2245 qsearch('cust_bill', { 'custnum' => $self->custnum, } )
2247 $total_bill += $cust_bill->owed;
2249 sprintf( "%.2f", $total_bill );
2252 =item apply_credits OPTION => VALUE ...
2254 Applies (see L<FS::cust_credit_bill>) unapplied credits (see L<FS::cust_credit>)
2255 to outstanding invoice balances in chronological order (or reverse
2256 chronological order if the I<order> option is set to B<newest>) and returns the
2257 value of any remaining unapplied credits available for refund (see
2258 L<FS::cust_refund>).
2266 return 0 unless $self->total_credited;
2268 my @credits = sort { $b->_date <=> $a->_date} (grep { $_->credited > 0 }
2269 qsearch('cust_credit', { 'custnum' => $self->custnum } ) );
2271 my @invoices = $self->open_cust_bill;
2272 @invoices = sort { $b->_date <=> $a->_date } @invoices
2273 if defined($opt{'order'}) && $opt{'order'} eq 'newest';
2276 foreach my $cust_bill ( @invoices ) {
2279 if ( !defined($credit) || $credit->credited == 0) {
2280 $credit = pop @credits or last;
2283 if ($cust_bill->owed >= $credit->credited) {
2284 $amount=$credit->credited;
2286 $amount=$cust_bill->owed;
2289 my $cust_credit_bill = new FS::cust_credit_bill ( {
2290 'crednum' => $credit->crednum,
2291 'invnum' => $cust_bill->invnum,
2292 'amount' => $amount,
2294 my $error = $cust_credit_bill->insert;
2295 die $error if $error;
2297 redo if ($cust_bill->owed > 0);
2301 return $self->total_credited;
2304 =item apply_payments
2306 Applies (see L<FS::cust_bill_pay>) unapplied payments (see L<FS::cust_pay>)
2307 to outstanding invoice balances in chronological order.
2309 #and returns the value of any remaining unapplied payments.
2313 sub apply_payments {
2318 my @payments = sort { $b->_date <=> $a->_date } ( grep { $_->unapplied > 0 }
2319 qsearch('cust_pay', { 'custnum' => $self->custnum } ) );
2321 my @invoices = sort { $a->_date <=> $b->_date} (grep { $_->owed > 0 }
2322 qsearch('cust_bill', { 'custnum' => $self->custnum } ) );
2326 foreach my $cust_bill ( @invoices ) {
2329 if ( !defined($payment) || $payment->unapplied == 0 ) {
2330 $payment = pop @payments or last;
2333 if ( $cust_bill->owed >= $payment->unapplied ) {
2334 $amount = $payment->unapplied;
2336 $amount = $cust_bill->owed;
2339 my $cust_bill_pay = new FS::cust_bill_pay ( {
2340 'paynum' => $payment->paynum,
2341 'invnum' => $cust_bill->invnum,
2342 'amount' => $amount,
2344 my $error = $cust_bill_pay->insert;
2345 die $error if $error;
2347 redo if ( $cust_bill->owed > 0);
2351 return $self->total_unapplied_payments;
2354 =item total_credited
2356 Returns the total outstanding credit (see L<FS::cust_credit>) for this
2357 customer. See L<FS::cust_credit/credited>.
2361 sub total_credited {
2363 my $total_credit = 0;
2364 foreach my $cust_credit ( qsearch('cust_credit', {
2365 'custnum' => $self->custnum,
2367 $total_credit += $cust_credit->credited;
2369 sprintf( "%.2f", $total_credit );
2372 =item total_unapplied_payments
2374 Returns the total unapplied payments (see L<FS::cust_pay>) for this customer.
2375 See L<FS::cust_pay/unapplied>.
2379 sub total_unapplied_payments {
2381 my $total_unapplied = 0;
2382 foreach my $cust_pay ( qsearch('cust_pay', {
2383 'custnum' => $self->custnum,
2385 $total_unapplied += $cust_pay->unapplied;
2387 sprintf( "%.2f", $total_unapplied );
2392 Returns the balance for this customer (total_owed minus total_credited
2393 minus total_unapplied_payments).
2400 $self->total_owed - $self->total_credited - $self->total_unapplied_payments
2404 =item balance_date TIME
2406 Returns the balance for this customer, only considering invoices with date
2407 earlier than TIME (total_owed_date minus total_credited minus
2408 total_unapplied_payments). TIME is specified as a UNIX timestamp; see
2409 L<perlfunc/"time">). Also see L<Time::Local> and L<Date::Parse> for conversion
2418 $self->total_owed_date($time)
2419 - $self->total_credited
2420 - $self->total_unapplied_payments
2424 =item paydate_monthyear
2426 Returns a two-element list consisting of the month and year of this customer's
2427 paydate (credit card expiration date for CARD customers)
2431 sub paydate_monthyear {
2433 if ( $self->paydate =~ /^(\d{4})-(\d{1,2})-\d{1,2}$/ ) { #Pg date format
2435 } elsif ( $self->paydate =~ /^(\d{1,2})-(\d{1,2}-)?(\d{4}$)/ ) {
2442 =item payinfo_masked
2444 Returns a "masked" payinfo field with all but the last four characters replaced
2445 by 'x'es. Useful for displaying credit cards.
2449 sub payinfo_masked {
2451 my $payinfo = $self->payinfo;
2452 'x'x(length($payinfo)-4). substr($payinfo,(length($payinfo)-4));
2455 =item invoicing_list [ ARRAYREF ]
2457 If an arguement is given, sets these email addresses as invoice recipients
2458 (see L<FS::cust_main_invoice>). Errors are not fatal and are not reported
2459 (except as warnings), so use check_invoicing_list first.
2461 Returns a list of email addresses (with svcnum entries expanded).
2463 Note: You can clear the invoicing list by passing an empty ARRAYREF. You can
2464 check it without disturbing anything by passing nothing.
2466 This interface may change in the future.
2470 sub invoicing_list {
2471 my( $self, $arrayref ) = @_;
2473 my @cust_main_invoice;
2474 if ( $self->custnum ) {
2475 @cust_main_invoice =
2476 qsearch( 'cust_main_invoice', { 'custnum' => $self->custnum } );
2478 @cust_main_invoice = ();
2480 foreach my $cust_main_invoice ( @cust_main_invoice ) {
2481 #warn $cust_main_invoice->destnum;
2482 unless ( grep { $cust_main_invoice->address eq $_ } @{$arrayref} ) {
2483 #warn $cust_main_invoice->destnum;
2484 my $error = $cust_main_invoice->delete;
2485 warn $error if $error;
2488 if ( $self->custnum ) {
2489 @cust_main_invoice =
2490 qsearch( 'cust_main_invoice', { 'custnum' => $self->custnum } );
2492 @cust_main_invoice = ();
2494 my %seen = map { $_->address => 1 } @cust_main_invoice;
2495 foreach my $address ( @{$arrayref} ) {
2496 next if exists $seen{$address} && $seen{$address};
2497 $seen{$address} = 1;
2498 my $cust_main_invoice = new FS::cust_main_invoice ( {
2499 'custnum' => $self->custnum,
2502 my $error = $cust_main_invoice->insert;
2503 warn $error if $error;
2506 if ( $self->custnum ) {
2508 qsearch( 'cust_main_invoice', { 'custnum' => $self->custnum } );
2514 =item check_invoicing_list ARRAYREF
2516 Checks these arguements as valid input for the invoicing_list method. If there
2517 is an error, returns the error, otherwise returns false.
2521 sub check_invoicing_list {
2522 my( $self, $arrayref ) = @_;
2523 foreach my $address ( @{$arrayref} ) {
2524 my $cust_main_invoice = new FS::cust_main_invoice ( {
2525 'custnum' => $self->custnum,
2528 my $error = $self->custnum
2529 ? $cust_main_invoice->check
2530 : $cust_main_invoice->checkdest
2532 return $error if $error;
2537 =item set_default_invoicing_list
2539 Sets the invoicing list to all accounts associated with this customer,
2540 overwriting any previous invoicing list.
2544 sub set_default_invoicing_list {
2546 $self->invoicing_list($self->all_emails);
2551 Returns the email addresses of all accounts provisioned for this customer.
2558 foreach my $cust_pkg ( $self->all_pkgs ) {
2559 my @cust_svc = qsearch('cust_svc', { 'pkgnum' => $cust_pkg->pkgnum } );
2561 map { qsearchs('svc_acct', { 'svcnum' => $_->svcnum } ) }
2562 grep { qsearchs('svc_acct', { 'svcnum' => $_->svcnum } ) }
2564 $list{$_}=1 foreach map { $_->email } @svc_acct;
2569 =item invoicing_list_addpost
2571 Adds postal invoicing to this customer. If this customer is already configured
2572 to receive postal invoices, does nothing.
2576 sub invoicing_list_addpost {
2578 return if grep { $_ eq 'POST' } $self->invoicing_list;
2579 my @invoicing_list = $self->invoicing_list;
2580 push @invoicing_list, 'POST';
2581 $self->invoicing_list(\@invoicing_list);
2584 =item referral_cust_main [ DEPTH [ EXCLUDE_HASHREF ] ]
2586 Returns an array of customers referred by this customer (referral_custnum set
2587 to this custnum). If DEPTH is given, recurses up to the given depth, returning
2588 customers referred by customers referred by this customer and so on, inclusive.
2589 The default behavior is DEPTH 1 (no recursion).
2593 sub referral_cust_main {
2595 my $depth = @_ ? shift : 1;
2596 my $exclude = @_ ? shift : {};
2599 map { $exclude->{$_->custnum}++; $_; }
2600 grep { ! $exclude->{ $_->custnum } }
2601 qsearch( 'cust_main', { 'referral_custnum' => $self->custnum } );
2605 map { $_->referral_cust_main($depth-1, $exclude) }
2612 =item referral_cust_main_ncancelled
2614 Same as referral_cust_main, except only returns customers with uncancelled
2619 sub referral_cust_main_ncancelled {
2621 grep { scalar($_->ncancelled_pkgs) } $self->referral_cust_main;
2624 =item referral_cust_pkg [ DEPTH ]
2626 Like referral_cust_main, except returns a flat list of all unsuspended (and
2627 uncancelled) packages for each customer. The number of items in this list may
2628 be useful for comission calculations (perhaps after a C<grep { my $pkgpart = $_->pkgpart; grep { $_ == $pkgpart } @commission_worthy_pkgparts> } $cust_main-> ).
2632 sub referral_cust_pkg {
2634 my $depth = @_ ? shift : 1;
2636 map { $_->unsuspended_pkgs }
2637 grep { $_->unsuspended_pkgs }
2638 $self->referral_cust_main($depth);
2641 =item credit AMOUNT, REASON
2643 Applies a credit to this customer. If there is an error, returns the error,
2644 otherwise returns false.
2649 my( $self, $amount, $reason ) = @_;
2650 my $cust_credit = new FS::cust_credit {
2651 'custnum' => $self->custnum,
2652 'amount' => $amount,
2653 'reason' => $reason,
2655 $cust_credit->insert;
2658 =item charge AMOUNT [ PKG [ COMMENT [ TAXCLASS ] ] ]
2660 Creates a one-time charge for this customer. If there is an error, returns
2661 the error, otherwise returns false.
2666 my ( $self, $amount ) = ( shift, shift );
2667 my $pkg = @_ ? shift : 'One-time charge';
2668 my $comment = @_ ? shift : '$'. sprintf("%.2f",$amount);
2669 my $taxclass = @_ ? shift : '';
2671 local $SIG{HUP} = 'IGNORE';
2672 local $SIG{INT} = 'IGNORE';
2673 local $SIG{QUIT} = 'IGNORE';
2674 local $SIG{TERM} = 'IGNORE';
2675 local $SIG{TSTP} = 'IGNORE';
2676 local $SIG{PIPE} = 'IGNORE';
2678 my $oldAutoCommit = $FS::UID::AutoCommit;
2679 local $FS::UID::AutoCommit = 0;
2682 my $part_pkg = new FS::part_pkg ( {
2684 'comment' => $comment,
2685 #'setup' => $amount,
2688 'plandata' => "setup_fee=$amount",
2691 'taxclass' => $taxclass,
2694 my $error = $part_pkg->insert;
2696 $dbh->rollback if $oldAutoCommit;
2700 my $pkgpart = $part_pkg->pkgpart;
2701 my %type_pkgs = ( 'typenum' => $self->agent->typenum, 'pkgpart' => $pkgpart );
2702 unless ( qsearchs('type_pkgs', \%type_pkgs ) ) {
2703 my $type_pkgs = new FS::type_pkgs \%type_pkgs;
2704 $error = $type_pkgs->insert;
2706 $dbh->rollback if $oldAutoCommit;
2711 my $cust_pkg = new FS::cust_pkg ( {
2712 'custnum' => $self->custnum,
2713 'pkgpart' => $pkgpart,
2716 $error = $cust_pkg->insert;
2718 $dbh->rollback if $oldAutoCommit;
2722 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2729 Returns all the invoices (see L<FS::cust_bill>) for this customer.
2735 sort { $a->_date <=> $b->_date }
2736 qsearch('cust_bill', { 'custnum' => $self->custnum, } )
2739 =item open_cust_bill
2741 Returns all the open (owed > 0) invoices (see L<FS::cust_bill>) for this
2746 sub open_cust_bill {
2748 grep { $_->owed > 0 } $self->cust_bill;
2753 Returns all the credits (see L<FS::cust_credit>) for this customer.
2759 sort { $a->_date <=> $b->_date }
2760 qsearch( 'cust_credit', { 'custnum' => $self->custnum } )
2765 Returns all the payments (see L<FS::cust_pay>) for this customer.
2771 sort { $a->_date <=> $b->_date }
2772 qsearch( 'cust_pay', { 'custnum' => $self->custnum } )
2777 Returns all voided payments (see L<FS::cust_pay_void>) for this customer.
2783 sort { $a->_date <=> $b->_date }
2784 qsearch( 'cust_pay_void', { 'custnum' => $self->custnum } )
2790 Returns all the refunds (see L<FS::cust_refund>) for this customer.
2796 sort { $a->_date <=> $b->_date }
2797 qsearch( 'cust_refund', { 'custnum' => $self->custnum } )
2800 =item select_for_update
2802 Selects this record with the SQL "FOR UPDATE" command. This can be useful as
2807 sub select_for_update {
2809 qsearch('cust_main', { 'custnum' => $self->custnum }, '*', 'FOR UPDATE' );
2814 Returns a name string for this customer, either "Company (Last, First)" or
2821 my $name = $self->get('last'). ', '. $self->first;
2822 $name = $self->company. " ($name)" if $self->company;
2828 Returns a status string for this customer, currently:
2832 =item prospect - No packages have ever been ordered
2834 =item active - One or more recurring packages is active
2836 =item suspended - All non-cancelled recurring packages are suspended
2838 =item cancelled - All recurring packages are cancelled
2846 for my $status (qw( prospect active suspended cancelled )) {
2847 my $method = $status.'_sql';
2848 my $numnum = ( my $sql = $self->$method() ) =~ s/cust_main\.custnum/?/g;
2849 my $sth = dbh->prepare("SELECT $sql") or die dbh->errstr;
2850 $sth->execute( ($self->custnum) x $numnum ) or die $sth->errstr;
2851 return $status if $sth->fetchrow_arrayref->[0];
2857 Returns a hex triplet color string for this customer's status.
2862 'prospect' => '000000',
2863 'active' => '00CC00',
2864 'suspended' => 'FF9900',
2865 'cancelled' => 'FF0000',
2869 $statuscolor{$self->status};
2874 =head1 CLASS METHODS
2880 Returns an SQL expression identifying prospective cust_main records (customers
2881 with no packages ever ordered)
2885 sub prospect_sql { "
2886 0 = ( SELECT COUNT(*) FROM cust_pkg
2887 WHERE cust_pkg.custnum = cust_main.custnum
2893 Returns an SQL expression identifying active cust_main records.
2898 0 < ( SELECT COUNT(*) FROM cust_pkg
2899 WHERE cust_pkg.custnum = cust_main.custnum
2900 AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
2901 AND ( cust_pkg.susp IS NULL OR cust_pkg.susp = 0 )
2908 Returns an SQL expression identifying suspended cust_main records.
2912 sub suspended_sql { susp_sql(@_); }
2914 0 < ( SELECT COUNT(*) FROM cust_pkg
2915 WHERE cust_pkg.custnum = cust_main.custnum
2916 AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
2918 AND 0 = ( SELECT COUNT(*) FROM cust_pkg
2919 WHERE cust_pkg.custnum = cust_main.custnum
2920 AND ( cust_pkg.susp IS NULL OR cust_pkg.susp = 0 )
2921 AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
2928 Returns an SQL expression identifying cancelled cust_main records.
2932 sub cancelled_sql { cancel_sql(@_); }
2934 0 < ( SELECT COUNT(*) FROM cust_pkg
2935 WHERE cust_pkg.custnum = cust_main.custnum
2937 AND 0 = ( SELECT COUNT(*) FROM cust_pkg
2938 WHERE cust_pkg.custnum = cust_main.custnum
2939 AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
2943 =item fuzzy_search FUZZY_HASHREF [ HASHREF, SELECT, EXTRA_SQL, CACHE_OBJ ]
2945 Performs a fuzzy (approximate) search and returns the matching FS::cust_main
2946 records. Currently, only I<last> or I<company> may be specified (the
2947 appropriate ship_ field is also searched if applicable).
2949 Additional options are the same as FS::Record::qsearch
2954 my( $self, $fuzzy, $hash, @opt) = @_;
2959 check_and_rebuild_fuzzyfiles();
2960 foreach my $field ( keys %$fuzzy ) {
2961 my $sub = \&{"all_$field"};
2963 $match{$_}=1 foreach ( amatch($fuzzy->{$field}, ['i'], @{ &$sub() } ) );
2965 foreach ( keys %match ) {
2966 push @cust_main, qsearch('cust_main', { %$hash, $field=>$_}, @opt);
2967 push @cust_main, qsearch('cust_main', { %$hash, "ship_$field"=>$_}, @opt)
2968 if defined dbdef->table('cust_main')->column('ship_last');
2973 @cust_main = grep { !$saw{$_->custnum}++ } @cust_main;
2985 =item smart_search OPTION => VALUE ...
2987 Accepts the following options: I<search>, the string to search for. The string
2988 will be searched for as a customer number, last name or company name, first
2989 searching for an exact match then fuzzy and substring matches.
2991 Any additional options treated as an additional qualifier on the search
2994 Returns a (possibly empty) array of FS::cust_main objects.
3000 my $search = delete $options{'search'};
3003 if ( $search =~ /^\s*(\d+)\s*$/ ) { # customer # search
3005 push @cust_main, qsearch('cust_main', { 'custnum' => $1, %options } );
3007 } elsif ( $search =~ /^\s*(\S.*\S)\s*$/ ) { #value search
3010 my $q_value = dbh->quote($value);
3013 my $sql = scalar(keys %options) ? ' AND ' : ' WHERE ';
3014 $sql .= " ( LOWER(last) = $q_value OR LOWER(company) = $q_value";
3015 $sql .= " OR LOWER(ship_last) = $q_value OR LOWER(ship_company) = $q_value"
3016 if defined dbdef->table('cust_main')->column('ship_last');
3019 push @cust_main, qsearch( 'cust_main', \%options, '', $sql );
3021 unless ( @cust_main ) { #no exact match, trying substring/fuzzy
3023 #still some false laziness w/ search/cust_main.cgi
3026 push @cust_main, qsearch( 'cust_main',
3027 { 'last' => { 'op' => 'ILIKE',
3028 'value' => "%$q_value%" },
3032 push @cust_main, qsearch( 'cust_main',
3033 { 'ship_last' => { 'op' => 'ILIKE',
3034 'value' => "%$q_value%" },
3039 if defined dbdef->table('cust_main')->column('ship_last');
3041 push @cust_main, qsearch( 'cust_main',
3042 { 'company' => { 'op' => 'ILIKE',
3043 'value' => "%$q_value%" },
3047 push @cust_main, qsearch( 'cust_main',
3048 { 'ship_company' => { 'op' => 'ILIKE',
3049 'value' => "%$q_value%" },
3053 if defined dbdef->table('cust_main')->column('ship_last');
3056 push @cust_main, FS::cust_main->fuzzy_search(
3057 { 'last' => $value },
3060 push @cust_main, FS::cust_main->fuzzy_search(
3061 { 'company' => $value },
3073 =item check_and_rebuild_fuzzyfiles
3077 sub check_and_rebuild_fuzzyfiles {
3078 my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
3079 -e "$dir/cust_main.last" && -e "$dir/cust_main.company"
3080 or &rebuild_fuzzyfiles;
3083 =item rebuild_fuzzyfiles
3087 sub rebuild_fuzzyfiles {
3089 use Fcntl qw(:flock);
3091 my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
3095 open(LASTLOCK,">>$dir/cust_main.last")
3096 or die "can't open $dir/cust_main.last: $!";
3097 flock(LASTLOCK,LOCK_EX)
3098 or die "can't lock $dir/cust_main.last: $!";
3100 my @all_last = map $_->getfield('last'), qsearch('cust_main', {});
3102 grep $_, map $_->getfield('ship_last'), qsearch('cust_main',{})
3103 if defined dbdef->table('cust_main')->column('ship_last');
3105 open (LASTCACHE,">$dir/cust_main.last.tmp")
3106 or die "can't open $dir/cust_main.last.tmp: $!";
3107 print LASTCACHE join("\n", @all_last), "\n";
3108 close LASTCACHE or die "can't close $dir/cust_main.last.tmp: $!";
3110 rename "$dir/cust_main.last.tmp", "$dir/cust_main.last";
3115 open(COMPANYLOCK,">>$dir/cust_main.company")
3116 or die "can't open $dir/cust_main.company: $!";
3117 flock(COMPANYLOCK,LOCK_EX)
3118 or die "can't lock $dir/cust_main.company: $!";
3120 my @all_company = grep $_ ne '', map $_->company, qsearch('cust_main',{});
3122 grep $_ ne '', map $_->ship_company, qsearch('cust_main', {})
3123 if defined dbdef->table('cust_main')->column('ship_last');
3125 open (COMPANYCACHE,">$dir/cust_main.company.tmp")
3126 or die "can't open $dir/cust_main.company.tmp: $!";
3127 print COMPANYCACHE join("\n", @all_company), "\n";
3128 close COMPANYCACHE or die "can't close $dir/cust_main.company.tmp: $!";
3130 rename "$dir/cust_main.company.tmp", "$dir/cust_main.company";
3140 my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
3141 open(LASTCACHE,"<$dir/cust_main.last")
3142 or die "can't open $dir/cust_main.last: $!";
3143 my @array = map { chomp; $_; } <LASTCACHE>;
3153 my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
3154 open(COMPANYCACHE,"<$dir/cust_main.company")
3155 or die "can't open $dir/cust_main.last: $!";
3156 my @array = map { chomp; $_; } <COMPANYCACHE>;
3161 =item append_fuzzyfiles LASTNAME COMPANY
3165 sub append_fuzzyfiles {
3166 my( $last, $company ) = @_;
3168 &check_and_rebuild_fuzzyfiles;
3170 use Fcntl qw(:flock);
3172 my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
3176 open(LAST,">>$dir/cust_main.last")
3177 or die "can't open $dir/cust_main.last: $!";
3179 or die "can't lock $dir/cust_main.last: $!";
3181 print LAST "$last\n";
3184 or die "can't unlock $dir/cust_main.last: $!";
3190 open(COMPANY,">>$dir/cust_main.company")
3191 or die "can't open $dir/cust_main.company: $!";
3192 flock(COMPANY,LOCK_EX)
3193 or die "can't lock $dir/cust_main.company: $!";
3195 print COMPANY "$company\n";
3197 flock(COMPANY,LOCK_UN)
3198 or die "can't unlock $dir/cust_main.company: $!";
3212 #warn join('-',keys %$param);
3213 my $fh = $param->{filehandle};
3214 my $agentnum = $param->{agentnum};
3215 my $refnum = $param->{refnum};
3216 my $pkgpart = $param->{pkgpart};
3217 my @fields = @{$param->{fields}};
3219 eval "use Date::Parse;";
3221 eval "use Text::CSV_XS;";
3224 my $csv = new Text::CSV_XS;
3231 local $SIG{HUP} = 'IGNORE';
3232 local $SIG{INT} = 'IGNORE';
3233 local $SIG{QUIT} = 'IGNORE';
3234 local $SIG{TERM} = 'IGNORE';
3235 local $SIG{TSTP} = 'IGNORE';
3236 local $SIG{PIPE} = 'IGNORE';
3238 my $oldAutoCommit = $FS::UID::AutoCommit;
3239 local $FS::UID::AutoCommit = 0;
3242 #while ( $columns = $csv->getline($fh) ) {
3244 while ( defined($line=<$fh>) ) {
3246 $csv->parse($line) or do {
3247 $dbh->rollback if $oldAutoCommit;
3248 return "can't parse: ". $csv->error_input();
3251 my @columns = $csv->fields();
3252 #warn join('-',@columns);
3255 agentnum => $agentnum,
3257 country => $conf->config('countrydefault') || 'US',
3258 payby => 'BILL', #default
3259 paydate => '12/2037', #default
3261 my $billtime = time;
3262 my %cust_pkg = ( pkgpart => $pkgpart );
3263 foreach my $field ( @fields ) {
3264 if ( $field =~ /^cust_pkg\.(setup|bill|susp|expire|cancel)$/ ) {
3265 #$cust_pkg{$1} = str2time( shift @$columns );
3266 if ( $1 eq 'setup' ) {
3267 $billtime = str2time(shift @columns);
3269 $cust_pkg{$1} = str2time( shift @columns );
3272 #$cust_main{$field} = shift @$columns;
3273 $cust_main{$field} = shift @columns;
3277 my $cust_pkg = new FS::cust_pkg ( \%cust_pkg ) if $pkgpart;
3278 my $cust_main = new FS::cust_main ( \%cust_main );
3280 tie my %hash, 'Tie::RefHash'; #this part is important
3281 $hash{$cust_pkg} = [] if $pkgpart;
3282 my $error = $cust_main->insert( \%hash );
3285 $dbh->rollback if $oldAutoCommit;
3286 return "can't insert customer for $line: $error";
3289 #false laziness w/bill.cgi
3290 $error = $cust_main->bill( 'time' => $billtime );
3292 $dbh->rollback if $oldAutoCommit;
3293 return "can't bill customer for $line: $error";
3296 $cust_main->apply_payments;
3297 $cust_main->apply_credits;
3299 $error = $cust_main->collect();
3301 $dbh->rollback if $oldAutoCommit;
3302 return "can't collect customer for $line: $error";
3308 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
3310 return "Empty file!" unless $imported;
3322 #warn join('-',keys %$param);
3323 my $fh = $param->{filehandle};
3324 my @fields = @{$param->{fields}};
3326 eval "use Date::Parse;";
3328 eval "use Text::CSV_XS;";
3331 my $csv = new Text::CSV_XS;
3338 local $SIG{HUP} = 'IGNORE';
3339 local $SIG{INT} = 'IGNORE';
3340 local $SIG{QUIT} = 'IGNORE';
3341 local $SIG{TERM} = 'IGNORE';
3342 local $SIG{TSTP} = 'IGNORE';
3343 local $SIG{PIPE} = 'IGNORE';
3345 my $oldAutoCommit = $FS::UID::AutoCommit;
3346 local $FS::UID::AutoCommit = 0;
3349 #while ( $columns = $csv->getline($fh) ) {
3351 while ( defined($line=<$fh>) ) {
3353 $csv->parse($line) or do {
3354 $dbh->rollback if $oldAutoCommit;
3355 return "can't parse: ". $csv->error_input();
3358 my @columns = $csv->fields();
3359 #warn join('-',@columns);
3362 foreach my $field ( @fields ) {
3363 $row{$field} = shift @columns;
3366 my $cust_main = qsearchs('cust_main', { 'custnum' => $row{'custnum'} } );
3367 unless ( $cust_main ) {
3368 $dbh->rollback if $oldAutoCommit;
3369 return "unknown custnum $row{'custnum'}";
3372 if ( $row{'amount'} > 0 ) {
3373 my $error = $cust_main->charge($row{'amount'}, $row{'pkg'});
3375 $dbh->rollback if $oldAutoCommit;
3379 } elsif ( $row{'amount'} < 0 ) {
3380 my $error = $cust_main->credit( sprintf( "%.2f", 0-$row{'amount'} ),
3383 $dbh->rollback if $oldAutoCommit;
3393 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
3395 return "Empty file!" unless $imported;
3407 The delete method should possibly take an FS::cust_main object reference
3408 instead of a scalar customer number.
3410 Bill and collect options should probably be passed as references instead of a
3413 There should probably be a configuration file with a list of allowed credit
3416 No multiple currency support (probably a larger project than just this module).
3418 payinfo_masked false laziness with cust_pay.pm and cust_refund.pm
3422 L<FS::Record>, L<FS::cust_pkg>, L<FS::cust_bill>, L<FS::cust_credit>
3423 L<FS::agent>, L<FS::part_referral>, L<FS::cust_main_county>,
3424 L<FS::cust_main_invoice>, L<FS::UID>, schema.html from the base documentation.