5 use vars qw( @ISA @EXPORT_OK $DEBUG $me $conf @encrypted_fields
6 $import $skip_fuzzyfiles $ignore_expired_card @paytypes);
7 use vars qw( $realtime_bop_decline_quiet ); #ugh
11 use Scalar::Util qw( blessed );
12 use Time::Local qw(timelocal);
15 use Digest::MD5 qw(md5_base64);
18 use File::Temp qw( tempfile );
19 use String::Approx qw(amatch);
20 use Business::CreditCard 0.28;
22 use FS::UID qw( getotaker dbh driver_name );
23 use FS::Record qw( qsearchs qsearch dbdef );
24 use FS::Misc qw( generate_email send_email generate_ps do_print );
25 use FS::Msgcat qw(gettext);
30 use FS::cust_bill_pkg;
31 use FS::cust_bill_pkg_display;
32 use FS::cust_bill_pkg_tax_location;
34 use FS::cust_pay_pending;
35 use FS::cust_pay_void;
36 use FS::cust_pay_batch;
39 use FS::part_referral;
40 use FS::cust_main_county;
41 use FS::cust_location;
43 use FS::cust_tax_location;
44 use FS::part_pkg_taxrate;
46 use FS::cust_main_invoice;
47 use FS::cust_credit_bill;
48 use FS::cust_bill_pay;
49 use FS::prepay_credit;
53 use FS::part_event_condition;
56 use FS::payment_gateway;
57 use FS::agent_payment_gateway;
59 use FS::payinfo_Mixin;
62 @ISA = qw( FS::payinfo_Mixin FS::Record );
64 @EXPORT_OK = qw( smart_search );
66 $realtime_bop_decline_quiet = 0;
68 # 1 is mostly method/subroutine entry and options
69 # 2 traces progress of some operations
70 # 3 is even more information including possibly sensitive data
72 $me = '[FS::cust_main]';
76 $ignore_expired_card = 0;
78 @encrypted_fields = ('payinfo', 'paycvv');
79 @paytypes = ('', 'Personal checking', 'Personal savings', 'Business checking', 'Business savings');
81 #ask FS::UID to run this stuff for us later
82 #$FS::UID::callback{'FS::cust_main'} = sub {
83 install_callback FS::UID sub {
85 #yes, need it for stuff below (prolly should be cached)
90 my ( $hashref, $cache ) = @_;
91 if ( exists $hashref->{'pkgnum'} ) {
92 #@{ $self->{'_pkgnum'} } = ();
93 my $subcache = $cache->subcache( 'pkgnum', 'cust_pkg', $hashref->{custnum});
94 $self->{'_pkgnum'} = $subcache;
95 #push @{ $self->{'_pkgnum'} },
96 FS::cust_pkg->new_or_cached($hashref, $subcache) if $hashref->{pkgnum};
102 FS::cust_main - Object methods for cust_main records
108 $record = new FS::cust_main \%hash;
109 $record = new FS::cust_main { 'column' => 'value' };
111 $error = $record->insert;
113 $error = $new_record->replace($old_record);
115 $error = $record->delete;
117 $error = $record->check;
119 @cust_pkg = $record->all_pkgs;
121 @cust_pkg = $record->ncancelled_pkgs;
123 @cust_pkg = $record->suspended_pkgs;
125 $error = $record->bill;
126 $error = $record->bill %options;
127 $error = $record->bill 'time' => $time;
129 $error = $record->collect;
130 $error = $record->collect %options;
131 $error = $record->collect 'invoice_time' => $time,
136 An FS::cust_main object represents a customer. FS::cust_main inherits from
137 FS::Record. The following fields are currently supported:
143 Primary key (assigned automatically for new customers)
147 Agent (see L<FS::agent>)
151 Advertising source (see L<FS::part_referral>)
163 Cocial security number (optional)
179 (optional, see L<FS::cust_main_county>)
183 (see L<FS::cust_main_county>)
189 (see L<FS::cust_main_county>)
225 (optional, see L<FS::cust_main_county>)
229 (see L<FS::cust_main_county>)
235 (see L<FS::cust_main_county>)
251 Payment Type (See L<FS::payinfo_Mixin> for valid payby values)
255 Payment Information (See L<FS::payinfo_Mixin> for data format)
259 Masked payinfo (See L<FS::payinfo_Mixin> for how this works)
263 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
267 Expiration date, mm/yyyy, m/yyyy, mm/yy or m/yy
271 Start date month (maestro/solo cards only)
275 Start date year (maestro/solo cards only)
279 Issue number (maestro/solo cards only)
283 Name on card or billing name
287 IP address from which payment information was received
291 Tax exempt, empty or `Y'
295 Order taker (assigned automatically, see L<FS::UID>)
301 =item referral_custnum
303 Referring customer number
307 Enable individual CDR spooling, empty or `Y'
311 A suggestion to events (see L<FS::part_bill_event">) to delay until this unix timestamp
315 Discourage individual CDR printing, empty or `Y'
325 Creates a new customer. To add the customer to the database, see L<"insert">.
327 Note that this stores the hash reference, not a distinct copy of the hash it
328 points to. You can ask the object for a copy with the I<hash> method.
332 sub table { 'cust_main'; }
334 =item insert [ CUST_PKG_HASHREF [ , INVOICING_LIST_ARYREF ] [ , OPTION => VALUE ... ] ]
336 Adds this customer to the database. If there is an error, returns the error,
337 otherwise returns false.
339 CUST_PKG_HASHREF: If you pass a Tie::RefHash data structure to the insert
340 method containing FS::cust_pkg and FS::svc_I<tablename> objects, all records
341 are inserted atomicly, or the transaction is rolled back. Passing an empty
342 hash reference is equivalent to not supplying this parameter. There should be
343 a better explanation of this, but until then, here's an example:
346 tie %hash, 'Tie::RefHash'; #this part is important
348 $cust_pkg => [ $svc_acct ],
351 $cust_main->insert( \%hash );
353 INVOICING_LIST_ARYREF: If you pass an arrarref to the insert method, it will
354 be set as the invoicing list (see L<"invoicing_list">). Errors return as
355 expected and rollback the entire transaction; it is not necessary to call
356 check_invoicing_list first. The invoicing_list is set after the records in the
357 CUST_PKG_HASHREF above are inserted, so it is now possible to set an
358 invoicing_list destination to the newly-created svc_acct. Here's an example:
360 $cust_main->insert( {}, [ $email, 'POST' ] );
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 successfully).
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.)
377 my $cust_pkgs = @_ ? shift : {};
378 my $invoicing_list = @_ ? shift : '';
380 warn "$me insert called with options ".
381 join(', ', map { "$_: $options{$_}" } keys %options ). "\n"
384 local $SIG{HUP} = 'IGNORE';
385 local $SIG{INT} = 'IGNORE';
386 local $SIG{QUIT} = 'IGNORE';
387 local $SIG{TERM} = 'IGNORE';
388 local $SIG{TSTP} = 'IGNORE';
389 local $SIG{PIPE} = 'IGNORE';
391 my $oldAutoCommit = $FS::UID::AutoCommit;
392 local $FS::UID::AutoCommit = 0;
395 my $prepay_identifier = '';
396 my( $amount, $seconds, $upbytes, $downbytes, $totalbytes ) = (0, 0, 0, 0, 0);
398 if ( $self->payby eq 'PREPAY' ) {
400 $self->payby('BILL');
401 $prepay_identifier = $self->payinfo;
404 warn " looking up prepaid card $prepay_identifier\n"
407 my $error = $self->get_prepay( $prepay_identifier,
408 'amount_ref' => \$amount,
409 'seconds_ref' => \$seconds,
410 'upbytes_ref' => \$upbytes,
411 'downbytes_ref' => \$downbytes,
412 'totalbytes_ref' => \$totalbytes,
415 $dbh->rollback if $oldAutoCommit;
416 #return "error applying prepaid card (transaction rolled back): $error";
420 $payby = 'PREP' if $amount;
422 } elsif ( $self->payby =~ /^(CASH|WEST|MCRD)$/ ) {
425 $self->payby('BILL');
426 $amount = $self->paid;
430 warn " inserting $self\n"
433 $self->signupdate(time) unless $self->signupdate;
435 $self->auto_agent_custid()
436 if $conf->config('cust_main-auto_agent_custid') && ! $self->agent_custid;
438 my $error = $self->SUPER::insert;
440 $dbh->rollback if $oldAutoCommit;
441 #return "inserting cust_main record (transaction rolled back): $error";
445 warn " setting invoicing list\n"
448 if ( $invoicing_list ) {
449 $error = $self->check_invoicing_list( $invoicing_list );
451 $dbh->rollback if $oldAutoCommit;
452 #return "checking invoicing_list (transaction rolled back): $error";
455 $self->invoicing_list( $invoicing_list );
458 if ( $conf->config('cust_main-skeleton_tables')
459 && $conf->config('cust_main-skeleton_custnum') ) {
461 warn " inserting skeleton records\n"
464 my $error = $self->start_copy_skel;
466 $dbh->rollback if $oldAutoCommit;
472 warn " ordering packages\n"
475 $error = $self->order_pkgs( $cust_pkgs,
477 'seconds_ref' => \$seconds,
478 'upbytes_ref' => \$upbytes,
479 'downbytes_ref' => \$downbytes,
480 'totalbytes_ref' => \$totalbytes,
483 $dbh->rollback if $oldAutoCommit;
488 $dbh->rollback if $oldAutoCommit;
489 return "No svc_acct record to apply pre-paid time";
491 if ( $upbytes || $downbytes || $totalbytes ) {
492 $dbh->rollback if $oldAutoCommit;
493 return "No svc_acct record to apply pre-paid data";
497 warn " inserting initial $payby payment of $amount\n"
499 $error = $self->insert_cust_pay($payby, $amount, $prepay_identifier);
501 $dbh->rollback if $oldAutoCommit;
502 return "inserting payment (transaction rolled back): $error";
506 unless ( $import || $skip_fuzzyfiles ) {
507 warn " queueing fuzzyfiles update\n"
509 $error = $self->queue_fuzzyfiles_update;
511 $dbh->rollback if $oldAutoCommit;
512 return "updating fuzzy search cache: $error";
516 warn " insert complete; committing transaction\n"
519 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
524 use File::CounterFile;
525 sub auto_agent_custid {
528 my $format = $conf->config('cust_main-auto_agent_custid');
530 if ( $format eq '1YMMXXXXXXXX' ) {
532 my $counter = new File::CounterFile 'cust_main.agent_custid';
535 my $ym = 100000000000 + time2str('%y%m00000000', time);
536 if ( $ym > $counter->value ) {
537 $counter->{'value'} = $agent_custid = $ym;
538 $counter->{'updated'} = 1;
540 $agent_custid = $counter->inc;
546 die "Unknown cust_main-auto_agent_custid format: $format";
549 $self->agent_custid($agent_custid);
553 sub start_copy_skel {
556 #'mg_user_preference' => {},
557 #'mg_user_indicator_profile.user_indicator_profile_id' => { 'mg_profile_indicator.profile_indicator_id' => { 'mg_profile_details.profile_detail_id' }, },
558 #'mg_watchlist_header.watchlist_header_id' => { 'mg_watchlist_details.watchlist_details_id' },
559 #'mg_user_grid_header.grid_header_id' => { 'mg_user_grid_details.user_grid_details_id' },
560 #'mg_portfolio_header.portfolio_header_id' => { 'mg_portfolio_trades.portfolio_trades_id' => { 'mg_portfolio_trades_positions.portfolio_trades_positions_id' } },
561 my @tables = eval(join('\n',$conf->config('cust_main-skeleton_tables')));
564 _copy_skel( 'cust_main', #tablename
565 $conf->config('cust_main-skeleton_custnum'), #sourceid
566 $self->custnum, #destid
567 @tables, #child tables
571 #recursive subroutine, not a method
573 my( $table, $sourceid, $destid, %child_tables ) = @_;
576 if ( $table =~ /^(\w+)\.(\w+)$/ ) {
577 ( $table, $primary_key ) = ( $1, $2 );
579 my $dbdef_table = dbdef->table($table);
580 $primary_key = $dbdef_table->primary_key
581 or return "$table has no primary key".
582 " (or do you need to run dbdef-create?)";
585 warn " _copy_skel: $table.$primary_key $sourceid to $destid for ".
586 join (', ', keys %child_tables). "\n"
589 foreach my $child_table_def ( keys %child_tables ) {
593 if ( $child_table_def =~ /^(\w+)\.(\w+)$/ ) {
594 ( $child_table, $child_pkey ) = ( $1, $2 );
596 $child_table = $child_table_def;
598 $child_pkey = dbdef->table($child_table)->primary_key;
599 # or return "$table has no primary key".
600 # " (or do you need to run dbdef-create?)\n";
604 if ( keys %{ $child_tables{$child_table_def} } ) {
606 return "$child_table has no primary key".
607 " (run dbdef-create or try specifying it?)\n"
610 #false laziness w/Record::insert and only works on Pg
611 #refactor the proper last-inserted-id stuff out of Record::insert if this
612 # ever gets use for anything besides a quick kludge for one customer
613 my $default = dbdef->table($child_table)->column($child_pkey)->default;
614 $default =~ /^nextval\(\(?'"?([\w\.]+)"?'/i
615 or return "can't parse $child_table.$child_pkey default value ".
616 " for sequence name: $default";
621 my @sel_columns = grep { $_ ne $primary_key }
622 dbdef->table($child_table)->columns;
623 my $sel_columns = join(', ', @sel_columns );
625 my @ins_columns = grep { $_ ne $child_pkey } @sel_columns;
626 my $ins_columns = ' ( '. join(', ', $primary_key, @ins_columns ). ' ) ';
627 my $placeholders = ' ( ?, '. join(', ', map '?', @ins_columns ). ' ) ';
629 my $sel_st = "SELECT $sel_columns FROM $child_table".
630 " WHERE $primary_key = $sourceid";
633 my $sel_sth = dbh->prepare( $sel_st )
634 or return dbh->errstr;
636 $sel_sth->execute or return $sel_sth->errstr;
638 while ( my $row = $sel_sth->fetchrow_hashref ) {
640 warn " selected row: ".
641 join(', ', map { "$_=".$row->{$_} } keys %$row ). "\n"
645 "INSERT INTO $child_table $ins_columns VALUES $placeholders";
646 my $ins_sth =dbh->prepare($statement)
647 or return dbh->errstr;
648 my @param = ( $destid, map $row->{$_}, @ins_columns );
649 warn " $statement: [ ". join(', ', @param). " ]\n"
651 $ins_sth->execute( @param )
652 or return $ins_sth->errstr;
654 #next unless keys %{ $child_tables{$child_table} };
655 next unless $sequence;
657 #another section of that laziness
658 my $seq_sql = "SELECT currval('$sequence')";
659 my $seq_sth = dbh->prepare($seq_sql) or return dbh->errstr;
660 $seq_sth->execute or return $seq_sth->errstr;
661 my $insertid = $seq_sth->fetchrow_arrayref->[0];
663 # don't drink soap! recurse! recurse! okay!
665 _copy_skel( $child_table_def,
666 $row->{$child_pkey}, #sourceid
668 %{ $child_tables{$child_table_def} },
670 return $error if $error;
680 =item order_pkg HASHREF | OPTION => VALUE ...
682 Orders a single package.
684 Options may be passed as a list of key/value pairs or as a hash reference.
695 Optional FS::cust_location object
699 Optional arryaref of FS::svc_* service objects.
703 If this option is set to a job queue jobnum (see L<FS::queue>), all provisioning
704 jobs will have a dependancy on the supplied job (they will not run until the
705 specific job completes). This can be used to defer provisioning until some
706 action completes (such as running the customer's credit card successfully).
714 my $opt = ref($_[0]) ? shift : { @_ };
716 warn "$me order_pkg called with options ".
717 join(', ', map { "$_: $opt->{$_}" } keys %$opt ). "\n"
720 my $cust_pkg = $opt->{'cust_pkg'};
721 my $svcs = $opt->{'svcs'} || [];
723 my %svc_options = ();
724 $svc_options{'depend_jobnum'} = $opt->{'depend_jobnum'}
725 if exists($opt->{'depend_jobnum'}) && $opt->{'depend_jobnum'};
727 local $SIG{HUP} = 'IGNORE';
728 local $SIG{INT} = 'IGNORE';
729 local $SIG{QUIT} = 'IGNORE';
730 local $SIG{TERM} = 'IGNORE';
731 local $SIG{TSTP} = 'IGNORE';
732 local $SIG{PIPE} = 'IGNORE';
734 my $oldAutoCommit = $FS::UID::AutoCommit;
735 local $FS::UID::AutoCommit = 0;
738 if ( $opt->{'cust_location'} &&
739 ( ! $cust_pkg->locationnum || $cust_pkg->locationnum == -1 ) ) {
740 my $error = $opt->{'cust_location'}->insert;
742 $dbh->rollback if $oldAutoCommit;
743 return "inserting cust_location (transaction rolled back): $error";
745 $cust_pkg->locationnum($opt->{'cust_location'}->locationnum);
748 $cust_pkg->custnum( $self->custnum );
750 my $error = $cust_pkg->insert;
752 $dbh->rollback if $oldAutoCommit;
753 return "inserting cust_pkg (transaction rolled back): $error";
756 foreach my $svc_something ( @{ $opt->{'svcs'} } ) {
757 if ( $svc_something->svcnum ) {
758 my $old_cust_svc = $svc_something->cust_svc;
759 my $new_cust_svc = new FS::cust_svc { $old_cust_svc->hash };
760 $new_cust_svc->pkgnum( $cust_pkg->pkgnum);
761 $error = $new_cust_svc->replace($old_cust_svc);
763 $svc_something->pkgnum( $cust_pkg->pkgnum );
764 if ( $svc_something->isa('FS::svc_acct') ) {
765 foreach ( grep { $opt->{$_.'_ref'} && ${ $opt->{$_.'_ref'} } }
766 qw( seconds upbytes downbytes totalbytes ) ) {
767 $svc_something->$_( $svc_something->$_() + ${ $opt->{$_.'_ref'} } );
768 ${ $opt->{$_.'_ref'} } = 0;
771 $error = $svc_something->insert(%svc_options);
774 $dbh->rollback if $oldAutoCommit;
775 return "inserting svc_ (transaction rolled back): $error";
779 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
784 #deprecated #=item order_pkgs HASHREF [ , SECONDSREF ] [ , OPTION => VALUE ... ]
785 =item order_pkgs HASHREF [ , OPTION => VALUE ... ]
787 Like the insert method on an existing record, this method orders multiple
788 packages and included services atomicaly. Pass a Tie::RefHash data structure
789 to this method containing FS::cust_pkg and FS::svc_I<tablename> objects.
790 There should be a better explanation of this, but until then, here's an
794 tie %hash, 'Tie::RefHash'; #this part is important
796 $cust_pkg => [ $svc_acct ],
799 $cust_main->order_pkgs( \%hash, 'noexport'=>1 );
801 Services can be new, in which case they are inserted, or existing unaudited
802 services, in which case they are linked to the newly-created package.
804 Currently available options are: I<depend_jobnum>, I<noexport>, I<seconds_ref>,
805 I<upbytes_ref>, I<downbytes_ref>, and I<totalbytes_ref>.
807 If I<depend_jobnum> is set, all provisioning jobs will have a dependancy
808 on the supplied jobnum (they will not run until the specific job completes).
809 This can be used to defer provisioning until some action completes (such
810 as running the customer's credit card successfully).
812 The I<noexport> option is deprecated. If I<noexport> is set true, no
813 provisioning jobs (exports) are scheduled. (You can schedule them later with
814 the B<reexport> method for each cust_pkg object. Using the B<reexport> method
815 on the cust_main object is not recommended, as existing services will also be
818 If I<seconds_ref>, I<upbytes_ref>, I<downbytes_ref>, or I<totalbytes_ref> is
819 provided, the scalars (provided by references) will be incremented by the
820 values of the prepaid card.`
826 my $cust_pkgs = shift;
827 my $seconds_ref = ref($_[0]) ? shift : ''; #deprecated
829 $seconds_ref ||= $options{'seconds_ref'};
831 warn "$me order_pkgs called with options ".
832 join(', ', map { "$_: $options{$_}" } keys %options ). "\n"
835 local $SIG{HUP} = 'IGNORE';
836 local $SIG{INT} = 'IGNORE';
837 local $SIG{QUIT} = 'IGNORE';
838 local $SIG{TERM} = 'IGNORE';
839 local $SIG{TSTP} = 'IGNORE';
840 local $SIG{PIPE} = 'IGNORE';
842 my $oldAutoCommit = $FS::UID::AutoCommit;
843 local $FS::UID::AutoCommit = 0;
846 local $FS::svc_Common::noexport_hack = 1 if $options{'noexport'};
848 foreach my $cust_pkg ( keys %$cust_pkgs ) {
850 my $error = $self->order_pkg(
851 'cust_pkg' => $cust_pkg,
852 'svcs' => $cust_pkgs->{$cust_pkg},
853 'seconds_ref' => $seconds_ref,
854 map { $_ => $options{$_} } qw( upbytes_ref downbytes_ref totalbytes_ref
859 $dbh->rollback if $oldAutoCommit;
865 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
869 =item recharge_prepay IDENTIFIER | PREPAY_CREDIT_OBJ [ , AMOUNTREF, SECONDSREF, UPBYTEREF, DOWNBYTEREF ]
871 Recharges this (existing) customer with the specified prepaid card (see
872 L<FS::prepay_credit>), specified either by I<identifier> or as an
873 FS::prepay_credit object. If there is an error, returns the error, otherwise
876 Optionally, five scalar references can be passed as well. They will have their
877 values filled in with the amount, number of seconds, and number of upload,
878 download, and total bytes applied by this prepaid card.
882 #the ref bullshit here should be refactored like get_prepay. MyAccount.pm is
883 #the only place that uses these args
884 sub recharge_prepay {
885 my( $self, $prepay_credit, $amountref, $secondsref,
886 $upbytesref, $downbytesref, $totalbytesref ) = @_;
888 local $SIG{HUP} = 'IGNORE';
889 local $SIG{INT} = 'IGNORE';
890 local $SIG{QUIT} = 'IGNORE';
891 local $SIG{TERM} = 'IGNORE';
892 local $SIG{TSTP} = 'IGNORE';
893 local $SIG{PIPE} = 'IGNORE';
895 my $oldAutoCommit = $FS::UID::AutoCommit;
896 local $FS::UID::AutoCommit = 0;
899 my( $amount, $seconds, $upbytes, $downbytes, $totalbytes) = ( 0, 0, 0, 0, 0 );
901 my $error = $self->get_prepay( $prepay_credit,
902 'amount_ref' => \$amount,
903 'seconds_ref' => \$seconds,
904 'upbytes_ref' => \$upbytes,
905 'downbytes_ref' => \$downbytes,
906 'totalbytes_ref' => \$totalbytes,
908 || $self->increment_seconds($seconds)
909 || $self->increment_upbytes($upbytes)
910 || $self->increment_downbytes($downbytes)
911 || $self->increment_totalbytes($totalbytes)
912 || $self->insert_cust_pay_prepay( $amount,
914 ? $prepay_credit->identifier
919 $dbh->rollback if $oldAutoCommit;
923 if ( defined($amountref) ) { $$amountref = $amount; }
924 if ( defined($secondsref) ) { $$secondsref = $seconds; }
925 if ( defined($upbytesref) ) { $$upbytesref = $upbytes; }
926 if ( defined($downbytesref) ) { $$downbytesref = $downbytes; }
927 if ( defined($totalbytesref) ) { $$totalbytesref = $totalbytes; }
929 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
934 =item get_prepay IDENTIFIER | PREPAY_CREDIT_OBJ [ , OPTION => VALUE ... ]
936 Looks up and deletes a prepaid card (see L<FS::prepay_credit>),
937 specified either by I<identifier> or as an FS::prepay_credit object.
939 Available options are: I<amount_ref>, I<seconds_ref>, I<upbytes_ref>, I<downbytes_ref>, and I<totalbytes_ref>. The scalars (provided by references) will be
940 incremented by the values of the prepaid card.
942 If the prepaid card specifies an I<agentnum> (see L<FS::agent>), it is used to
943 check or set this customer's I<agentnum>.
945 If there is an error, returns the error, otherwise returns false.
951 my( $self, $prepay_credit, %opt ) = @_;
953 local $SIG{HUP} = 'IGNORE';
954 local $SIG{INT} = 'IGNORE';
955 local $SIG{QUIT} = 'IGNORE';
956 local $SIG{TERM} = 'IGNORE';
957 local $SIG{TSTP} = 'IGNORE';
958 local $SIG{PIPE} = 'IGNORE';
960 my $oldAutoCommit = $FS::UID::AutoCommit;
961 local $FS::UID::AutoCommit = 0;
964 unless ( ref($prepay_credit) ) {
966 my $identifier = $prepay_credit;
968 $prepay_credit = qsearchs(
970 { 'identifier' => $prepay_credit },
975 unless ( $prepay_credit ) {
976 $dbh->rollback if $oldAutoCommit;
977 return "Invalid prepaid card: ". $identifier;
982 if ( $prepay_credit->agentnum ) {
983 if ( $self->agentnum && $self->agentnum != $prepay_credit->agentnum ) {
984 $dbh->rollback if $oldAutoCommit;
985 return "prepaid card not valid for agent ". $self->agentnum;
987 $self->agentnum($prepay_credit->agentnum);
990 my $error = $prepay_credit->delete;
992 $dbh->rollback if $oldAutoCommit;
993 return "removing prepay_credit (transaction rolled back): $error";
996 ${ $opt{$_.'_ref'} } += $prepay_credit->$_()
997 for grep $opt{$_.'_ref'}, qw( amount seconds upbytes downbytes totalbytes );
999 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1004 =item increment_upbytes SECONDS
1006 Updates this customer's single or primary account (see L<FS::svc_acct>) by
1007 the specified number of upbytes. If there is an error, returns the error,
1008 otherwise returns false.
1012 sub increment_upbytes {
1013 _increment_column( shift, 'upbytes', @_);
1016 =item increment_downbytes SECONDS
1018 Updates this customer's single or primary account (see L<FS::svc_acct>) by
1019 the specified number of downbytes. If there is an error, returns the error,
1020 otherwise returns false.
1024 sub increment_downbytes {
1025 _increment_column( shift, 'downbytes', @_);
1028 =item increment_totalbytes SECONDS
1030 Updates this customer's single or primary account (see L<FS::svc_acct>) by
1031 the specified number of totalbytes. If there is an error, returns the error,
1032 otherwise returns false.
1036 sub increment_totalbytes {
1037 _increment_column( shift, 'totalbytes', @_);
1040 =item increment_seconds SECONDS
1042 Updates this customer's single or primary account (see L<FS::svc_acct>) by
1043 the specified number of seconds. If there is an error, returns the error,
1044 otherwise returns false.
1048 sub increment_seconds {
1049 _increment_column( shift, 'seconds', @_);
1052 =item _increment_column AMOUNT
1054 Updates this customer's single or primary account (see L<FS::svc_acct>) by
1055 the specified number of seconds or bytes. If there is an error, returns
1056 the error, otherwise returns false.
1060 sub _increment_column {
1061 my( $self, $column, $amount ) = @_;
1062 warn "$me increment_column called: $column, $amount\n"
1065 return '' unless $amount;
1067 my @cust_pkg = grep { $_->part_pkg->svcpart('svc_acct') }
1068 $self->ncancelled_pkgs;
1070 if ( ! @cust_pkg ) {
1071 return 'No packages with primary or single services found'.
1072 ' to apply pre-paid time';
1073 } elsif ( scalar(@cust_pkg) > 1 ) {
1074 #maybe have a way to specify the package/account?
1075 return 'Multiple packages found to apply pre-paid time';
1078 my $cust_pkg = $cust_pkg[0];
1079 warn " found package pkgnum ". $cust_pkg->pkgnum. "\n"
1083 $cust_pkg->cust_svc( $cust_pkg->part_pkg->svcpart('svc_acct') );
1085 if ( ! @cust_svc ) {
1086 return 'No account found to apply pre-paid time';
1087 } elsif ( scalar(@cust_svc) > 1 ) {
1088 return 'Multiple accounts found to apply pre-paid time';
1091 my $svc_acct = $cust_svc[0]->svc_x;
1092 warn " found service svcnum ". $svc_acct->pkgnum.
1093 ' ('. $svc_acct->email. ")\n"
1096 $column = "increment_$column";
1097 $svc_acct->$column($amount);
1101 =item insert_cust_pay_prepay AMOUNT [ PAYINFO ]
1103 Inserts a prepayment in the specified amount for this customer. An optional
1104 second argument can specify the prepayment identifier for tracking purposes.
1105 If there is an error, returns the error, otherwise returns false.
1109 sub insert_cust_pay_prepay {
1110 shift->insert_cust_pay('PREP', @_);
1113 =item insert_cust_pay_cash AMOUNT [ PAYINFO ]
1115 Inserts a cash payment in the specified amount for this customer. An optional
1116 second argument can specify the payment identifier for tracking purposes.
1117 If there is an error, returns the error, otherwise returns false.
1121 sub insert_cust_pay_cash {
1122 shift->insert_cust_pay('CASH', @_);
1125 =item insert_cust_pay_west AMOUNT [ PAYINFO ]
1127 Inserts a Western Union payment in the specified amount for this customer. An
1128 optional second argument can specify the prepayment identifier for tracking
1129 purposes. If there is an error, returns the error, otherwise returns false.
1133 sub insert_cust_pay_west {
1134 shift->insert_cust_pay('WEST', @_);
1137 sub insert_cust_pay {
1138 my( $self, $payby, $amount ) = splice(@_, 0, 3);
1139 my $payinfo = scalar(@_) ? shift : '';
1141 my $cust_pay = new FS::cust_pay {
1142 'custnum' => $self->custnum,
1143 'paid' => sprintf('%.2f', $amount),
1144 #'_date' => #date the prepaid card was purchased???
1146 'payinfo' => $payinfo,
1154 This method is deprecated. See the I<depend_jobnum> option to the insert and
1155 order_pkgs methods for a better way to defer provisioning.
1157 Re-schedules all exports by calling the B<reexport> method of all associated
1158 packages (see L<FS::cust_pkg>). If there is an error, returns the error;
1159 otherwise returns false.
1166 carp "WARNING: FS::cust_main::reexport is deprectated; ".
1167 "use the depend_jobnum option to insert or order_pkgs to delay export";
1169 local $SIG{HUP} = 'IGNORE';
1170 local $SIG{INT} = 'IGNORE';
1171 local $SIG{QUIT} = 'IGNORE';
1172 local $SIG{TERM} = 'IGNORE';
1173 local $SIG{TSTP} = 'IGNORE';
1174 local $SIG{PIPE} = 'IGNORE';
1176 my $oldAutoCommit = $FS::UID::AutoCommit;
1177 local $FS::UID::AutoCommit = 0;
1180 foreach my $cust_pkg ( $self->ncancelled_pkgs ) {
1181 my $error = $cust_pkg->reexport;
1183 $dbh->rollback if $oldAutoCommit;
1188 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1193 =item delete NEW_CUSTNUM
1195 This deletes the customer. If there is an error, returns the error, otherwise
1198 This will completely remove all traces of the customer record. This is not
1199 what you want when a customer cancels service; for that, cancel all of the
1200 customer's packages (see L</cancel>).
1202 If the customer has any uncancelled packages, you need to pass a new (valid)
1203 customer number for those packages to be transferred to. Cancelled packages
1204 will be deleted. Did I mention that this is NOT what you want when a customer
1205 cancels service and that you really should be looking see L<FS::cust_pkg/cancel>?
1207 You can't delete a customer with invoices (see L<FS::cust_bill>),
1208 or credits (see L<FS::cust_credit>), payments (see L<FS::cust_pay>) or
1209 refunds (see L<FS::cust_refund>).
1216 local $SIG{HUP} = 'IGNORE';
1217 local $SIG{INT} = 'IGNORE';
1218 local $SIG{QUIT} = 'IGNORE';
1219 local $SIG{TERM} = 'IGNORE';
1220 local $SIG{TSTP} = 'IGNORE';
1221 local $SIG{PIPE} = 'IGNORE';
1223 my $oldAutoCommit = $FS::UID::AutoCommit;
1224 local $FS::UID::AutoCommit = 0;
1227 if ( $self->cust_bill ) {
1228 $dbh->rollback if $oldAutoCommit;
1229 return "Can't delete a customer with invoices";
1231 if ( $self->cust_credit ) {
1232 $dbh->rollback if $oldAutoCommit;
1233 return "Can't delete a customer with credits";
1235 if ( $self->cust_pay ) {
1236 $dbh->rollback if $oldAutoCommit;
1237 return "Can't delete a customer with payments";
1239 if ( $self->cust_refund ) {
1240 $dbh->rollback if $oldAutoCommit;
1241 return "Can't delete a customer with refunds";
1244 my @cust_pkg = $self->ncancelled_pkgs;
1246 my $new_custnum = shift;
1247 unless ( qsearchs( 'cust_main', { 'custnum' => $new_custnum } ) ) {
1248 $dbh->rollback if $oldAutoCommit;
1249 return "Invalid new customer number: $new_custnum";
1251 foreach my $cust_pkg ( @cust_pkg ) {
1252 my %hash = $cust_pkg->hash;
1253 $hash{'custnum'} = $new_custnum;
1254 my $new_cust_pkg = new FS::cust_pkg ( \%hash );
1255 my $error = $new_cust_pkg->replace($cust_pkg,
1256 options => { $cust_pkg->options },
1259 $dbh->rollback if $oldAutoCommit;
1264 my @cancelled_cust_pkg = $self->all_pkgs;
1265 foreach my $cust_pkg ( @cancelled_cust_pkg ) {
1266 my $error = $cust_pkg->delete;
1268 $dbh->rollback if $oldAutoCommit;
1273 foreach my $cust_main_invoice ( #(email invoice destinations, not invoices)
1274 qsearch( 'cust_main_invoice', { 'custnum' => $self->custnum } )
1276 my $error = $cust_main_invoice->delete;
1278 $dbh->rollback if $oldAutoCommit;
1283 my $error = $self->SUPER::delete;
1285 $dbh->rollback if $oldAutoCommit;
1289 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1294 =item replace [ OLD_RECORD ] [ INVOICING_LIST_ARYREF ]
1296 Replaces the OLD_RECORD with this one in the database. If there is an error,
1297 returns the error, otherwise returns false.
1299 INVOICING_LIST_ARYREF: If you pass an arrarref to the insert method, it will
1300 be set as the invoicing list (see L<"invoicing_list">). Errors return as
1301 expected and rollback the entire transaction; it is not necessary to call
1302 check_invoicing_list first. Here's an example:
1304 $new_cust_main->replace( $old_cust_main, [ $email, 'POST' ] );
1311 my $old = ( blessed($_[0]) && $_[0]->isa('FS::Record') )
1313 : $self->replace_old;
1317 warn "$me replace called\n"
1320 my $curuser = $FS::CurrentUser::CurrentUser;
1321 if ( $self->payby eq 'COMP'
1322 && $self->payby ne $old->payby
1323 && ! $curuser->access_right('Complimentary customer')
1326 return "You are not permitted to create complimentary accounts.";
1329 local($ignore_expired_card) = 1
1330 if $old->payby =~ /^(CARD|DCRD)$/
1331 && $self->payby =~ /^(CARD|DCRD)$/
1332 && ( $old->payinfo eq $self->payinfo || $old->paymask eq $self->paymask );
1334 local $SIG{HUP} = 'IGNORE';
1335 local $SIG{INT} = 'IGNORE';
1336 local $SIG{QUIT} = 'IGNORE';
1337 local $SIG{TERM} = 'IGNORE';
1338 local $SIG{TSTP} = 'IGNORE';
1339 local $SIG{PIPE} = 'IGNORE';
1341 my $oldAutoCommit = $FS::UID::AutoCommit;
1342 local $FS::UID::AutoCommit = 0;
1345 my $error = $self->SUPER::replace($old);
1348 $dbh->rollback if $oldAutoCommit;
1352 if ( @param ) { # INVOICING_LIST_ARYREF
1353 my $invoicing_list = shift @param;
1354 $error = $self->check_invoicing_list( $invoicing_list );
1356 $dbh->rollback if $oldAutoCommit;
1359 $self->invoicing_list( $invoicing_list );
1362 if ( $self->payby =~ /^(CARD|CHEK|LECB)$/ &&
1363 grep { $self->get($_) ne $old->get($_) } qw(payinfo paydate payname) ) {
1364 # card/check/lec info has changed, want to retry realtime_ invoice events
1365 my $error = $self->retry_realtime;
1367 $dbh->rollback if $oldAutoCommit;
1372 unless ( $import || $skip_fuzzyfiles ) {
1373 $error = $self->queue_fuzzyfiles_update;
1375 $dbh->rollback if $oldAutoCommit;
1376 return "updating fuzzy search cache: $error";
1380 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1385 =item queue_fuzzyfiles_update
1387 Used by insert & replace to update the fuzzy search cache
1391 sub queue_fuzzyfiles_update {
1394 local $SIG{HUP} = 'IGNORE';
1395 local $SIG{INT} = 'IGNORE';
1396 local $SIG{QUIT} = 'IGNORE';
1397 local $SIG{TERM} = 'IGNORE';
1398 local $SIG{TSTP} = 'IGNORE';
1399 local $SIG{PIPE} = 'IGNORE';
1401 my $oldAutoCommit = $FS::UID::AutoCommit;
1402 local $FS::UID::AutoCommit = 0;
1405 my $queue = new FS::queue { 'job' => 'FS::cust_main::append_fuzzyfiles' };
1406 my $error = $queue->insert( map $self->getfield($_),
1407 qw(first last company)
1410 $dbh->rollback if $oldAutoCommit;
1411 return "queueing job (transaction rolled back): $error";
1414 if ( $self->ship_last ) {
1415 $queue = new FS::queue { 'job' => 'FS::cust_main::append_fuzzyfiles' };
1416 $error = $queue->insert( map $self->getfield("ship_$_"),
1417 qw(first last company)
1420 $dbh->rollback if $oldAutoCommit;
1421 return "queueing job (transaction rolled back): $error";
1425 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1432 Checks all fields to make sure this is a valid customer record. If there is
1433 an error, returns the error, otherwise returns false. Called by the insert
1434 and replace methods.
1441 warn "$me check BEFORE: \n". $self->_dump
1445 $self->ut_numbern('custnum')
1446 || $self->ut_number('agentnum')
1447 || $self->ut_textn('agent_custid')
1448 || $self->ut_number('refnum')
1449 || $self->ut_textn('custbatch')
1450 || $self->ut_name('last')
1451 || $self->ut_name('first')
1452 || $self->ut_snumbern('birthdate')
1453 || $self->ut_snumbern('signupdate')
1454 || $self->ut_textn('company')
1455 || $self->ut_text('address1')
1456 || $self->ut_textn('address2')
1457 || $self->ut_text('city')
1458 || $self->ut_textn('county')
1459 || $self->ut_textn('state')
1460 || $self->ut_country('country')
1461 || $self->ut_anything('comments')
1462 || $self->ut_numbern('referral_custnum')
1463 || $self->ut_textn('stateid')
1464 || $self->ut_textn('stateid_state')
1465 || $self->ut_textn('invoice_terms')
1466 || $self->ut_alphan('geocode')
1469 #barf. need message catalogs. i18n. etc.
1470 $error .= "Please select an advertising source."
1471 if $error =~ /^Illegal or empty \(numeric\) refnum: /;
1472 return $error if $error;
1474 return "Unknown agent"
1475 unless qsearchs( 'agent', { 'agentnum' => $self->agentnum } );
1477 return "Unknown refnum"
1478 unless qsearchs( 'part_referral', { 'refnum' => $self->refnum } );
1480 return "Unknown referring custnum: ". $self->referral_custnum
1481 unless ! $self->referral_custnum
1482 || qsearchs( 'cust_main', { 'custnum' => $self->referral_custnum } );
1484 if ( $self->ss eq '' ) {
1489 $ss =~ /^(\d{3})(\d{2})(\d{4})$/
1490 or return "Illegal social security number: ". $self->ss;
1491 $self->ss("$1-$2-$3");
1495 # bad idea to disable, causes billing to fail because of no tax rates later
1496 # unless ( $import ) {
1497 unless ( qsearch('cust_main_county', {
1498 'country' => $self->country,
1501 return "Unknown state/county/country: ".
1502 $self->state. "/". $self->county. "/". $self->country
1503 unless qsearch('cust_main_county',{
1504 'state' => $self->state,
1505 'county' => $self->county,
1506 'country' => $self->country,
1512 $self->ut_phonen('daytime', $self->country)
1513 || $self->ut_phonen('night', $self->country)
1514 || $self->ut_phonen('fax', $self->country)
1515 || $self->ut_zip('zip', $self->country)
1517 return $error if $error;
1519 if ( $conf->exists('cust_main-require_phone')
1520 && ! length($self->daytime) && ! length($self->night)
1523 my $daytime_label = FS::Msgcat::_gettext('daytime') =~ /^(daytime)?$/
1525 : FS::Msgcat::_gettext('daytime');
1526 my $night_label = FS::Msgcat::_gettext('night') =~ /^(night)?$/
1528 : FS::Msgcat::_gettext('night');
1530 return "$daytime_label or $night_label is required"
1534 if ( $self->has_ship_address
1535 && scalar ( grep { $self->getfield($_) ne $self->getfield("ship_$_") }
1536 $self->addr_fields )
1540 $self->ut_name('ship_last')
1541 || $self->ut_name('ship_first')
1542 || $self->ut_textn('ship_company')
1543 || $self->ut_text('ship_address1')
1544 || $self->ut_textn('ship_address2')
1545 || $self->ut_text('ship_city')
1546 || $self->ut_textn('ship_county')
1547 || $self->ut_textn('ship_state')
1548 || $self->ut_country('ship_country')
1550 return $error if $error;
1552 #false laziness with above
1553 unless ( qsearchs('cust_main_county', {
1554 'country' => $self->ship_country,
1557 return "Unknown ship_state/ship_county/ship_country: ".
1558 $self->ship_state. "/". $self->ship_county. "/". $self->ship_country
1559 unless qsearch('cust_main_county',{
1560 'state' => $self->ship_state,
1561 'county' => $self->ship_county,
1562 'country' => $self->ship_country,
1568 $self->ut_phonen('ship_daytime', $self->ship_country)
1569 || $self->ut_phonen('ship_night', $self->ship_country)
1570 || $self->ut_phonen('ship_fax', $self->ship_country)
1571 || $self->ut_zip('ship_zip', $self->ship_country)
1573 return $error if $error;
1575 return "Unit # is required."
1576 if $self->ship_address2 =~ /^\s*$/
1577 && $conf->exists('cust_main-require_address2');
1579 } else { # ship_ info eq billing info, so don't store dup info in database
1581 $self->setfield("ship_$_", '')
1582 foreach $self->addr_fields;
1584 return "Unit # is required."
1585 if $self->address2 =~ /^\s*$/
1586 && $conf->exists('cust_main-require_address2');
1590 #$self->payby =~ /^(CARD|DCRD|CHEK|DCHK|LECB|BILL|COMP|PREPAY|CASH|WEST|MCRD)$/
1591 # or return "Illegal payby: ". $self->payby;
1593 FS::payby->can_payby($self->table, $self->payby)
1594 or return "Illegal payby: ". $self->payby;
1596 $error = $self->ut_numbern('paystart_month')
1597 || $self->ut_numbern('paystart_year')
1598 || $self->ut_numbern('payissue')
1599 || $self->ut_textn('paytype')
1601 return $error if $error;
1603 if ( $self->payip eq '' ) {
1606 $error = $self->ut_ip('payip');
1607 return $error if $error;
1610 # If it is encrypted and the private key is not availaible then we can't
1611 # check the credit card.
1613 my $check_payinfo = 1;
1615 if ($self->is_encrypted($self->payinfo)) {
1619 if ( $check_payinfo && $self->payby =~ /^(CARD|DCRD)$/ ) {
1621 my $payinfo = $self->payinfo;
1622 $payinfo =~ s/\D//g;
1623 $payinfo =~ /^(\d{13,16})$/
1624 or return gettext('invalid_card'); # . ": ". $self->payinfo;
1626 $self->payinfo($payinfo);
1628 or return gettext('invalid_card'); # . ": ". $self->payinfo;
1630 return gettext('unknown_card_type')
1631 if cardtype($self->payinfo) eq "Unknown";
1633 my $ban = qsearchs('banned_pay', $self->_banned_pay_hashref);
1635 return 'Banned credit card: banned on '.
1636 time2str('%a %h %o at %r', $ban->_date).
1637 ' by '. $ban->otaker.
1638 ' (ban# '. $ban->bannum. ')';
1641 if (length($self->paycvv) && !$self->is_encrypted($self->paycvv)) {
1642 if ( cardtype($self->payinfo) eq 'American Express card' ) {
1643 $self->paycvv =~ /^(\d{4})$/
1644 or return "CVV2 (CID) for American Express cards is four digits.";
1647 $self->paycvv =~ /^(\d{3})$/
1648 or return "CVV2 (CVC2/CID) is three digits.";
1655 my $cardtype = cardtype($payinfo);
1656 if ( $cardtype =~ /^(Switch|Solo)$/i ) {
1658 return "Start date or issue number is required for $cardtype cards"
1659 unless $self->paystart_month && $self->paystart_year or $self->payissue;
1661 return "Start month must be between 1 and 12"
1662 if $self->paystart_month
1663 and $self->paystart_month < 1 || $self->paystart_month > 12;
1665 return "Start year must be 1990 or later"
1666 if $self->paystart_year
1667 and $self->paystart_year < 1990;
1669 return "Issue number must be beween 1 and 99"
1671 and $self->payissue < 1 || $self->payissue > 99;
1674 $self->paystart_month('');
1675 $self->paystart_year('');
1676 $self->payissue('');
1679 } elsif ( $check_payinfo && $self->payby =~ /^(CHEK|DCHK)$/ ) {
1681 my $payinfo = $self->payinfo;
1682 $payinfo =~ s/[^\d\@]//g;
1683 if ( $conf->exists('echeck-nonus') ) {
1684 $payinfo =~ /^(\d+)\@(\d+)$/ or return 'invalid echeck account@aba';
1685 $payinfo = "$1\@$2";
1687 $payinfo =~ /^(\d+)\@(\d{9})$/ or return 'invalid echeck account@aba';
1688 $payinfo = "$1\@$2";
1690 $self->payinfo($payinfo);
1693 my $ban = qsearchs('banned_pay', $self->_banned_pay_hashref);
1695 return 'Banned ACH account: banned on '.
1696 time2str('%a %h %o at %r', $ban->_date).
1697 ' by '. $ban->otaker.
1698 ' (ban# '. $ban->bannum. ')';
1701 } elsif ( $self->payby eq 'LECB' ) {
1703 my $payinfo = $self->payinfo;
1704 $payinfo =~ s/\D//g;
1705 $payinfo =~ /^1?(\d{10})$/ or return 'invalid btn billing telephone number';
1707 $self->payinfo($payinfo);
1710 } elsif ( $self->payby eq 'BILL' ) {
1712 $error = $self->ut_textn('payinfo');
1713 return "Illegal P.O. number: ". $self->payinfo if $error;
1716 } elsif ( $self->payby eq 'COMP' ) {
1718 my $curuser = $FS::CurrentUser::CurrentUser;
1719 if ( ! $self->custnum
1720 && ! $curuser->access_right('Complimentary customer')
1723 return "You are not permitted to create complimentary accounts."
1726 $error = $self->ut_textn('payinfo');
1727 return "Illegal comp account issuer: ". $self->payinfo if $error;
1730 } elsif ( $self->payby eq 'PREPAY' ) {
1732 my $payinfo = $self->payinfo;
1733 $payinfo =~ s/\W//g; #anything else would just confuse things
1734 $self->payinfo($payinfo);
1735 $error = $self->ut_alpha('payinfo');
1736 return "Illegal prepayment identifier: ". $self->payinfo if $error;
1737 return "Unknown prepayment identifier"
1738 unless qsearchs('prepay_credit', { 'identifier' => $self->payinfo } );
1743 if ( $self->paydate eq '' || $self->paydate eq '-' ) {
1744 return "Expiration date required"
1745 unless $self->payby =~ /^(BILL|PREPAY|CHEK|DCHK|LECB|CASH|WEST|MCRD)$/;
1749 if ( $self->paydate =~ /^(\d{1,2})[\/\-](\d{2}(\d{2})?)$/ ) {
1750 ( $m, $y ) = ( $1, length($2) == 4 ? $2 : "20$2" );
1751 } elsif ( $self->paydate =~ /^(20)?(\d{2})[\/\-](\d{1,2})[\/\-]\d+$/ ) {
1752 ( $m, $y ) = ( $3, "20$2" );
1754 return "Illegal expiration date: ". $self->paydate;
1756 $self->paydate("$y-$m-01");
1757 my($nowm,$nowy)=(localtime(time))[4,5]; $nowm++; $nowy+=1900;
1758 return gettext('expired_card')
1760 && !$ignore_expired_card
1761 && ( $y<$nowy || ( $y==$nowy && $1<$nowm ) );
1764 if ( $self->payname eq '' && $self->payby !~ /^(CHEK|DCHK)$/ &&
1765 ( ! $conf->exists('require_cardname')
1766 || $self->payby !~ /^(CARD|DCRD)$/ )
1768 $self->payname( $self->first. " ". $self->getfield('last') );
1770 $self->payname =~ /^([\w \,\.\-\'\&]+)$/
1771 or return gettext('illegal_name'). " payname: ". $self->payname;
1775 foreach my $flag (qw( tax spool_cdr squelch_cdr )) {
1776 $self->$flag() =~ /^(Y?)$/ or return "Illegal $flag: ". $self->$flag();
1780 $self->otaker(getotaker) unless $self->otaker;
1782 warn "$me check AFTER: \n". $self->_dump
1785 $self->SUPER::check;
1790 Returns a list of fields which have ship_ duplicates.
1795 qw( last first company
1796 address1 address2 city county state zip country
1801 =item has_ship_address
1803 Returns true if this customer record has a separate shipping address.
1807 sub has_ship_address {
1809 scalar( grep { $self->getfield("ship_$_") ne '' } $self->addr_fields );
1812 =item all_pkgs [ EXTRA_QSEARCH_PARAMS_HASHREF ]
1814 Returns all packages (see L<FS::cust_pkg>) for this customer.
1820 my $extra_qsearch = ref($_[0]) ? shift : {};
1822 return $self->num_pkgs unless wantarray; #XXX doesn't work w/$extra_qsearch
1825 if ( $self->{'_pkgnum'} ) {
1826 @cust_pkg = values %{ $self->{'_pkgnum'}->cache };
1828 @cust_pkg = qsearch({
1830 'table' => 'cust_pkg',
1831 'hashref' => { 'custnum' => $self->custnum },
1835 sort sort_packages @cust_pkg;
1840 Synonym for B<all_pkgs>.
1845 shift->all_pkgs(@_);
1850 Returns all locations (see L<FS::cust_location>) for this customer.
1856 qsearch('cust_location', { 'custnum' => $self->custnum } );
1859 =item ncancelled_pkgs [ EXTRA_QSEARCH_PARAMS_HASHREF ]
1861 Returns all non-cancelled packages (see L<FS::cust_pkg>) for this customer.
1865 sub ncancelled_pkgs {
1867 my $extra_qsearch = ref($_[0]) ? shift : {};
1869 return $self->num_ncancelled_pkgs unless wantarray;
1872 if ( $self->{'_pkgnum'} ) {
1874 warn "$me ncancelled_pkgs: returning cached objects"
1877 @cust_pkg = grep { ! $_->getfield('cancel') }
1878 values %{ $self->{'_pkgnum'}->cache };
1882 warn "$me ncancelled_pkgs: searching for packages with custnum ".
1883 $self->custnum. "\n"
1886 @cust_pkg = qsearch({
1888 'table' => 'cust_pkg',
1889 'hashref' => { 'custnum' => $self->custnum },
1890 'extra_sql' => ' AND ( cancel IS NULL OR cancel = 0 ) ',
1895 sort sort_packages @cust_pkg;
1899 # This should be generalized to use config options to determine order.
1902 if ( $a->get('cancel') xor $b->get('cancel') ) {
1903 return -1 if $b->get('cancel');
1904 return 1 if $a->get('cancel');
1905 #shouldn't get here...
1908 my @a_cust_svc = $a->cust_svc;
1909 my @b_cust_svc = $b->cust_svc;
1910 return 0 if !scalar(@a_cust_svc) && !scalar(@b_cust_svc);
1911 return -1 if scalar(@a_cust_svc) && !scalar(@b_cust_svc);
1912 return 1 if !scalar(@a_cust_svc) && scalar(@b_cust_svc);
1913 $a_cust_svc[0]->svc_x->label cmp $b_cust_svc[0]->svc_x->label;
1918 =item suspended_pkgs
1920 Returns all suspended packages (see L<FS::cust_pkg>) for this customer.
1924 sub suspended_pkgs {
1926 grep { $_->susp } $self->ncancelled_pkgs;
1929 =item unflagged_suspended_pkgs
1931 Returns all unflagged suspended packages (see L<FS::cust_pkg>) for this
1932 customer (thouse packages without the `manual_flag' set).
1936 sub unflagged_suspended_pkgs {
1938 return $self->suspended_pkgs
1939 unless dbdef->table('cust_pkg')->column('manual_flag');
1940 grep { ! $_->manual_flag } $self->suspended_pkgs;
1943 =item unsuspended_pkgs
1945 Returns all unsuspended (and uncancelled) packages (see L<FS::cust_pkg>) for
1950 sub unsuspended_pkgs {
1952 grep { ! $_->susp } $self->ncancelled_pkgs;
1955 =item num_cancelled_pkgs
1957 Returns the number of cancelled packages (see L<FS::cust_pkg>) for this
1962 sub num_cancelled_pkgs {
1963 shift->num_pkgs("cust_pkg.cancel IS NOT NULL AND cust_pkg.cancel != 0");
1966 sub num_ncancelled_pkgs {
1967 shift->num_pkgs("( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )");
1971 my( $self ) = shift;
1972 my $sql = scalar(@_) ? shift : '';
1973 $sql = "AND $sql" if $sql && $sql !~ /^\s*$/ && $sql !~ /^\s*AND/i;
1974 my $sth = dbh->prepare(
1975 "SELECT COUNT(*) FROM cust_pkg WHERE custnum = ? $sql"
1976 ) or die dbh->errstr;
1977 $sth->execute($self->custnum) or die $sth->errstr;
1978 $sth->fetchrow_arrayref->[0];
1983 Unsuspends all unflagged suspended packages (see L</unflagged_suspended_pkgs>
1984 and L<FS::cust_pkg>) for this customer. Always returns a list: an empty list
1985 on success or a list of errors.
1991 grep { $_->unsuspend } $self->suspended_pkgs;
1996 Suspends all unsuspended packages (see L<FS::cust_pkg>) for this customer.
1998 Returns a list: an empty list on success or a list of errors.
2004 grep { $_->suspend(@_) } $self->unsuspended_pkgs;
2007 =item suspend_if_pkgpart HASHREF | PKGPART [ , PKGPART ... ]
2009 Suspends all unsuspended packages (see L<FS::cust_pkg>) matching the listed
2010 PKGPARTs (see L<FS::part_pkg>). Preferred usage is to pass a hashref instead
2011 of a list of pkgparts; the hashref has the following keys:
2015 =item pkgparts - listref of pkgparts
2017 =item (other options are passed to the suspend method)
2022 Returns a list: an empty list on success or a list of errors.
2026 sub suspend_if_pkgpart {
2028 my (@pkgparts, %opt);
2029 if (ref($_[0]) eq 'HASH'){
2030 @pkgparts = @{$_[0]{pkgparts}};
2035 grep { $_->suspend(%opt) }
2036 grep { my $pkgpart = $_->pkgpart; grep { $pkgpart eq $_ } @pkgparts }
2037 $self->unsuspended_pkgs;
2040 =item suspend_unless_pkgpart HASHREF | PKGPART [ , PKGPART ... ]
2042 Suspends all unsuspended packages (see L<FS::cust_pkg>) unless they match the
2043 given PKGPARTs (see L<FS::part_pkg>). Preferred usage is to pass a hashref
2044 instead of a list of pkgparts; the hashref has the following keys:
2048 =item pkgparts - listref of pkgparts
2050 =item (other options are passed to the suspend method)
2054 Returns a list: an empty list on success or a list of errors.
2058 sub suspend_unless_pkgpart {
2060 my (@pkgparts, %opt);
2061 if (ref($_[0]) eq 'HASH'){
2062 @pkgparts = @{$_[0]{pkgparts}};
2067 grep { $_->suspend(%opt) }
2068 grep { my $pkgpart = $_->pkgpart; ! grep { $pkgpart eq $_ } @pkgparts }
2069 $self->unsuspended_pkgs;
2072 =item cancel [ OPTION => VALUE ... ]
2074 Cancels all uncancelled packages (see L<FS::cust_pkg>) for this customer.
2076 Available options are:
2080 =item quiet - can be set true to supress email cancellation notices.
2082 =item reason - can be set to a cancellation reason (see L<FS:reason>), either a reasonnum of an existing reason, or passing a hashref will create a new reason. The hashref should have the following keys: typenum - Reason type (see L<FS::reason_type>, reason - Text of the new reason.
2084 =item ban - can be set true to ban this customer's credit card or ACH information, if present.
2088 Always returns a list: an empty list on success or a list of errors.
2093 my( $self, %opt ) = @_;
2095 warn "$me cancel called on customer ". $self->custnum. " with options ".
2096 join(', ', map { "$_: $opt{$_}" } keys %opt ). "\n"
2099 return ( 'access denied' )
2100 unless $FS::CurrentUser::CurrentUser->access_right('Cancel customer');
2102 if ( $opt{'ban'} && $self->payby =~ /^(CARD|DCRD|CHEK|DCHK)$/ ) {
2104 #should try decryption (we might have the private key)
2105 # and if not maybe queue a job for the server that does?
2106 return ( "Can't (yet) ban encrypted credit cards" )
2107 if $self->is_encrypted($self->payinfo);
2109 my $ban = new FS::banned_pay $self->_banned_pay_hashref;
2110 my $error = $ban->insert;
2111 return ( $error ) if $error;
2115 my @pkgs = $self->ncancelled_pkgs;
2117 warn "$me cancelling ". scalar($self->ncancelled_pkgs). "/".
2118 scalar(@pkgs). " packages for customer ". $self->custnum. "\n"
2121 grep { $_ } map { $_->cancel(%opt) } $self->ncancelled_pkgs;
2124 sub _banned_pay_hashref {
2135 'payby' => $payby2ban{$self->payby},
2136 'payinfo' => md5_base64($self->payinfo),
2137 #don't ever *search* on reason! #'reason' =>
2143 Returns all notes (see L<FS::cust_main_note>) for this customer.
2150 qsearch( 'cust_main_note',
2151 { 'custnum' => $self->custnum },
2153 'ORDER BY _DATE DESC'
2159 Returns the agent (see L<FS::agent>) for this customer.
2165 qsearchs( 'agent', { 'agentnum' => $self->agentnum } );
2168 =item bill_and_collect
2170 Cancels and suspends any packages due, generates bills, applies payments and
2173 Warns on errors (Does not currently: If there is an error, returns the error, otherwise returns false.)
2175 Options are passed as name-value pairs. Currently available options are:
2181 Bills the customer as if it were that time. Specified as a UNIX timestamp; see L<perlfunc/"time">). Also see L<Time::Local> and L<Date::Parse> for conversion functions. For example:
2185 $cust_main->bill( 'time' => str2time('April 20th, 2001') );
2189 Used in conjunction with the I<time> option, this option specifies the date of for the generated invoices. Other calculations, such as whether or not to generate the invoice in the first place, are not affected.
2193 "1d" for the traditional, daily events (the default), or "1m" for the new monthly events (part_event.check_freq)
2197 If set true, re-charges setup fees.
2201 Debugging level. Default is 0 (no debugging), or can be set to 1 (passed-in options), 2 (traces progress), 3 (more information), or 4 (include full search queries)
2207 sub bill_and_collect {
2208 my( $self, %options ) = @_;
2214 #$options{actual_time} not $options{time} because freeside-daily -d is for
2215 #pre-printing invoices
2216 my @cancel_pkgs = grep { $_->expire && $_->expire <= $options{actual_time} }
2217 $self->ncancelled_pkgs;
2219 foreach my $cust_pkg ( @cancel_pkgs ) {
2220 my $cpr = $cust_pkg->last_cust_pkg_reason('expire');
2221 my $error = $cust_pkg->cancel($cpr ? ( 'reason' => $cpr->reasonnum,
2222 'reason_otaker' => $cpr->otaker
2226 warn "Error cancelling expired pkg ". $cust_pkg->pkgnum.
2227 " for custnum ". $self->custnum. ": $error"
2235 #$options{actual_time} not $options{time} because freeside-daily -d is for
2236 #pre-printing invoices
2239 && ( ( $_->part_pkg->is_prepaid
2241 && $_->bill < $options{actual_time}
2244 && $_->adjourn <= $options{actual_time}
2248 $self->ncancelled_pkgs;
2250 foreach my $cust_pkg ( @susp_pkgs ) {
2251 my $cpr = $cust_pkg->last_cust_pkg_reason('adjourn')
2252 if ($cust_pkg->adjourn && $cust_pkg->adjourn < $^T);
2253 my $error = $cust_pkg->suspend($cpr ? ( 'reason' => $cpr->reasonnum,
2254 'reason_otaker' => $cpr->otaker
2259 warn "Error suspending package ". $cust_pkg->pkgnum.
2260 " for custnum ". $self->custnum. ": $error"
2268 my $error = $self->bill( %options );
2269 warn "Error billing, custnum ". $self->custnum. ": $error" if $error;
2271 $self->apply_payments_and_credits;
2273 $error = $self->collect( %options );
2274 warn "Error collecting, custnum". $self->custnum. ": $error" if $error;
2280 Generates invoices (see L<FS::cust_bill>) for this customer. Usually used in
2281 conjunction with the collect method by calling B<bill_and_collect>.
2283 If there is an error, returns the error, otherwise returns false.
2285 Options are passed as name-value pairs. Currently available options are:
2291 If set true, re-charges setup fees.
2295 Bills the customer as if it were that time. Specified as a UNIX timestamp; see L<perlfunc/"time">). Also see L<Time::Local> and L<Date::Parse> for conversion functions. For example:
2299 $cust_main->bill( 'time' => str2time('April 20th, 2001') );
2303 An array ref of specific packages (objects) to attempt billing, instead trying all of them.
2305 $cust_main->bill( pkg_list => [$pkg1, $pkg2] );
2309 Used in conjunction with the I<time> option, this option specifies the date of for the generated invoices. Other calculations, such as whether or not to generate the invoice in the first place, are not affected.
2316 my( $self, %options ) = @_;
2317 return '' if $self->payby eq 'COMP';
2318 warn "$me bill customer ". $self->custnum. "\n"
2321 my $time = $options{'time'} || time;
2322 my $invoice_time = $options{'invoice_time'} || $time;
2325 local $SIG{HUP} = 'IGNORE';
2326 local $SIG{INT} = 'IGNORE';
2327 local $SIG{QUIT} = 'IGNORE';
2328 local $SIG{TERM} = 'IGNORE';
2329 local $SIG{TSTP} = 'IGNORE';
2330 local $SIG{PIPE} = 'IGNORE';
2332 my $oldAutoCommit = $FS::UID::AutoCommit;
2333 local $FS::UID::AutoCommit = 0;
2336 $self->select_for_update; #mutex
2338 my @cust_bill_pkg = ();
2341 # find the packages which are due for billing, find out how much they are
2342 # & generate invoice database.
2345 my( $total_setup, $total_recur, $postal_charge ) = ( 0, 0, 0 );
2347 my @precommit_hooks = ();
2349 my @cust_pkgs = qsearch('cust_pkg', { 'custnum' => $self->custnum } );
2350 foreach my $cust_pkg (@cust_pkgs) {
2352 #NO!! next if $cust_pkg->cancel;
2353 next if $cust_pkg->getfield('cancel');
2355 warn " bill package ". $cust_pkg->pkgnum. "\n" if $DEBUG > 1;
2357 #? to avoid use of uninitialized value errors... ?
2358 $cust_pkg->setfield('bill', '')
2359 unless defined($cust_pkg->bill);
2361 #my $part_pkg = $cust_pkg->part_pkg;
2363 my $real_pkgpart = $cust_pkg->pkgpart;
2364 my %hash = $cust_pkg->hash;
2366 foreach my $part_pkg ( $cust_pkg->part_pkg->self_and_bill_linked ) {
2368 $cust_pkg->set($_, $hash{$_}) foreach qw ( setup last_bill bill );
2371 $self->_make_lines( 'part_pkg' => $part_pkg,
2372 'cust_pkg' => $cust_pkg,
2373 'precommit_hooks' => \@precommit_hooks,
2374 'line_items' => \@cust_bill_pkg,
2375 'setup' => \$total_setup,
2376 'recur' => \$total_recur,
2377 'tax_matrix' => \%taxlisthash,
2379 'options' => \%options,
2382 $dbh->rollback if $oldAutoCommit;
2386 } #foreach my $part_pkg
2388 } #foreach my $cust_pkg
2390 unless ( @cust_bill_pkg ) { #don't create an invoice w/o line items
2391 #but do commit any package date cycling that happened
2392 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2396 my $postal_pkg = $self->charge_postal_fee();
2397 if ( $postal_pkg && !ref( $postal_pkg ) ) {
2398 $dbh->rollback if $oldAutoCommit;
2399 return "can't charge postal invoice fee for customer ".
2400 $self->custnum. ": $postal_pkg";
2403 ( scalar( grep { $_->recur && $_->recur > 0 } @cust_bill_pkg) ||
2404 !$conf->exists('postal_invoice-recurring_only')
2408 foreach my $part_pkg ( $postal_pkg->part_pkg->self_and_bill_linked ) {
2410 $self->_make_lines( 'part_pkg' => $part_pkg,
2411 'cust_pkg' => $postal_pkg,
2412 'precommit_hooks' => \@precommit_hooks,
2413 'line_items' => \@cust_bill_pkg,
2414 'setup' => \$total_setup,
2415 'recur' => \$total_recur,
2416 'tax_matrix' => \%taxlisthash,
2418 'options' => \%options,
2421 $dbh->rollback if $oldAutoCommit;
2427 warn "having a look at the taxes we found...\n" if $DEBUG > 2;
2429 # keys are tax names (as printed on invoices / itemdesc )
2430 # values are listrefs of taxlisthash keys (internal identifiers)
2433 # keys are taxlisthash keys (internal identifiers)
2434 # values are (cumulative) amounts
2437 # keys are taxlisthash keys (internal identifiers)
2438 # values are listrefs of cust_bill_pkg_tax_location hashrefs
2439 my %tax_location = ();
2441 foreach my $tax ( keys %taxlisthash ) {
2442 my $tax_object = shift @{ $taxlisthash{$tax} };
2443 warn "found ". $tax_object->taxname. " as $tax\n" if $DEBUG > 2;
2444 my $hashref_or_error =
2445 $tax_object->taxline( $taxlisthash{$tax},
2446 'custnum' => $self->custnum,
2447 'invoice_time' => $invoice_time
2449 unless ( ref($hashref_or_error) ) {
2450 $dbh->rollback if $oldAutoCommit;
2451 return $hashref_or_error;
2453 unshift @{ $taxlisthash{$tax} }, $tax_object;
2455 my $name = $hashref_or_error->{'name'};
2456 my $amount = $hashref_or_error->{'amount'};
2458 #warn "adding $amount as $name\n";
2459 $taxname{ $name } ||= [];
2460 push @{ $taxname{ $name } }, $tax;
2462 $tax{ $tax } += $amount;
2464 $tax_location{ $tax } ||= [];
2465 if ( $tax_object->get('pkgnum') || $tax_object->get('locationnum') ) {
2466 push @{ $tax_location{ $tax } },
2468 'taxnum' => $tax_object->taxnum,
2469 'taxtype' => ref($tax_object),
2470 'pkgnum' => $tax_object->get('pkgnum'),
2471 'locationnum' => $tax_object->get('locationnum'),
2472 'amount' => sprintf('%.2f', $amount ),
2478 #move the cust_tax_exempt_pkg records to the cust_bill_pkgs we will commit
2479 my %packagemap = map { $_->pkgnum => $_ } @cust_bill_pkg;
2480 foreach my $tax ( keys %taxlisthash ) {
2481 foreach ( @{ $taxlisthash{$tax} }[1 ... scalar(@{ $taxlisthash{$tax} })] ) {
2482 next unless ref($_) eq 'FS::cust_bill_pkg'; # shouldn't happen
2484 push @{ $packagemap{$_->pkgnum}->_cust_tax_exempt_pkg },
2485 splice( @{ $_->_cust_tax_exempt_pkg } );
2489 #some taxes are taxed
2492 warn "finding taxed taxes...\n" if $DEBUG > 2;
2493 foreach my $tax ( keys %taxlisthash ) {
2494 my $tax_object = shift @{ $taxlisthash{$tax} };
2495 warn "found possible taxed tax ". $tax_object->taxname. " we call $tax\n"
2497 next unless $tax_object->can('tax_on_tax');
2499 foreach my $tot ( $tax_object->tax_on_tax( $self ) ) {
2500 my $totname = ref( $tot ). ' '. $tot->taxnum;
2502 warn "checking $totname which we call ". $tot->taxname. " as applicable\n"
2504 next unless exists( $taxlisthash{ $totname } ); # only increase
2506 warn "adding $totname to taxed taxes\n" if $DEBUG > 2;
2507 if ( exists( $totlisthash{ $totname } ) ) {
2508 push @{ $totlisthash{ $totname } }, $tax{ $tax };
2510 $totlisthash{ $totname } = [ $tot, $tax{ $tax } ];
2515 warn "having a look at taxed taxes...\n" if $DEBUG > 2;
2516 foreach my $tax ( keys %totlisthash ) {
2517 my $tax_object = shift @{ $totlisthash{$tax} };
2518 warn "found previously found taxed tax ". $tax_object->taxname. "\n"
2520 my $listref_or_error =
2521 $tax_object->taxline( $totlisthash{$tax},
2522 'custnum' => $self->custnum,
2523 'invoice_time' => $invoice_time
2525 unless (ref($listref_or_error)) {
2526 $dbh->rollback if $oldAutoCommit;
2527 return $listref_or_error;
2530 warn "adding taxed tax amount ". $listref_or_error->[1].
2531 " as ". $tax_object->taxname. "\n"
2533 $tax{ $tax } += $listref_or_error->[1];
2536 #consolidate and create tax line items
2537 warn "consolidating and generating...\n" if $DEBUG > 2;
2538 foreach my $taxname ( keys %taxname ) {
2541 my @cust_bill_pkg_tax_location = ();
2542 warn "adding $taxname\n" if $DEBUG > 1;
2543 foreach my $taxitem ( @{ $taxname{$taxname} } ) {
2544 next if $seen{$taxitem}++;
2545 warn "adding $tax{$taxitem}\n" if $DEBUG > 1;
2546 $tax += $tax{$taxitem};
2547 push @cust_bill_pkg_tax_location,
2548 map { new FS::cust_bill_pkg_tax_location $_ }
2549 @{ $tax_location{ $taxitem } };
2553 $tax = sprintf('%.2f', $tax );
2554 $total_setup = sprintf('%.2f', $total_setup+$tax );
2556 push @cust_bill_pkg, new FS::cust_bill_pkg {
2562 'itemdesc' => $taxname,
2563 'cust_bill_pkg_tax_location' => \@cust_bill_pkg_tax_location,
2568 my $charged = sprintf('%.2f', $total_setup + $total_recur );
2570 #create the new invoice
2571 my $cust_bill = new FS::cust_bill ( {
2572 'custnum' => $self->custnum,
2573 '_date' => ( $invoice_time ),
2574 'charged' => $charged,
2576 my $error = $cust_bill->insert;
2578 $dbh->rollback if $oldAutoCommit;
2579 return "can't create invoice for customer #". $self->custnum. ": $error";
2582 foreach my $cust_bill_pkg ( @cust_bill_pkg ) {
2583 $cust_bill_pkg->invnum($cust_bill->invnum);
2584 my $error = $cust_bill_pkg->insert;
2586 $dbh->rollback if $oldAutoCommit;
2587 return "can't create invoice line item: $error";
2592 foreach my $hook ( @precommit_hooks ) {
2594 &{$hook}; #($self) ?
2597 $dbh->rollback if $oldAutoCommit;
2598 return "$@ running precommit hook $hook\n";
2602 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2608 my ($self, %params) = @_;
2610 my $part_pkg = $params{part_pkg} or die "no part_pkg specified";
2611 my $cust_pkg = $params{cust_pkg} or die "no cust_pkg specified";
2612 my $precommit_hooks = $params{precommit_hooks} or die "no package specified";
2613 my $cust_bill_pkgs = $params{line_items} or die "no line buffer specified";
2614 my $total_setup = $params{setup} or die "no setup accumulator specified";
2615 my $total_recur = $params{recur} or die "no recur accumulator specified";
2616 my $taxlisthash = $params{tax_matrix} or die "no tax accumulator specified";
2617 my $time = $params{'time'} or die "no time specified";
2618 my (%options) = %{$params{options}}; #hmmm only for 'resetup'
2621 my $real_pkgpart = $cust_pkg->pkgpart;
2622 my %hash = $cust_pkg->hash;
2623 my $old_cust_pkg = new FS::cust_pkg \%hash;
2629 $cust_pkg->pkgpart($part_pkg->pkgpart);
2637 if ( ! $cust_pkg->setup &&
2639 ( $conf->exists('disable_setup_suspended_pkgs') &&
2640 ! $cust_pkg->getfield('susp')
2641 ) || ! $conf->exists('disable_setup_suspended_pkgs')
2643 || $options{'resetup'}
2646 warn " bill setup\n" if $DEBUG > 1;
2649 $setup = eval { $cust_pkg->calc_setup( $time, \@details ) };
2650 return "$@ running calc_setup for $cust_pkg\n"
2653 $unitsetup = $cust_pkg->part_pkg->unit_setup || $setup; #XXX uuh
2655 $cust_pkg->setfield('setup', $time)
2656 unless $cust_pkg->setup;
2657 #do need it, but it won't get written to the db
2658 #|| $cust_pkg->pkgpart != $real_pkgpart;
2663 # bill recurring fee
2666 #XXX unit stuff here too
2670 if ( ! $cust_pkg->getfield('susp') and
2671 ( $part_pkg->getfield('freq') ne '0' &&
2672 ( $cust_pkg->getfield('bill') || 0 ) <= $time
2674 || ( $part_pkg->plan eq 'voip_cdr'
2675 && $part_pkg->option('bill_every_call')
2679 # XXX should this be a package event? probably. events are called
2680 # at collection time at the moment, though...
2681 $part_pkg->reset_usage($cust_pkg, 'debug'=>$DEBUG)
2682 if $part_pkg->can('reset_usage');
2683 #don't want to reset usage just cause we want a line item??
2684 #&& $part_pkg->pkgpart == $real_pkgpart;
2686 warn " bill recur\n" if $DEBUG > 1;
2689 # XXX shared with $recur_prog
2690 $sdate = $cust_pkg->bill || $cust_pkg->setup || $time;
2692 #over two params! lets at least switch to a hashref for the rest...
2693 my $increment_next_bill = ( $part_pkg->freq ne '0'
2694 && ( $cust_pkg->getfield('bill') || 0 ) <= $time
2696 my %param = ( 'precommit_hooks' => $precommit_hooks,
2697 'increment_next_bill' => $increment_next_bill,
2700 $recur = eval { $cust_pkg->calc_recur( \$sdate, \@details, \%param ) };
2701 return "$@ running calc_recur for $cust_pkg\n"
2704 if ( $increment_next_bill ) {
2706 my $next_bill = $part_pkg->add_freq($sdate);
2707 return "unparsable frequency: ". $part_pkg->freq
2708 if $next_bill == -1;
2710 #pro-rating magic - if $recur_prog fiddled $sdate, want to use that
2711 # only for figuring next bill date, nothing else, so, reset $sdate again
2713 $sdate = $cust_pkg->bill || $cust_pkg->setup || $time;
2714 #no need, its in $hash{last_bill}# my $last_bill = $cust_pkg->last_bill;
2715 $cust_pkg->last_bill($sdate);
2717 $cust_pkg->setfield('bill', $next_bill );
2723 warn "\$setup is undefined" unless defined($setup);
2724 warn "\$recur is undefined" unless defined($recur);
2725 warn "\$cust_pkg->bill is undefined" unless defined($cust_pkg->bill);
2728 # If there's line items, create em cust_bill_pkg records
2729 # If $cust_pkg has been modified, update it (if we're a real pkgpart)
2734 if ( $cust_pkg->modified && $cust_pkg->pkgpart == $real_pkgpart ) {
2735 # hmm.. and if just the options are modified in some weird price plan?
2737 warn " package ". $cust_pkg->pkgnum. " modified; updating\n"
2740 my $error = $cust_pkg->replace( $old_cust_pkg,
2741 'options' => { $cust_pkg->options },
2743 return "Error modifying pkgnum ". $cust_pkg->pkgnum. ": $error"
2744 if $error; #just in case
2747 $setup = sprintf( "%.2f", $setup );
2748 $recur = sprintf( "%.2f", $recur );
2749 if ( $setup < 0 && ! $conf->exists('allow_negative_charges') ) {
2750 return "negative setup $setup for pkgnum ". $cust_pkg->pkgnum;
2752 if ( $recur < 0 && ! $conf->exists('allow_negative_charges') ) {
2753 return "negative recur $recur for pkgnum ". $cust_pkg->pkgnum;
2756 if ( $setup != 0 || $recur != 0 ) {
2758 warn " charges (setup=$setup, recur=$recur); adding line items\n"
2761 my @cust_pkg_detail = map { $_->detail } $cust_pkg->cust_pkg_detail('I');
2763 warn " adding customer package invoice detail: $_\n"
2764 foreach @cust_pkg_detail;
2766 push @details, @cust_pkg_detail;
2768 my $cust_bill_pkg = new FS::cust_bill_pkg {
2769 'pkgnum' => $cust_pkg->pkgnum,
2771 'unitsetup' => $unitsetup,
2773 'unitrecur' => $unitrecur,
2774 'quantity' => $cust_pkg->quantity,
2775 'details' => \@details,
2778 if ( $part_pkg->option('recur_temporality', 1) eq 'preceding' ) {
2779 $cust_bill_pkg->sdate( $hash{last_bill} );
2780 $cust_bill_pkg->edate( $sdate - 86399 ); #60s*60m*24h-1
2781 } else { #if ( $part_pkg->option('recur_temporality', 1) eq 'upcoming' ) {
2782 $cust_bill_pkg->sdate( $sdate );
2783 $cust_bill_pkg->edate( $cust_pkg->bill );
2786 $cust_bill_pkg->pkgpart_override($part_pkg->pkgpart)
2787 unless $part_pkg->pkgpart == $real_pkgpart;
2789 $$total_setup += $setup;
2790 $$total_recur += $recur;
2797 $self->_handle_taxes($part_pkg, $taxlisthash, $cust_bill_pkg, $cust_pkg);
2798 return $error if $error;
2800 push @$cust_bill_pkgs, $cust_bill_pkg;
2802 } #if $setup != 0 || $recur != 0
2812 my $part_pkg = shift;
2813 my $taxlisthash = shift;
2814 my $cust_bill_pkg = shift;
2815 my $cust_pkg = shift;
2817 my %cust_bill_pkg = ();
2821 #push @classes, $cust_bill_pkg->usage_classes if $cust_bill_pkg->type eq 'U';
2822 push @classes, $cust_bill_pkg->usage_classes if $cust_bill_pkg->usage;
2823 push @classes, 'setup' if $cust_bill_pkg->setup;
2824 push @classes, 'recur' if $cust_bill_pkg->recur;
2826 if ( $self->tax !~ /Y/i && $self->payby ne 'COMP' ) {
2828 if ( $conf->exists('enable_taxproducts')
2829 && ( scalar($part_pkg->part_pkg_taxoverride)
2830 || $part_pkg->has_taxproduct
2835 if ( $conf->exists('tax-pkg_address') && $cust_pkg->locationnum ) {
2836 return "fatal: Can't (yet) use tax-pkg_address with taxproducts";
2839 foreach my $class (@classes) {
2840 my $err_or_ref = $self->_gather_taxes( $part_pkg, $class );
2841 return $err_or_ref unless ref($err_or_ref);
2842 $taxes{$class} = $err_or_ref;
2845 unless (exists $taxes{''}) {
2846 my $err_or_ref = $self->_gather_taxes( $part_pkg, '' );
2847 return $err_or_ref unless ref($err_or_ref);
2848 $taxes{''} = $err_or_ref;
2853 my @loc_keys = qw( state county country );
2855 if ( $conf->exists('tax-pkg_address') && $cust_pkg->locationnum ) {
2856 my $cust_location = $cust_pkg->cust_location;
2857 %taxhash = map { $_ => $cust_location->$_() } @loc_keys;
2860 ( $conf->exists('tax-ship_address') && length($self->ship_last) )
2863 %taxhash = map { $_ => $self->get("$prefix$_") } @loc_keys;
2866 $taxhash{'taxclass'} = $part_pkg->taxclass;
2868 my @taxes = qsearch( 'cust_main_county', \%taxhash );
2870 my %taxhash_elim = %taxhash;
2872 my @elim = qw( taxclass county state );
2873 while ( !scalar(@taxes) && scalar(@elim) ) {
2874 $taxhash_elim{ shift(@elim) } = '';
2875 @taxes = qsearch( 'cust_main_county', \%taxhash_elim );
2878 if ( $conf->exists('tax-pkg_address') && $cust_pkg->locationnum ) {
2880 $_->set('pkgnum', $cust_pkg->pkgnum );
2881 $_->set('locationnum', $cust_pkg->locationnum );
2885 $taxes{''} = [ @taxes ];
2886 $taxes{'setup'} = [ @taxes ];
2887 $taxes{'recur'} = [ @taxes ];
2888 $taxes{$_} = [ @taxes ] foreach (@classes);
2890 # maybe eliminate this entirely, along with all the 0% records
2893 "fatal: can't find tax rate for state/county/country/taxclass ".
2894 join('/', map $taxhash{$_}, qw(state county country taxclass) );
2897 } #if $conf->exists('enable_taxproducts') ...
2902 if ( $conf->exists('separate_usage') ) {
2903 my $section = $cust_pkg->part_pkg->option('usage_section', 'Hush!');
2904 my $summary = $cust_pkg->part_pkg->option('summarize_usage', 'Hush!');
2905 push @display, new FS::cust_bill_pkg_display { type => 'S' };
2906 push @display, new FS::cust_bill_pkg_display { type => 'R' };
2907 push @display, new FS::cust_bill_pkg_display { type => 'U',
2910 if ($section && $summary) {
2911 $display[2]->post_total('Y');
2912 push @display, new FS::cust_bill_pkg_display { type => 'U',
2917 $cust_bill_pkg->set('display', \@display);
2919 my %tax_cust_bill_pkg = $cust_bill_pkg->disintegrate;
2920 foreach my $key (keys %tax_cust_bill_pkg) {
2921 my @taxes = @{ $taxes{$key} || [] };
2922 my $tax_cust_bill_pkg = $tax_cust_bill_pkg{$key};
2924 foreach my $tax ( @taxes ) {
2926 my $taxname = ref( $tax ). ' taxnum'. $tax->taxnum;
2927 # $taxname .= ' pkgnum'. $cust_pkg->pkgnum.
2928 # ' locationnum'. $cust_pkg->locationnum
2929 # if $conf->exists('tax-pkg_address') && $cust_pkg->locationnum;
2931 if ( exists( $taxlisthash->{ $taxname } ) ) {
2932 push @{ $taxlisthash->{ $taxname } }, $tax_cust_bill_pkg;
2934 $taxlisthash->{ $taxname } = [ $tax, $tax_cust_bill_pkg ];
2944 my $part_pkg = shift;
2948 my $geocode = $self->geocode('cch');
2950 my @taxclassnums = map { $_->taxclassnum }
2951 $part_pkg->part_pkg_taxoverride($class);
2953 unless (@taxclassnums) {
2954 @taxclassnums = map { $_->taxclassnum }
2955 $part_pkg->part_pkg_taxrate('cch', $geocode, $class);
2957 warn "Found taxclassnum values of ". join(',', @taxclassnums)
2962 join(' OR ', map { "taxclassnum = $_" } @taxclassnums ). ")";
2964 @taxes = qsearch({ 'table' => 'tax_rate',
2965 'hashref' => { 'geocode' => $geocode, },
2966 'extra_sql' => $extra_sql,
2968 if scalar(@taxclassnums);
2970 warn "Found taxes ".
2971 join(',', map{ ref($_). " ". $_->get($_->primary_key) } @taxes). "\n"
2978 =item collect OPTIONS
2980 (Attempt to) collect money for this customer's outstanding invoices (see
2981 L<FS::cust_bill>). Usually used after the bill method.
2983 Actions are now triggered by billing events; see L<FS::part_event> and the
2984 billing events web interface. Old-style invoice events (see
2985 L<FS::part_bill_event>) have been deprecated.
2987 If there is an error, returns the error, otherwise returns false.
2989 Options are passed as name-value pairs.
2991 Currently available options are:
2997 Use this time when deciding when to print invoices and 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> for conversion functions.
3001 Retry card/echeck/LEC transactions even when not scheduled by invoice events.
3005 set true to surpress email card/ACH decline notices.
3009 "1d" for the traditional, daily events (the default), or "1m" for the new monthly events (part_event.check_freq)
3013 allows for one time override of normal customer billing method
3017 Debugging level. Default is 0 (no debugging), or can be set to 1 (passed-in options), 2 (traces progress), 3 (more information), or 4 (include full search queries)
3025 my( $self, %options ) = @_;
3026 my $invoice_time = $options{'invoice_time'} || time;
3029 local $SIG{HUP} = 'IGNORE';
3030 local $SIG{INT} = 'IGNORE';
3031 local $SIG{QUIT} = 'IGNORE';
3032 local $SIG{TERM} = 'IGNORE';
3033 local $SIG{TSTP} = 'IGNORE';
3034 local $SIG{PIPE} = 'IGNORE';
3036 my $oldAutoCommit = $FS::UID::AutoCommit;
3037 local $FS::UID::AutoCommit = 0;
3040 $self->select_for_update; #mutex
3043 my $balance = $self->balance;
3044 warn "$me collect customer ". $self->custnum. ": balance $balance\n"
3047 if ( exists($options{'retry_card'}) ) {
3048 carp 'retry_card option passed to collect is deprecated; use retry';
3049 $options{'retry'} ||= $options{'retry_card'};
3051 if ( exists($options{'retry'}) && $options{'retry'} ) {
3052 my $error = $self->retry_realtime;
3054 $dbh->rollback if $oldAutoCommit;
3059 # false laziness w/pay_batch::import_results
3061 my $due_cust_event = $self->due_cust_event(
3062 'debug' => ( $options{'debug'} || 0 ),
3063 'time' => $invoice_time,
3064 'check_freq' => $options{'check_freq'},
3066 unless( ref($due_cust_event) ) {
3067 $dbh->rollback if $oldAutoCommit;
3068 return $due_cust_event;
3071 foreach my $cust_event ( @$due_cust_event ) {
3075 #re-eval event conditions (a previous event could have changed things)
3076 unless ( $cust_event->test_conditions( 'time' => $invoice_time ) ) {
3077 #don't leave stray "new/locked" records around
3078 my $error = $cust_event->delete;
3080 #gah, even with transactions
3081 $dbh->commit if $oldAutoCommit; #well.
3088 local $realtime_bop_decline_quiet = 1 if $options{'quiet'};
3089 warn " running cust_event ". $cust_event->eventnum. "\n"
3093 #if ( my $error = $cust_event->do_event(%options) ) { #XXX %options?
3094 if ( my $error = $cust_event->do_event() ) {
3095 #XXX wtf is this? figure out a proper dealio with return value
3097 # gah, even with transactions.
3098 $dbh->commit if $oldAutoCommit; #well.
3105 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
3110 =item due_cust_event [ HASHREF | OPTION => VALUE ... ]
3112 Inserts database records for and returns an ordered listref of new events due
3113 for this customer, as FS::cust_event objects (see L<FS::cust_event>). If no
3114 events are due, an empty listref is returned. If there is an error, returns a
3115 scalar error message.
3117 To actually run the events, call each event's test_condition method, and if
3118 still true, call the event's do_event method.
3120 Options are passed as a hashref or as a list of name-value pairs. Available
3127 Search only for events of this check frequency (how often events of this type are checked); currently "1d" (daily, the default) and "1m" (monthly) are recognized.
3131 "Current time" for the events.
3135 Debugging level. Default is 0 (no debugging), or can be set to 1 (passed-in options), 2 (traces progress), 3 (more information), or 4 (include full search queries)
3139 Only return events for the specified eventtable (by default, events of all eventtables are returned)
3143 Explicitly pass the objects to be tested (typically used with eventtable).
3147 Set to true to return the objects, but not actually insert them into the
3154 sub due_cust_event {
3156 my %opt = ref($_[0]) ? %{ $_[0] } : @_;
3159 #my $DEBUG = $opt{'debug'}
3160 local($DEBUG) = $opt{'debug'}
3161 if defined($opt{'debug'}) && $opt{'debug'} > $DEBUG;
3163 warn "$me due_cust_event called with options ".
3164 join(', ', map { "$_: $opt{$_}" } keys %opt). "\n"
3167 $opt{'time'} ||= time;
3169 local $SIG{HUP} = 'IGNORE';
3170 local $SIG{INT} = 'IGNORE';
3171 local $SIG{QUIT} = 'IGNORE';
3172 local $SIG{TERM} = 'IGNORE';
3173 local $SIG{TSTP} = 'IGNORE';
3174 local $SIG{PIPE} = 'IGNORE';
3176 my $oldAutoCommit = $FS::UID::AutoCommit;
3177 local $FS::UID::AutoCommit = 0;
3180 $self->select_for_update #mutex
3181 unless $opt{testonly};
3184 # 1: find possible events (initial search)
3187 my @cust_event = ();
3189 my @eventtable = $opt{'eventtable'}
3190 ? ( $opt{'eventtable'} )
3191 : FS::part_event->eventtables_runorder;
3193 foreach my $eventtable ( @eventtable ) {
3196 if ( $opt{'objects'} ) {
3198 @objects = @{ $opt{'objects'} };
3202 #my @objects = $self->eventtable(); # sub cust_main { @{ [ $self ] }; }
3203 @objects = ( $eventtable eq 'cust_main' )
3205 : ( $self->$eventtable() );
3209 my @e_cust_event = ();
3211 my $cross = "CROSS JOIN $eventtable";
3212 $cross .= ' LEFT JOIN cust_main USING ( custnum )'
3213 unless $eventtable eq 'cust_main';
3215 foreach my $object ( @objects ) {
3217 #this first search uses the condition_sql magic for optimization.
3218 #the more possible events we can eliminate in this step the better
3220 my $cross_where = '';
3221 my $pkey = $object->primary_key;
3222 $cross_where = "$eventtable.$pkey = ". $object->$pkey();
3224 my $join = FS::part_event_condition->join_conditions_sql( $eventtable );
3226 FS::part_event_condition->where_conditions_sql( $eventtable,
3227 'time'=>$opt{'time'}
3229 my $order = FS::part_event_condition->order_conditions_sql( $eventtable );
3231 $extra_sql = "AND $extra_sql" if $extra_sql;
3233 #here is the agent virtualization
3234 $extra_sql .= " AND ( part_event.agentnum IS NULL
3235 OR part_event.agentnum = ". $self->agentnum. ' )';
3237 $extra_sql .= " $order";
3239 warn "searching for events for $eventtable ". $object->$pkey. "\n"
3240 if $opt{'debug'} > 2;
3241 my @part_event = qsearch( {
3242 'debug' => ( $opt{'debug'} > 3 ? 1 : 0 ),
3243 'select' => 'part_event.*',
3244 'table' => 'part_event',
3245 'addl_from' => "$cross $join",
3246 'hashref' => { 'check_freq' => ( $opt{'check_freq'} || '1d' ),
3247 'eventtable' => $eventtable,
3250 'extra_sql' => "AND $cross_where $extra_sql",
3254 my $pkey = $object->primary_key;
3255 warn " ". scalar(@part_event).
3256 " possible events found for $eventtable ". $object->$pkey(). "\n";
3259 push @e_cust_event, map { $_->new_cust_event($object) } @part_event;
3263 warn " ". scalar(@e_cust_event).
3264 " subtotal possible cust events found for $eventtable\n"
3267 push @cust_event, @e_cust_event;
3271 warn " ". scalar(@cust_event).
3272 " total possible cust events found in initial search\n"
3276 # 2: test conditions
3281 @cust_event = grep $_->test_conditions( 'time' => $opt{'time'},
3282 'stats_hashref' => \%unsat ),
3285 warn " ". scalar(@cust_event). " cust events left satisfying conditions\n"
3288 warn " invalid conditions not eliminated with condition_sql:\n".
3289 join('', map " $_: ".$unsat{$_}."\n", keys %unsat )
3296 unless( $opt{testonly} ) {
3297 foreach my $cust_event ( @cust_event ) {
3299 my $error = $cust_event->insert();
3301 $dbh->rollback if $oldAutoCommit;
3308 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
3314 warn " returning events: ". Dumper(@cust_event). "\n"
3321 =item retry_realtime
3323 Schedules realtime / batch credit card / electronic check / LEC billing
3324 events for for retry. Useful if card information has changed or manual
3325 retry is desired. The 'collect' method must be called to actually retry
3328 Implementation details: For either this customer, or for each of this
3329 customer's open invoices, changes the status of the first "done" (with
3330 statustext error) realtime processing event to "failed".
3334 sub retry_realtime {
3337 local $SIG{HUP} = 'IGNORE';
3338 local $SIG{INT} = 'IGNORE';
3339 local $SIG{QUIT} = 'IGNORE';
3340 local $SIG{TERM} = 'IGNORE';
3341 local $SIG{TSTP} = 'IGNORE';
3342 local $SIG{PIPE} = 'IGNORE';
3344 my $oldAutoCommit = $FS::UID::AutoCommit;
3345 local $FS::UID::AutoCommit = 0;
3348 #a little false laziness w/due_cust_event (not too bad, really)
3350 my $join = FS::part_event_condition->join_conditions_sql;
3351 my $order = FS::part_event_condition->order_conditions_sql;
3354 . join ( ' OR ' , map {
3355 "( part_event.eventtable = " . dbh->quote($_)
3356 . " AND tablenum IN( SELECT " . dbdef->table($_)->primary_key . " from $_ where custnum = " . dbh->quote( $self->custnum ) . "))" ;
3357 } FS::part_event->eventtables)
3360 #here is the agent virtualization
3361 my $agent_virt = " ( part_event.agentnum IS NULL
3362 OR part_event.agentnum = ". $self->agentnum. ' )';
3364 #XXX this shouldn't be hardcoded, actions should declare it...
3365 my @realtime_events = qw(
3366 cust_bill_realtime_card
3367 cust_bill_realtime_check
3368 cust_bill_realtime_lec
3372 my $is_realtime_event = ' ( '. join(' OR ', map "part_event.action = '$_'",
3377 my @cust_event = qsearchs({
3378 'table' => 'cust_event',
3379 'select' => 'cust_event.*',
3380 'addl_from' => "LEFT JOIN part_event USING ( eventpart ) $join",
3381 'hashref' => { 'status' => 'done' },
3382 'extra_sql' => " AND statustext IS NOT NULL AND statustext != '' ".
3383 " AND $mine AND $is_realtime_event AND $agent_virt $order" # LIMIT 1"
3386 my %seen_invnum = ();
3387 foreach my $cust_event (@cust_event) {
3389 #max one for the customer, one for each open invoice
3390 my $cust_X = $cust_event->cust_X;
3391 next if $seen_invnum{ $cust_event->part_event->eventtable eq 'cust_bill'
3395 or $cust_event->part_event->eventtable eq 'cust_bill'
3398 my $error = $cust_event->retry;
3400 $dbh->rollback if $oldAutoCommit;
3401 return "error scheduling event for retry: $error";
3406 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
3411 # some horrid false laziness here to avoid refactor fallout
3412 # eventually realtime realtime_bop and realtime_refund_bop should go
3413 # away and be replaced by _new_realtime_bop and _new_realtime_refund_bop
3415 =item realtime_bop METHOD AMOUNT [ OPTION => VALUE ... ]
3417 Runs a realtime credit card, ACH (electronic check) or phone bill transaction
3418 via a Business::OnlinePayment realtime gateway. See
3419 L<http://420.am/business-onlinepayment> for supported gateways.
3421 Available methods are: I<CC>, I<ECHECK> and I<LEC>
3423 Available options are: I<description>, I<invnum>, I<quiet>, I<paynum_ref>, I<payunique>
3425 The additional options I<payname>, I<address1>, I<address2>, I<city>, I<state>,
3426 I<zip>, I<payinfo> and I<paydate> are also available. Any of these options,
3427 if set, will override the value from the customer record.
3429 I<description> is a free-text field passed to the gateway. It defaults to
3430 "Internet services".
3432 If an I<invnum> is specified, this payment (if successful) is applied to the
3433 specified invoice. If you don't specify an I<invnum> you might want to
3434 call the B<apply_payments> method.
3436 I<quiet> can be set true to surpress email decline notices.
3438 I<paynum_ref> can be set to a scalar reference. It will be filled in with the
3439 resulting paynum, if any.
3441 I<payunique> is a unique identifier for this payment.
3443 (moved from cust_bill) (probably should get realtime_{card,ach,lec} here too)
3450 return $self->_new_realtime_bop(@_)
3451 if $self->_new_bop_required();
3453 my( $method, $amount, %options ) = @_;
3455 warn "$me realtime_bop: $method $amount\n";
3456 warn " $_ => $options{$_}\n" foreach keys %options;
3459 $options{'description'} ||= 'Internet services';
3461 return $self->fake_bop($method, $amount, %options) if $options{'fake'};
3463 eval "use Business::OnlinePayment";
3466 my $payinfo = exists($options{'payinfo'})
3467 ? $options{'payinfo'}
3470 my %method2payby = (
3477 # check for banned credit card/ACH
3480 my $ban = qsearchs('banned_pay', {
3481 'payby' => $method2payby{$method},
3482 'payinfo' => md5_base64($payinfo),
3484 return "Banned credit card" if $ban;
3491 if ( $options{'invnum'} ) {
3492 my $cust_bill = qsearchs('cust_bill', { 'invnum' => $options{'invnum'} } );
3493 die "invnum ". $options{'invnum'}. " not found" unless $cust_bill;
3495 map { $_->part_pkg->taxclass }
3497 map { $_->cust_pkg }
3498 $cust_bill->cust_bill_pkg;
3499 unless ( grep { $taxclasses[0] ne $_ } @taxclasses ) { #unless there are
3500 #different taxclasses
3501 $taxclass = $taxclasses[0];
3505 #look for an agent gateway override first
3507 if ( $method eq 'CC' ) {
3508 $cardtype = cardtype($payinfo);
3509 } elsif ( $method eq 'ECHECK' ) {
3512 $cardtype = $method;
3516 qsearchs('agent_payment_gateway', { agentnum => $self->agentnum,
3517 cardtype => $cardtype,
3518 taxclass => $taxclass, } )
3519 || qsearchs('agent_payment_gateway', { agentnum => $self->agentnum,
3521 taxclass => $taxclass, } )
3522 || qsearchs('agent_payment_gateway', { agentnum => $self->agentnum,
3523 cardtype => $cardtype,
3525 || qsearchs('agent_payment_gateway', { agentnum => $self->agentnum,
3527 taxclass => '', } );
3529 my $payment_gateway = '';
3530 my( $processor, $login, $password, $action, @bop_options );
3531 if ( $override ) { #use a payment gateway override
3533 $payment_gateway = $override->payment_gateway;
3535 $processor = $payment_gateway->gateway_module;
3536 $login = $payment_gateway->gateway_username;
3537 $password = $payment_gateway->gateway_password;
3538 $action = $payment_gateway->gateway_action;
3539 @bop_options = $payment_gateway->options;
3541 } else { #use the standard settings from the config
3543 ( $processor, $login, $password, $action, @bop_options ) =
3544 $self->default_payment_gateway($method);
3552 my $address = exists($options{'address1'})
3553 ? $options{'address1'}
3555 my $address2 = exists($options{'address2'})
3556 ? $options{'address2'}
3558 $address .= ", ". $address2 if length($address2);
3560 my $o_payname = exists($options{'payname'})
3561 ? $options{'payname'}
3563 my($payname, $payfirst, $paylast);
3564 if ( $o_payname && $method ne 'ECHECK' ) {
3565 ($payname = $o_payname) =~ /^\s*([\w \,\.\-\']*)?\s+([\w\,\.\-\']+)\s*$/
3566 or return "Illegal payname $payname";
3567 ($payfirst, $paylast) = ($1, $2);
3569 $payfirst = $self->getfield('first');
3570 $paylast = $self->getfield('last');
3571 $payname = "$payfirst $paylast";
3574 my @invoicing_list = $self->invoicing_list_emailonly;
3575 if ( $conf->exists('emailinvoiceautoalways')
3576 || $conf->exists('emailinvoiceauto') && ! @invoicing_list
3577 || ( $conf->exists('emailinvoiceonly') && ! @invoicing_list ) ) {
3578 push @invoicing_list, $self->all_emails;
3581 my $email = ($conf->exists('business-onlinepayment-email-override'))
3582 ? $conf->config('business-onlinepayment-email-override')
3583 : $invoicing_list[0];
3587 my $payip = exists($options{'payip'})
3590 $content{customer_ip} = $payip
3593 $content{invoice_number} = $options{'invnum'}
3594 if exists($options{'invnum'}) && length($options{'invnum'});
3596 $content{email_customer} =
3597 ( $conf->exists('business-onlinepayment-email_customer')
3598 || $conf->exists('business-onlinepayment-email-override') );
3601 if ( $method eq 'CC' ) {
3603 $content{card_number} = $payinfo;
3604 $paydate = exists($options{'paydate'})
3605 ? $options{'paydate'}
3607 $paydate =~ /^\d{2}(\d{2})[\/\-](\d+)[\/\-]\d+$/;
3608 $content{expiration} = "$2/$1";
3610 my $paycvv = exists($options{'paycvv'})
3611 ? $options{'paycvv'}
3613 $content{cvv2} = $paycvv
3616 my $paystart_month = exists($options{'paystart_month'})
3617 ? $options{'paystart_month'}
3618 : $self->paystart_month;
3620 my $paystart_year = exists($options{'paystart_year'})
3621 ? $options{'paystart_year'}
3622 : $self->paystart_year;
3624 $content{card_start} = "$paystart_month/$paystart_year"
3625 if $paystart_month && $paystart_year;
3627 my $payissue = exists($options{'payissue'})
3628 ? $options{'payissue'}
3630 $content{issue_number} = $payissue if $payissue;
3632 $content{recurring_billing} = 'YES'
3633 if qsearch('cust_pay', { 'custnum' => $self->custnum,
3635 'payinfo' => $payinfo,
3637 || qsearch('cust_pay', { 'custnum' => $self->custnum,
3639 'paymask' => $self->mask_payinfo('CARD', $payinfo),
3643 } elsif ( $method eq 'ECHECK' ) {
3644 ( $content{account_number}, $content{routing_code} ) =
3645 split('@', $payinfo);
3646 $content{bank_name} = $o_payname;
3647 $content{bank_state} = exists($options{'paystate'})
3648 ? $options{'paystate'}
3649 : $self->getfield('paystate');
3650 $content{account_type} = exists($options{'paytype'})
3651 ? uc($options{'paytype'}) || 'CHECKING'
3652 : uc($self->getfield('paytype')) || 'CHECKING';
3653 $content{account_name} = $payname;
3654 $content{customer_org} = $self->company ? 'B' : 'I';
3655 $content{state_id} = exists($options{'stateid'})
3656 ? $options{'stateid'}
3657 : $self->getfield('stateid');
3658 $content{state_id_state} = exists($options{'stateid_state'})
3659 ? $options{'stateid_state'}
3660 : $self->getfield('stateid_state');
3661 $content{customer_ssn} = exists($options{'ss'})
3664 } elsif ( $method eq 'LEC' ) {
3665 $content{phone} = $payinfo;
3669 # run transaction(s)
3672 my $balance = exists( $options{'balance'} )
3673 ? $options{'balance'}
3676 $self->select_for_update; #mutex ... just until we get our pending record in
3678 #the checks here are intended to catch concurrent payments
3679 #double-form-submission prevention is taken care of in cust_pay_pending::check
3682 return "The customer's balance has changed; $method transaction aborted."
3683 if $self->balance < $balance;
3684 #&& $self->balance < $amount; #might as well anyway?
3686 #also check and make sure there aren't *other* pending payments for this cust
3688 my @pending = qsearch('cust_pay_pending', {
3689 'custnum' => $self->custnum,
3690 'status' => { op=>'!=', value=>'done' }
3692 return "A payment is already being processed for this customer (".
3693 join(', ', map 'paypendingnum '. $_->paypendingnum, @pending ).
3694 "); $method transaction aborted."
3695 if scalar(@pending);
3697 #okay, good to go, if we're a duplicate, cust_pay_pending will kick us out
3699 my $cust_pay_pending = new FS::cust_pay_pending {
3700 'custnum' => $self->custnum,
3701 #'invnum' => $options{'invnum'},
3704 'payby' => $method2payby{$method},
3705 'payinfo' => $payinfo,
3706 'paydate' => $paydate,
3708 'gatewaynum' => ( $payment_gateway ? $payment_gateway->gatewaynum : '' ),
3710 $cust_pay_pending->payunique( $options{payunique} )
3711 if defined($options{payunique}) && length($options{payunique});
3712 my $cpp_new_err = $cust_pay_pending->insert; #mutex lost when this is inserted
3713 return $cpp_new_err if $cpp_new_err;
3715 my( $action1, $action2 ) = split(/\s*\,\s*/, $action );
3717 my $transaction = new Business::OnlinePayment( $processor, @bop_options );
3718 $transaction->content(
3721 'password' => $password,
3722 'action' => $action1,
3723 'description' => $options{'description'},
3724 'amount' => $amount,
3725 #'invoice_number' => $options{'invnum'},
3726 'customer_id' => $self->custnum,
3727 'last_name' => $paylast,
3728 'first_name' => $payfirst,
3730 'address' => $address,
3731 'city' => ( exists($options{'city'})
3734 'state' => ( exists($options{'state'})
3737 'zip' => ( exists($options{'zip'})
3740 'country' => ( exists($options{'country'})
3741 ? $options{'country'}
3743 'referer' => 'http://cleanwhisker.420.am/', #XXX fix referer :/
3745 'phone' => $self->daytime || $self->night,
3749 $cust_pay_pending->status('pending');
3750 my $cpp_pending_err = $cust_pay_pending->replace;
3751 return $cpp_pending_err if $cpp_pending_err;
3754 my $BOP_TESTING = 0;
3755 my $BOP_TESTING_SUCCESS = 1;
3757 unless ( $BOP_TESTING ) {
3758 $transaction->submit();
3760 if ( $BOP_TESTING_SUCCESS ) {
3761 $transaction->is_success(1);
3762 $transaction->authorization('fake auth');
3764 $transaction->is_success(0);
3765 $transaction->error_message('fake failure');
3769 if ( $transaction->is_success() && $action2 ) {
3771 $cust_pay_pending->status('authorized');
3772 my $cpp_authorized_err = $cust_pay_pending->replace;
3773 return $cpp_authorized_err if $cpp_authorized_err;
3775 my $auth = $transaction->authorization;
3776 my $ordernum = $transaction->can('order_number')
3777 ? $transaction->order_number
3781 new Business::OnlinePayment( $processor, @bop_options );
3788 password => $password,
3789 order_number => $ordernum,
3791 authorization => $auth,
3792 description => $options{'description'},
3795 foreach my $field (qw( authorization_source_code returned_ACI
3796 transaction_identifier validation_code
3797 transaction_sequence_num local_transaction_date
3798 local_transaction_time AVS_result_code )) {
3799 $capture{$field} = $transaction->$field() if $transaction->can($field);
3802 $capture->content( %capture );
3806 unless ( $capture->is_success ) {
3807 my $e = "Authorization successful but capture failed, custnum #".
3808 $self->custnum. ': '. $capture->result_code.
3809 ": ". $capture->error_message;
3816 $cust_pay_pending->status($transaction->is_success() ? 'captured' : 'declined');
3817 my $cpp_captured_err = $cust_pay_pending->replace;
3818 return $cpp_captured_err if $cpp_captured_err;
3821 # remove paycvv after initial transaction
3824 #false laziness w/misc/process/payment.cgi - check both to make sure working
3826 if ( defined $self->dbdef_table->column('paycvv')
3827 && length($self->paycvv)
3828 && ! grep { $_ eq cardtype($payinfo) } $conf->config('cvv-save')
3830 my $error = $self->remove_cvv;
3832 warn "WARNING: error removing cvv: $error\n";
3840 if ( $transaction->is_success() ) {
3843 if ( $payment_gateway ) { # agent override
3844 $paybatch = $payment_gateway->gatewaynum. '-';
3847 $paybatch .= "$processor:". $transaction->authorization;
3849 $paybatch .= ':'. $transaction->order_number
3850 if $transaction->can('order_number')
3851 && length($transaction->order_number);
3853 my $cust_pay = new FS::cust_pay ( {
3854 'custnum' => $self->custnum,
3855 'invnum' => $options{'invnum'},
3858 'payby' => $method2payby{$method},
3859 'payinfo' => $payinfo,
3860 'paybatch' => $paybatch,
3861 'paydate' => $paydate,
3863 #doesn't hurt to know, even though the dup check is in cust_pay_pending now
3864 $cust_pay->payunique( $options{payunique} )
3865 if defined($options{payunique}) && length($options{payunique});
3867 my $oldAutoCommit = $FS::UID::AutoCommit;
3868 local $FS::UID::AutoCommit = 0;
3871 #start a transaction, insert the cust_pay and set cust_pay_pending.status to done in a single transction
3873 my $error = $cust_pay->insert($options{'manual'} ? ( 'manual' => 1 ) : () );
3876 $cust_pay->invnum(''); #try again with no specific invnum
3877 my $error2 = $cust_pay->insert( $options{'manual'} ?
3878 ( 'manual' => 1 ) : ()
3881 # gah. but at least we have a record of the state we had to abort in
3882 # from cust_pay_pending now.
3883 my $e = "WARNING: $method captured but payment not recorded - ".
3884 "error inserting payment ($processor): $error2".
3885 " (previously tried insert with invnum #$options{'invnum'}" .
3886 ": $error ) - pending payment saved as paypendingnum ".
3887 $cust_pay_pending->paypendingnum. "\n";
3893 if ( $options{'paynum_ref'} ) {
3894 ${ $options{'paynum_ref'} } = $cust_pay->paynum;
3897 $cust_pay_pending->status('done');
3898 $cust_pay_pending->statustext('captured');
3899 $cust_pay_pending->paynum($cust_pay->paynum);
3900 my $cpp_done_err = $cust_pay_pending->replace;
3902 if ( $cpp_done_err ) {
3904 $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
3905 my $e = "WARNING: $method captured but payment not recorded - ".
3906 "error updating status for paypendingnum ".
3907 $cust_pay_pending->paypendingnum. ": $cpp_done_err \n";
3913 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
3914 return ''; #no error
3920 my $perror = "$processor error: ". $transaction->error_message;
3922 unless ( $transaction->error_message ) {
3925 if ( $transaction->can('response_page') ) {
3927 'page' => ( $transaction->can('response_page')
3928 ? $transaction->response_page
3931 'code' => ( $transaction->can('response_code')
3932 ? $transaction->response_code
3935 'headers' => ( $transaction->can('response_headers')
3936 ? $transaction->response_headers
3942 "No additional debugging information available for $processor";
3945 $perror .= "No error_message returned from $processor -- ".
3946 ( ref($t_response) ? Dumper($t_response) : $t_response );
3950 if ( !$options{'quiet'} && !$realtime_bop_decline_quiet
3951 && $conf->exists('emaildecline')
3952 && grep { $_ ne 'POST' } $self->invoicing_list
3953 && ! grep { $transaction->error_message =~ /$_/ }
3954 $conf->config('emaildecline-exclude')
3956 my @templ = $conf->config('declinetemplate');
3957 my $template = new Text::Template (
3959 SOURCE => [ map "$_\n", @templ ],
3960 ) or return "($perror) can't create template: $Text::Template::ERROR";
3961 $template->compile()
3962 or return "($perror) can't compile template: $Text::Template::ERROR";
3964 my $templ_hash = { error => $transaction->error_message };
3966 my $error = send_email(
3967 'from' => $conf->config('invoice_from', $self->agentnum ),
3968 'to' => [ grep { $_ ne 'POST' } $self->invoicing_list ],
3969 'subject' => 'Your payment could not be processed',
3970 'body' => [ $template->fill_in(HASH => $templ_hash) ],
3973 $perror .= " (also received error sending decline notification: $error)"
3978 $cust_pay_pending->status('done');
3979 $cust_pay_pending->statustext("declined: $perror");
3980 my $cpp_done_err = $cust_pay_pending->replace;
3981 if ( $cpp_done_err ) {
3982 my $e = "WARNING: $method declined but pending payment not resolved - ".
3983 "error updating status for paypendingnum ".
3984 $cust_pay_pending->paypendingnum. ": $cpp_done_err \n";
3986 $perror = "$e ($perror)";
3994 =item realtime_refund_bop METHOD [ OPTION => VALUE ... ]
3996 Refunds a realtime credit card, ACH (electronic check) or phone bill transaction
3997 via a Business::OnlinePayment realtime gateway. See
3998 L<http://420.am/business-onlinepayment> for supported gateways.
4000 Available methods are: I<CC>, I<ECHECK> and I<LEC>
4002 Available options are: I<amount>, I<reason>, I<paynum>, I<paydate>
4004 Most gateways require a reference to an original payment transaction to refund,
4005 so you probably need to specify a I<paynum>.
4007 I<amount> defaults to the original amount of the payment if not specified.
4009 I<reason> specifies a reason for the refund.
4011 I<paydate> specifies the expiration date for a credit card overriding the
4012 value from the customer record or the payment record. Specified as yyyy-mm-dd
4014 Implementation note: If I<amount> is unspecified or equal to the amount of the
4015 orignal payment, first an attempt is made to "void" the transaction via
4016 the gateway (to cancel a not-yet settled transaction) and then if that fails,
4017 the normal attempt is made to "refund" ("credit") the transaction via the
4018 gateway is attempted.
4020 #The additional options I<payname>, I<address1>, I<address2>, I<city>, I<state>,
4021 #I<zip>, I<payinfo> and I<paydate> are also available. Any of these options,
4022 #if set, will override the value from the customer record.
4024 #If an I<invnum> is specified, this payment (if successful) is applied to the
4025 #specified invoice. If you don't specify an I<invnum> you might want to
4026 #call the B<apply_payments> method.
4030 #some false laziness w/realtime_bop, not enough to make it worth merging
4031 #but some useful small subs should be pulled out
4032 sub realtime_refund_bop {
4035 return $self->_new_realtime_refund_bop(@_)
4036 if $self->_new_bop_required();
4038 my( $method, %options ) = @_;
4040 warn "$me realtime_refund_bop: $method refund\n";
4041 warn " $_ => $options{$_}\n" foreach keys %options;
4044 eval "use Business::OnlinePayment";
4048 # look up the original payment and optionally a gateway for that payment
4052 my $amount = $options{'amount'};
4054 my( $processor, $login, $password, @bop_options ) ;
4055 my( $auth, $order_number ) = ( '', '', '' );
4057 if ( $options{'paynum'} ) {
4059 warn " paynum: $options{paynum}\n" if $DEBUG > 1;
4060 $cust_pay = qsearchs('cust_pay', { paynum=>$options{'paynum'} } )
4061 or return "Unknown paynum $options{'paynum'}";
4062 $amount ||= $cust_pay->paid;
4064 $cust_pay->paybatch =~ /^((\d+)\-)?(\w+):\s*([\w\-\/ ]*)(:([\w\-]+))?$/
4065 or return "Can't parse paybatch for paynum $options{'paynum'}: ".
4066 $cust_pay->paybatch;
4067 my $gatewaynum = '';
4068 ( $gatewaynum, $processor, $auth, $order_number ) = ( $2, $3, $4, $6 );
4070 if ( $gatewaynum ) { #gateway for the payment to be refunded
4072 my $payment_gateway =
4073 qsearchs('payment_gateway', { 'gatewaynum' => $gatewaynum } );
4074 die "payment gateway $gatewaynum not found"
4075 unless $payment_gateway;
4077 $processor = $payment_gateway->gateway_module;
4078 $login = $payment_gateway->gateway_username;
4079 $password = $payment_gateway->gateway_password;
4080 @bop_options = $payment_gateway->options;
4082 } else { #try the default gateway
4084 my( $conf_processor, $unused_action );
4085 ( $conf_processor, $login, $password, $unused_action, @bop_options ) =
4086 $self->default_payment_gateway($method);
4088 return "processor of payment $options{'paynum'} $processor does not".
4089 " match default processor $conf_processor"
4090 unless $processor eq $conf_processor;
4095 } else { # didn't specify a paynum, so look for agent gateway overrides
4096 # like a normal transaction
4099 if ( $method eq 'CC' ) {
4100 $cardtype = cardtype($self->payinfo);
4101 } elsif ( $method eq 'ECHECK' ) {
4104 $cardtype = $method;
4107 qsearchs('agent_payment_gateway', { agentnum => $self->agentnum,
4108 cardtype => $cardtype,
4110 || qsearchs('agent_payment_gateway', { agentnum => $self->agentnum,
4112 taxclass => '', } );
4114 if ( $override ) { #use a payment gateway override
4116 my $payment_gateway = $override->payment_gateway;
4118 $processor = $payment_gateway->gateway_module;
4119 $login = $payment_gateway->gateway_username;
4120 $password = $payment_gateway->gateway_password;
4121 #$action = $payment_gateway->gateway_action;
4122 @bop_options = $payment_gateway->options;
4124 } else { #use the standard settings from the config
4127 ( $processor, $login, $password, $unused_action, @bop_options ) =
4128 $self->default_payment_gateway($method);
4133 return "neither amount nor paynum specified" unless $amount;
4138 'password' => $password,
4139 'order_number' => $order_number,
4140 'amount' => $amount,
4141 'referer' => 'http://cleanwhisker.420.am/', #XXX fix referer :/
4143 $content{authorization} = $auth
4144 if length($auth); #echeck/ACH transactions have an order # but no auth
4145 #(at least with authorize.net)
4147 my $disable_void_after;
4148 if ($conf->exists('disable_void_after')
4149 && $conf->config('disable_void_after') =~ /^(\d+)$/) {
4150 $disable_void_after = $1;
4153 #first try void if applicable
4154 if ( $cust_pay && $cust_pay->paid == $amount
4156 ( not defined($disable_void_after) )
4157 || ( time < ($cust_pay->_date + $disable_void_after ) )
4160 warn " attempting void\n" if $DEBUG > 1;
4161 my $void = new Business::OnlinePayment( $processor, @bop_options );
4162 $void->content( 'action' => 'void', %content );
4164 if ( $void->is_success ) {
4165 my $error = $cust_pay->void($options{'reason'});
4167 # gah, even with transactions.
4168 my $e = 'WARNING: Card/ACH voided but database not updated - '.
4169 "error voiding payment: $error";
4173 warn " void successful\n" if $DEBUG > 1;
4178 warn " void unsuccessful, trying refund\n"
4182 my $address = $self->address1;
4183 $address .= ", ". $self->address2 if $self->address2;
4185 my($payname, $payfirst, $paylast);
4186 if ( $self->payname && $method ne 'ECHECK' ) {
4187 $payname = $self->payname;
4188 $payname =~ /^\s*([\w \,\.\-\']*)?\s+([\w\,\.\-\']+)\s*$/
4189 or return "Illegal payname $payname";
4190 ($payfirst, $paylast) = ($1, $2);
4192 $payfirst = $self->getfield('first');
4193 $paylast = $self->getfield('last');
4194 $payname = "$payfirst $paylast";
4197 my @invoicing_list = $self->invoicing_list_emailonly;
4198 if ( $conf->exists('emailinvoiceautoalways')
4199 || $conf->exists('emailinvoiceauto') && ! @invoicing_list
4200 || ( $conf->exists('emailinvoiceonly') && ! @invoicing_list ) ) {
4201 push @invoicing_list, $self->all_emails;
4204 my $email = ($conf->exists('business-onlinepayment-email-override'))
4205 ? $conf->config('business-onlinepayment-email-override')
4206 : $invoicing_list[0];
4208 my $payip = exists($options{'payip'})
4211 $content{customer_ip} = $payip
4215 if ( $method eq 'CC' ) {
4218 $content{card_number} = $payinfo = $cust_pay->payinfo;
4219 (exists($options{'paydate'}) ? $options{'paydate'} : $cust_pay->paydate)
4220 =~ /^\d{2}(\d{2})[\/\-](\d+)[\/\-]\d+$/ &&
4221 ($content{expiration} = "$2/$1"); # where available
4223 $content{card_number} = $payinfo = $self->payinfo;
4224 (exists($options{'paydate'}) ? $options{'paydate'} : $self->paydate)
4225 =~ /^\d{2}(\d{2})[\/\-](\d+)[\/\-]\d+$/;
4226 $content{expiration} = "$2/$1";
4229 } elsif ( $method eq 'ECHECK' ) {
4232 $payinfo = $cust_pay->payinfo;
4234 $payinfo = $self->payinfo;
4236 ( $content{account_number}, $content{routing_code} )= split('@', $payinfo );
4237 $content{bank_name} = $self->payname;
4238 $content{account_type} = 'CHECKING';
4239 $content{account_name} = $payname;
4240 $content{customer_org} = $self->company ? 'B' : 'I';
4241 $content{customer_ssn} = $self->ss;
4242 } elsif ( $method eq 'LEC' ) {
4243 $content{phone} = $payinfo = $self->payinfo;
4247 my $refund = new Business::OnlinePayment( $processor, @bop_options );
4248 my %sub_content = $refund->content(
4249 'action' => 'credit',
4250 'customer_id' => $self->custnum,
4251 'last_name' => $paylast,
4252 'first_name' => $payfirst,
4254 'address' => $address,
4255 'city' => $self->city,
4256 'state' => $self->state,
4257 'zip' => $self->zip,
4258 'country' => $self->country,
4260 'phone' => $self->daytime || $self->night,
4263 warn join('', map { " $_ => $sub_content{$_}\n" } keys %sub_content )
4267 return "$processor error: ". $refund->error_message
4268 unless $refund->is_success();
4270 my %method2payby = (
4276 my $paybatch = "$processor:". $refund->authorization;
4277 $paybatch .= ':'. $refund->order_number
4278 if $refund->can('order_number') && $refund->order_number;
4280 while ( $cust_pay && $cust_pay->unapplied < $amount ) {
4281 my @cust_bill_pay = $cust_pay->cust_bill_pay;
4282 last unless @cust_bill_pay;
4283 my $cust_bill_pay = pop @cust_bill_pay;
4284 my $error = $cust_bill_pay->delete;
4288 my $cust_refund = new FS::cust_refund ( {
4289 'custnum' => $self->custnum,
4290 'paynum' => $options{'paynum'},
4291 'refund' => $amount,
4293 'payby' => $method2payby{$method},
4294 'payinfo' => $payinfo,
4295 'paybatch' => $paybatch,
4296 'reason' => $options{'reason'} || 'card or ACH refund',
4298 my $error = $cust_refund->insert;
4300 $cust_refund->paynum(''); #try again with no specific paynum
4301 my $error2 = $cust_refund->insert;
4303 # gah, even with transactions.
4304 my $e = 'WARNING: Card/ACH refunded but database not updated - '.
4305 "error inserting refund ($processor): $error2".
4306 " (previously tried insert with paynum #$options{'paynum'}" .
4317 # does the configuration indicate the new bop routines are required?
4319 sub _new_bop_required {
4322 my $botpp = 'Business::OnlineThirdPartyPayment';
4325 if ( $conf->config('business-onlinepayment-namespace') eq $botpp ||
4326 scalar( grep { $_->gateway_namespace eq $botpp }
4327 qsearch( 'payment_gateway', { 'disabled' => '' } )
4336 =item realtime_collect [ OPTION => VALUE ... ]
4338 Runs a realtime credit card, ACH (electronic check) or phone bill transaction
4339 via a Business::OnlinePayment or Business::OnlineThirdPartyPayment realtime
4340 gateway. See L<http://420.am/business-onlinepayment> and
4341 L<http://420.am/business-onlinethirdpartypayment> for supported gateways.
4343 On failure returns an error message.
4345 Returns false or a hashref upon success. The hashref contains keys popup_url reference, and collectitems. The first is a URL to which a browser should be redirected for completion of collection. The second is a reference id for the transaction suitable for the end user. The collectitems is a reference to a list of name value pairs suitable for assigning to a html form and posted to popup_url.
4347 Available options are: I<method>, I<amount>, I<description>, I<invnum>, I<quiet>, I<paynum_ref>, I<payunique>, I<session_id>
4349 I<method> is one of: I<CC>, I<ECHECK> and I<LEC>. If none is specified
4350 then it is deduced from the customer record.
4352 If no I<amount> is specified, then the customer balance is used.
4354 The additional options I<payname>, I<address1>, I<address2>, I<city>, I<state>,
4355 I<zip>, I<payinfo> and I<paydate> are also available. Any of these options,
4356 if set, will override the value from the customer record.
4358 I<description> is a free-text field passed to the gateway. It defaults to
4359 "Internet services".
4361 If an I<invnum> is specified, this payment (if successful) is applied to the
4362 specified invoice. If you don't specify an I<invnum> you might want to
4363 call the B<apply_payments> method.
4365 I<quiet> can be set true to surpress email decline notices.
4367 I<paynum_ref> can be set to a scalar reference. It will be filled in with the
4368 resulting paynum, if any.
4370 I<payunique> is a unique identifier for this payment.
4372 I<session_id> is a session identifier associated with this payment.
4374 I<depend_jobnum> allows payment capture to unlock export jobs
4378 sub realtime_collect {
4379 my( $self, %options ) = @_;
4382 warn "$me realtime_collect:\n";
4383 warn " $_ => $options{$_}\n" foreach keys %options;
4386 $options{amount} = $self->balance unless exists( $options{amount} );
4387 $options{method} = FS::payby->payby2bop($self->payby)
4388 unless exists( $options{method} );
4390 return $self->realtime_bop({%options});
4394 =item _realtime_bop { [ ARG => VALUE ... ] }
4396 Runs a realtime credit card, ACH (electronic check) or phone bill transaction
4397 via a Business::OnlinePayment realtime gateway. See
4398 L<http://420.am/business-onlinepayment> for supported gateways.
4400 Required arguments in the hashref are I<method>, and I<amount>
4402 Available methods are: I<CC>, I<ECHECK> and I<LEC>
4404 Available optional arguments are: I<description>, I<invnum>, I<quiet>, I<paynum_ref>, I<payunique>, I<session_id>
4406 The additional options I<payname>, I<address1>, I<address2>, I<city>, I<state>,
4407 I<zip>, I<payinfo> and I<paydate> are also available. Any of these options,
4408 if set, will override the value from the customer record.
4410 I<description> is a free-text field passed to the gateway. It defaults to
4411 "Internet services".
4413 If an I<invnum> is specified, this payment (if successful) is applied to the
4414 specified invoice. If you don't specify an I<invnum> you might want to
4415 call the B<apply_payments> method.
4417 I<quiet> can be set true to surpress email decline notices.
4419 I<paynum_ref> can be set to a scalar reference. It will be filled in with the
4420 resulting paynum, if any.
4422 I<payunique> is a unique identifier for this payment.
4424 I<session_id> is a session identifier associated with this payment.
4426 I<depend_jobnum> allows payment capture to unlock export jobs
4428 (moved from cust_bill) (probably should get realtime_{card,ach,lec} here too)
4432 # some helper routines
4433 sub _payment_gateway {
4434 my ($self, $options) = @_;
4436 $options->{payment_gateway} = $self->agent->payment_gateway( %$options )
4437 unless exists($options->{payment_gateway});
4439 $options->{payment_gateway};
4443 my ($self, $options) = @_;
4446 'login' => $options->{payment_gateway}->gateway_username,
4447 'password' => $options->{payment_gateway}->gateway_password,
4452 my ($self, $options) = @_;
4454 $options->{payment_gateway}->gatewaynum
4455 ? $options->{payment_gateway}->options
4456 : @{ $options->{payment_gateway}->get('options') };
4460 my ($self, $options) = @_;
4462 $options->{description} ||= 'Internet services';
4463 $options->{payinfo} = $self->payinfo unless exists( $options->{payinfo} );
4464 $options->{invnum} ||= '';
4465 $options->{payname} = $self->payname unless exists( $options->{payname} );
4469 my ($self, $options) = @_;
4472 $content{address} = exists($options->{'address1'})
4473 ? $options->{'address1'}
4475 my $address2 = exists($options->{'address2'})
4476 ? $options->{'address2'}
4478 $content{address} .= ", ". $address2 if length($address2);
4480 my $payip = exists($options->{'payip'}) ? $options->{'payip'} : $self->payip;
4481 $content{customer_ip} = $payip if length($payip);
4483 $content{invoice_number} = $options->{'invnum'}
4484 if exists($options->{'invnum'}) && length($options->{'invnum'});
4486 $content{email_customer} =
4487 ( $conf->exists('business-onlinepayment-email_customer')
4488 || $conf->exists('business-onlinepayment-email-override') );
4490 $content{payfirst} = $self->getfield('first');
4491 $content{paylast} = $self->getfield('last');
4493 $content{account_name} = "$content{payfirst} $content{paylast}"
4494 if $options->{method} eq 'ECHECK';
4496 $content{name} = $options->{payname};
4497 $content{name} = $content{account_name} if exists($content{account_name});
4499 $content{city} = exists($options->{city})
4502 $content{state} = exists($options->{state})
4505 $content{zip} = exists($options->{zip})
4508 $content{country} = exists($options->{country})
4509 ? $options->{country}
4511 $content{referer} = 'http://cleanwhisker.420.am/'; #XXX fix referer :/
4512 $content{phone} = $self->daytime || $self->night;
4517 my %bop_method2payby = (
4523 sub _new_realtime_bop {
4527 if (ref($_[0]) eq 'HASH') {
4528 %options = %{$_[0]};
4530 my ( $method, $amount ) = ( shift, shift );
4532 $options{method} = $method;
4533 $options{amount} = $amount;
4537 warn "$me realtime_bop (new): $options{method} $options{amount}\n";
4538 warn " $_ => $options{$_}\n" foreach keys %options;
4541 return $self->fake_bop(%options) if $options{'fake'};
4543 $self->_bop_defaults(\%options);
4549 my $payment_gateway = $self->_payment_gateway( \%options );
4550 my $namespace = $payment_gateway->gateway_namespace;
4552 eval "use $namespace";
4556 # check for banned credit card/ACH
4559 my $ban = qsearchs('banned_pay', {
4560 'payby' => $bop_method2payby{$options{method}},
4561 'payinfo' => md5_base64($options{payinfo}),
4563 return "Banned credit card" if $ban;
4569 my (%bop_content) = $self->_bop_content(\%options);
4571 if ( $options{method} ne 'ECHECK' ) {
4572 $options{payname} =~ /^\s*([\w \,\.\-\']*)?\s+([\w\,\.\-\']+)\s*$/
4573 or return "Illegal payname $options{payname}";
4574 ($bop_content{payfirst}, $bop_content{paylast}) = ($1, $2);
4577 my @invoicing_list = $self->invoicing_list_emailonly;
4578 if ( $conf->exists('emailinvoiceautoalways')
4579 || $conf->exists('emailinvoiceauto') && ! @invoicing_list
4580 || ( $conf->exists('emailinvoiceonly') && ! @invoicing_list ) ) {
4581 push @invoicing_list, $self->all_emails;
4584 my $email = ($conf->exists('business-onlinepayment-email-override'))
4585 ? $conf->config('business-onlinepayment-email-override')
4586 : $invoicing_list[0];
4590 if ( $namespace eq 'Business::OnlinePayment' && $options{method} eq 'CC' ) {
4592 $content{card_number} = $options{payinfo};
4593 $paydate = exists($options{'paydate'})
4594 ? $options{'paydate'}
4596 $paydate =~ /^\d{2}(\d{2})[\/\-](\d+)[\/\-]\d+$/;
4597 $content{expiration} = "$2/$1";
4599 my $paycvv = exists($options{'paycvv'})
4600 ? $options{'paycvv'}
4602 $content{cvv2} = $paycvv
4605 my $paystart_month = exists($options{'paystart_month'})
4606 ? $options{'paystart_month'}
4607 : $self->paystart_month;
4609 my $paystart_year = exists($options{'paystart_year'})
4610 ? $options{'paystart_year'}
4611 : $self->paystart_year;
4613 $content{card_start} = "$paystart_month/$paystart_year"
4614 if $paystart_month && $paystart_year;
4616 my $payissue = exists($options{'payissue'})
4617 ? $options{'payissue'}
4619 $content{issue_number} = $payissue if $payissue;
4621 $content{recurring_billing} = 'YES'
4622 if qsearch('cust_pay', { 'custnum' => $self->custnum,
4624 'payinfo' => $options{payinfo},
4626 || qsearch('cust_pay', { 'custnum' => $self->custnum,
4628 'paymask' => $self->mask_payinfo('CARD', $options{payinfo}),
4632 } elsif ( $namespace eq 'Business::OnlinePayment' && $options{method} eq 'ECHECK' ){
4633 ( $content{account_number}, $content{routing_code} ) =
4634 split('@', $options{payinfo});
4635 $content{bank_name} = $options{payname};
4636 $content{bank_state} = exists($options{'paystate'})
4637 ? $options{'paystate'}
4638 : $self->getfield('paystate');
4639 $content{account_type} = exists($options{'paytype'})
4640 ? uc($options{'paytype'}) || 'CHECKING'
4641 : uc($self->getfield('paytype')) || 'CHECKING';
4642 $content{customer_org} = $self->company ? 'B' : 'I';
4643 $content{state_id} = exists($options{'stateid'})
4644 ? $options{'stateid'}
4645 : $self->getfield('stateid');
4646 $content{state_id_state} = exists($options{'stateid_state'})
4647 ? $options{'stateid_state'}
4648 : $self->getfield('stateid_state');
4649 $content{customer_ssn} = exists($options{'ss'})
4652 } elsif ( $namespace eq 'Business::OnlinePayment' && $options{method} eq 'LEC' ) {
4653 $content{phone} = $options{payinfo};
4654 } elsif ( $namespace eq 'Business::OnlineThirdPartyPayment' ) {
4661 # run transaction(s)
4664 my $balance = exists( $options{'balance'} )
4665 ? $options{'balance'}
4668 $self->select_for_update; #mutex ... just until we get our pending record in
4670 #the checks here are intended to catch concurrent payments
4671 #double-form-submission prevention is taken care of in cust_pay_pending::check
4674 return "The customer's balance has changed; $options{method} transaction aborted."
4675 if $self->balance < $balance;
4676 #&& $self->balance < $options{amount}; #might as well anyway?
4678 #also check and make sure there aren't *other* pending payments for this cust
4680 my @pending = qsearch('cust_pay_pending', {
4681 'custnum' => $self->custnum,
4682 'status' => { op=>'!=', value=>'done' }
4684 return "A payment is already being processed for this customer (".
4685 join(', ', map 'paypendingnum '. $_->paypendingnum, @pending ).
4686 "); $options{method} transaction aborted."
4687 if scalar(@pending);
4689 #okay, good to go, if we're a duplicate, cust_pay_pending will kick us out
4691 my $cust_pay_pending = new FS::cust_pay_pending {
4692 'custnum' => $self->custnum,
4693 #'invnum' => $options{'invnum'},
4694 'paid' => $options{amount},
4696 'payby' => $bop_method2payby{$options{method}},
4697 'payinfo' => $options{payinfo},
4698 'paydate' => $paydate,
4700 'gatewaynum' => $payment_gateway->gatewaynum || '',
4701 'session_id' => $options{session_id} || '',
4702 'jobnum' => $options{depend_jobnum} || '',
4704 $cust_pay_pending->payunique( $options{payunique} )
4705 if defined($options{payunique}) && length($options{payunique});
4706 my $cpp_new_err = $cust_pay_pending->insert; #mutex lost when this is inserted
4707 return $cpp_new_err if $cpp_new_err;
4709 my( $action1, $action2 ) =
4710 split( /\s*\,\s*/, $payment_gateway->gateway_action );
4712 my $transaction = new $namespace( $payment_gateway->gateway_module,
4713 $self->_bop_options(\%options),
4716 $transaction->content(
4717 'type' => $options{method},
4718 $self->_bop_auth(\%options),
4719 'action' => $action1,
4720 'description' => $options{'description'},
4721 'amount' => $options{amount},
4722 #'invoice_number' => $options{'invnum'},
4723 'customer_id' => $self->custnum,
4725 'reference' => $cust_pay_pending->paypendingnum, #for now
4730 $cust_pay_pending->status('pending');
4731 my $cpp_pending_err = $cust_pay_pending->replace;
4732 return $cpp_pending_err if $cpp_pending_err;
4735 my $BOP_TESTING = 0;
4736 my $BOP_TESTING_SUCCESS = 1;
4738 unless ( $BOP_TESTING ) {
4739 $transaction->submit();
4741 if ( $BOP_TESTING_SUCCESS ) {
4742 $transaction->is_success(1);
4743 $transaction->authorization('fake auth');
4745 $transaction->is_success(0);
4746 $transaction->error_message('fake failure');
4750 if ( $transaction->is_success() && $namespace eq 'Business::OnlineThirdPartyPayment' ) {
4752 return { reference => $cust_pay_pending->paypendingnum,
4753 map { $_ => $transaction->$_ } qw ( popup_url collectitems ) };
4755 } elsif ( $transaction->is_success() && $action2 ) {
4757 $cust_pay_pending->status('authorized');
4758 my $cpp_authorized_err = $cust_pay_pending->replace;
4759 return $cpp_authorized_err if $cpp_authorized_err;
4761 my $auth = $transaction->authorization;
4762 my $ordernum = $transaction->can('order_number')
4763 ? $transaction->order_number
4767 new Business::OnlinePayment( $payment_gateway->gateway_module,
4768 $self->_bop_options(\%options),
4773 type => $options{method},
4775 $self->_bop_auth(\%options),
4776 order_number => $ordernum,
4777 amount => $options{amount},
4778 authorization => $auth,
4779 description => $options{'description'},
4782 foreach my $field (qw( authorization_source_code returned_ACI
4783 transaction_identifier validation_code
4784 transaction_sequence_num local_transaction_date
4785 local_transaction_time AVS_result_code )) {
4786 $capture{$field} = $transaction->$field() if $transaction->can($field);
4789 $capture->content( %capture );
4793 unless ( $capture->is_success ) {
4794 my $e = "Authorization successful but capture failed, custnum #".
4795 $self->custnum. ': '. $capture->result_code.
4796 ": ". $capture->error_message;
4804 # remove paycvv after initial transaction
4807 #false laziness w/misc/process/payment.cgi - check both to make sure working
4809 if ( defined $self->dbdef_table->column('paycvv')
4810 && length($self->paycvv)
4811 && ! grep { $_ eq cardtype($options{payinfo}) } $conf->config('cvv-save')
4813 my $error = $self->remove_cvv;
4815 warn "WARNING: error removing cvv: $error\n";
4823 $self->_realtime_bop_result( $cust_pay_pending, $transaction, %options );
4835 if (ref($_[0]) eq 'HASH') {
4836 %options = %{$_[0]};
4838 my ( $method, $amount ) = ( shift, shift );
4840 $options{method} = $method;
4841 $options{amount} = $amount;
4844 if ( $options{'fake_failure'} ) {
4845 return "Error: No error; test failure requested with fake_failure";
4849 #if ( $payment_gateway->gatewaynum ) { # agent override
4850 # $paybatch = $payment_gateway->gatewaynum. '-';
4853 #$paybatch .= "$processor:". $transaction->authorization;
4855 #$paybatch .= ':'. $transaction->order_number
4856 # if $transaction->can('order_number')
4857 # && length($transaction->order_number);
4859 my $paybatch = 'FakeProcessor:54:32';
4861 my $cust_pay = new FS::cust_pay ( {
4862 'custnum' => $self->custnum,
4863 'invnum' => $options{'invnum'},
4864 'paid' => $options{amount},
4866 'payby' => $bop_method2payby{$options{method}},
4867 #'payinfo' => $payinfo,
4868 'payinfo' => '4111111111111111',
4869 'paybatch' => $paybatch,
4870 #'paydate' => $paydate,
4871 'paydate' => '2012-05-01',
4873 $cust_pay->payunique( $options{payunique} ) if length($options{payunique});
4875 my $error = $cust_pay->insert($options{'manual'} ? ( 'manual' => 1 ) : () );
4878 $cust_pay->invnum(''); #try again with no specific invnum
4879 my $error2 = $cust_pay->insert( $options{'manual'} ?
4880 ( 'manual' => 1 ) : ()
4883 # gah, even with transactions.
4884 my $e = 'WARNING: Card/ACH debited but database not updated - '.
4885 "error inserting (fake!) payment: $error2".
4886 " (previously tried insert with invnum #$options{'invnum'}" .
4893 if ( $options{'paynum_ref'} ) {
4894 ${ $options{'paynum_ref'} } = $cust_pay->paynum;
4897 return ''; #no error
4902 # item _realtime_bop_result CUST_PAY_PENDING, BOP_OBJECT [ OPTION => VALUE ... ]
4904 # Wraps up processing of a realtime credit card, ACH (electronic check) or
4905 # phone bill transaction.
4907 sub _realtime_bop_result {
4908 my( $self, $cust_pay_pending, $transaction, %options ) = @_;
4910 warn "$me _realtime_bop_result: pending transaction ".
4911 $cust_pay_pending->paypendingnum. "\n";
4912 warn " $_ => $options{$_}\n" foreach keys %options;
4915 my $payment_gateway = $options{payment_gateway}
4916 or return "no payment gateway in arguments to _realtime_bop_result";
4918 $cust_pay_pending->status($transaction->is_success() ? 'captured' : 'declined');
4919 my $cpp_captured_err = $cust_pay_pending->replace;
4920 return $cpp_captured_err if $cpp_captured_err;
4922 if ( $transaction->is_success() ) {
4925 if ( $payment_gateway->gatewaynum ) { # agent override
4926 $paybatch = $payment_gateway->gatewaynum. '-';
4929 $paybatch .= $payment_gateway->gateway_module. ":".
4930 $transaction->authorization;
4932 $paybatch .= ':'. $transaction->order_number
4933 if $transaction->can('order_number')
4934 && length($transaction->order_number);
4936 my $cust_pay = new FS::cust_pay ( {
4937 'custnum' => $self->custnum,
4938 'invnum' => $options{'invnum'},
4939 'paid' => $cust_pay_pending->paid,
4941 'payby' => $cust_pay_pending->payby,
4942 #'payinfo' => $payinfo,
4943 'paybatch' => $paybatch,
4944 'paydate' => $cust_pay_pending->paydate,
4946 #doesn't hurt to know, even though the dup check is in cust_pay_pending now
4947 $cust_pay->payunique( $options{payunique} )
4948 if defined($options{payunique}) && length($options{payunique});
4950 my $oldAutoCommit = $FS::UID::AutoCommit;
4951 local $FS::UID::AutoCommit = 0;
4954 #start a transaction, insert the cust_pay and set cust_pay_pending.status to done in a single transction
4956 my $error = $cust_pay->insert($options{'manual'} ? ( 'manual' => 1 ) : () );
4959 $cust_pay->invnum(''); #try again with no specific invnum
4960 my $error2 = $cust_pay->insert( $options{'manual'} ?
4961 ( 'manual' => 1 ) : ()
4964 # gah. but at least we have a record of the state we had to abort in
4965 # from cust_pay_pending now.
4966 my $e = "WARNING: $options{method} captured but payment not recorded -".
4967 " error inserting payment (". $payment_gateway->gateway_module.
4969 " (previously tried insert with invnum #$options{'invnum'}" .
4970 ": $error ) - pending payment saved as paypendingnum ".
4971 $cust_pay_pending->paypendingnum. "\n";
4977 my $jobnum = $cust_pay_pending->jobnum;
4979 my $placeholder = qsearchs( 'queue', { 'jobnum' => $jobnum } );
4981 unless ( $placeholder ) {
4982 $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
4983 my $e = "WARNING: $options{method} captured but job $jobnum not ".
4984 "found for paypendingnum ". $cust_pay_pending->paypendingnum. "\n";
4989 $error = $placeholder->delete;
4992 $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
4993 my $e = "WARNING: $options{method} captured but could not delete ".
4994 "job $jobnum for paypendingnum ".
4995 $cust_pay_pending->paypendingnum. ": $error\n";
5002 if ( $options{'paynum_ref'} ) {
5003 ${ $options{'paynum_ref'} } = $cust_pay->paynum;
5006 $cust_pay_pending->status('done');
5007 $cust_pay_pending->statustext('captured');
5008 $cust_pay_pending->paynum($cust_pay->paynum);
5009 my $cpp_done_err = $cust_pay_pending->replace;
5011 if ( $cpp_done_err ) {
5013 $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
5014 my $e = "WARNING: $options{method} captured but payment not recorded - ".
5015 "error updating status for paypendingnum ".
5016 $cust_pay_pending->paypendingnum. ": $cpp_done_err \n";
5022 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
5023 return ''; #no error
5029 my $perror = $payment_gateway->gateway_module. " error: ".
5030 $transaction->error_message;
5032 my $jobnum = $cust_pay_pending->jobnum;
5034 my $placeholder = qsearchs( 'queue', { 'jobnum' => $jobnum } );
5036 if ( $placeholder ) {
5037 my $error = $placeholder->depended_delete;
5038 $error ||= $placeholder->delete;
5039 warn "error removing provisioning jobs after declined paypendingnum ".
5040 $cust_pay_pending->paypendingnum. "\n";
5042 my $e = "error finding job $jobnum for declined paypendingnum ".
5043 $cust_pay_pending->paypendingnum. "\n";
5049 unless ( $transaction->error_message ) {
5052 if ( $transaction->can('response_page') ) {
5054 'page' => ( $transaction->can('response_page')
5055 ? $transaction->response_page
5058 'code' => ( $transaction->can('response_code')
5059 ? $transaction->response_code
5062 'headers' => ( $transaction->can('response_headers')
5063 ? $transaction->response_headers
5069 "No additional debugging information available for ".
5070 $payment_gateway->gateway_module;
5073 $perror .= "No error_message returned from ".
5074 $payment_gateway->gateway_module. " -- ".
5075 ( ref($t_response) ? Dumper($t_response) : $t_response );
5079 if ( !$options{'quiet'} && !$realtime_bop_decline_quiet
5080 && $conf->exists('emaildecline')
5081 && grep { $_ ne 'POST' } $self->invoicing_list
5082 && ! grep { $transaction->error_message =~ /$_/ }
5083 $conf->config('emaildecline-exclude')
5085 my @templ = $conf->config('declinetemplate');
5086 my $template = new Text::Template (
5088 SOURCE => [ map "$_\n", @templ ],
5089 ) or return "($perror) can't create template: $Text::Template::ERROR";
5090 $template->compile()
5091 or return "($perror) can't compile template: $Text::Template::ERROR";
5093 my $templ_hash = { error => $transaction->error_message };
5095 my $error = send_email(
5096 'from' => $conf->config('invoice_from', $self->agentnum ),
5097 'to' => [ grep { $_ ne 'POST' } $self->invoicing_list ],
5098 'subject' => 'Your payment could not be processed',
5099 'body' => [ $template->fill_in(HASH => $templ_hash) ],
5102 $perror .= " (also received error sending decline notification: $error)"
5107 $cust_pay_pending->status('done');
5108 $cust_pay_pending->statustext("declined: $perror");
5109 my $cpp_done_err = $cust_pay_pending->replace;
5110 if ( $cpp_done_err ) {
5111 my $e = "WARNING: $options{method} declined but pending payment not ".
5112 "resolved - error updating status for paypendingnum ".
5113 $cust_pay_pending->paypendingnum. ": $cpp_done_err \n";
5115 $perror = "$e ($perror)";
5123 =item realtime_botpp_capture CUST_PAY_PENDING [ OPTION => VALUE ... ]
5125 Verifies successful third party processing of a realtime credit card,
5126 ACH (electronic check) or phone bill transaction via a
5127 Business::OnlineThirdPartyPayment realtime gateway. See
5128 L<http://420.am/business-onlinethirdpartypayment> for supported gateways.
5130 Available options are: I<description>, I<invnum>, I<quiet>, I<paynum_ref>, I<payunique>
5132 The additional options I<payname>, I<city>, I<state>,
5133 I<zip>, I<payinfo> and I<paydate> are also available. Any of these options,
5134 if set, will override the value from the customer record.
5136 I<description> is a free-text field passed to the gateway. It defaults to
5137 "Internet services".
5139 If an I<invnum> is specified, this payment (if successful) is applied to the
5140 specified invoice. If you don't specify an I<invnum> you might want to
5141 call the B<apply_payments> method.
5143 I<quiet> can be set true to surpress email decline notices.
5145 I<paynum_ref> can be set to a scalar reference. It will be filled in with the
5146 resulting paynum, if any.
5148 I<payunique> is a unique identifier for this payment.
5150 Returns a hashref containing elements bill_error (which will be undefined
5151 upon success) and session_id of any associated session.
5155 sub realtime_botpp_capture {
5156 my( $self, $cust_pay_pending, %options ) = @_;
5158 warn "$me realtime_botpp_capture: pending transaction $cust_pay_pending\n";
5159 warn " $_ => $options{$_}\n" foreach keys %options;
5162 eval "use Business::OnlineThirdPartyPayment";
5166 # select the gateway
5169 my $method = FS::payby->payby2bop($cust_pay_pending->payby);
5171 my $payment_gateway = $cust_pay_pending->gatewaynum
5172 ? qsearchs( 'payment_gateway',
5173 { gatewaynum => $cust_pay_pending->gatewaynum }
5175 : $self->agent->payment_gateway( 'method' => $method,
5176 # 'invnum' => $cust_pay_pending->invnum,
5177 # 'payinfo' => $cust_pay_pending->payinfo,
5180 $options{payment_gateway} = $payment_gateway; # for the helper subs
5186 my @invoicing_list = $self->invoicing_list_emailonly;
5187 if ( $conf->exists('emailinvoiceautoalways')
5188 || $conf->exists('emailinvoiceauto') && ! @invoicing_list
5189 || ( $conf->exists('emailinvoiceonly') && ! @invoicing_list ) ) {
5190 push @invoicing_list, $self->all_emails;
5193 my $email = ($conf->exists('business-onlinepayment-email-override'))
5194 ? $conf->config('business-onlinepayment-email-override')
5195 : $invoicing_list[0];
5199 $content{email_customer} =
5200 ( $conf->exists('business-onlinepayment-email_customer')
5201 || $conf->exists('business-onlinepayment-email-override') );
5204 # run transaction(s)
5208 new Business::OnlineThirdPartyPayment( $payment_gateway->gateway_module,
5209 $self->_bop_options(\%options),
5212 $transaction->reference({ %options });
5214 $transaction->content(
5216 $self->_bop_auth(\%options),
5217 'action' => 'Post Authorization',
5218 'description' => $options{'description'},
5219 'amount' => $cust_pay_pending->paid,
5220 #'invoice_number' => $options{'invnum'},
5221 'customer_id' => $self->custnum,
5222 'referer' => 'http://cleanwhisker.420.am/',
5223 'reference' => $cust_pay_pending->paypendingnum,
5225 'phone' => $self->daytime || $self->night,
5227 # plus whatever is required for bogus capture avoidance
5230 $transaction->submit();
5233 $self->_realtime_bop_result( $cust_pay_pending, $transaction, %options );
5236 bill_error => $error,
5237 session_id => $cust_pay_pending->session_id,
5242 =item default_payment_gateway DEPRECATED -- use agent->payment_gateway
5246 sub default_payment_gateway {
5247 my( $self, $method ) = @_;
5249 die "Real-time processing not enabled\n"
5250 unless $conf->exists('business-onlinepayment');
5252 #warn "default_payment_gateway deprecated -- use agent->payment_gateway\n";
5255 my $bop_config = 'business-onlinepayment';
5256 $bop_config .= '-ach'
5257 if $method =~ /^(ECHECK|CHEK)$/ && $conf->exists($bop_config. '-ach');
5258 my ( $processor, $login, $password, $action, @bop_options ) =
5259 $conf->config($bop_config);
5260 $action ||= 'normal authorization';
5261 pop @bop_options if scalar(@bop_options) % 2 && $bop_options[-1] =~ /^\s*$/;
5262 die "No real-time processor is enabled - ".
5263 "did you set the business-onlinepayment configuration value?\n"
5266 ( $processor, $login, $password, $action, @bop_options )
5271 Removes the I<paycvv> field from the database directly.
5273 If there is an error, returns the error, otherwise returns false.
5279 my $sth = dbh->prepare("UPDATE cust_main SET paycvv = '' WHERE custnum = ?")
5280 or return dbh->errstr;
5281 $sth->execute($self->custnum)
5282 or return $sth->errstr;
5287 =item _new_realtime_refund_bop METHOD [ OPTION => VALUE ... ]
5289 Refunds a realtime credit card, ACH (electronic check) or phone bill transaction
5290 via a Business::OnlinePayment realtime gateway. See
5291 L<http://420.am/business-onlinepayment> for supported gateways.
5293 Available methods are: I<CC>, I<ECHECK> and I<LEC>
5295 Available options are: I<amount>, I<reason>, I<paynum>, I<paydate>
5297 Most gateways require a reference to an original payment transaction to refund,
5298 so you probably need to specify a I<paynum>.
5300 I<amount> defaults to the original amount of the payment if not specified.
5302 I<reason> specifies a reason for the refund.
5304 I<paydate> specifies the expiration date for a credit card overriding the
5305 value from the customer record or the payment record. Specified as yyyy-mm-dd
5307 Implementation note: If I<amount> is unspecified or equal to the amount of the
5308 orignal payment, first an attempt is made to "void" the transaction via
5309 the gateway (to cancel a not-yet settled transaction) and then if that fails,
5310 the normal attempt is made to "refund" ("credit") the transaction via the
5311 gateway is attempted.
5313 #The additional options I<payname>, I<address1>, I<address2>, I<city>, I<state>,
5314 #I<zip>, I<payinfo> and I<paydate> are also available. Any of these options,
5315 #if set, will override the value from the customer record.
5317 #If an I<invnum> is specified, this payment (if successful) is applied to the
5318 #specified invoice. If you don't specify an I<invnum> you might want to
5319 #call the B<apply_payments> method.
5323 #some false laziness w/realtime_bop, not enough to make it worth merging
5324 #but some useful small subs should be pulled out
5325 sub _new_realtime_refund_bop {
5329 if (ref($_[0]) ne 'HASH') {
5330 %options = %{$_[0]};
5334 $options{method} = $method;
5338 warn "$me realtime_refund_bop (new): $options{method} refund\n";
5339 warn " $_ => $options{$_}\n" foreach keys %options;
5343 # look up the original payment and optionally a gateway for that payment
5347 my $amount = $options{'amount'};
5349 my( $processor, $login, $password, @bop_options, $namespace ) ;
5350 my( $auth, $order_number ) = ( '', '', '' );
5352 if ( $options{'paynum'} ) {
5354 warn " paynum: $options{paynum}\n" if $DEBUG > 1;
5355 $cust_pay = qsearchs('cust_pay', { paynum=>$options{'paynum'} } )
5356 or return "Unknown paynum $options{'paynum'}";
5357 $amount ||= $cust_pay->paid;
5359 $cust_pay->paybatch =~ /^((\d+)\-)?(\w+):\s*([\w\-\/ ]*)(:([\w\-]+))?$/
5360 or return "Can't parse paybatch for paynum $options{'paynum'}: ".
5361 $cust_pay->paybatch;
5362 my $gatewaynum = '';
5363 ( $gatewaynum, $processor, $auth, $order_number ) = ( $2, $3, $4, $6 );
5365 if ( $gatewaynum ) { #gateway for the payment to be refunded
5367 my $payment_gateway =
5368 qsearchs('payment_gateway', { 'gatewaynum' => $gatewaynum } );
5369 die "payment gateway $gatewaynum not found"
5370 unless $payment_gateway;
5372 $processor = $payment_gateway->gateway_module;
5373 $login = $payment_gateway->gateway_username;
5374 $password = $payment_gateway->gateway_password;
5375 $namespace = $payment_gateway->gateway_namespace;
5376 @bop_options = $payment_gateway->options;
5378 } else { #try the default gateway
5381 my $payment_gateway =
5382 $self->agent->payment_gateway('method' => $options{method});
5384 ( $conf_processor, $login, $password, $namespace ) =
5385 map { my $method = "gateway_$_"; $payment_gateway->$method }
5386 qw( module username password namespace );
5388 @bop_options = $payment_gateway->gatewaynum
5389 ? $payment_gateway->options
5390 : @{ $payment_gateway->get('options') };
5392 return "processor of payment $options{'paynum'} $processor does not".
5393 " match default processor $conf_processor"
5394 unless $processor eq $conf_processor;
5399 } else { # didn't specify a paynum, so look for agent gateway overrides
5400 # like a normal transaction
5402 my $payment_gateway =
5403 $self->agent->payment_gateway( 'method' => $options{method},
5404 #'payinfo' => $payinfo,
5406 my( $processor, $login, $password, $namespace ) =
5407 map { my $method = "gateway_$_"; $payment_gateway->$method }
5408 qw( module username password namespace );
5410 my @bop_options = $payment_gateway->gatewaynum
5411 ? $payment_gateway->options
5412 : @{ $payment_gateway->get('options') };
5415 return "neither amount nor paynum specified" unless $amount;
5417 eval "use $namespace";
5421 'type' => $options{method},
5423 'password' => $password,
5424 'order_number' => $order_number,
5425 'amount' => $amount,
5426 'referer' => 'http://cleanwhisker.420.am/', #XXX fix referer :/
5428 $content{authorization} = $auth
5429 if length($auth); #echeck/ACH transactions have an order # but no auth
5430 #(at least with authorize.net)
5432 my $disable_void_after;
5433 if ($conf->exists('disable_void_after')
5434 && $conf->config('disable_void_after') =~ /^(\d+)$/) {
5435 $disable_void_after = $1;
5438 #first try void if applicable
5439 if ( $cust_pay && $cust_pay->paid == $amount
5441 ( not defined($disable_void_after) )
5442 || ( time < ($cust_pay->_date + $disable_void_after ) )
5445 warn " attempting void\n" if $DEBUG > 1;
5446 my $void = new Business::OnlinePayment( $processor, @bop_options );
5447 $void->content( 'action' => 'void', %content );
5449 if ( $void->is_success ) {
5450 my $error = $cust_pay->void($options{'reason'});
5452 # gah, even with transactions.
5453 my $e = 'WARNING: Card/ACH voided but database not updated - '.
5454 "error voiding payment: $error";
5458 warn " void successful\n" if $DEBUG > 1;
5463 warn " void unsuccessful, trying refund\n"
5467 my $address = $self->address1;
5468 $address .= ", ". $self->address2 if $self->address2;
5470 my($payname, $payfirst, $paylast);
5471 if ( $self->payname && $options{method} ne 'ECHECK' ) {
5472 $payname = $self->payname;
5473 $payname =~ /^\s*([\w \,\.\-\']*)?\s+([\w\,\.\-\']+)\s*$/
5474 or return "Illegal payname $payname";
5475 ($payfirst, $paylast) = ($1, $2);
5477 $payfirst = $self->getfield('first');
5478 $paylast = $self->getfield('last');
5479 $payname = "$payfirst $paylast";
5482 my @invoicing_list = $self->invoicing_list_emailonly;
5483 if ( $conf->exists('emailinvoiceautoalways')
5484 || $conf->exists('emailinvoiceauto') && ! @invoicing_list
5485 || ( $conf->exists('emailinvoiceonly') && ! @invoicing_list ) ) {
5486 push @invoicing_list, $self->all_emails;
5489 my $email = ($conf->exists('business-onlinepayment-email-override'))
5490 ? $conf->config('business-onlinepayment-email-override')
5491 : $invoicing_list[0];
5493 my $payip = exists($options{'payip'})
5496 $content{customer_ip} = $payip
5500 if ( $options{method} eq 'CC' ) {
5503 $content{card_number} = $payinfo = $cust_pay->payinfo;
5504 (exists($options{'paydate'}) ? $options{'paydate'} : $cust_pay->paydate)
5505 =~ /^\d{2}(\d{2})[\/\-](\d+)[\/\-]\d+$/ &&
5506 ($content{expiration} = "$2/$1"); # where available
5508 $content{card_number} = $payinfo = $self->payinfo;
5509 (exists($options{'paydate'}) ? $options{'paydate'} : $self->paydate)
5510 =~ /^\d{2}(\d{2})[\/\-](\d+)[\/\-]\d+$/;
5511 $content{expiration} = "$2/$1";
5514 } elsif ( $options{method} eq 'ECHECK' ) {
5517 $payinfo = $cust_pay->payinfo;
5519 $payinfo = $self->payinfo;
5521 ( $content{account_number}, $content{routing_code} )= split('@', $payinfo );
5522 $content{bank_name} = $self->payname;
5523 $content{account_type} = 'CHECKING';
5524 $content{account_name} = $payname;
5525 $content{customer_org} = $self->company ? 'B' : 'I';
5526 $content{customer_ssn} = $self->ss;
5527 } elsif ( $options{method} eq 'LEC' ) {
5528 $content{phone} = $payinfo = $self->payinfo;
5532 my $refund = new Business::OnlinePayment( $processor, @bop_options );
5533 my %sub_content = $refund->content(
5534 'action' => 'credit',
5535 'customer_id' => $self->custnum,
5536 'last_name' => $paylast,
5537 'first_name' => $payfirst,
5539 'address' => $address,
5540 'city' => $self->city,
5541 'state' => $self->state,
5542 'zip' => $self->zip,
5543 'country' => $self->country,
5545 'phone' => $self->daytime || $self->night,
5548 warn join('', map { " $_ => $sub_content{$_}\n" } keys %sub_content )
5552 return "$processor error: ". $refund->error_message
5553 unless $refund->is_success();
5555 my $paybatch = "$processor:". $refund->authorization;
5556 $paybatch .= ':'. $refund->order_number
5557 if $refund->can('order_number') && $refund->order_number;
5559 while ( $cust_pay && $cust_pay->unapplied < $amount ) {
5560 my @cust_bill_pay = $cust_pay->cust_bill_pay;
5561 last unless @cust_bill_pay;
5562 my $cust_bill_pay = pop @cust_bill_pay;
5563 my $error = $cust_bill_pay->delete;
5567 my $cust_refund = new FS::cust_refund ( {
5568 'custnum' => $self->custnum,
5569 'paynum' => $options{'paynum'},
5570 'refund' => $amount,
5572 'payby' => $bop_method2payby{$options{method}},
5573 'payinfo' => $payinfo,
5574 'paybatch' => $paybatch,
5575 'reason' => $options{'reason'} || 'card or ACH refund',
5577 my $error = $cust_refund->insert;
5579 $cust_refund->paynum(''); #try again with no specific paynum
5580 my $error2 = $cust_refund->insert;
5582 # gah, even with transactions.
5583 my $e = 'WARNING: Card/ACH refunded but database not updated - '.
5584 "error inserting refund ($processor): $error2".
5585 " (previously tried insert with paynum #$options{'paynum'}" .
5596 =item batch_card OPTION => VALUE...
5598 Adds a payment for this invoice to the pending credit card batch (see
5599 L<FS::cust_pay_batch>), or, if the B<realtime> option is set to a true value,
5600 runs the payment using a realtime gateway.
5605 my ($self, %options) = @_;
5608 if (exists($options{amount})) {
5609 $amount = $options{amount};
5611 $amount = sprintf("%.2f", $self->balance - $self->in_transit_payments);
5613 return '' unless $amount > 0;
5615 my $invnum = delete $options{invnum};
5616 my $payby = $options{invnum} || $self->payby; #dubious
5618 if ($options{'realtime'}) {
5619 return $self->realtime_bop( FS::payby->payby2bop($self->payby),
5625 my $oldAutoCommit = $FS::UID::AutoCommit;
5626 local $FS::UID::AutoCommit = 0;
5629 #this needs to handle mysql as well as Pg, like svc_acct.pm
5630 #(make it into a common function if folks need to do batching with mysql)
5631 $dbh->do("LOCK TABLE pay_batch IN SHARE ROW EXCLUSIVE MODE")
5632 or return "Cannot lock pay_batch: " . $dbh->errstr;
5636 'payby' => FS::payby->payby2payment($payby),
5639 my $pay_batch = qsearchs( 'pay_batch', \%pay_batch );
5641 unless ( $pay_batch ) {
5642 $pay_batch = new FS::pay_batch \%pay_batch;
5643 my $error = $pay_batch->insert;
5645 $dbh->rollback if $oldAutoCommit;
5646 die "error creating new batch: $error\n";
5650 my $old_cust_pay_batch = qsearchs('cust_pay_batch', {
5651 'batchnum' => $pay_batch->batchnum,
5652 'custnum' => $self->custnum,
5655 foreach (qw( address1 address2 city state zip country payby payinfo paydate
5657 $options{$_} = '' unless exists($options{$_});
5660 my $cust_pay_batch = new FS::cust_pay_batch ( {
5661 'batchnum' => $pay_batch->batchnum,
5662 'invnum' => $invnum || 0, # is there a better value?
5663 # this field should be
5665 # cust_bill_pay_batch now
5666 'custnum' => $self->custnum,
5667 'last' => $self->getfield('last'),
5668 'first' => $self->getfield('first'),
5669 'address1' => $options{address1} || $self->address1,
5670 'address2' => $options{address2} || $self->address2,
5671 'city' => $options{city} || $self->city,
5672 'state' => $options{state} || $self->state,
5673 'zip' => $options{zip} || $self->zip,
5674 'country' => $options{country} || $self->country,
5675 'payby' => $options{payby} || $self->payby,
5676 'payinfo' => $options{payinfo} || $self->payinfo,
5677 'exp' => $options{paydate} || $self->paydate,
5678 'payname' => $options{payname} || $self->payname,
5679 'amount' => $amount, # consolidating
5682 $cust_pay_batch->paybatchnum($old_cust_pay_batch->paybatchnum)
5683 if $old_cust_pay_batch;
5686 if ($old_cust_pay_batch) {
5687 $error = $cust_pay_batch->replace($old_cust_pay_batch)
5689 $error = $cust_pay_batch->insert;
5693 $dbh->rollback if $oldAutoCommit;
5697 my $unapplied = $self->total_unapplied_credits
5698 + $self->total_unapplied_payments
5699 + $self->in_transit_payments;
5700 foreach my $cust_bill ($self->open_cust_bill) {
5701 #$dbh->commit or die $dbh->errstr if $oldAutoCommit;
5702 my $cust_bill_pay_batch = new FS::cust_bill_pay_batch {
5703 'invnum' => $cust_bill->invnum,
5704 'paybatchnum' => $cust_pay_batch->paybatchnum,
5705 'amount' => $cust_bill->owed,
5708 if ($unapplied >= $cust_bill_pay_batch->amount){
5709 $unapplied -= $cust_bill_pay_batch->amount;
5712 $cust_bill_pay_batch->amount(sprintf ( "%.2f",
5713 $cust_bill_pay_batch->amount - $unapplied )); $unapplied = 0;
5715 $error = $cust_bill_pay_batch->insert;
5717 $dbh->rollback if $oldAutoCommit;
5722 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
5726 =item apply_payments_and_credits
5728 Applies unapplied payments and credits.
5730 In most cases, this new method should be used in place of sequential
5731 apply_payments and apply_credits methods.
5733 If there is an error, returns the error, otherwise returns false.
5737 sub apply_payments_and_credits {
5740 local $SIG{HUP} = 'IGNORE';
5741 local $SIG{INT} = 'IGNORE';
5742 local $SIG{QUIT} = 'IGNORE';
5743 local $SIG{TERM} = 'IGNORE';
5744 local $SIG{TSTP} = 'IGNORE';
5745 local $SIG{PIPE} = 'IGNORE';
5747 my $oldAutoCommit = $FS::UID::AutoCommit;
5748 local $FS::UID::AutoCommit = 0;
5751 $self->select_for_update; #mutex
5753 foreach my $cust_bill ( $self->open_cust_bill ) {
5754 my $error = $cust_bill->apply_payments_and_credits;
5756 $dbh->rollback if $oldAutoCommit;
5757 return "Error applying: $error";
5761 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
5766 =item apply_credits OPTION => VALUE ...
5768 Applies (see L<FS::cust_credit_bill>) unapplied credits (see L<FS::cust_credit>)
5769 to outstanding invoice balances in chronological order (or reverse
5770 chronological order if the I<order> option is set to B<newest>) and returns the
5771 value of any remaining unapplied credits available for refund (see
5772 L<FS::cust_refund>).
5774 Dies if there is an error.
5782 local $SIG{HUP} = 'IGNORE';
5783 local $SIG{INT} = 'IGNORE';
5784 local $SIG{QUIT} = 'IGNORE';
5785 local $SIG{TERM} = 'IGNORE';
5786 local $SIG{TSTP} = 'IGNORE';
5787 local $SIG{PIPE} = 'IGNORE';
5789 my $oldAutoCommit = $FS::UID::AutoCommit;
5790 local $FS::UID::AutoCommit = 0;
5793 $self->select_for_update; #mutex
5795 unless ( $self->total_unapplied_credits ) {
5796 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
5800 my @credits = sort { $b->_date <=> $a->_date} (grep { $_->credited > 0 }
5801 qsearch('cust_credit', { 'custnum' => $self->custnum } ) );
5803 my @invoices = $self->open_cust_bill;
5804 @invoices = sort { $b->_date <=> $a->_date } @invoices
5805 if defined($opt{'order'}) && $opt{'order'} eq 'newest';
5808 foreach my $cust_bill ( @invoices ) {
5811 if ( !defined($credit) || $credit->credited == 0) {
5812 $credit = pop @credits or last;
5815 if ($cust_bill->owed >= $credit->credited) {
5816 $amount=$credit->credited;
5818 $amount=$cust_bill->owed;
5821 my $cust_credit_bill = new FS::cust_credit_bill ( {
5822 'crednum' => $credit->crednum,
5823 'invnum' => $cust_bill->invnum,
5824 'amount' => $amount,
5826 my $error = $cust_credit_bill->insert;
5828 $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
5832 redo if ($cust_bill->owed > 0);
5836 my $total_unapplied_credits = $self->total_unapplied_credits;
5838 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
5840 return $total_unapplied_credits;
5843 =item apply_payments
5845 Applies (see L<FS::cust_bill_pay>) unapplied payments (see L<FS::cust_pay>)
5846 to outstanding invoice balances in chronological order.
5848 #and returns the value of any remaining unapplied payments.
5850 Dies if there is an error.
5854 sub apply_payments {
5857 local $SIG{HUP} = 'IGNORE';
5858 local $SIG{INT} = 'IGNORE';
5859 local $SIG{QUIT} = 'IGNORE';
5860 local $SIG{TERM} = 'IGNORE';
5861 local $SIG{TSTP} = 'IGNORE';
5862 local $SIG{PIPE} = 'IGNORE';
5864 my $oldAutoCommit = $FS::UID::AutoCommit;
5865 local $FS::UID::AutoCommit = 0;
5868 $self->select_for_update; #mutex
5872 my @payments = sort { $b->_date <=> $a->_date }
5873 grep { $_->unapplied > 0 }
5876 my @invoices = sort { $a->_date <=> $b->_date}
5877 grep { $_->owed > 0 }
5882 foreach my $cust_bill ( @invoices ) {
5885 if ( !defined($payment) || $payment->unapplied == 0 ) {
5886 $payment = pop @payments or last;
5889 if ( $cust_bill->owed >= $payment->unapplied ) {
5890 $amount = $payment->unapplied;
5892 $amount = $cust_bill->owed;
5895 my $cust_bill_pay = new FS::cust_bill_pay ( {
5896 'paynum' => $payment->paynum,
5897 'invnum' => $cust_bill->invnum,
5898 'amount' => $amount,
5900 my $error = $cust_bill_pay->insert;
5902 $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
5906 redo if ( $cust_bill->owed > 0);
5910 my $total_unapplied_payments = $self->total_unapplied_payments;
5912 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
5914 return $total_unapplied_payments;
5919 Returns the total owed for this customer on all invoices
5920 (see L<FS::cust_bill/owed>).
5926 $self->total_owed_date(2145859200); #12/31/2037
5929 =item total_owed_date TIME
5931 Returns the total owed for this customer on all invoices with date earlier than
5932 TIME. TIME is specified as a UNIX timestamp; see L<perlfunc/"time">). Also
5933 see L<Time::Local> and L<Date::Parse> for conversion functions.
5937 sub total_owed_date {
5941 foreach my $cust_bill (
5942 grep { $_->_date <= $time }
5943 qsearch('cust_bill', { 'custnum' => $self->custnum, } )
5945 $total_bill += $cust_bill->owed;
5947 sprintf( "%.2f", $total_bill );
5952 Returns the total amount of all payments.
5959 $total += $_->paid foreach $self->cust_pay;
5960 sprintf( "%.2f", $total );
5963 =item total_unapplied_credits
5965 Returns the total outstanding credit (see L<FS::cust_credit>) for this
5966 customer. See L<FS::cust_credit/credited>.
5968 =item total_credited
5970 Old name for total_unapplied_credits. Don't use.
5974 sub total_credited {
5975 #carp "total_credited deprecated, use total_unapplied_credits";
5976 shift->total_unapplied_credits(@_);
5979 sub total_unapplied_credits {
5981 my $total_credit = 0;
5982 $total_credit += $_->credited foreach $self->cust_credit;
5983 sprintf( "%.2f", $total_credit );
5986 =item total_unapplied_payments
5988 Returns the total unapplied payments (see L<FS::cust_pay>) for this customer.
5989 See L<FS::cust_pay/unapplied>.
5993 sub total_unapplied_payments {
5995 my $total_unapplied = 0;
5996 $total_unapplied += $_->unapplied foreach $self->cust_pay;
5997 sprintf( "%.2f", $total_unapplied );
6000 =item total_unapplied_refunds
6002 Returns the total unrefunded refunds (see L<FS::cust_refund>) for this
6003 customer. See L<FS::cust_refund/unapplied>.
6007 sub total_unapplied_refunds {
6009 my $total_unapplied = 0;
6010 $total_unapplied += $_->unapplied foreach $self->cust_refund;
6011 sprintf( "%.2f", $total_unapplied );
6016 Returns the balance for this customer (total_owed plus total_unrefunded, minus
6017 total_unapplied_credits minus total_unapplied_payments).
6025 + $self->total_unapplied_refunds
6026 - $self->total_unapplied_credits
6027 - $self->total_unapplied_payments
6031 =item balance_date TIME
6033 Returns the balance for this customer, only considering invoices with date
6034 earlier than TIME (total_owed_date minus total_credited minus
6035 total_unapplied_payments). TIME is specified as a UNIX timestamp; see
6036 L<perlfunc/"time">). Also see L<Time::Local> and L<Date::Parse> for conversion
6045 $self->total_owed_date($time)
6046 + $self->total_unapplied_refunds
6047 - $self->total_unapplied_credits
6048 - $self->total_unapplied_payments
6052 =item in_transit_payments
6054 Returns the total of requests for payments for this customer pending in
6055 batches in transit to the bank. See L<FS::pay_batch> and L<FS::cust_pay_batch>
6059 sub in_transit_payments {
6061 my $in_transit_payments = 0;
6062 foreach my $pay_batch ( qsearch('pay_batch', {
6065 foreach my $cust_pay_batch ( qsearch('cust_pay_batch', {
6066 'batchnum' => $pay_batch->batchnum,
6067 'custnum' => $self->custnum,
6069 $in_transit_payments += $cust_pay_batch->amount;
6072 sprintf( "%.2f", $in_transit_payments );
6077 Returns a hash of useful information for making a payment.
6087 'CARD' (credit card - automatic), 'DCRD' (credit card - on-demand),
6088 'CHEK' (electronic check - automatic), 'DCHK' (electronic check - on-demand),
6089 'LECB' (Phone bill billing), 'BILL' (billing), or 'COMP' (free).
6093 For credit card transactions:
6105 For electronic check transactions:
6120 $return{balance} = $self->balance;
6122 $return{payname} = $self->payname
6123 || ( $self->first. ' '. $self->get('last') );
6125 $return{$_} = $self->get($_) for qw(address1 address2 city state zip);
6127 $return{payby} = $self->payby;
6128 $return{stateid_state} = $self->stateid_state;
6130 if ( $self->payby =~ /^(CARD|DCRD)$/ ) {
6131 $return{card_type} = cardtype($self->payinfo);
6132 $return{payinfo} = $self->paymask;
6134 @return{'month', 'year'} = $self->paydate_monthyear;
6138 if ( $self->payby =~ /^(CHEK|DCHK)$/ ) {
6139 my ($payinfo1, $payinfo2) = split '@', $self->paymask;
6140 $return{payinfo1} = $payinfo1;
6141 $return{payinfo2} = $payinfo2;
6142 $return{paytype} = $self->paytype;
6143 $return{paystate} = $self->paystate;
6147 #doubleclick protection
6149 $return{paybatch} = "webui-MyAccount-$_date-$$-". rand() * 2**32;
6155 =item paydate_monthyear
6157 Returns a two-element list consisting of the month and year of this customer's
6158 paydate (credit card expiration date for CARD customers)
6162 sub paydate_monthyear {
6164 if ( $self->paydate =~ /^(\d{4})-(\d{1,2})-\d{1,2}$/ ) { #Pg date format
6166 } elsif ( $self->paydate =~ /^(\d{1,2})-(\d{1,2}-)?(\d{4}$)/ ) {
6173 =item invoicing_list [ ARRAYREF ]
6175 If an arguement is given, sets these email addresses as invoice recipients
6176 (see L<FS::cust_main_invoice>). Errors are not fatal and are not reported
6177 (except as warnings), so use check_invoicing_list first.
6179 Returns a list of email addresses (with svcnum entries expanded).
6181 Note: You can clear the invoicing list by passing an empty ARRAYREF. You can
6182 check it without disturbing anything by passing nothing.
6184 This interface may change in the future.
6188 sub invoicing_list {
6189 my( $self, $arrayref ) = @_;
6192 my @cust_main_invoice;
6193 if ( $self->custnum ) {
6194 @cust_main_invoice =
6195 qsearch( 'cust_main_invoice', { 'custnum' => $self->custnum } );
6197 @cust_main_invoice = ();
6199 foreach my $cust_main_invoice ( @cust_main_invoice ) {
6200 #warn $cust_main_invoice->destnum;
6201 unless ( grep { $cust_main_invoice->address eq $_ } @{$arrayref} ) {
6202 #warn $cust_main_invoice->destnum;
6203 my $error = $cust_main_invoice->delete;
6204 warn $error if $error;
6207 if ( $self->custnum ) {
6208 @cust_main_invoice =
6209 qsearch( 'cust_main_invoice', { 'custnum' => $self->custnum } );
6211 @cust_main_invoice = ();
6213 my %seen = map { $_->address => 1 } @cust_main_invoice;
6214 foreach my $address ( @{$arrayref} ) {
6215 next if exists $seen{$address} && $seen{$address};
6216 $seen{$address} = 1;
6217 my $cust_main_invoice = new FS::cust_main_invoice ( {
6218 'custnum' => $self->custnum,
6221 my $error = $cust_main_invoice->insert;
6222 warn $error if $error;
6226 if ( $self->custnum ) {
6228 qsearch( 'cust_main_invoice', { 'custnum' => $self->custnum } );
6235 =item check_invoicing_list ARRAYREF
6237 Checks these arguements as valid input for the invoicing_list method. If there
6238 is an error, returns the error, otherwise returns false.
6242 sub check_invoicing_list {
6243 my( $self, $arrayref ) = @_;
6245 foreach my $address ( @$arrayref ) {
6247 if ($address eq 'FAX' and $self->getfield('fax') eq '') {
6248 return 'Can\'t add FAX invoice destination with a blank FAX number.';
6251 my $cust_main_invoice = new FS::cust_main_invoice ( {
6252 'custnum' => $self->custnum,
6255 my $error = $self->custnum
6256 ? $cust_main_invoice->check
6257 : $cust_main_invoice->checkdest
6259 return $error if $error;
6263 return "Email address required"
6264 if $conf->exists('cust_main-require_invoicing_list_email')
6265 && ! grep { $_ !~ /^([A-Z]+)$/ } @$arrayref;
6270 =item set_default_invoicing_list
6272 Sets the invoicing list to all accounts associated with this customer,
6273 overwriting any previous invoicing list.
6277 sub set_default_invoicing_list {
6279 $self->invoicing_list($self->all_emails);
6284 Returns the email addresses of all accounts provisioned for this customer.
6291 foreach my $cust_pkg ( $self->all_pkgs ) {
6292 my @cust_svc = qsearch('cust_svc', { 'pkgnum' => $cust_pkg->pkgnum } );
6294 map { qsearchs('svc_acct', { 'svcnum' => $_->svcnum } ) }
6295 grep { qsearchs('svc_acct', { 'svcnum' => $_->svcnum } ) }
6297 $list{$_}=1 foreach map { $_->email } @svc_acct;
6302 =item invoicing_list_addpost
6304 Adds postal invoicing to this customer. If this customer is already configured
6305 to receive postal invoices, does nothing.
6309 sub invoicing_list_addpost {
6311 return if grep { $_ eq 'POST' } $self->invoicing_list;
6312 my @invoicing_list = $self->invoicing_list;
6313 push @invoicing_list, 'POST';
6314 $self->invoicing_list(\@invoicing_list);
6317 =item invoicing_list_emailonly
6319 Returns the list of email invoice recipients (invoicing_list without non-email
6320 destinations such as POST and FAX).
6324 sub invoicing_list_emailonly {
6326 warn "$me invoicing_list_emailonly called"
6328 grep { $_ !~ /^([A-Z]+)$/ } $self->invoicing_list;
6331 =item invoicing_list_emailonly_scalar
6333 Returns the list of email invoice recipients (invoicing_list without non-email
6334 destinations such as POST and FAX) as a comma-separated scalar.
6338 sub invoicing_list_emailonly_scalar {
6340 warn "$me invoicing_list_emailonly_scalar called"
6342 join(', ', $self->invoicing_list_emailonly);
6345 =item referral_cust_main [ DEPTH [ EXCLUDE_HASHREF ] ]
6347 Returns an array of customers referred by this customer (referral_custnum set
6348 to this custnum). If DEPTH is given, recurses up to the given depth, returning
6349 customers referred by customers referred by this customer and so on, inclusive.
6350 The default behavior is DEPTH 1 (no recursion).
6354 sub referral_cust_main {
6356 my $depth = @_ ? shift : 1;
6357 my $exclude = @_ ? shift : {};
6360 map { $exclude->{$_->custnum}++; $_; }
6361 grep { ! $exclude->{ $_->custnum } }
6362 qsearch( 'cust_main', { 'referral_custnum' => $self->custnum } );
6366 map { $_->referral_cust_main($depth-1, $exclude) }
6373 =item referral_cust_main_ncancelled
6375 Same as referral_cust_main, except only returns customers with uncancelled
6380 sub referral_cust_main_ncancelled {
6382 grep { scalar($_->ncancelled_pkgs) } $self->referral_cust_main;
6385 =item referral_cust_pkg [ DEPTH ]
6387 Like referral_cust_main, except returns a flat list of all unsuspended (and
6388 uncancelled) packages for each customer. The number of items in this list may
6389 be useful for comission calculations (perhaps after a C<grep { my $pkgpart = $_->pkgpart; grep { $_ == $pkgpart } @commission_worthy_pkgparts> } $cust_main-> ).
6393 sub referral_cust_pkg {
6395 my $depth = @_ ? shift : 1;
6397 map { $_->unsuspended_pkgs }
6398 grep { $_->unsuspended_pkgs }
6399 $self->referral_cust_main($depth);
6402 =item referring_cust_main
6404 Returns the single cust_main record for the customer who referred this customer
6405 (referral_custnum), or false.
6409 sub referring_cust_main {
6411 return '' unless $self->referral_custnum;
6412 qsearchs('cust_main', { 'custnum' => $self->referral_custnum } );
6415 =item credit AMOUNT, REASON [ , OPTION => VALUE ... ]
6417 Applies a credit to this customer. If there is an error, returns the error,
6418 otherwise returns false.
6420 REASON can be a text string, an FS::reason object, or a scalar reference to
6421 a reasonnum. If a text string, it will be automatically inserted as a new
6422 reason, and a 'reason_type' option must be passed to indicate the
6423 FS::reason_type for the new reason.
6425 An I<addlinfo> option may be passed to set the credit's I<addlinfo> field.
6427 Any other options are passed to FS::cust_credit::insert.
6432 my( $self, $amount, $reason, %options ) = @_;
6434 my $cust_credit = new FS::cust_credit {
6435 'custnum' => $self->custnum,
6436 'amount' => $amount,
6439 if ( ref($reason) ) {
6441 if ( ref($reason) eq 'SCALAR' ) {
6442 $cust_credit->reasonnum( $$reason );
6444 $cust_credit->reasonnum( $reason->reasonnum );
6448 $cust_credit->set('reason', $reason)
6451 $cust_credit->addlinfo( delete $options{'addlinfo'} )
6452 if exists($options{'addlinfo'});
6454 $cust_credit->insert(%options);
6458 =item charge AMOUNT [ PKG [ COMMENT [ TAXCLASS ] ] ]
6460 Creates a one-time charge for this customer. If there is an error, returns
6461 the error, otherwise returns false.
6467 my ( $amount, $quantity, $pkg, $comment, $classnum, $additional );
6468 my ( $setuptax, $taxclass ); #internal taxes
6469 my ( $taxproduct, $override ); #vendor (CCH) taxes
6470 if ( ref( $_[0] ) ) {
6471 $amount = $_[0]->{amount};
6472 $quantity = exists($_[0]->{quantity}) ? $_[0]->{quantity} : 1;
6473 $pkg = exists($_[0]->{pkg}) ? $_[0]->{pkg} : 'One-time charge';
6474 $comment = exists($_[0]->{comment}) ? $_[0]->{comment}
6475 : '$'. sprintf("%.2f",$amount);
6476 $setuptax = exists($_[0]->{setuptax}) ? $_[0]->{setuptax} : '';
6477 $taxclass = exists($_[0]->{taxclass}) ? $_[0]->{taxclass} : '';
6478 $classnum = exists($_[0]->{classnum}) ? $_[0]->{classnum} : '';
6479 $additional = $_[0]->{additional};
6480 $taxproduct = $_[0]->{taxproductnum};
6481 $override = { '' => $_[0]->{tax_override} };
6485 $pkg = @_ ? shift : 'One-time charge';
6486 $comment = @_ ? shift : '$'. sprintf("%.2f",$amount);
6488 $taxclass = @_ ? shift : '';
6492 local $SIG{HUP} = 'IGNORE';
6493 local $SIG{INT} = 'IGNORE';
6494 local $SIG{QUIT} = 'IGNORE';
6495 local $SIG{TERM} = 'IGNORE';
6496 local $SIG{TSTP} = 'IGNORE';
6497 local $SIG{PIPE} = 'IGNORE';
6499 my $oldAutoCommit = $FS::UID::AutoCommit;
6500 local $FS::UID::AutoCommit = 0;
6503 my $part_pkg = new FS::part_pkg ( {
6505 'comment' => $comment,
6509 'classnum' => $classnum ? $classnum : '',
6510 'setuptax' => $setuptax,
6511 'taxclass' => $taxclass,
6512 'taxproductnum' => $taxproduct,
6515 my %options = ( ( map { ("additional_info$_" => $additional->[$_] ) }
6516 ( 0 .. @$additional - 1 )
6518 'additional_count' => scalar(@$additional),
6519 'setup_fee' => $amount,
6522 my $error = $part_pkg->insert( options => \%options,
6523 tax_overrides => $override,
6526 $dbh->rollback if $oldAutoCommit;
6530 my $pkgpart = $part_pkg->pkgpart;
6531 my %type_pkgs = ( 'typenum' => $self->agent->typenum, 'pkgpart' => $pkgpart );
6532 unless ( qsearchs('type_pkgs', \%type_pkgs ) ) {
6533 my $type_pkgs = new FS::type_pkgs \%type_pkgs;
6534 $error = $type_pkgs->insert;
6536 $dbh->rollback if $oldAutoCommit;
6541 my $cust_pkg = new FS::cust_pkg ( {
6542 'custnum' => $self->custnum,
6543 'pkgpart' => $pkgpart,
6544 'quantity' => $quantity,
6547 $error = $cust_pkg->insert;
6549 $dbh->rollback if $oldAutoCommit;
6553 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
6558 #=item charge_postal_fee
6560 #Applies a one time charge this customer. If there is an error,
6561 #returns the error, returns the cust_pkg charge object or false
6562 #if there was no charge.
6566 # This should be a customer event. For that to work requires that bill
6567 # also be a customer event.
6569 sub charge_postal_fee {
6572 my $pkgpart = $conf->config('postal_invoice-fee_pkgpart');
6573 return '' unless ($pkgpart && grep { $_ eq 'POST' } $self->invoicing_list);
6575 my $cust_pkg = new FS::cust_pkg ( {
6576 'custnum' => $self->custnum,
6577 'pkgpart' => $pkgpart,
6581 my $error = $cust_pkg->insert;
6582 $error ? $error : $cust_pkg;
6587 Returns all the invoices (see L<FS::cust_bill>) for this customer.
6593 sort { $a->_date <=> $b->_date }
6594 qsearch('cust_bill', { 'custnum' => $self->custnum, } )
6597 =item open_cust_bill
6599 Returns all the open (owed > 0) invoices (see L<FS::cust_bill>) for this
6604 sub open_cust_bill {
6606 grep { $_->owed > 0 } $self->cust_bill;
6611 Returns all the credits (see L<FS::cust_credit>) for this customer.
6617 sort { $a->_date <=> $b->_date }
6618 qsearch( 'cust_credit', { 'custnum' => $self->custnum } )
6623 Returns all the payments (see L<FS::cust_pay>) for this customer.
6629 sort { $a->_date <=> $b->_date }
6630 qsearch( 'cust_pay', { 'custnum' => $self->custnum } )
6635 Returns all voided payments (see L<FS::cust_pay_void>) for this customer.
6641 sort { $a->_date <=> $b->_date }
6642 qsearch( 'cust_pay_void', { 'custnum' => $self->custnum } )
6645 =item cust_pay_batch
6647 Returns all batched payments (see L<FS::cust_pay_void>) for this customer.
6651 sub cust_pay_batch {
6653 sort { $a->_date <=> $b->_date }
6654 qsearch( 'cust_pay_batch', { 'custnum' => $self->custnum } )
6657 =item cust_pay_pending
6659 Returns all pending payments (see L<FS::cust_pay_pending>) for this customer
6660 (without status "done").
6664 sub cust_pay_pending {
6666 return $self->num_cust_pay_pending unless wantarray;
6667 sort { $a->_date <=> $b->_date }
6668 qsearch( 'cust_pay_pending', {
6669 'custnum' => $self->custnum,
6670 'status' => { op=>'!=', value=>'done' },
6675 =item num_cust_pay_pending
6677 Returns the number of pending payments (see L<FS::cust_pay_pending>) for this
6678 customer (without status "done"). Also called automatically when the
6679 cust_pay_pending method is used in a scalar context.
6683 sub num_cust_pay_pending {
6685 my $sql = " SELECT COUNT(*) FROM cust_pay_pending ".
6686 " WHERE custnum = ? AND status != 'done' ";
6687 my $sth = dbh->prepare($sql) or die dbh->errstr;
6688 $sth->execute($self->custnum) or die $sth->errstr;
6689 $sth->fetchrow_arrayref->[0];
6694 Returns all the refunds (see L<FS::cust_refund>) for this customer.
6700 sort { $a->_date <=> $b->_date }
6701 qsearch( 'cust_refund', { 'custnum' => $self->custnum } )
6704 =item display_custnum
6706 Returns the displayed customer number for this customer: agent_custid if
6707 cust_main-default_agent_custid is set and it has a value, custnum otherwise.
6711 sub display_custnum {
6713 if ( $conf->exists('cust_main-default_agent_custid') && $self->agent_custid ){
6714 return $self->agent_custid;
6716 return $self->custnum;
6722 Returns a name string for this customer, either "Company (Last, First)" or
6729 my $name = $self->contact;
6730 $name = $self->company. " ($name)" if $self->company;
6736 Returns a name string for this (service/shipping) contact, either
6737 "Company (Last, First)" or "Last, First".
6743 if ( $self->get('ship_last') ) {
6744 my $name = $self->ship_contact;
6745 $name = $self->ship_company. " ($name)" if $self->ship_company;
6754 Returns a name string for this customer, either "Company" or "First Last".
6760 $self->company !~ /^\s*$/ ? $self->company : $self->contact_firstlast;
6763 =item ship_name_short
6765 Returns a name string for this (service/shipping) contact, either "Company"
6770 sub ship_name_short {
6772 if ( $self->get('ship_last') ) {
6773 $self->ship_company !~ /^\s*$/
6774 ? $self->ship_company
6775 : $self->ship_contact_firstlast;
6777 $self->name_company_or_firstlast;
6783 Returns this customer's full (billing) contact name only, "Last, First"
6789 $self->get('last'). ', '. $self->first;
6794 Returns this customer's full (shipping) contact name only, "Last, First"
6800 $self->get('ship_last')
6801 ? $self->get('ship_last'). ', '. $self->ship_first
6805 =item contact_firstlast
6807 Returns this customers full (billing) contact name only, "First Last".
6811 sub contact_firstlast {
6813 $self->first. ' '. $self->get('last');
6816 =item ship_contact_firstlast
6818 Returns this customer's full (shipping) contact name only, "First Last".
6822 sub ship_contact_firstlast {
6824 $self->get('ship_last')
6825 ? $self->first. ' '. $self->get('ship_last')
6826 : $self->contact_firstlast;
6831 Returns this customer's full country name
6837 code2country($self->country);
6840 =item geocode DATA_VENDOR
6842 Returns a value for the customer location as encoded by DATA_VENDOR.
6843 Currently this only makes sense for "CCH" as DATA_VENDOR.
6848 my ($self, $data_vendor) = (shift, shift); #always cch for now
6850 my $geocode = $self->get('geocode'); #XXX only one data_vendor for geocode
6851 return $geocode if $geocode;
6853 my $prefix = ( $conf->exists('tax-ship_address') && length($self->ship_last) )
6857 my ($zip,$plus4) = split /-/, $self->get("${prefix}zip")
6858 if $self->country eq 'US';
6860 #CCH specific location stuff
6861 my $extra_sql = "AND plus4lo <= '$plus4' AND plus4hi >= '$plus4'";
6863 my @cust_tax_location =
6865 'table' => 'cust_tax_location',
6866 'hashref' => { 'zip' => $zip, 'data_vendor' => $data_vendor },
6867 'extra_sql' => $extra_sql,
6868 'order_by' => 'ORDER BY plus4hi',#overlapping with distinct ends
6871 $geocode = $cust_tax_location[0]->geocode
6872 if scalar(@cust_tax_location);
6881 Returns a status string for this customer, currently:
6885 =item prospect - No packages have ever been ordered
6887 =item active - One or more recurring packages is active
6889 =item inactive - No active recurring packages, but otherwise unsuspended/uncancelled (the inactive status is new - previously inactive customers were mis-identified as cancelled)
6891 =item suspended - All non-cancelled recurring packages are suspended
6893 =item cancelled - All recurring packages are cancelled
6899 sub status { shift->cust_status(@_); }
6903 for my $status (qw( prospect active inactive suspended cancelled )) {
6904 my $method = $status.'_sql';
6905 my $numnum = ( my $sql = $self->$method() ) =~ s/cust_main\.custnum/?/g;
6906 my $sth = dbh->prepare("SELECT $sql") or die dbh->errstr;
6907 $sth->execute( ($self->custnum) x $numnum )
6908 or die "Error executing 'SELECT $sql': ". $sth->errstr;
6909 return $status if $sth->fetchrow_arrayref->[0];
6913 =item ucfirst_cust_status
6915 =item ucfirst_status
6917 Returns the status with the first character capitalized.
6921 sub ucfirst_status { shift->ucfirst_cust_status(@_); }
6923 sub ucfirst_cust_status {
6925 ucfirst($self->cust_status);
6930 Returns a hex triplet color string for this customer's status.
6934 use vars qw(%statuscolor);
6935 tie %statuscolor, 'Tie::IxHash',
6936 'prospect' => '7e0079', #'000000', #black? naw, purple
6937 'active' => '00CC00', #green
6938 'inactive' => '0000CC', #blue
6939 'suspended' => 'FF9900', #yellow
6940 'cancelled' => 'FF0000', #red
6943 sub statuscolor { shift->cust_statuscolor(@_); }
6945 sub cust_statuscolor {
6947 $statuscolor{$self->cust_status};
6952 Returns an array of hashes representing the customer's RT tickets.
6959 my $num = $conf->config('cust_main-max_tickets') || 10;
6962 if ( $conf->config('ticket_system') ) {
6963 unless ( $conf->config('ticket_system-custom_priority_field') ) {
6965 @tickets = @{ FS::TicketSystem->customer_tickets($self->custnum, $num) };
6969 foreach my $priority (
6970 $conf->config('ticket_system-custom_priority_field-values'), ''
6972 last if scalar(@tickets) >= $num;
6974 @{ FS::TicketSystem->customer_tickets( $self->custnum,
6975 $num - scalar(@tickets),
6985 # Return services representing svc_accts in customer support packages
6986 sub support_services {
6988 my %packages = map { $_ => 1 } $conf->config('support_packages');
6990 grep { $_->pkg_svc && $_->pkg_svc->primary_svc eq 'Y' }
6991 grep { $_->part_svc->svcdb eq 'svc_acct' }
6992 map { $_->cust_svc }
6993 grep { exists $packages{ $_->pkgpart } }
6994 $self->ncancelled_pkgs;
7000 =head1 CLASS METHODS
7006 Class method that returns the list of possible status strings for customers
7007 (see L<the status method|/status>). For example:
7009 @statuses = FS::cust_main->statuses();
7014 #my $self = shift; #could be class...
7020 Returns an SQL expression identifying prospective cust_main records (customers
7021 with no packages ever ordered)
7025 use vars qw($select_count_pkgs);
7026 $select_count_pkgs =
7027 "SELECT COUNT(*) FROM cust_pkg
7028 WHERE cust_pkg.custnum = cust_main.custnum";
7030 sub select_count_pkgs_sql {
7034 sub prospect_sql { "
7035 0 = ( $select_count_pkgs )
7040 Returns an SQL expression identifying active cust_main records (customers with
7041 active recurring packages).
7046 0 < ( $select_count_pkgs AND ". FS::cust_pkg->active_sql. "
7052 Returns an SQL expression identifying inactive cust_main records (customers with
7053 no active recurring packages, but otherwise unsuspended/uncancelled).
7057 sub inactive_sql { "
7058 0 = ( $select_count_pkgs AND ". FS::cust_pkg->active_sql. " )
7060 0 < ( $select_count_pkgs AND ". FS::cust_pkg->inactive_sql. " )
7066 Returns an SQL expression identifying suspended cust_main records.
7071 sub suspended_sql { susp_sql(@_); }
7073 0 < ( $select_count_pkgs AND ". FS::cust_pkg->suspended_sql. " )
7075 0 = ( $select_count_pkgs AND ". FS::cust_pkg->active_sql. " )
7081 Returns an SQL expression identifying cancelled cust_main records.
7085 sub cancelled_sql { cancel_sql(@_); }
7088 my $recurring_sql = FS::cust_pkg->recurring_sql;
7089 my $cancelled_sql = FS::cust_pkg->cancelled_sql;
7092 0 < ( $select_count_pkgs )
7093 AND 0 < ( $select_count_pkgs AND $recurring_sql AND $cancelled_sql )
7094 AND 0 = ( $select_count_pkgs AND $recurring_sql
7095 AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
7097 AND 0 = ( $select_count_pkgs AND ". FS::cust_pkg->inactive_sql. " )
7103 =item uncancelled_sql
7105 Returns an SQL expression identifying un-cancelled cust_main records.
7109 sub uncancelled_sql { uncancel_sql(@_); }
7110 sub uncancel_sql { "
7111 ( 0 < ( $select_count_pkgs
7112 AND ( cust_pkg.cancel IS NULL
7113 OR cust_pkg.cancel = 0
7116 OR 0 = ( $select_count_pkgs )
7122 Returns an SQL fragment to retreive the balance.
7127 ( SELECT COALESCE( SUM(charged), 0 ) FROM cust_bill
7128 WHERE cust_bill.custnum = cust_main.custnum )
7129 - ( SELECT COALESCE( SUM(paid), 0 ) FROM cust_pay
7130 WHERE cust_pay.custnum = cust_main.custnum )
7131 - ( SELECT COALESCE( SUM(amount), 0 ) FROM cust_credit
7132 WHERE cust_credit.custnum = cust_main.custnum )
7133 + ( SELECT COALESCE( SUM(refund), 0 ) FROM cust_refund
7134 WHERE cust_refund.custnum = cust_main.custnum )
7137 =item balance_date_sql START_TIME [ END_TIME [ OPTION => VALUE ... ] ]
7139 Returns an SQL fragment to retreive the balance for this customer, only
7140 considering invoices with date earlier than START_TIME, and optionally not
7141 later than END_TIME (total_owed_date minus total_unapplied_credits minus
7142 total_unapplied_payments).
7144 Times are specified as SQL fragments or numeric
7145 UNIX timestamps; see L<perlfunc/"time">). Also see L<Time::Local> and
7146 L<Date::Parse> for conversion functions. The empty string can be passed
7147 to disable that time constraint completely.
7149 Available options are:
7153 =item unapplied_date
7155 set to true to disregard unapplied credits, payments and refunds outside the specified time period - by default the time period restriction only applies to invoices (useful for reporting, probably a bad idea for event triggering)
7160 set to true to remove all customer comparison clauses, for totals
7165 WHERE clause hashref (elements "AND"ed together) (typically used with the total option)
7170 JOIN clause (typically used with the total option)
7176 sub balance_date_sql {
7177 my( $class, $start, $end, %opt ) = @_;
7179 my $owed = FS::cust_bill->owed_sql;
7180 my $unapp_refund = FS::cust_refund->unapplied_sql;
7181 my $unapp_credit = FS::cust_credit->unapplied_sql;
7182 my $unapp_pay = FS::cust_pay->unapplied_sql;
7184 my $j = $opt{'join'} || '';
7186 my $owed_wh = $class->_money_table_where( 'cust_bill', $start,$end,%opt );
7187 my $refund_wh = $class->_money_table_where( 'cust_refund', $start,$end,%opt );
7188 my $credit_wh = $class->_money_table_where( 'cust_credit', $start,$end,%opt );
7189 my $pay_wh = $class->_money_table_where( 'cust_pay', $start,$end,%opt );
7191 " ( SELECT COALESCE(SUM($owed), 0) FROM cust_bill $j $owed_wh )
7192 + ( SELECT COALESCE(SUM($unapp_refund), 0) FROM cust_refund $j $refund_wh )
7193 - ( SELECT COALESCE(SUM($unapp_credit), 0) FROM cust_credit $j $credit_wh )
7194 - ( SELECT COALESCE(SUM($unapp_pay), 0) FROM cust_pay $j $pay_wh )
7199 =item _money_table_where TABLE START_TIME [ END_TIME [ OPTION => VALUE ... ] ]
7201 Helper method for balance_date_sql; name (and usage) subject to change
7202 (suggestions welcome).
7204 Returns a WHERE clause for the specified monetary TABLE (cust_bill,
7205 cust_refund, cust_credit or cust_pay).
7207 If TABLE is "cust_bill" or the unapplied_date option is true, only
7208 considers records with date earlier than START_TIME, and optionally not
7209 later than END_TIME .
7213 sub _money_table_where {
7214 my( $class, $table, $start, $end, %opt ) = @_;
7217 push @where, "cust_main.custnum = $table.custnum" unless $opt{'total'};
7218 if ( $table eq 'cust_bill' || $opt{'unapplied_date'} ) {
7219 push @where, "$table._date <= $start" if defined($start) && length($start);
7220 push @where, "$table._date > $end" if defined($end) && length($end);
7222 push @where, @{$opt{'where'}} if $opt{'where'};
7223 my $where = scalar(@where) ? 'WHERE '. join(' AND ', @where ) : '';
7229 =item search_sql HASHREF
7233 Returns a qsearch hash expression to search for parameters specified in HREF.
7234 Valid parameters are
7242 =item cancelled_pkgs
7248 listref of start date, end date
7254 =item current_balance
7256 listref (list returned by FS::UI::Web::parse_lt_gt($cgi, 'current_balance'))
7260 =item flattened_pkgs
7269 my ($class, $params) = @_;
7280 if ( $params->{'agentnum'} =~ /^(\d+)$/ and $1 ) {
7282 "cust_main.agentnum = $1";
7289 #prospect active inactive suspended cancelled
7290 if ( grep { $params->{'status'} eq $_ } FS::cust_main->statuses() ) {
7291 my $method = $params->{'status'}. '_sql';
7292 #push @where, $class->$method();
7293 push @where, FS::cust_main->$method();
7297 # parse cancelled package checkbox
7302 $pkgwhere .= "AND (cancel = 0 or cancel is null)"
7303 unless $params->{'cancelled_pkgs'};
7309 foreach my $field (qw( signupdate )) {
7311 next unless exists($params->{$field});
7313 my($beginning, $ending) = @{$params->{$field}};
7316 "cust_main.$field IS NOT NULL",
7317 "cust_main.$field >= $beginning",
7318 "cust_main.$field <= $ending";
7320 $orderby ||= "ORDER BY cust_main.$field";
7328 my @payby = grep /^([A-Z]{4})$/, @{ $params->{'payby'} };
7330 push @where, '( '. join(' OR ', map "cust_main.payby = '$_'", @payby). ' )';
7337 #my $balance_sql = $class->balance_sql();
7338 my $balance_sql = FS::cust_main->balance_sql();
7340 push @where, map { s/current_balance/$balance_sql/; $_ }
7341 @{ $params->{'current_balance'} };
7347 if ( $params->{'custbatch'} =~ /^([\w\/\-\:\.]+)$/ and $1 ) {
7349 "cust_main.custbatch = '$1'";
7353 # setup queries, subs, etc. for the search
7356 $orderby ||= 'ORDER BY custnum';
7358 # here is the agent virtualization
7359 push @where, $FS::CurrentUser::CurrentUser->agentnums_sql;
7361 my $extra_sql = scalar(@where) ? ' WHERE '. join(' AND ', @where) : '';
7363 my $addl_from = 'LEFT JOIN cust_pkg USING ( custnum ) ';
7365 my $count_query = "SELECT COUNT(*) FROM cust_main $extra_sql";
7367 my $select = join(', ',
7368 'cust_main.custnum',
7369 FS::UI::Web::cust_sql_fields($params->{'cust_fields'}),
7372 my(@extra_headers) = ();
7373 my(@extra_fields) = ();
7375 if ($params->{'flattened_pkgs'}) {
7377 if ($dbh->{Driver}->{Name} eq 'Pg') {
7379 $select .= ", array_to_string(array(select pkg from cust_pkg left join part_pkg using ( pkgpart ) where cust_main.custnum = cust_pkg.custnum $pkgwhere),'|') as magic";
7381 }elsif ($dbh->{Driver}->{Name} =~ /^mysql/i) {
7382 $select .= ", GROUP_CONCAT(pkg SEPARATOR '|') as magic";
7383 $addl_from .= " LEFT JOIN part_pkg using ( pkgpart )";
7385 warn "warning: unknown database type ". $dbh->{Driver}->{Name}.
7386 "omitting packing information from report.";
7389 my $header_query = "SELECT COUNT(cust_pkg.custnum = cust_main.custnum) AS count FROM cust_main $addl_from $extra_sql $pkgwhere group by cust_main.custnum order by count desc limit 1";
7391 my $sth = dbh->prepare($header_query) or die dbh->errstr;
7392 $sth->execute() or die $sth->errstr;
7393 my $headerrow = $sth->fetchrow_arrayref;
7394 my $headercount = $headerrow ? $headerrow->[0] : 0;
7395 while($headercount) {
7396 unshift @extra_headers, "Package ". $headercount;
7397 unshift @extra_fields, eval q!sub {my $c = shift;
7398 my @a = split '\|', $c->magic;
7399 my $p = $a[!.--$headercount. q!];
7407 'table' => 'cust_main',
7408 'select' => $select,
7410 'extra_sql' => $extra_sql,
7411 'order_by' => $orderby,
7412 'count_query' => $count_query,
7413 'extra_headers' => \@extra_headers,
7414 'extra_fields' => \@extra_fields,
7419 =item email_search_sql HASHREF
7423 Emails a notice to the specified customers.
7425 Valid parameters are those of the L<search_sql> method, plus the following:
7447 Optional job queue job for status updates.
7451 Returns an error message, or false for success.
7453 If an error occurs during any email, stops the enture send and returns that
7454 error. Presumably if you're getting SMTP errors aborting is better than
7455 retrying everything.
7459 sub email_search_sql {
7460 my($class, $params) = @_;
7462 my $from = delete $params->{from};
7463 my $subject = delete $params->{subject};
7464 my $html_body = delete $params->{html_body};
7465 my $text_body = delete $params->{text_body};
7467 my $job = delete $params->{'job'};
7469 my $sql_query = $class->search_sql($params);
7471 my $count_query = delete($sql_query->{'count_query'});
7472 my $count_sth = dbh->prepare($count_query)
7473 or die "Error preparing $count_query: ". dbh->errstr;
7475 or die "Error executing $count_query: ". $count_sth->errstr;
7476 my $count_arrayref = $count_sth->fetchrow_arrayref;
7477 my $num_cust = $count_arrayref->[0];
7479 #my @extra_headers = @{ delete($sql_query->{'extra_headers'}) };
7480 #my @extra_fields = @{ delete($sql_query->{'extra_fields'}) };
7483 my( $num, $last, $min_sec ) = (0, time, 5); #progresbar foo
7485 #eventually order+limit magic to reduce memory use?
7486 foreach my $cust_main ( qsearch($sql_query) ) {
7488 my $to = $cust_main->invoicing_list_emailonly_scalar;
7491 my $error = send_email(
7495 'subject' => $subject,
7496 'html_body' => $html_body,
7497 'text_body' => $text_body,
7500 return $error if $error;
7502 if ( $job ) { #progressbar foo
7504 if ( time - $min_sec > $last ) {
7505 my $error = $job->update_statustext(
7506 int( 100 * $num / $num_cust )
7508 die $error if $error;
7518 use Storable qw(thaw);
7521 sub process_email_search_sql {
7523 #warn "$me process_re_X $method for job $job\n" if $DEBUG;
7525 my $param = thaw(decode_base64(shift));
7526 warn Dumper($param) if $DEBUG;
7528 $param->{'job'} = $job;
7530 my $error = FS::cust_main->email_search_sql( $param );
7531 die $error if $error;
7535 =item fuzzy_search FUZZY_HASHREF [ HASHREF, SELECT, EXTRA_SQL, CACHE_OBJ ]
7537 Performs a fuzzy (approximate) search and returns the matching FS::cust_main
7538 records. Currently, I<first>, I<last> and/or I<company> may be specified (the
7539 appropriate ship_ field is also searched).
7541 Additional options are the same as FS::Record::qsearch
7546 my( $self, $fuzzy, $hash, @opt) = @_;
7551 check_and_rebuild_fuzzyfiles();
7552 foreach my $field ( keys %$fuzzy ) {
7554 my $all = $self->all_X($field);
7555 next unless scalar(@$all);
7558 $match{$_}=1 foreach ( amatch( $fuzzy->{$field}, ['i'], @$all ) );
7561 foreach ( keys %match ) {
7562 push @fcust, qsearch('cust_main', { %$hash, $field=>$_}, @opt);
7563 push @fcust, qsearch('cust_main', { %$hash, "ship_$field"=>$_}, @opt);
7566 push @cust_main, grep { ! $fsaw{$_->custnum}++ } @fcust;
7569 # we want the components of $fuzzy ANDed, not ORed, but still don't want dupes
7571 @cust_main = grep { ++$saw{$_->custnum} == scalar(keys %$fuzzy) } @cust_main;
7579 Returns a masked version of the named field
7584 my ($self,$field) = @_;
7588 'x'x(length($self->getfield($field))-4).
7589 substr($self->getfield($field), (length($self->getfield($field))-4));
7599 =item smart_search OPTION => VALUE ...
7601 Accepts the following options: I<search>, the string to search for. The string
7602 will be searched for as a customer number, phone number, name or company name,
7603 as an exact, or, in some cases, a substring or fuzzy match (see the source code
7604 for the exact heuristics used); I<no_fuzzy_on_exact>, causes smart_search to
7605 skip fuzzy matching when an exact match is found.
7607 Any additional options are treated as an additional qualifier on the search
7610 Returns a (possibly empty) array of FS::cust_main objects.
7617 #here is the agent virtualization
7618 my $agentnums_sql = $FS::CurrentUser::CurrentUser->agentnums_sql;
7622 my $skip_fuzzy = delete $options{'no_fuzzy_on_exact'};
7623 my $search = delete $options{'search'};
7624 ( my $alphanum_search = $search ) =~ s/\W//g;
7626 if ( $alphanum_search =~ /^1?(\d{3})(\d{3})(\d{4})(\d*)$/ ) { #phone# search
7628 #false laziness w/Record::ut_phone
7629 my $phonen = "$1-$2-$3";
7630 $phonen .= " x$4" if $4;
7632 push @cust_main, qsearch( {
7633 'table' => 'cust_main',
7634 'hashref' => { %options },
7635 'extra_sql' => ( scalar(keys %options) ? ' AND ' : ' WHERE ' ).
7637 join(' OR ', map "$_ = '$phonen'",
7638 qw( daytime night fax
7639 ship_daytime ship_night ship_fax )
7642 " AND $agentnums_sql", #agent virtualization
7645 unless ( @cust_main || $phonen =~ /x\d+$/ ) { #no exact match
7646 #try looking for matches with extensions unless one was specified
7648 push @cust_main, qsearch( {
7649 'table' => 'cust_main',
7650 'hashref' => { %options },
7651 'extra_sql' => ( scalar(keys %options) ? ' AND ' : ' WHERE ' ).
7653 join(' OR ', map "$_ LIKE '$phonen\%'",
7655 ship_daytime ship_night )
7658 " AND $agentnums_sql", #agent virtualization
7663 # custnum search (also try agent_custid), with some tweaking options if your
7664 # legacy cust "numbers" have letters
7667 if ( $search =~ /^\s*(\d+)\s*$/
7668 || ( $conf->config('cust_main-agent_custid-format') eq 'ww?d+'
7669 && $search =~ /^\s*(\w\w?\d+)\s*$/
7676 if ( $num <= 2147483647 ) { #need a bigint custnum? wow.
7677 push @cust_main, qsearch( {
7678 'table' => 'cust_main',
7679 'hashref' => { 'custnum' => $num, %options },
7680 'extra_sql' => " AND $agentnums_sql", #agent virtualization
7684 push @cust_main, qsearch( {
7685 'table' => 'cust_main',
7686 'hashref' => { 'agent_custid' => $num, %options },
7687 'extra_sql' => " AND $agentnums_sql", #agent virtualization
7690 } elsif ( $search =~ /^\s*(\S.*\S)\s+\((.+), ([^,]+)\)\s*$/ ) {
7692 my($company, $last, $first) = ( $1, $2, $3 );
7694 # "Company (Last, First)"
7695 #this is probably something a browser remembered,
7696 #so just do an exact search
7698 foreach my $prefix ( '', 'ship_' ) {
7699 push @cust_main, qsearch( {
7700 'table' => 'cust_main',
7701 'hashref' => { $prefix.'first' => $first,
7702 $prefix.'last' => $last,
7703 $prefix.'company' => $company,
7706 'extra_sql' => " AND $agentnums_sql",
7710 } elsif ( $search =~ /^\s*(\S.*\S)\s*$/ ) { # value search
7711 # try (ship_){last,company}
7715 # # remove "(Last, First)" in "Company (Last, First)", otherwise the
7716 # # full strings the browser remembers won't work
7717 # $value =~ s/\([\w \,\.\-\']*\)$//; #false laziness w/Record::ut_name
7719 use Lingua::EN::NameParse;
7720 my $NameParse = new Lingua::EN::NameParse(
7722 allow_reversed => 1,
7725 my($last, $first) = ( '', '' );
7726 #maybe disable this too and just rely on NameParse?
7727 if ( $value =~ /^(.+),\s*([^,]+)$/ ) { # Last, First
7729 ($last, $first) = ( $1, $2 );
7731 #} elsif ( $value =~ /^(.+)\s+(.+)$/ ) {
7732 } elsif ( ! $NameParse->parse($value) ) {
7734 my %name = $NameParse->components;
7735 $first = $name{'given_name_1'};
7736 $last = $name{'surname_1'};
7740 if ( $first && $last ) {
7742 my($q_last, $q_first) = ( dbh->quote($last), dbh->quote($first) );
7745 my $sql = scalar(keys %options) ? ' AND ' : ' WHERE ';
7747 ( ( LOWER(last) = $q_last AND LOWER(first) = $q_first )
7748 OR ( LOWER(ship_last) = $q_last AND LOWER(ship_first) = $q_first )
7751 push @cust_main, qsearch( {
7752 'table' => 'cust_main',
7753 'hashref' => \%options,
7754 'extra_sql' => "$sql AND $agentnums_sql", #agent virtualization
7757 # or it just be something that was typed in... (try that in a sec)
7761 my $q_value = dbh->quote($value);
7764 my $sql = scalar(keys %options) ? ' AND ' : ' WHERE ';
7765 $sql .= " ( LOWER(last) = $q_value
7766 OR LOWER(company) = $q_value
7767 OR LOWER(ship_last) = $q_value
7768 OR LOWER(ship_company) = $q_value
7771 push @cust_main, qsearch( {
7772 'table' => 'cust_main',
7773 'hashref' => \%options,
7774 'extra_sql' => "$sql AND $agentnums_sql", #agent virtualization
7777 #no exact match, trying substring/fuzzy
7778 #always do substring & fuzzy (unless they're explicity config'ed off)
7779 #getting complaints searches are not returning enough
7780 unless ( @cust_main && $skip_fuzzy || $conf->exists('disable-fuzzy') ) {
7782 #still some false laziness w/search_sql (was search/cust_main.cgi)
7787 { 'company' => { op=>'ILIKE', value=>"%$value%" }, },
7788 { 'ship_company' => { op=>'ILIKE', value=>"%$value%" }, },
7791 if ( $first && $last ) {
7794 { 'first' => { op=>'ILIKE', value=>"%$first%" },
7795 'last' => { op=>'ILIKE', value=>"%$last%" },
7797 { 'ship_first' => { op=>'ILIKE', value=>"%$first%" },
7798 'ship_last' => { op=>'ILIKE', value=>"%$last%" },
7805 { 'last' => { op=>'ILIKE', value=>"%$value%" }, },
7806 { 'ship_last' => { op=>'ILIKE', value=>"%$value%" }, },
7810 foreach my $hashref ( @hashrefs ) {
7812 push @cust_main, qsearch( {
7813 'table' => 'cust_main',
7814 'hashref' => { %$hashref,
7817 'extra_sql' => " AND $agentnums_sql", #agent virtualizaiton
7826 " AND $agentnums_sql", #extra_sql #agent virtualization
7829 if ( $first && $last ) {
7830 push @cust_main, FS::cust_main->fuzzy_search(
7831 { 'last' => $last, #fuzzy hashref
7832 'first' => $first }, #
7836 foreach my $field ( 'last', 'company' ) {
7838 FS::cust_main->fuzzy_search( { $field => $value }, @fuzopts );
7843 #eliminate duplicates
7845 @cust_main = grep { !$saw{$_->custnum}++ } @cust_main;
7855 Accepts the following options: I<email>, the email address to search for. The
7856 email address will be searched for as an email invoice destination and as an
7859 #Any additional options are treated as an additional qualifier on the search
7860 #(i.e. I<agentnum>).
7862 Returns a (possibly empty) array of FS::cust_main objects (but usually just
7872 my $email = delete $options{'email'};
7874 #we're only being used by RT at the moment... no agent virtualization yet
7875 #my $agentnums_sql = $FS::CurrentUser::CurrentUser->agentnums_sql;
7879 if ( $email =~ /([^@]+)\@([^@]+)/ ) {
7881 my ( $user, $domain ) = ( $1, $2 );
7883 warn "$me smart_search: searching for $user in domain $domain"
7889 'table' => 'cust_main_invoice',
7890 'hashref' => { 'dest' => $email },
7897 map $_->cust_svc->cust_pkg,
7899 'table' => 'svc_acct',
7900 'hashref' => { 'username' => $user, },
7902 'AND ( SELECT domain FROM svc_domain
7903 WHERE svc_acct.domsvc = svc_domain.svcnum
7904 ) = '. dbh->quote($domain),
7910 @cust_main = grep { !$saw{$_->custnum}++ } @cust_main;
7912 warn "$me smart_search: found ". scalar(@cust_main). " unique customers"
7919 =item check_and_rebuild_fuzzyfiles
7923 use vars qw(@fuzzyfields);
7924 @fuzzyfields = ( 'last', 'first', 'company' );
7926 sub check_and_rebuild_fuzzyfiles {
7927 my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
7928 rebuild_fuzzyfiles() if grep { ! -e "$dir/cust_main.$_" } @fuzzyfields
7931 =item rebuild_fuzzyfiles
7935 sub rebuild_fuzzyfiles {
7937 use Fcntl qw(:flock);
7939 my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
7940 mkdir $dir, 0700 unless -d $dir;
7942 foreach my $fuzzy ( @fuzzyfields ) {
7944 open(LOCK,">>$dir/cust_main.$fuzzy")
7945 or die "can't open $dir/cust_main.$fuzzy: $!";
7947 or die "can't lock $dir/cust_main.$fuzzy: $!";
7949 open (CACHE,">$dir/cust_main.$fuzzy.tmp")
7950 or die "can't open $dir/cust_main.$fuzzy.tmp: $!";
7952 foreach my $field ( $fuzzy, "ship_$fuzzy" ) {
7953 my $sth = dbh->prepare("SELECT $field FROM cust_main".
7954 " WHERE $field != '' AND $field IS NOT NULL");
7955 $sth->execute or die $sth->errstr;
7957 while ( my $row = $sth->fetchrow_arrayref ) {
7958 print CACHE $row->[0]. "\n";
7963 close CACHE or die "can't close $dir/cust_main.$fuzzy.tmp: $!";
7965 rename "$dir/cust_main.$fuzzy.tmp", "$dir/cust_main.$fuzzy";
7976 my( $self, $field ) = @_;
7977 my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
7978 open(CACHE,"<$dir/cust_main.$field")
7979 or die "can't open $dir/cust_main.$field: $!";
7980 my @array = map { chomp; $_; } <CACHE>;
7985 =item append_fuzzyfiles LASTNAME COMPANY
7989 sub append_fuzzyfiles {
7990 #my( $first, $last, $company ) = @_;
7992 &check_and_rebuild_fuzzyfiles;
7994 use Fcntl qw(:flock);
7996 my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
7998 foreach my $field (qw( first last company )) {
8003 open(CACHE,">>$dir/cust_main.$field")
8004 or die "can't open $dir/cust_main.$field: $!";
8005 flock(CACHE,LOCK_EX)
8006 or die "can't lock $dir/cust_main.$field: $!";
8008 print CACHE "$value\n";
8010 flock(CACHE,LOCK_UN)
8011 or die "can't unlock $dir/cust_main.$field: $!";
8026 #warn join('-',keys %$param);
8027 my $fh = $param->{filehandle};
8028 my @fields = @{$param->{fields}};
8030 eval "use Text::CSV_XS;";
8033 my $csv = new Text::CSV_XS;
8040 local $SIG{HUP} = 'IGNORE';
8041 local $SIG{INT} = 'IGNORE';
8042 local $SIG{QUIT} = 'IGNORE';
8043 local $SIG{TERM} = 'IGNORE';
8044 local $SIG{TSTP} = 'IGNORE';
8045 local $SIG{PIPE} = 'IGNORE';
8047 my $oldAutoCommit = $FS::UID::AutoCommit;
8048 local $FS::UID::AutoCommit = 0;
8051 #while ( $columns = $csv->getline($fh) ) {
8053 while ( defined($line=<$fh>) ) {
8055 $csv->parse($line) or do {
8056 $dbh->rollback if $oldAutoCommit;
8057 return "can't parse: ". $csv->error_input();
8060 my @columns = $csv->fields();
8061 #warn join('-',@columns);
8064 foreach my $field ( @fields ) {
8065 $row{$field} = shift @columns;
8068 my $cust_main = qsearchs('cust_main', { 'custnum' => $row{'custnum'} } );
8069 unless ( $cust_main ) {
8070 $dbh->rollback if $oldAutoCommit;
8071 return "unknown custnum $row{'custnum'}";
8074 if ( $row{'amount'} > 0 ) {
8075 my $error = $cust_main->charge($row{'amount'}, $row{'pkg'});
8077 $dbh->rollback if $oldAutoCommit;
8081 } elsif ( $row{'amount'} < 0 ) {
8082 my $error = $cust_main->credit( sprintf( "%.2f", 0-$row{'amount'} ),
8085 $dbh->rollback if $oldAutoCommit;
8095 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
8097 return "Empty file!" unless $imported;
8103 =item notify CUSTOMER_OBJECT TEMPLATE_NAME OPTIONS
8105 Sends a templated email notification to the customer (see L<Text::Template>).
8107 OPTIONS is a hash and may include
8109 I<from> - the email sender (default is invoice_from)
8111 I<to> - comma-separated scalar or arrayref of recipients
8112 (default is invoicing_list)
8114 I<subject> - The subject line of the sent email notification
8115 (default is "Notice from company_name")
8117 I<extra_fields> - a hashref of name/value pairs which will be substituted
8120 The following variables are vavailable in the template.
8122 I<$first> - the customer first name
8123 I<$last> - the customer last name
8124 I<$company> - the customer company
8125 I<$payby> - a description of the method of payment for the customer
8126 # would be nice to use FS::payby::shortname
8127 I<$payinfo> - the account information used to collect for this customer
8128 I<$expdate> - the expiration of the customer payment in seconds from epoch
8133 my ($self, $template, %options) = @_;
8135 return unless $conf->exists($template);
8137 my $from = $conf->config('invoice_from', $self->agentnum)
8138 if $conf->exists('invoice_from', $self->agentnum);
8139 $from = $options{from} if exists($options{from});
8141 my $to = join(',', $self->invoicing_list_emailonly);
8142 $to = $options{to} if exists($options{to});
8144 my $subject = "Notice from " . $conf->config('company_name', $self->agentnum)
8145 if $conf->exists('company_name', $self->agentnum);
8146 $subject = $options{subject} if exists($options{subject});
8148 my $notify_template = new Text::Template (TYPE => 'ARRAY',
8149 SOURCE => [ map "$_\n",
8150 $conf->config($template)]
8152 or die "can't create new Text::Template object: Text::Template::ERROR";
8153 $notify_template->compile()
8154 or die "can't compile template: Text::Template::ERROR";
8156 $FS::notify_template::_template::company_name =
8157 $conf->config('company_name', $self->agentnum);
8158 $FS::notify_template::_template::company_address =
8159 join("\n", $conf->config('company_address', $self->agentnum) ). "\n";
8161 my $paydate = $self->paydate || '2037-12-31';
8162 $FS::notify_template::_template::first = $self->first;
8163 $FS::notify_template::_template::last = $self->last;
8164 $FS::notify_template::_template::company = $self->company;
8165 $FS::notify_template::_template::payinfo = $self->mask_payinfo;
8166 my $payby = $self->payby;
8167 my ($payyear,$paymonth,$payday) = split (/-/,$paydate);
8168 my $expire_time = timelocal(0,0,0,$payday,--$paymonth,$payyear);
8170 #credit cards expire at the end of the month/year of their exp date
8171 if ($payby eq 'CARD' || $payby eq 'DCRD') {
8172 $FS::notify_template::_template::payby = 'credit card';
8173 ($paymonth < 11) ? $paymonth++ : ($paymonth=0, $payyear++);
8174 $expire_time = timelocal(0,0,0,$payday,$paymonth,$payyear);
8176 }elsif ($payby eq 'COMP') {
8177 $FS::notify_template::_template::payby = 'complimentary account';
8179 $FS::notify_template::_template::payby = 'current method';
8181 $FS::notify_template::_template::expdate = $expire_time;
8183 for (keys %{$options{extra_fields}}){
8185 ${"FS::notify_template::_template::$_"} = $options{extra_fields}->{$_};
8188 send_email(from => $from,
8190 subject => $subject,
8191 body => $notify_template->fill_in( PACKAGE =>
8192 'FS::notify_template::_template' ),
8197 =item generate_letter CUSTOMER_OBJECT TEMPLATE_NAME OPTIONS
8199 Generates a templated notification to the customer (see L<Text::Template>).
8201 OPTIONS is a hash and may include
8203 I<extra_fields> - a hashref of name/value pairs which will be substituted
8204 into the template. These values may override values mentioned below
8205 and those from the customer record.
8207 The following variables are available in the template instead of or in addition
8208 to the fields of the customer record.
8210 I<$payby> - a description of the method of payment for the customer
8211 # would be nice to use FS::payby::shortname
8212 I<$payinfo> - the masked account information used to collect for this customer
8213 I<$expdate> - the expiration of the customer payment method in seconds from epoch
8214 I<$returnaddress> - the return address defaults to invoice_latexreturnaddress or company_address
8218 sub generate_letter {
8219 my ($self, $template, %options) = @_;
8221 return unless $conf->exists($template);
8223 my $letter_template = new Text::Template
8225 SOURCE => [ map "$_\n", $conf->config($template)],
8226 DELIMITERS => [ '[@--', '--@]' ],
8228 or die "can't create new Text::Template object: Text::Template::ERROR";
8230 $letter_template->compile()
8231 or die "can't compile template: Text::Template::ERROR";
8233 my %letter_data = map { $_ => $self->$_ } $self->fields;
8234 $letter_data{payinfo} = $self->mask_payinfo;
8236 #my $paydate = $self->paydate || '2037-12-31';
8237 my $paydate = $self->paydate =~ /^\S+$/ ? $self->paydate : '2037-12-31';
8239 my $payby = $self->payby;
8240 my ($payyear,$paymonth,$payday) = split (/-/,$paydate);
8241 my $expire_time = timelocal(0,0,0,$payday,--$paymonth,$payyear);
8243 #credit cards expire at the end of the month/year of their exp date
8244 if ($payby eq 'CARD' || $payby eq 'DCRD') {
8245 $letter_data{payby} = 'credit card';
8246 ($paymonth < 11) ? $paymonth++ : ($paymonth=0, $payyear++);
8247 $expire_time = timelocal(0,0,0,$payday,$paymonth,$payyear);
8249 }elsif ($payby eq 'COMP') {
8250 $letter_data{payby} = 'complimentary account';
8252 $letter_data{payby} = 'current method';
8254 $letter_data{expdate} = $expire_time;
8256 for (keys %{$options{extra_fields}}){
8257 $letter_data{$_} = $options{extra_fields}->{$_};
8260 unless(exists($letter_data{returnaddress})){
8261 my $retadd = join("\n", $conf->config_orbase( 'invoice_latexreturnaddress',
8262 $self->agent_template)
8264 if ( length($retadd) ) {
8265 $letter_data{returnaddress} = $retadd;
8266 } elsif ( grep /\S/, $conf->config('company_address', $self->agentnum) ) {
8267 $letter_data{returnaddress} =
8268 join( '\\*'."\n", map s/( {2,})/'~' x length($1)/eg,
8269 $conf->config('company_address', $self->agentnum)
8272 $letter_data{returnaddress} = '~';
8276 $letter_data{conf_dir} = "$FS::UID::conf_dir/conf.$FS::UID::datasrc";
8278 $letter_data{company_name} = $conf->config('company_name', $self->agentnum);
8280 my $dir = $FS::UID::conf_dir."/cache.". $FS::UID::datasrc;
8281 my $fh = new File::Temp( TEMPLATE => 'letter.'. $self->custnum. '.XXXXXXXX',
8285 ) or die "can't open temp file: $!\n";
8287 $letter_template->fill_in( OUTPUT => $fh, HASH => \%letter_data );
8289 $fh->filename =~ /^(.*).tex$/ or die "unparsable filename: ". $fh->filename;
8293 =item print_ps TEMPLATE
8295 Returns an postscript letter filled in from TEMPLATE, as a scalar.
8301 my $file = $self->generate_letter(@_);
8302 FS::Misc::generate_ps($file);
8305 =item print TEMPLATE
8307 Prints the filled in template.
8309 TEMPLATE is the name of a L<Text::Template> to fill in and print.
8313 sub queueable_print {
8316 my $self = qsearchs('cust_main', { 'custnum' => $opt{custnum} } )
8317 or die "invalid customer number: " . $opt{custvnum};
8319 my $error = $self->print( $opt{template} );
8320 die $error if $error;
8324 my ($self, $template) = (shift, shift);
8325 do_print [ $self->print_ps($template) ];
8328 #these three subs should just go away once agent stuff is all config overrides
8330 sub agent_template {
8332 $self->_agent_plandata('agent_templatename');
8335 sub agent_invoice_from {
8337 $self->_agent_plandata('agent_invoice_from');
8340 sub _agent_plandata {
8341 my( $self, $option ) = @_;
8343 #yuck. this whole thing needs to be reconciled better with 1.9's idea of
8344 #agent-specific Conf
8346 use FS::part_event::Condition;
8348 my $agentnum = $self->agentnum;
8351 if ( driver_name =~ /^Pg/i ) {
8353 } elsif ( driver_name =~ /^mysql/i ) {
8356 die "don't know how to use regular expressions in ". driver_name. " databases";
8359 my $part_event_option =
8361 'select' => 'part_event_option.*',
8362 'table' => 'part_event_option',
8364 LEFT JOIN part_event USING ( eventpart )
8365 LEFT JOIN part_event_option AS peo_agentnum
8366 ON ( part_event.eventpart = peo_agentnum.eventpart
8367 AND peo_agentnum.optionname = 'agentnum'
8368 AND peo_agentnum.optionvalue }. $regexp. q{ '(^|,)}. $agentnum. q{(,|$)'
8370 LEFT JOIN part_event_condition
8371 ON ( part_event.eventpart = part_event_condition.eventpart
8372 AND part_event_condition.conditionname = 'cust_bill_age'
8374 LEFT JOIN part_event_condition_option
8375 ON ( part_event_condition.eventconditionnum = part_event_condition_option.eventconditionnum
8376 AND part_event_condition_option.optionname = 'age'
8379 #'hashref' => { 'optionname' => $option },
8380 #'hashref' => { 'part_event_option.optionname' => $option },
8382 " WHERE part_event_option.optionname = ". dbh->quote($option).
8383 " AND action = 'cust_bill_send_agent' ".
8384 " AND ( disabled IS NULL OR disabled != 'Y' ) ".
8385 " AND peo_agentnum.optionname = 'agentnum' ".
8386 " AND ( agentnum IS NULL OR agentnum = $agentnum ) ".
8388 CASE WHEN part_event_condition_option.optionname IS NULL
8390 ELSE ". FS::part_event::Condition->age2seconds_sql('part_event_condition_option.optionvalue').
8392 , part_event.weight".
8396 unless ( $part_event_option ) {
8397 return $self->agent->invoice_template || ''
8398 if $option eq 'agent_templatename';
8402 $part_event_option->optionvalue;
8407 ## actual sub, not a method, designed to be called from the queue.
8408 ## sets up the customer, and calls the bill_and_collect
8409 my (%args) = @_; #, ($time, $invoice_time, $check_freq, $resetup) = @_;
8410 my $cust_main = qsearchs( 'cust_main', { custnum => $args{'custnum'} } );
8411 $cust_main->bill_and_collect(
8422 The delete method should possibly take an FS::cust_main object reference
8423 instead of a scalar customer number.
8425 Bill and collect options should probably be passed as references instead of a
8428 There should probably be a configuration file with a list of allowed credit
8431 No multiple currency support (probably a larger project than just this module).
8433 payinfo_masked false laziness with cust_pay.pm and cust_refund.pm
8435 Birthdates rely on negative epoch values.
8437 The payby for card/check batches is broken. With mixed batching, bad
8440 B<collect> I<invoice_time> should be renamed I<time>, like B<bill>.
8444 L<FS::Record>, L<FS::cust_pkg>, L<FS::cust_bill>, L<FS::cust_credit>
8445 L<FS::agent>, L<FS::part_referral>, L<FS::cust_main_county>,
8446 L<FS::cust_main_invoice>, L<FS::UID>, schema.html from the base documentation.