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;
33 use FS::cust_bill_pkg_tax_rate_location;
35 use FS::cust_pay_pending;
36 use FS::cust_pay_void;
37 use FS::cust_pay_batch;
40 use FS::part_referral;
41 use FS::cust_main_county;
42 use FS::cust_location;
43 use FS::cust_main_exemption;
45 use FS::tax_rate_location;
46 use FS::cust_tax_location;
47 use FS::part_pkg_taxrate;
49 use FS::cust_main_invoice;
50 use FS::cust_credit_bill;
51 use FS::cust_bill_pay;
52 use FS::prepay_credit;
56 use FS::part_event_condition;
59 use FS::payment_gateway;
60 use FS::agent_payment_gateway;
62 use FS::payinfo_Mixin;
65 @ISA = qw( FS::payinfo_Mixin FS::Record );
67 @EXPORT_OK = qw( smart_search );
69 $realtime_bop_decline_quiet = 0;
71 # 1 is mostly method/subroutine entry and options
72 # 2 traces progress of some operations
73 # 3 is even more information including possibly sensitive data
75 $me = '[FS::cust_main]';
79 $ignore_expired_card = 0;
81 @encrypted_fields = ('payinfo', 'paycvv');
82 sub nohistory_fields { ('paycvv'); }
84 @paytypes = ('', 'Personal checking', 'Personal savings', 'Business checking', 'Business savings');
86 #ask FS::UID to run this stuff for us later
87 #$FS::UID::callback{'FS::cust_main'} = sub {
88 install_callback FS::UID sub {
90 #yes, need it for stuff below (prolly should be cached)
95 my ( $hashref, $cache ) = @_;
96 if ( exists $hashref->{'pkgnum'} ) {
97 #@{ $self->{'_pkgnum'} } = ();
98 my $subcache = $cache->subcache( 'pkgnum', 'cust_pkg', $hashref->{custnum});
99 $self->{'_pkgnum'} = $subcache;
100 #push @{ $self->{'_pkgnum'} },
101 FS::cust_pkg->new_or_cached($hashref, $subcache) if $hashref->{pkgnum};
107 FS::cust_main - Object methods for cust_main records
113 $record = new FS::cust_main \%hash;
114 $record = new FS::cust_main { 'column' => 'value' };
116 $error = $record->insert;
118 $error = $new_record->replace($old_record);
120 $error = $record->delete;
122 $error = $record->check;
124 @cust_pkg = $record->all_pkgs;
126 @cust_pkg = $record->ncancelled_pkgs;
128 @cust_pkg = $record->suspended_pkgs;
130 $error = $record->bill;
131 $error = $record->bill %options;
132 $error = $record->bill 'time' => $time;
134 $error = $record->collect;
135 $error = $record->collect %options;
136 $error = $record->collect 'invoice_time' => $time,
141 An FS::cust_main object represents a customer. FS::cust_main inherits from
142 FS::Record. The following fields are currently supported:
148 Primary key (assigned automatically for new customers)
152 Agent (see L<FS::agent>)
156 Advertising source (see L<FS::part_referral>)
168 Cocial security number (optional)
184 (optional, see L<FS::cust_main_county>)
188 (see L<FS::cust_main_county>)
194 (see L<FS::cust_main_county>)
230 (optional, see L<FS::cust_main_county>)
234 (see L<FS::cust_main_county>)
240 (see L<FS::cust_main_county>)
256 Payment Type (See L<FS::payinfo_Mixin> for valid payby values)
260 Payment Information (See L<FS::payinfo_Mixin> for data format)
264 Masked payinfo (See L<FS::payinfo_Mixin> for how this works)
268 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
272 Expiration date, mm/yyyy, m/yyyy, mm/yy or m/yy
276 Start date month (maestro/solo cards only)
280 Start date year (maestro/solo cards only)
284 Issue number (maestro/solo cards only)
288 Name on card or billing name
292 IP address from which payment information was received
296 Tax exempt, empty or `Y'
300 Order taker (assigned automatically, see L<FS::UID>)
306 =item referral_custnum
308 Referring customer number
312 Enable individual CDR spooling, empty or `Y'
316 A suggestion to events (see L<FS::part_bill_event">) to delay until this unix timestamp
320 Discourage individual CDR printing, empty or `Y'
330 Creates a new customer. To add the customer to the database, see L<"insert">.
332 Note that this stores the hash reference, not a distinct copy of the hash it
333 points to. You can ask the object for a copy with the I<hash> method.
337 sub table { 'cust_main'; }
339 =item insert [ CUST_PKG_HASHREF [ , INVOICING_LIST_ARYREF ] [ , OPTION => VALUE ... ] ]
341 Adds this customer to the database. If there is an error, returns the error,
342 otherwise returns false.
344 CUST_PKG_HASHREF: If you pass a Tie::RefHash data structure to the insert
345 method containing FS::cust_pkg and FS::svc_I<tablename> objects, all records
346 are inserted atomicly, or the transaction is rolled back. Passing an empty
347 hash reference is equivalent to not supplying this parameter. There should be
348 a better explanation of this, but until then, here's an example:
351 tie %hash, 'Tie::RefHash'; #this part is important
353 $cust_pkg => [ $svc_acct ],
356 $cust_main->insert( \%hash );
358 INVOICING_LIST_ARYREF: If you pass an arrarref to the insert method, it will
359 be set as the invoicing list (see L<"invoicing_list">). Errors return as
360 expected and rollback the entire transaction; it is not necessary to call
361 check_invoicing_list first. The invoicing_list is set after the records in the
362 CUST_PKG_HASHREF above are inserted, so it is now possible to set an
363 invoicing_list destination to the newly-created svc_acct. Here's an example:
365 $cust_main->insert( {}, [ $email, 'POST' ] );
367 Currently available options are: I<depend_jobnum>, I<noexport> and I<tax_exemption>.
369 If I<depend_jobnum> is set, all provisioning jobs will have a dependancy
370 on the supplied jobnum (they will not run until the specific job completes).
371 This can be used to defer provisioning until some action completes (such
372 as running the customer's credit card successfully).
374 The I<noexport> option is deprecated. If I<noexport> is set true, no
375 provisioning jobs (exports) are scheduled. (You can schedule them later with
376 the B<reexport> method.)
378 The I<tax_exemption> option can be set to an arrayref of tax names.
379 FS::cust_main_exemption records will be created and inserted.
385 my $cust_pkgs = @_ ? shift : {};
386 my $invoicing_list = @_ ? shift : '';
388 warn "$me insert called with options ".
389 join(', ', map { "$_: $options{$_}" } keys %options ). "\n"
392 local $SIG{HUP} = 'IGNORE';
393 local $SIG{INT} = 'IGNORE';
394 local $SIG{QUIT} = 'IGNORE';
395 local $SIG{TERM} = 'IGNORE';
396 local $SIG{TSTP} = 'IGNORE';
397 local $SIG{PIPE} = 'IGNORE';
399 my $oldAutoCommit = $FS::UID::AutoCommit;
400 local $FS::UID::AutoCommit = 0;
403 my $prepay_identifier = '';
404 my( $amount, $seconds, $upbytes, $downbytes, $totalbytes ) = (0, 0, 0, 0, 0);
406 if ( $self->payby eq 'PREPAY' ) {
408 $self->payby('BILL');
409 $prepay_identifier = $self->payinfo;
412 warn " looking up prepaid card $prepay_identifier\n"
415 my $error = $self->get_prepay( $prepay_identifier,
416 'amount_ref' => \$amount,
417 'seconds_ref' => \$seconds,
418 'upbytes_ref' => \$upbytes,
419 'downbytes_ref' => \$downbytes,
420 'totalbytes_ref' => \$totalbytes,
423 $dbh->rollback if $oldAutoCommit;
424 #return "error applying prepaid card (transaction rolled back): $error";
428 $payby = 'PREP' if $amount;
430 } elsif ( $self->payby =~ /^(CASH|WEST|MCRD)$/ ) {
433 $self->payby('BILL');
434 $amount = $self->paid;
438 warn " inserting $self\n"
441 $self->signupdate(time) unless $self->signupdate;
443 $self->auto_agent_custid()
444 if $conf->config('cust_main-auto_agent_custid') && ! $self->agent_custid;
446 my $error = $self->SUPER::insert;
448 $dbh->rollback if $oldAutoCommit;
449 #return "inserting cust_main record (transaction rolled back): $error";
453 warn " setting invoicing list\n"
456 if ( $invoicing_list ) {
457 $error = $self->check_invoicing_list( $invoicing_list );
459 $dbh->rollback if $oldAutoCommit;
460 #return "checking invoicing_list (transaction rolled back): $error";
463 $self->invoicing_list( $invoicing_list );
466 warn " setting cust_main_exemption\n"
469 my $tax_exemption = delete $options{'tax_exemption'};
470 if ( $tax_exemption ) {
471 foreach my $taxname ( @$tax_exemption ) {
472 my $cust_main_exemption = new FS::cust_main_exemption {
473 'custnum' => $self->custnum,
474 'taxname' => $taxname,
476 my $error = $cust_main_exemption->insert;
478 $dbh->rollback if $oldAutoCommit;
479 return "inserting cust_main_exemption (transaction rolled back): $error";
484 if ( $conf->config('cust_main-skeleton_tables')
485 && $conf->config('cust_main-skeleton_custnum') ) {
487 warn " inserting skeleton records\n"
490 my $error = $self->start_copy_skel;
492 $dbh->rollback if $oldAutoCommit;
498 warn " ordering packages\n"
501 $error = $self->order_pkgs( $cust_pkgs,
503 'seconds_ref' => \$seconds,
504 'upbytes_ref' => \$upbytes,
505 'downbytes_ref' => \$downbytes,
506 'totalbytes_ref' => \$totalbytes,
509 $dbh->rollback if $oldAutoCommit;
514 $dbh->rollback if $oldAutoCommit;
515 return "No svc_acct record to apply pre-paid time";
517 if ( $upbytes || $downbytes || $totalbytes ) {
518 $dbh->rollback if $oldAutoCommit;
519 return "No svc_acct record to apply pre-paid data";
523 warn " inserting initial $payby payment of $amount\n"
525 $error = $self->insert_cust_pay($payby, $amount, $prepay_identifier);
527 $dbh->rollback if $oldAutoCommit;
528 return "inserting payment (transaction rolled back): $error";
532 unless ( $import || $skip_fuzzyfiles ) {
533 warn " queueing fuzzyfiles update\n"
535 $error = $self->queue_fuzzyfiles_update;
537 $dbh->rollback if $oldAutoCommit;
538 return "updating fuzzy search cache: $error";
542 warn " insert complete; committing transaction\n"
545 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
550 use File::CounterFile;
551 sub auto_agent_custid {
554 my $format = $conf->config('cust_main-auto_agent_custid');
556 if ( $format eq '1YMMXXXXXXXX' ) {
558 my $counter = new File::CounterFile 'cust_main.agent_custid';
561 my $ym = 100000000000 + time2str('%y%m00000000', time);
562 if ( $ym > $counter->value ) {
563 $counter->{'value'} = $agent_custid = $ym;
564 $counter->{'updated'} = 1;
566 $agent_custid = $counter->inc;
572 die "Unknown cust_main-auto_agent_custid format: $format";
575 $self->agent_custid($agent_custid);
579 sub start_copy_skel {
582 #'mg_user_preference' => {},
583 #'mg_user_indicator_profile.user_indicator_profile_id' => { 'mg_profile_indicator.profile_indicator_id' => { 'mg_profile_details.profile_detail_id' }, },
584 #'mg_watchlist_header.watchlist_header_id' => { 'mg_watchlist_details.watchlist_details_id' },
585 #'mg_user_grid_header.grid_header_id' => { 'mg_user_grid_details.user_grid_details_id' },
586 #'mg_portfolio_header.portfolio_header_id' => { 'mg_portfolio_trades.portfolio_trades_id' => { 'mg_portfolio_trades_positions.portfolio_trades_positions_id' } },
587 my @tables = eval(join('\n',$conf->config('cust_main-skeleton_tables')));
590 _copy_skel( 'cust_main', #tablename
591 $conf->config('cust_main-skeleton_custnum'), #sourceid
592 $self->custnum, #destid
593 @tables, #child tables
597 #recursive subroutine, not a method
599 my( $table, $sourceid, $destid, %child_tables ) = @_;
602 if ( $table =~ /^(\w+)\.(\w+)$/ ) {
603 ( $table, $primary_key ) = ( $1, $2 );
605 my $dbdef_table = dbdef->table($table);
606 $primary_key = $dbdef_table->primary_key
607 or return "$table has no primary key".
608 " (or do you need to run dbdef-create?)";
611 warn " _copy_skel: $table.$primary_key $sourceid to $destid for ".
612 join (', ', keys %child_tables). "\n"
615 foreach my $child_table_def ( keys %child_tables ) {
619 if ( $child_table_def =~ /^(\w+)\.(\w+)$/ ) {
620 ( $child_table, $child_pkey ) = ( $1, $2 );
622 $child_table = $child_table_def;
624 $child_pkey = dbdef->table($child_table)->primary_key;
625 # or return "$table has no primary key".
626 # " (or do you need to run dbdef-create?)\n";
630 if ( keys %{ $child_tables{$child_table_def} } ) {
632 return "$child_table has no primary key".
633 " (run dbdef-create or try specifying it?)\n"
636 #false laziness w/Record::insert and only works on Pg
637 #refactor the proper last-inserted-id stuff out of Record::insert if this
638 # ever gets use for anything besides a quick kludge for one customer
639 my $default = dbdef->table($child_table)->column($child_pkey)->default;
640 $default =~ /^nextval\(\(?'"?([\w\.]+)"?'/i
641 or return "can't parse $child_table.$child_pkey default value ".
642 " for sequence name: $default";
647 my @sel_columns = grep { $_ ne $primary_key }
648 dbdef->table($child_table)->columns;
649 my $sel_columns = join(', ', @sel_columns );
651 my @ins_columns = grep { $_ ne $child_pkey } @sel_columns;
652 my $ins_columns = ' ( '. join(', ', $primary_key, @ins_columns ). ' ) ';
653 my $placeholders = ' ( ?, '. join(', ', map '?', @ins_columns ). ' ) ';
655 my $sel_st = "SELECT $sel_columns FROM $child_table".
656 " WHERE $primary_key = $sourceid";
659 my $sel_sth = dbh->prepare( $sel_st )
660 or return dbh->errstr;
662 $sel_sth->execute or return $sel_sth->errstr;
664 while ( my $row = $sel_sth->fetchrow_hashref ) {
666 warn " selected row: ".
667 join(', ', map { "$_=".$row->{$_} } keys %$row ). "\n"
671 "INSERT INTO $child_table $ins_columns VALUES $placeholders";
672 my $ins_sth =dbh->prepare($statement)
673 or return dbh->errstr;
674 my @param = ( $destid, map $row->{$_}, @ins_columns );
675 warn " $statement: [ ". join(', ', @param). " ]\n"
677 $ins_sth->execute( @param )
678 or return $ins_sth->errstr;
680 #next unless keys %{ $child_tables{$child_table} };
681 next unless $sequence;
683 #another section of that laziness
684 my $seq_sql = "SELECT currval('$sequence')";
685 my $seq_sth = dbh->prepare($seq_sql) or return dbh->errstr;
686 $seq_sth->execute or return $seq_sth->errstr;
687 my $insertid = $seq_sth->fetchrow_arrayref->[0];
689 # don't drink soap! recurse! recurse! okay!
691 _copy_skel( $child_table_def,
692 $row->{$child_pkey}, #sourceid
694 %{ $child_tables{$child_table_def} },
696 return $error if $error;
706 =item order_pkg HASHREF | OPTION => VALUE ...
708 Orders a single package.
710 Options may be passed as a list of key/value pairs or as a hash reference.
721 Optional FS::cust_location object
725 Optional arryaref of FS::svc_* service objects.
729 If this option is set to a job queue jobnum (see L<FS::queue>), all provisioning
730 jobs will have a dependancy on the supplied job (they will not run until the
731 specific job completes). This can be used to defer provisioning until some
732 action completes (such as running the customer's credit card successfully).
736 Optional subject for a ticket created and attached to this customer
740 Optional queue name for ticket additions
748 my $opt = ref($_[0]) ? shift : { @_ };
750 warn "$me order_pkg called with options ".
751 join(', ', map { "$_: $opt->{$_}" } keys %$opt ). "\n"
754 my $cust_pkg = $opt->{'cust_pkg'};
755 my $svcs = $opt->{'svcs'} || [];
757 my %svc_options = ();
758 $svc_options{'depend_jobnum'} = $opt->{'depend_jobnum'}
759 if exists($opt->{'depend_jobnum'}) && $opt->{'depend_jobnum'};
761 my %insert_params = map { $opt->{$_} ? ( $_ => $opt->{$_} ) : () }
762 qw( ticket_subject ticket_queue );
764 local $SIG{HUP} = 'IGNORE';
765 local $SIG{INT} = 'IGNORE';
766 local $SIG{QUIT} = 'IGNORE';
767 local $SIG{TERM} = 'IGNORE';
768 local $SIG{TSTP} = 'IGNORE';
769 local $SIG{PIPE} = 'IGNORE';
771 my $oldAutoCommit = $FS::UID::AutoCommit;
772 local $FS::UID::AutoCommit = 0;
775 if ( $opt->{'cust_location'} &&
776 ( ! $cust_pkg->locationnum || $cust_pkg->locationnum == -1 ) ) {
777 my $error = $opt->{'cust_location'}->insert;
779 $dbh->rollback if $oldAutoCommit;
780 return "inserting cust_location (transaction rolled back): $error";
782 $cust_pkg->locationnum($opt->{'cust_location'}->locationnum);
785 $cust_pkg->custnum( $self->custnum );
787 my $error = $cust_pkg->insert( %insert_params );
789 $dbh->rollback if $oldAutoCommit;
790 return "inserting cust_pkg (transaction rolled back): $error";
793 foreach my $svc_something ( @{ $opt->{'svcs'} } ) {
794 if ( $svc_something->svcnum ) {
795 my $old_cust_svc = $svc_something->cust_svc;
796 my $new_cust_svc = new FS::cust_svc { $old_cust_svc->hash };
797 $new_cust_svc->pkgnum( $cust_pkg->pkgnum);
798 $error = $new_cust_svc->replace($old_cust_svc);
800 $svc_something->pkgnum( $cust_pkg->pkgnum );
801 if ( $svc_something->isa('FS::svc_acct') ) {
802 foreach ( grep { $opt->{$_.'_ref'} && ${ $opt->{$_.'_ref'} } }
803 qw( seconds upbytes downbytes totalbytes ) ) {
804 $svc_something->$_( $svc_something->$_() + ${ $opt->{$_.'_ref'} } );
805 ${ $opt->{$_.'_ref'} } = 0;
808 $error = $svc_something->insert(%svc_options);
811 $dbh->rollback if $oldAutoCommit;
812 return "inserting svc_ (transaction rolled back): $error";
816 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
821 #deprecated #=item order_pkgs HASHREF [ , SECONDSREF ] [ , OPTION => VALUE ... ]
822 =item order_pkgs HASHREF [ , OPTION => VALUE ... ]
824 Like the insert method on an existing record, this method orders multiple
825 packages and included services atomicaly. Pass a Tie::RefHash data structure
826 to this method containing FS::cust_pkg and FS::svc_I<tablename> objects.
827 There should be a better explanation of this, but until then, here's an
831 tie %hash, 'Tie::RefHash'; #this part is important
833 $cust_pkg => [ $svc_acct ],
836 $cust_main->order_pkgs( \%hash, 'noexport'=>1 );
838 Services can be new, in which case they are inserted, or existing unaudited
839 services, in which case they are linked to the newly-created package.
841 Currently available options are: I<depend_jobnum>, I<noexport>, I<seconds_ref>,
842 I<upbytes_ref>, I<downbytes_ref>, and I<totalbytes_ref>.
844 If I<depend_jobnum> is set, all provisioning jobs will have a dependancy
845 on the supplied jobnum (they will not run until the specific job completes).
846 This can be used to defer provisioning until some action completes (such
847 as running the customer's credit card successfully).
849 The I<noexport> option is deprecated. If I<noexport> is set true, no
850 provisioning jobs (exports) are scheduled. (You can schedule them later with
851 the B<reexport> method for each cust_pkg object. Using the B<reexport> method
852 on the cust_main object is not recommended, as existing services will also be
855 If I<seconds_ref>, I<upbytes_ref>, I<downbytes_ref>, or I<totalbytes_ref> is
856 provided, the scalars (provided by references) will be incremented by the
857 values of the prepaid card.`
863 my $cust_pkgs = shift;
864 my $seconds_ref = ref($_[0]) ? shift : ''; #deprecated
866 $seconds_ref ||= $options{'seconds_ref'};
868 warn "$me order_pkgs called with options ".
869 join(', ', map { "$_: $options{$_}" } keys %options ). "\n"
872 local $SIG{HUP} = 'IGNORE';
873 local $SIG{INT} = 'IGNORE';
874 local $SIG{QUIT} = 'IGNORE';
875 local $SIG{TERM} = 'IGNORE';
876 local $SIG{TSTP} = 'IGNORE';
877 local $SIG{PIPE} = 'IGNORE';
879 my $oldAutoCommit = $FS::UID::AutoCommit;
880 local $FS::UID::AutoCommit = 0;
883 local $FS::svc_Common::noexport_hack = 1 if $options{'noexport'};
885 foreach my $cust_pkg ( keys %$cust_pkgs ) {
887 my $error = $self->order_pkg(
888 'cust_pkg' => $cust_pkg,
889 'svcs' => $cust_pkgs->{$cust_pkg},
890 'seconds_ref' => $seconds_ref,
891 map { $_ => $options{$_} } qw( upbytes_ref downbytes_ref totalbytes_ref
896 $dbh->rollback if $oldAutoCommit;
902 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
906 =item recharge_prepay IDENTIFIER | PREPAY_CREDIT_OBJ [ , AMOUNTREF, SECONDSREF, UPBYTEREF, DOWNBYTEREF ]
908 Recharges this (existing) customer with the specified prepaid card (see
909 L<FS::prepay_credit>), specified either by I<identifier> or as an
910 FS::prepay_credit object. If there is an error, returns the error, otherwise
913 Optionally, five scalar references can be passed as well. They will have their
914 values filled in with the amount, number of seconds, and number of upload,
915 download, and total bytes applied by this prepaid card.
919 #the ref bullshit here should be refactored like get_prepay. MyAccount.pm is
920 #the only place that uses these args
921 sub recharge_prepay {
922 my( $self, $prepay_credit, $amountref, $secondsref,
923 $upbytesref, $downbytesref, $totalbytesref ) = @_;
925 local $SIG{HUP} = 'IGNORE';
926 local $SIG{INT} = 'IGNORE';
927 local $SIG{QUIT} = 'IGNORE';
928 local $SIG{TERM} = 'IGNORE';
929 local $SIG{TSTP} = 'IGNORE';
930 local $SIG{PIPE} = 'IGNORE';
932 my $oldAutoCommit = $FS::UID::AutoCommit;
933 local $FS::UID::AutoCommit = 0;
936 my( $amount, $seconds, $upbytes, $downbytes, $totalbytes) = ( 0, 0, 0, 0, 0 );
938 my $error = $self->get_prepay( $prepay_credit,
939 'amount_ref' => \$amount,
940 'seconds_ref' => \$seconds,
941 'upbytes_ref' => \$upbytes,
942 'downbytes_ref' => \$downbytes,
943 'totalbytes_ref' => \$totalbytes,
945 || $self->increment_seconds($seconds)
946 || $self->increment_upbytes($upbytes)
947 || $self->increment_downbytes($downbytes)
948 || $self->increment_totalbytes($totalbytes)
949 || $self->insert_cust_pay_prepay( $amount,
951 ? $prepay_credit->identifier
956 $dbh->rollback if $oldAutoCommit;
960 if ( defined($amountref) ) { $$amountref = $amount; }
961 if ( defined($secondsref) ) { $$secondsref = $seconds; }
962 if ( defined($upbytesref) ) { $$upbytesref = $upbytes; }
963 if ( defined($downbytesref) ) { $$downbytesref = $downbytes; }
964 if ( defined($totalbytesref) ) { $$totalbytesref = $totalbytes; }
966 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
971 =item get_prepay IDENTIFIER | PREPAY_CREDIT_OBJ [ , OPTION => VALUE ... ]
973 Looks up and deletes a prepaid card (see L<FS::prepay_credit>),
974 specified either by I<identifier> or as an FS::prepay_credit object.
976 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
977 incremented by the values of the prepaid card.
979 If the prepaid card specifies an I<agentnum> (see L<FS::agent>), it is used to
980 check or set this customer's I<agentnum>.
982 If there is an error, returns the error, otherwise returns false.
988 my( $self, $prepay_credit, %opt ) = @_;
990 local $SIG{HUP} = 'IGNORE';
991 local $SIG{INT} = 'IGNORE';
992 local $SIG{QUIT} = 'IGNORE';
993 local $SIG{TERM} = 'IGNORE';
994 local $SIG{TSTP} = 'IGNORE';
995 local $SIG{PIPE} = 'IGNORE';
997 my $oldAutoCommit = $FS::UID::AutoCommit;
998 local $FS::UID::AutoCommit = 0;
1001 unless ( ref($prepay_credit) ) {
1003 my $identifier = $prepay_credit;
1005 $prepay_credit = qsearchs(
1007 { 'identifier' => $prepay_credit },
1012 unless ( $prepay_credit ) {
1013 $dbh->rollback if $oldAutoCommit;
1014 return "Invalid prepaid card: ". $identifier;
1019 if ( $prepay_credit->agentnum ) {
1020 if ( $self->agentnum && $self->agentnum != $prepay_credit->agentnum ) {
1021 $dbh->rollback if $oldAutoCommit;
1022 return "prepaid card not valid for agent ". $self->agentnum;
1024 $self->agentnum($prepay_credit->agentnum);
1027 my $error = $prepay_credit->delete;
1029 $dbh->rollback if $oldAutoCommit;
1030 return "removing prepay_credit (transaction rolled back): $error";
1033 ${ $opt{$_.'_ref'} } += $prepay_credit->$_()
1034 for grep $opt{$_.'_ref'}, qw( amount seconds upbytes downbytes totalbytes );
1036 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1041 =item increment_upbytes SECONDS
1043 Updates this customer's single or primary account (see L<FS::svc_acct>) by
1044 the specified number of upbytes. If there is an error, returns the error,
1045 otherwise returns false.
1049 sub increment_upbytes {
1050 _increment_column( shift, 'upbytes', @_);
1053 =item increment_downbytes SECONDS
1055 Updates this customer's single or primary account (see L<FS::svc_acct>) by
1056 the specified number of downbytes. If there is an error, returns the error,
1057 otherwise returns false.
1061 sub increment_downbytes {
1062 _increment_column( shift, 'downbytes', @_);
1065 =item increment_totalbytes SECONDS
1067 Updates this customer's single or primary account (see L<FS::svc_acct>) by
1068 the specified number of totalbytes. If there is an error, returns the error,
1069 otherwise returns false.
1073 sub increment_totalbytes {
1074 _increment_column( shift, 'totalbytes', @_);
1077 =item increment_seconds SECONDS
1079 Updates this customer's single or primary account (see L<FS::svc_acct>) by
1080 the specified number of seconds. If there is an error, returns the error,
1081 otherwise returns false.
1085 sub increment_seconds {
1086 _increment_column( shift, 'seconds', @_);
1089 =item _increment_column AMOUNT
1091 Updates this customer's single or primary account (see L<FS::svc_acct>) by
1092 the specified number of seconds or bytes. If there is an error, returns
1093 the error, otherwise returns false.
1097 sub _increment_column {
1098 my( $self, $column, $amount ) = @_;
1099 warn "$me increment_column called: $column, $amount\n"
1102 return '' unless $amount;
1104 my @cust_pkg = grep { $_->part_pkg->svcpart('svc_acct') }
1105 $self->ncancelled_pkgs;
1107 if ( ! @cust_pkg ) {
1108 return 'No packages with primary or single services found'.
1109 ' to apply pre-paid time';
1110 } elsif ( scalar(@cust_pkg) > 1 ) {
1111 #maybe have a way to specify the package/account?
1112 return 'Multiple packages found to apply pre-paid time';
1115 my $cust_pkg = $cust_pkg[0];
1116 warn " found package pkgnum ". $cust_pkg->pkgnum. "\n"
1120 $cust_pkg->cust_svc( $cust_pkg->part_pkg->svcpart('svc_acct') );
1122 if ( ! @cust_svc ) {
1123 return 'No account found to apply pre-paid time';
1124 } elsif ( scalar(@cust_svc) > 1 ) {
1125 return 'Multiple accounts found to apply pre-paid time';
1128 my $svc_acct = $cust_svc[0]->svc_x;
1129 warn " found service svcnum ". $svc_acct->pkgnum.
1130 ' ('. $svc_acct->email. ")\n"
1133 $column = "increment_$column";
1134 $svc_acct->$column($amount);
1138 =item insert_cust_pay_prepay AMOUNT [ PAYINFO ]
1140 Inserts a prepayment in the specified amount for this customer. An optional
1141 second argument can specify the prepayment identifier for tracking purposes.
1142 If there is an error, returns the error, otherwise returns false.
1146 sub insert_cust_pay_prepay {
1147 shift->insert_cust_pay('PREP', @_);
1150 =item insert_cust_pay_cash AMOUNT [ PAYINFO ]
1152 Inserts a cash payment in the specified amount for this customer. An optional
1153 second argument can specify the payment identifier for tracking purposes.
1154 If there is an error, returns the error, otherwise returns false.
1158 sub insert_cust_pay_cash {
1159 shift->insert_cust_pay('CASH', @_);
1162 =item insert_cust_pay_west AMOUNT [ PAYINFO ]
1164 Inserts a Western Union payment in the specified amount for this customer. An
1165 optional second argument can specify the prepayment identifier for tracking
1166 purposes. If there is an error, returns the error, otherwise returns false.
1170 sub insert_cust_pay_west {
1171 shift->insert_cust_pay('WEST', @_);
1174 sub insert_cust_pay {
1175 my( $self, $payby, $amount ) = splice(@_, 0, 3);
1176 my $payinfo = scalar(@_) ? shift : '';
1178 my $cust_pay = new FS::cust_pay {
1179 'custnum' => $self->custnum,
1180 'paid' => sprintf('%.2f', $amount),
1181 #'_date' => #date the prepaid card was purchased???
1183 'payinfo' => $payinfo,
1191 This method is deprecated. See the I<depend_jobnum> option to the insert and
1192 order_pkgs methods for a better way to defer provisioning.
1194 Re-schedules all exports by calling the B<reexport> method of all associated
1195 packages (see L<FS::cust_pkg>). If there is an error, returns the error;
1196 otherwise returns false.
1203 carp "WARNING: FS::cust_main::reexport is deprectated; ".
1204 "use the depend_jobnum option to insert or order_pkgs to delay export";
1206 local $SIG{HUP} = 'IGNORE';
1207 local $SIG{INT} = 'IGNORE';
1208 local $SIG{QUIT} = 'IGNORE';
1209 local $SIG{TERM} = 'IGNORE';
1210 local $SIG{TSTP} = 'IGNORE';
1211 local $SIG{PIPE} = 'IGNORE';
1213 my $oldAutoCommit = $FS::UID::AutoCommit;
1214 local $FS::UID::AutoCommit = 0;
1217 foreach my $cust_pkg ( $self->ncancelled_pkgs ) {
1218 my $error = $cust_pkg->reexport;
1220 $dbh->rollback if $oldAutoCommit;
1225 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1230 =item delete NEW_CUSTNUM
1232 This deletes the customer. If there is an error, returns the error, otherwise
1235 This will completely remove all traces of the customer record. This is not
1236 what you want when a customer cancels service; for that, cancel all of the
1237 customer's packages (see L</cancel>).
1239 If the customer has any uncancelled packages, you need to pass a new (valid)
1240 customer number for those packages to be transferred to. Cancelled packages
1241 will be deleted. Did I mention that this is NOT what you want when a customer
1242 cancels service and that you really should be looking see L<FS::cust_pkg/cancel>?
1244 You can't delete a customer with invoices (see L<FS::cust_bill>),
1245 or credits (see L<FS::cust_credit>), payments (see L<FS::cust_pay>) or
1246 refunds (see L<FS::cust_refund>).
1253 local $SIG{HUP} = 'IGNORE';
1254 local $SIG{INT} = 'IGNORE';
1255 local $SIG{QUIT} = 'IGNORE';
1256 local $SIG{TERM} = 'IGNORE';
1257 local $SIG{TSTP} = 'IGNORE';
1258 local $SIG{PIPE} = 'IGNORE';
1260 my $oldAutoCommit = $FS::UID::AutoCommit;
1261 local $FS::UID::AutoCommit = 0;
1264 if ( $self->cust_bill ) {
1265 $dbh->rollback if $oldAutoCommit;
1266 return "Can't delete a customer with invoices";
1268 if ( $self->cust_credit ) {
1269 $dbh->rollback if $oldAutoCommit;
1270 return "Can't delete a customer with credits";
1272 if ( $self->cust_pay ) {
1273 $dbh->rollback if $oldAutoCommit;
1274 return "Can't delete a customer with payments";
1276 if ( $self->cust_refund ) {
1277 $dbh->rollback if $oldAutoCommit;
1278 return "Can't delete a customer with refunds";
1281 my @cust_pkg = $self->ncancelled_pkgs;
1283 my $new_custnum = shift;
1284 unless ( qsearchs( 'cust_main', { 'custnum' => $new_custnum } ) ) {
1285 $dbh->rollback if $oldAutoCommit;
1286 return "Invalid new customer number: $new_custnum";
1288 foreach my $cust_pkg ( @cust_pkg ) {
1289 my %hash = $cust_pkg->hash;
1290 $hash{'custnum'} = $new_custnum;
1291 my $new_cust_pkg = new FS::cust_pkg ( \%hash );
1292 my $error = $new_cust_pkg->replace($cust_pkg,
1293 options => { $cust_pkg->options },
1296 $dbh->rollback if $oldAutoCommit;
1301 my @cancelled_cust_pkg = $self->all_pkgs;
1302 foreach my $cust_pkg ( @cancelled_cust_pkg ) {
1303 my $error = $cust_pkg->delete;
1305 $dbh->rollback if $oldAutoCommit;
1310 foreach my $cust_main_invoice ( #(email invoice destinations, not invoices)
1311 qsearch( 'cust_main_invoice', { 'custnum' => $self->custnum } )
1313 my $error = $cust_main_invoice->delete;
1315 $dbh->rollback if $oldAutoCommit;
1320 foreach my $cust_main_exemption (
1321 qsearch( 'cust_main_exemption', { 'custnum' => $self->custnum } )
1323 my $error = $cust_main_exemption->delete;
1325 $dbh->rollback if $oldAutoCommit;
1330 my $error = $self->SUPER::delete;
1332 $dbh->rollback if $oldAutoCommit;
1336 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1341 =item replace [ OLD_RECORD ] [ INVOICING_LIST_ARYREF ] [ , OPTION => VALUE ... ] ]
1344 Replaces the OLD_RECORD with this one in the database. If there is an error,
1345 returns the error, otherwise returns false.
1347 INVOICING_LIST_ARYREF: If you pass an arrarref to the insert method, it will
1348 be set as the invoicing list (see L<"invoicing_list">). Errors return as
1349 expected and rollback the entire transaction; it is not necessary to call
1350 check_invoicing_list first. Here's an example:
1352 $new_cust_main->replace( $old_cust_main, [ $email, 'POST' ] );
1354 Currently available options are: I<tax_exemption>.
1356 The I<tax_exemption> option can be set to an arrayref of tax names.
1357 FS::cust_main_exemption records will be deleted and inserted as appropriate.
1364 my $old = ( blessed($_[0]) && $_[0]->isa('FS::Record') )
1366 : $self->replace_old;
1370 warn "$me replace called\n"
1373 my $curuser = $FS::CurrentUser::CurrentUser;
1374 if ( $self->payby eq 'COMP'
1375 && $self->payby ne $old->payby
1376 && ! $curuser->access_right('Complimentary customer')
1379 return "You are not permitted to create complimentary accounts.";
1382 local($ignore_expired_card) = 1
1383 if $old->payby =~ /^(CARD|DCRD)$/
1384 && $self->payby =~ /^(CARD|DCRD)$/
1385 && ( $old->payinfo eq $self->payinfo || $old->paymask eq $self->paymask );
1387 local $SIG{HUP} = 'IGNORE';
1388 local $SIG{INT} = 'IGNORE';
1389 local $SIG{QUIT} = 'IGNORE';
1390 local $SIG{TERM} = 'IGNORE';
1391 local $SIG{TSTP} = 'IGNORE';
1392 local $SIG{PIPE} = 'IGNORE';
1394 my $oldAutoCommit = $FS::UID::AutoCommit;
1395 local $FS::UID::AutoCommit = 0;
1398 my $error = $self->SUPER::replace($old);
1401 $dbh->rollback if $oldAutoCommit;
1405 if ( @param && ref($param[0]) eq 'ARRAY' ) { # INVOICING_LIST_ARYREF
1406 my $invoicing_list = shift @param;
1407 $error = $self->check_invoicing_list( $invoicing_list );
1409 $dbh->rollback if $oldAutoCommit;
1412 $self->invoicing_list( $invoicing_list );
1415 my %options = @param;
1417 my $tax_exemption = delete $options{'tax_exemption'};
1418 if ( $tax_exemption ) {
1420 my %cust_main_exemption =
1421 map { $_->taxname => $_ }
1422 qsearch('cust_main_exemption', { 'custnum' => $old->custnum } );
1424 foreach my $taxname ( @$tax_exemption ) {
1426 next if delete $cust_main_exemption{$taxname};
1428 my $cust_main_exemption = new FS::cust_main_exemption {
1429 'custnum' => $self->custnum,
1430 'taxname' => $taxname,
1432 my $error = $cust_main_exemption->insert;
1434 $dbh->rollback if $oldAutoCommit;
1435 return "inserting cust_main_exemption (transaction rolled back): $error";
1439 foreach my $cust_main_exemption ( values %cust_main_exemption ) {
1440 my $error = $cust_main_exemption->delete;
1442 $dbh->rollback if $oldAutoCommit;
1443 return "deleting cust_main_exemption (transaction rolled back): $error";
1449 if ( $self->payby =~ /^(CARD|CHEK|LECB)$/ &&
1450 grep { $self->get($_) ne $old->get($_) } qw(payinfo paydate payname) ) {
1451 # card/check/lec info has changed, want to retry realtime_ invoice events
1452 my $error = $self->retry_realtime;
1454 $dbh->rollback if $oldAutoCommit;
1459 unless ( $import || $skip_fuzzyfiles ) {
1460 $error = $self->queue_fuzzyfiles_update;
1462 $dbh->rollback if $oldAutoCommit;
1463 return "updating fuzzy search cache: $error";
1467 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1472 =item queue_fuzzyfiles_update
1474 Used by insert & replace to update the fuzzy search cache
1478 sub queue_fuzzyfiles_update {
1481 local $SIG{HUP} = 'IGNORE';
1482 local $SIG{INT} = 'IGNORE';
1483 local $SIG{QUIT} = 'IGNORE';
1484 local $SIG{TERM} = 'IGNORE';
1485 local $SIG{TSTP} = 'IGNORE';
1486 local $SIG{PIPE} = 'IGNORE';
1488 my $oldAutoCommit = $FS::UID::AutoCommit;
1489 local $FS::UID::AutoCommit = 0;
1492 my $queue = new FS::queue { 'job' => 'FS::cust_main::append_fuzzyfiles' };
1493 my $error = $queue->insert( map $self->getfield($_),
1494 qw(first last company)
1497 $dbh->rollback if $oldAutoCommit;
1498 return "queueing job (transaction rolled back): $error";
1501 if ( $self->ship_last ) {
1502 $queue = new FS::queue { 'job' => 'FS::cust_main::append_fuzzyfiles' };
1503 $error = $queue->insert( map $self->getfield("ship_$_"),
1504 qw(first last company)
1507 $dbh->rollback if $oldAutoCommit;
1508 return "queueing job (transaction rolled back): $error";
1512 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1519 Checks all fields to make sure this is a valid customer record. If there is
1520 an error, returns the error, otherwise returns false. Called by the insert
1521 and replace methods.
1528 warn "$me check BEFORE: \n". $self->_dump
1532 $self->ut_numbern('custnum')
1533 || $self->ut_number('agentnum')
1534 || $self->ut_textn('agent_custid')
1535 || $self->ut_number('refnum')
1536 || $self->ut_textn('custbatch')
1537 || $self->ut_name('last')
1538 || $self->ut_name('first')
1539 || $self->ut_snumbern('birthdate')
1540 || $self->ut_snumbern('signupdate')
1541 || $self->ut_textn('company')
1542 || $self->ut_text('address1')
1543 || $self->ut_textn('address2')
1544 || $self->ut_text('city')
1545 || $self->ut_textn('county')
1546 || $self->ut_textn('state')
1547 || $self->ut_country('country')
1548 || $self->ut_anything('comments')
1549 || $self->ut_numbern('referral_custnum')
1550 || $self->ut_textn('stateid')
1551 || $self->ut_textn('stateid_state')
1552 || $self->ut_textn('invoice_terms')
1553 || $self->ut_alphan('geocode')
1556 #barf. need message catalogs. i18n. etc.
1557 $error .= "Please select an advertising source."
1558 if $error =~ /^Illegal or empty \(numeric\) refnum: /;
1559 return $error if $error;
1561 return "Unknown agent"
1562 unless qsearchs( 'agent', { 'agentnum' => $self->agentnum } );
1564 return "Unknown refnum"
1565 unless qsearchs( 'part_referral', { 'refnum' => $self->refnum } );
1567 return "Unknown referring custnum: ". $self->referral_custnum
1568 unless ! $self->referral_custnum
1569 || qsearchs( 'cust_main', { 'custnum' => $self->referral_custnum } );
1571 if ( $self->ss eq '' ) {
1576 $ss =~ /^(\d{3})(\d{2})(\d{4})$/
1577 or return "Illegal social security number: ". $self->ss;
1578 $self->ss("$1-$2-$3");
1582 # bad idea to disable, causes billing to fail because of no tax rates later
1583 # unless ( $import ) {
1584 unless ( qsearch('cust_main_county', {
1585 'country' => $self->country,
1588 return "Unknown state/county/country: ".
1589 $self->state. "/". $self->county. "/". $self->country
1590 unless qsearch('cust_main_county',{
1591 'state' => $self->state,
1592 'county' => $self->county,
1593 'country' => $self->country,
1599 $self->ut_phonen('daytime', $self->country)
1600 || $self->ut_phonen('night', $self->country)
1601 || $self->ut_phonen('fax', $self->country)
1602 || $self->ut_zip('zip', $self->country)
1604 return $error if $error;
1606 if ( $conf->exists('cust_main-require_phone')
1607 && ! length($self->daytime) && ! length($self->night)
1610 my $daytime_label = FS::Msgcat::_gettext('daytime') =~ /^(daytime)?$/
1612 : FS::Msgcat::_gettext('daytime');
1613 my $night_label = FS::Msgcat::_gettext('night') =~ /^(night)?$/
1615 : FS::Msgcat::_gettext('night');
1617 return "$daytime_label or $night_label is required"
1621 if ( $self->has_ship_address
1622 && scalar ( grep { $self->getfield($_) ne $self->getfield("ship_$_") }
1623 $self->addr_fields )
1627 $self->ut_name('ship_last')
1628 || $self->ut_name('ship_first')
1629 || $self->ut_textn('ship_company')
1630 || $self->ut_text('ship_address1')
1631 || $self->ut_textn('ship_address2')
1632 || $self->ut_text('ship_city')
1633 || $self->ut_textn('ship_county')
1634 || $self->ut_textn('ship_state')
1635 || $self->ut_country('ship_country')
1637 return $error if $error;
1639 #false laziness with above
1640 unless ( qsearchs('cust_main_county', {
1641 'country' => $self->ship_country,
1644 return "Unknown ship_state/ship_county/ship_country: ".
1645 $self->ship_state. "/". $self->ship_county. "/". $self->ship_country
1646 unless qsearch('cust_main_county',{
1647 'state' => $self->ship_state,
1648 'county' => $self->ship_county,
1649 'country' => $self->ship_country,
1655 $self->ut_phonen('ship_daytime', $self->ship_country)
1656 || $self->ut_phonen('ship_night', $self->ship_country)
1657 || $self->ut_phonen('ship_fax', $self->ship_country)
1658 || $self->ut_zip('ship_zip', $self->ship_country)
1660 return $error if $error;
1662 return "Unit # is required."
1663 if $self->ship_address2 =~ /^\s*$/
1664 && $conf->exists('cust_main-require_address2');
1666 } else { # ship_ info eq billing info, so don't store dup info in database
1668 $self->setfield("ship_$_", '')
1669 foreach $self->addr_fields;
1671 return "Unit # is required."
1672 if $self->address2 =~ /^\s*$/
1673 && $conf->exists('cust_main-require_address2');
1677 #$self->payby =~ /^(CARD|DCRD|CHEK|DCHK|LECB|BILL|COMP|PREPAY|CASH|WEST|MCRD)$/
1678 # or return "Illegal payby: ". $self->payby;
1680 FS::payby->can_payby($self->table, $self->payby)
1681 or return "Illegal payby: ". $self->payby;
1683 $error = $self->ut_numbern('paystart_month')
1684 || $self->ut_numbern('paystart_year')
1685 || $self->ut_numbern('payissue')
1686 || $self->ut_textn('paytype')
1688 return $error if $error;
1690 if ( $self->payip eq '' ) {
1693 $error = $self->ut_ip('payip');
1694 return $error if $error;
1697 # If it is encrypted and the private key is not availaible then we can't
1698 # check the credit card.
1700 my $check_payinfo = 1;
1702 if ($self->is_encrypted($self->payinfo)) {
1706 if ( $check_payinfo && $self->payby =~ /^(CARD|DCRD)$/ ) {
1708 my $payinfo = $self->payinfo;
1709 $payinfo =~ s/\D//g;
1710 $payinfo =~ /^(\d{13,16})$/
1711 or return gettext('invalid_card'); # . ": ". $self->payinfo;
1713 $self->payinfo($payinfo);
1715 or return gettext('invalid_card'); # . ": ". $self->payinfo;
1717 return gettext('unknown_card_type')
1718 if cardtype($self->payinfo) eq "Unknown";
1720 my $ban = qsearchs('banned_pay', $self->_banned_pay_hashref);
1722 return 'Banned credit card: banned on '.
1723 time2str('%a %h %o at %r', $ban->_date).
1724 ' by '. $ban->otaker.
1725 ' (ban# '. $ban->bannum. ')';
1728 if (length($self->paycvv) && !$self->is_encrypted($self->paycvv)) {
1729 if ( cardtype($self->payinfo) eq 'American Express card' ) {
1730 $self->paycvv =~ /^(\d{4})$/
1731 or return "CVV2 (CID) for American Express cards is four digits.";
1734 $self->paycvv =~ /^(\d{3})$/
1735 or return "CVV2 (CVC2/CID) is three digits.";
1742 my $cardtype = cardtype($payinfo);
1743 if ( $cardtype =~ /^(Switch|Solo)$/i ) {
1745 return "Start date or issue number is required for $cardtype cards"
1746 unless $self->paystart_month && $self->paystart_year or $self->payissue;
1748 return "Start month must be between 1 and 12"
1749 if $self->paystart_month
1750 and $self->paystart_month < 1 || $self->paystart_month > 12;
1752 return "Start year must be 1990 or later"
1753 if $self->paystart_year
1754 and $self->paystart_year < 1990;
1756 return "Issue number must be beween 1 and 99"
1758 and $self->payissue < 1 || $self->payissue > 99;
1761 $self->paystart_month('');
1762 $self->paystart_year('');
1763 $self->payissue('');
1766 } elsif ( $check_payinfo && $self->payby =~ /^(CHEK|DCHK)$/ ) {
1768 my $payinfo = $self->payinfo;
1769 $payinfo =~ s/[^\d\@]//g;
1770 if ( $conf->exists('echeck-nonus') ) {
1771 $payinfo =~ /^(\d+)\@(\d+)$/ or return 'invalid echeck account@aba';
1772 $payinfo = "$1\@$2";
1774 $payinfo =~ /^(\d+)\@(\d{9})$/ or return 'invalid echeck account@aba';
1775 $payinfo = "$1\@$2";
1777 $self->payinfo($payinfo);
1780 my $ban = qsearchs('banned_pay', $self->_banned_pay_hashref);
1782 return 'Banned ACH account: banned on '.
1783 time2str('%a %h %o at %r', $ban->_date).
1784 ' by '. $ban->otaker.
1785 ' (ban# '. $ban->bannum. ')';
1788 } elsif ( $self->payby eq 'LECB' ) {
1790 my $payinfo = $self->payinfo;
1791 $payinfo =~ s/\D//g;
1792 $payinfo =~ /^1?(\d{10})$/ or return 'invalid btn billing telephone number';
1794 $self->payinfo($payinfo);
1797 } elsif ( $self->payby eq 'BILL' ) {
1799 $error = $self->ut_textn('payinfo');
1800 return "Illegal P.O. number: ". $self->payinfo if $error;
1803 } elsif ( $self->payby eq 'COMP' ) {
1805 my $curuser = $FS::CurrentUser::CurrentUser;
1806 if ( ! $self->custnum
1807 && ! $curuser->access_right('Complimentary customer')
1810 return "You are not permitted to create complimentary accounts."
1813 $error = $self->ut_textn('payinfo');
1814 return "Illegal comp account issuer: ". $self->payinfo if $error;
1817 } elsif ( $self->payby eq 'PREPAY' ) {
1819 my $payinfo = $self->payinfo;
1820 $payinfo =~ s/\W//g; #anything else would just confuse things
1821 $self->payinfo($payinfo);
1822 $error = $self->ut_alpha('payinfo');
1823 return "Illegal prepayment identifier: ". $self->payinfo if $error;
1824 return "Unknown prepayment identifier"
1825 unless qsearchs('prepay_credit', { 'identifier' => $self->payinfo } );
1830 if ( $self->paydate eq '' || $self->paydate eq '-' ) {
1831 return "Expiration date required"
1832 unless $self->payby =~ /^(BILL|PREPAY|CHEK|DCHK|LECB|CASH|WEST|MCRD)$/;
1836 if ( $self->paydate =~ /^(\d{1,2})[\/\-](\d{2}(\d{2})?)$/ ) {
1837 ( $m, $y ) = ( $1, length($2) == 4 ? $2 : "20$2" );
1838 } elsif ( $self->paydate =~ /^(20)?(\d{2})[\/\-](\d{1,2})[\/\-]\d+$/ ) {
1839 ( $m, $y ) = ( $3, "20$2" );
1841 return "Illegal expiration date: ". $self->paydate;
1843 $self->paydate("$y-$m-01");
1844 my($nowm,$nowy)=(localtime(time))[4,5]; $nowm++; $nowy+=1900;
1845 return gettext('expired_card')
1847 && !$ignore_expired_card
1848 && ( $y<$nowy || ( $y==$nowy && $1<$nowm ) );
1851 if ( $self->payname eq '' && $self->payby !~ /^(CHEK|DCHK)$/ &&
1852 ( ! $conf->exists('require_cardname')
1853 || $self->payby !~ /^(CARD|DCRD)$/ )
1855 $self->payname( $self->first. " ". $self->getfield('last') );
1857 $self->payname =~ /^([\w \,\.\-\'\&]+)$/
1858 or return gettext('illegal_name'). " payname: ". $self->payname;
1862 foreach my $flag (qw( tax spool_cdr squelch_cdr archived )) {
1863 $self->$flag() =~ /^(Y?)$/ or return "Illegal $flag: ". $self->$flag();
1867 $self->otaker(getotaker) unless $self->otaker;
1869 warn "$me check AFTER: \n". $self->_dump
1872 $self->SUPER::check;
1877 Returns a list of fields which have ship_ duplicates.
1882 qw( last first company
1883 address1 address2 city county state zip country
1888 =item has_ship_address
1890 Returns true if this customer record has a separate shipping address.
1894 sub has_ship_address {
1896 scalar( grep { $self->getfield("ship_$_") ne '' } $self->addr_fields );
1899 =item all_pkgs [ EXTRA_QSEARCH_PARAMS_HASHREF ]
1901 Returns all packages (see L<FS::cust_pkg>) for this customer.
1907 my $extra_qsearch = ref($_[0]) ? shift : {};
1909 return $self->num_pkgs unless wantarray || keys(%$extra_qsearch);
1912 if ( $self->{'_pkgnum'} ) {
1913 @cust_pkg = values %{ $self->{'_pkgnum'}->cache };
1915 @cust_pkg = $self->_cust_pkg($extra_qsearch);
1918 sort sort_packages @cust_pkg;
1923 Synonym for B<all_pkgs>.
1928 shift->all_pkgs(@_);
1933 Returns all locations (see L<FS::cust_location>) for this customer.
1939 qsearch('cust_location', { 'custnum' => $self->custnum } );
1942 =item ncancelled_pkgs [ EXTRA_QSEARCH_PARAMS_HASHREF ]
1944 Returns all non-cancelled packages (see L<FS::cust_pkg>) for this customer.
1948 sub ncancelled_pkgs {
1950 my $extra_qsearch = ref($_[0]) ? shift : {};
1952 return $self->num_ncancelled_pkgs unless wantarray;
1955 if ( $self->{'_pkgnum'} ) {
1957 warn "$me ncancelled_pkgs: returning cached objects"
1960 @cust_pkg = grep { ! $_->getfield('cancel') }
1961 values %{ $self->{'_pkgnum'}->cache };
1965 warn "$me ncancelled_pkgs: searching for packages with custnum ".
1966 $self->custnum. "\n"
1969 $extra_qsearch->{'extra_sql'} .= ' AND ( cancel IS NULL OR cancel = 0 ) ';
1971 @cust_pkg = $self->_cust_pkg($extra_qsearch);
1975 sort sort_packages @cust_pkg;
1981 my $extra_qsearch = ref($_[0]) ? shift : {};
1983 $extra_qsearch->{'select'} ||= '*';
1984 $extra_qsearch->{'select'} .=
1985 ',( SELECT COUNT(*) FROM cust_svc WHERE cust_pkg.pkgnum = cust_svc.pkgnum )
1989 $_->{'_num_cust_svc'} = $_->get('_num_cust_svc');
1994 'table' => 'cust_pkg',
1995 'hashref' => { 'custnum' => $self->custnum },
2000 # This should be generalized to use config options to determine order.
2003 if ( $a->get('cancel') xor $b->get('cancel') ) {
2004 return -1 if $b->get('cancel');
2005 return 1 if $a->get('cancel');
2006 #shouldn't get here...
2009 my $a_num_cust_svc = $a->num_cust_svc;
2010 my $b_num_cust_svc = $b->num_cust_svc;
2011 return 0 if !$a_num_cust_svc && !$b_num_cust_svc;
2012 return -1 if $a_num_cust_svc && !$b_num_cust_svc;
2013 return 1 if !$a_num_cust_svc && $b_num_cust_svc;
2014 my @a_cust_svc = $a->cust_svc;
2015 my @b_cust_svc = $b->cust_svc;
2016 $a_cust_svc[0]->svc_x->label cmp $b_cust_svc[0]->svc_x->label;
2021 =item suspended_pkgs
2023 Returns all suspended packages (see L<FS::cust_pkg>) for this customer.
2027 sub suspended_pkgs {
2029 grep { $_->susp } $self->ncancelled_pkgs;
2032 =item unflagged_suspended_pkgs
2034 Returns all unflagged suspended packages (see L<FS::cust_pkg>) for this
2035 customer (thouse packages without the `manual_flag' set).
2039 sub unflagged_suspended_pkgs {
2041 return $self->suspended_pkgs
2042 unless dbdef->table('cust_pkg')->column('manual_flag');
2043 grep { ! $_->manual_flag } $self->suspended_pkgs;
2046 =item unsuspended_pkgs
2048 Returns all unsuspended (and uncancelled) packages (see L<FS::cust_pkg>) for
2053 sub unsuspended_pkgs {
2055 grep { ! $_->susp } $self->ncancelled_pkgs;
2058 =item num_cancelled_pkgs
2060 Returns the number of cancelled packages (see L<FS::cust_pkg>) for this
2065 sub num_cancelled_pkgs {
2066 shift->num_pkgs("cust_pkg.cancel IS NOT NULL AND cust_pkg.cancel != 0");
2069 sub num_ncancelled_pkgs {
2070 shift->num_pkgs("( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )");
2074 my( $self ) = shift;
2075 my $sql = scalar(@_) ? shift : '';
2076 $sql = "AND $sql" if $sql && $sql !~ /^\s*$/ && $sql !~ /^\s*AND/i;
2077 my $sth = dbh->prepare(
2078 "SELECT COUNT(*) FROM cust_pkg WHERE custnum = ? $sql"
2079 ) or die dbh->errstr;
2080 $sth->execute($self->custnum) or die $sth->errstr;
2081 $sth->fetchrow_arrayref->[0];
2086 Unsuspends all unflagged suspended packages (see L</unflagged_suspended_pkgs>
2087 and L<FS::cust_pkg>) for this customer. Always returns a list: an empty list
2088 on success or a list of errors.
2094 grep { $_->unsuspend } $self->suspended_pkgs;
2099 Suspends all unsuspended packages (see L<FS::cust_pkg>) for this customer.
2101 Returns a list: an empty list on success or a list of errors.
2107 grep { $_->suspend(@_) } $self->unsuspended_pkgs;
2110 =item suspend_if_pkgpart HASHREF | PKGPART [ , PKGPART ... ]
2112 Suspends all unsuspended packages (see L<FS::cust_pkg>) matching the listed
2113 PKGPARTs (see L<FS::part_pkg>). Preferred usage is to pass a hashref instead
2114 of a list of pkgparts; the hashref has the following keys:
2118 =item pkgparts - listref of pkgparts
2120 =item (other options are passed to the suspend method)
2125 Returns a list: an empty list on success or a list of errors.
2129 sub suspend_if_pkgpart {
2131 my (@pkgparts, %opt);
2132 if (ref($_[0]) eq 'HASH'){
2133 @pkgparts = @{$_[0]{pkgparts}};
2138 grep { $_->suspend(%opt) }
2139 grep { my $pkgpart = $_->pkgpart; grep { $pkgpart eq $_ } @pkgparts }
2140 $self->unsuspended_pkgs;
2143 =item suspend_unless_pkgpart HASHREF | PKGPART [ , PKGPART ... ]
2145 Suspends all unsuspended packages (see L<FS::cust_pkg>) unless they match the
2146 given PKGPARTs (see L<FS::part_pkg>). Preferred usage is to pass a hashref
2147 instead of a list of pkgparts; the hashref has the following keys:
2151 =item pkgparts - listref of pkgparts
2153 =item (other options are passed to the suspend method)
2157 Returns a list: an empty list on success or a list of errors.
2161 sub suspend_unless_pkgpart {
2163 my (@pkgparts, %opt);
2164 if (ref($_[0]) eq 'HASH'){
2165 @pkgparts = @{$_[0]{pkgparts}};
2170 grep { $_->suspend(%opt) }
2171 grep { my $pkgpart = $_->pkgpart; ! grep { $pkgpart eq $_ } @pkgparts }
2172 $self->unsuspended_pkgs;
2175 =item cancel [ OPTION => VALUE ... ]
2177 Cancels all uncancelled packages (see L<FS::cust_pkg>) for this customer.
2179 Available options are:
2183 =item quiet - can be set true to supress email cancellation notices.
2185 =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.
2187 =item ban - can be set true to ban this customer's credit card or ACH information, if present.
2191 Always returns a list: an empty list on success or a list of errors.
2196 my( $self, %opt ) = @_;
2198 warn "$me cancel called on customer ". $self->custnum. " with options ".
2199 join(', ', map { "$_: $opt{$_}" } keys %opt ). "\n"
2202 return ( 'access denied' )
2203 unless $FS::CurrentUser::CurrentUser->access_right('Cancel customer');
2205 if ( $opt{'ban'} && $self->payby =~ /^(CARD|DCRD|CHEK|DCHK)$/ ) {
2207 #should try decryption (we might have the private key)
2208 # and if not maybe queue a job for the server that does?
2209 return ( "Can't (yet) ban encrypted credit cards" )
2210 if $self->is_encrypted($self->payinfo);
2212 my $ban = new FS::banned_pay $self->_banned_pay_hashref;
2213 my $error = $ban->insert;
2214 return ( $error ) if $error;
2218 my @pkgs = $self->ncancelled_pkgs;
2220 warn "$me cancelling ". scalar($self->ncancelled_pkgs). "/".
2221 scalar(@pkgs). " packages for customer ". $self->custnum. "\n"
2224 grep { $_ } map { $_->cancel(%opt) } $self->ncancelled_pkgs;
2227 sub _banned_pay_hashref {
2238 'payby' => $payby2ban{$self->payby},
2239 'payinfo' => md5_base64($self->payinfo),
2240 #don't ever *search* on reason! #'reason' =>
2246 Returns all notes (see L<FS::cust_main_note>) for this customer.
2253 qsearch( 'cust_main_note',
2254 { 'custnum' => $self->custnum },
2256 'ORDER BY _DATE DESC'
2262 Returns the agent (see L<FS::agent>) for this customer.
2268 qsearchs( 'agent', { 'agentnum' => $self->agentnum } );
2271 =item bill_and_collect
2273 Cancels and suspends any packages due, generates bills, applies payments and
2276 Warns on errors (Does not currently: If there is an error, returns the error, otherwise returns false.)
2278 Options are passed as name-value pairs. Currently available options are:
2284 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:
2288 $cust_main->bill( 'time' => str2time('April 20th, 2001') );
2292 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.
2296 "1d" for the traditional, daily events (the default), or "1m" for the new monthly events (part_event.check_freq)
2300 If set true, re-charges setup fees.
2304 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)
2310 sub bill_and_collect {
2311 my( $self, %options ) = @_;
2313 #$options{actual_time} not $options{time} because freeside-daily -d is for
2314 #pre-printing invoices
2315 $self->cancel_expired_pkgs( $options{actual_time} );
2316 $self->suspend_adjourned_pkgs( $options{actual_time} );
2318 my $error = $self->bill( %options );
2319 warn "Error billing, custnum ". $self->custnum. ": $error" if $error;
2321 $self->apply_payments_and_credits;
2323 unless ( $conf->exists('cancelled_cust-noevents')
2324 && ! $self->num_ncancelled_pkgs
2327 $error = $self->collect( %options );
2328 warn "Error collecting, custnum". $self->custnum. ": $error" if $error;
2334 sub cancel_expired_pkgs {
2335 my ( $self, $time ) = @_;
2337 my @cancel_pkgs = $self->ncancelled_pkgs( {
2338 'extra_sql' => " AND expire IS NOT NULL AND expire > 0 AND expire <= $time "
2341 foreach my $cust_pkg ( @cancel_pkgs ) {
2342 my $cpr = $cust_pkg->last_cust_pkg_reason('expire');
2343 my $error = $cust_pkg->cancel($cpr ? ( 'reason' => $cpr->reasonnum,
2344 'reason_otaker' => $cpr->otaker
2348 warn "Error cancelling expired pkg ". $cust_pkg->pkgnum.
2349 " for custnum ". $self->custnum. ": $error"
2355 sub suspend_adjourned_pkgs {
2356 my ( $self, $time ) = @_;
2358 my @susp_pkgs = $self->ncancelled_pkgs( {
2360 " AND ( susp IS NULL OR susp = 0 )
2361 AND ( ( bill IS NOT NULL AND bill != 0 AND bill < $time )
2362 OR ( adjourn IS NOT NULL AND adjourn != 0 AND adjourn <= $time )
2367 #only because there's no SQL test for is_prepaid :/
2369 grep { ( $_->part_pkg->is_prepaid
2374 && $_->adjourn <= $time
2380 foreach my $cust_pkg ( @susp_pkgs ) {
2381 my $cpr = $cust_pkg->last_cust_pkg_reason('adjourn')
2382 if ($cust_pkg->adjourn && $cust_pkg->adjourn < $^T);
2383 my $error = $cust_pkg->suspend($cpr ? ( 'reason' => $cpr->reasonnum,
2384 'reason_otaker' => $cpr->otaker
2389 warn "Error suspending package ". $cust_pkg->pkgnum.
2390 " for custnum ". $self->custnum. ": $error"
2398 Generates invoices (see L<FS::cust_bill>) for this customer. Usually used in
2399 conjunction with the collect method by calling B<bill_and_collect>.
2401 If there is an error, returns the error, otherwise returns false.
2403 Options are passed as name-value pairs. Currently available options are:
2409 If set true, re-charges setup fees.
2413 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:
2417 $cust_main->bill( 'time' => str2time('April 20th, 2001') );
2421 An array ref of specific packages (objects) to attempt billing, instead trying all of them.
2423 $cust_main->bill( pkg_list => [$pkg1, $pkg2] );
2427 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.
2434 my( $self, %options ) = @_;
2435 return '' if $self->payby eq 'COMP';
2436 warn "$me bill customer ". $self->custnum. "\n"
2439 my $time = $options{'time'} || time;
2440 my $invoice_time = $options{'invoice_time'} || $time;
2443 local $SIG{HUP} = 'IGNORE';
2444 local $SIG{INT} = 'IGNORE';
2445 local $SIG{QUIT} = 'IGNORE';
2446 local $SIG{TERM} = 'IGNORE';
2447 local $SIG{TSTP} = 'IGNORE';
2448 local $SIG{PIPE} = 'IGNORE';
2450 my $oldAutoCommit = $FS::UID::AutoCommit;
2451 local $FS::UID::AutoCommit = 0;
2454 $self->select_for_update; #mutex
2456 my @cust_bill_pkg = ();
2459 # find the packages which are due for billing, find out how much they are
2460 # & generate invoice database.
2463 my( $total_setup, $total_recur, $postal_charge ) = ( 0, 0, 0 );
2465 my @precommit_hooks = ();
2467 foreach my $cust_pkg ( $self->ncancelled_pkgs ) {
2469 warn " bill package ". $cust_pkg->pkgnum. "\n" if $DEBUG > 1;
2471 #? to avoid use of uninitialized value errors... ?
2472 $cust_pkg->setfield('bill', '')
2473 unless defined($cust_pkg->bill);
2475 #my $part_pkg = $cust_pkg->part_pkg;
2477 my $real_pkgpart = $cust_pkg->pkgpart;
2478 my %hash = $cust_pkg->hash;
2480 foreach my $part_pkg ( $cust_pkg->part_pkg->self_and_bill_linked ) {
2482 $cust_pkg->set($_, $hash{$_}) foreach qw ( setup last_bill bill );
2485 $self->_make_lines( 'part_pkg' => $part_pkg,
2486 'cust_pkg' => $cust_pkg,
2487 'precommit_hooks' => \@precommit_hooks,
2488 'line_items' => \@cust_bill_pkg,
2489 'setup' => \$total_setup,
2490 'recur' => \$total_recur,
2491 'tax_matrix' => \%taxlisthash,
2493 'options' => \%options,
2496 $dbh->rollback if $oldAutoCommit;
2500 } #foreach my $part_pkg
2502 } #foreach my $cust_pkg
2504 unless ( @cust_bill_pkg ) { #don't create an invoice w/o line items
2505 #but do commit any package date cycling that happened
2506 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2510 if ( scalar( grep { $_->recur && $_->recur > 0 } @cust_bill_pkg) ||
2511 !$conf->exists('postal_invoice-recurring_only')
2515 my $postal_pkg = $self->charge_postal_fee();
2516 if ( $postal_pkg && !ref( $postal_pkg ) ) {
2518 $dbh->rollback if $oldAutoCommit;
2519 return "can't charge postal invoice fee for customer ".
2520 $self->custnum. ": $postal_pkg";
2522 } elsif ( $postal_pkg ) {
2524 foreach my $part_pkg ( $postal_pkg->part_pkg->self_and_bill_linked ) {
2526 $self->_make_lines( 'part_pkg' => $part_pkg,
2527 'cust_pkg' => $postal_pkg,
2528 'precommit_hooks' => \@precommit_hooks,
2529 'line_items' => \@cust_bill_pkg,
2530 'setup' => \$total_setup,
2531 'recur' => \$total_recur,
2532 'tax_matrix' => \%taxlisthash,
2534 'options' => \%options,
2537 $dbh->rollback if $oldAutoCommit;
2546 warn "having a look at the taxes we found...\n" if $DEBUG > 2;
2548 # keys are tax names (as printed on invoices / itemdesc )
2549 # values are listrefs of taxlisthash keys (internal identifiers)
2552 # keys are taxlisthash keys (internal identifiers)
2553 # values are (cumulative) amounts
2556 # keys are taxlisthash keys (internal identifiers)
2557 # values are listrefs of cust_bill_pkg_tax_location hashrefs
2558 my %tax_location = ();
2560 # keys are taxlisthash keys (internal identifiers)
2561 # values are listrefs of cust_bill_pkg_tax_rate_location hashrefs
2562 my %tax_rate_location = ();
2564 foreach my $tax ( keys %taxlisthash ) {
2565 my $tax_object = shift @{ $taxlisthash{$tax} };
2566 warn "found ". $tax_object->taxname. " as $tax\n" if $DEBUG > 2;
2567 warn " ". join('/', @{ $taxlisthash{$tax} } ). "\n" if $DEBUG > 2;
2568 my $hashref_or_error =
2569 $tax_object->taxline( $taxlisthash{$tax},
2570 'custnum' => $self->custnum,
2571 'invoice_time' => $invoice_time
2573 unless ( ref($hashref_or_error) ) {
2574 $dbh->rollback if $oldAutoCommit;
2575 return $hashref_or_error;
2577 unshift @{ $taxlisthash{$tax} }, $tax_object;
2579 my $name = $hashref_or_error->{'name'};
2580 my $amount = $hashref_or_error->{'amount'};
2582 #warn "adding $amount as $name\n";
2583 $taxname{ $name } ||= [];
2584 push @{ $taxname{ $name } }, $tax;
2586 $tax{ $tax } += $amount;
2588 $tax_location{ $tax } ||= [];
2589 if ( $tax_object->get('pkgnum') || $tax_object->get('locationnum') ) {
2590 push @{ $tax_location{ $tax } },
2592 'taxnum' => $tax_object->taxnum,
2593 'taxtype' => ref($tax_object),
2594 'pkgnum' => $tax_object->get('pkgnum'),
2595 'locationnum' => $tax_object->get('locationnum'),
2596 'amount' => sprintf('%.2f', $amount ),
2600 $tax_rate_location{ $tax } ||= [];
2601 if ( ref($tax_object) eq 'FS::tax_rate' ) {
2602 my $taxratelocationnum =
2603 $tax_object->tax_rate_location->taxratelocationnum;
2604 push @{ $tax_rate_location{ $tax } },
2606 'taxnum' => $tax_object->taxnum,
2607 'taxtype' => ref($tax_object),
2608 'amount' => sprintf('%.2f', $amount ),
2609 'locationtaxid' => $tax_object->location,
2610 'taxratelocationnum' => $taxratelocationnum,
2616 #move the cust_tax_exempt_pkg records to the cust_bill_pkgs we will commit
2617 my %packagemap = map { $_->pkgnum => $_ } @cust_bill_pkg;
2618 foreach my $tax ( keys %taxlisthash ) {
2619 foreach ( @{ $taxlisthash{$tax} }[1 ... scalar(@{ $taxlisthash{$tax} })] ) {
2620 next unless ref($_) eq 'FS::cust_bill_pkg';
2622 push @{ $packagemap{$_->pkgnum}->_cust_tax_exempt_pkg },
2623 splice( @{ $_->_cust_tax_exempt_pkg } );
2627 #consolidate and create tax line items
2628 warn "consolidating and generating...\n" if $DEBUG > 2;
2629 foreach my $taxname ( keys %taxname ) {
2632 my @cust_bill_pkg_tax_location = ();
2633 my @cust_bill_pkg_tax_rate_location = ();
2634 warn "adding $taxname\n" if $DEBUG > 1;
2635 foreach my $taxitem ( @{ $taxname{$taxname} } ) {
2636 next if $seen{$taxitem}++;
2637 warn "adding $tax{$taxitem}\n" if $DEBUG > 1;
2638 $tax += $tax{$taxitem};
2639 push @cust_bill_pkg_tax_location,
2640 map { new FS::cust_bill_pkg_tax_location $_ }
2641 @{ $tax_location{ $taxitem } };
2642 push @cust_bill_pkg_tax_rate_location,
2643 map { new FS::cust_bill_pkg_tax_rate_location $_ }
2644 @{ $tax_rate_location{ $taxitem } };
2648 $tax = sprintf('%.2f', $tax );
2649 $total_setup = sprintf('%.2f', $total_setup+$tax );
2651 push @cust_bill_pkg, new FS::cust_bill_pkg {
2657 'itemdesc' => $taxname,
2658 'cust_bill_pkg_tax_location' => \@cust_bill_pkg_tax_location,
2659 'cust_bill_pkg_tax_rate_location' => \@cust_bill_pkg_tax_rate_location,
2664 my $charged = sprintf('%.2f', $total_setup + $total_recur );
2666 #create the new invoice
2667 my $cust_bill = new FS::cust_bill ( {
2668 'custnum' => $self->custnum,
2669 '_date' => ( $invoice_time ),
2670 'charged' => $charged,
2672 my $error = $cust_bill->insert;
2674 $dbh->rollback if $oldAutoCommit;
2675 return "can't create invoice for customer #". $self->custnum. ": $error";
2678 foreach my $cust_bill_pkg ( @cust_bill_pkg ) {
2679 $cust_bill_pkg->invnum($cust_bill->invnum);
2680 my $error = $cust_bill_pkg->insert;
2682 $dbh->rollback if $oldAutoCommit;
2683 return "can't create invoice line item: $error";
2688 foreach my $hook ( @precommit_hooks ) {
2690 &{$hook}; #($self) ?
2693 $dbh->rollback if $oldAutoCommit;
2694 return "$@ running precommit hook $hook\n";
2698 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2704 my ($self, %params) = @_;
2706 my $part_pkg = $params{part_pkg} or die "no part_pkg specified";
2707 my $cust_pkg = $params{cust_pkg} or die "no cust_pkg specified";
2708 my $precommit_hooks = $params{precommit_hooks} or die "no package specified";
2709 my $cust_bill_pkgs = $params{line_items} or die "no line buffer specified";
2710 my $total_setup = $params{setup} or die "no setup accumulator specified";
2711 my $total_recur = $params{recur} or die "no recur accumulator specified";
2712 my $taxlisthash = $params{tax_matrix} or die "no tax accumulator specified";
2713 my $time = $params{'time'} or die "no time specified";
2714 my (%options) = %{$params{options}};
2717 my $real_pkgpart = $cust_pkg->pkgpart;
2718 my %hash = $cust_pkg->hash;
2719 my $old_cust_pkg = new FS::cust_pkg \%hash;
2725 $cust_pkg->pkgpart($part_pkg->pkgpart);
2733 if ( ! $cust_pkg->setup &&
2735 ( $conf->exists('disable_setup_suspended_pkgs') &&
2736 ! $cust_pkg->getfield('susp')
2737 ) || ! $conf->exists('disable_setup_suspended_pkgs')
2739 || $options{'resetup'}
2742 warn " bill setup\n" if $DEBUG > 1;
2745 $setup = eval { $cust_pkg->calc_setup( $time, \@details ) };
2746 return "$@ running calc_setup for $cust_pkg\n"
2749 $unitsetup = $cust_pkg->part_pkg->unit_setup || $setup; #XXX uuh
2751 $cust_pkg->setfield('setup', $time)
2752 unless $cust_pkg->setup;
2753 #do need it, but it won't get written to the db
2754 #|| $cust_pkg->pkgpart != $real_pkgpart;
2759 # bill recurring fee
2762 #XXX unit stuff here too
2766 if ( ! $cust_pkg->getfield('susp') and
2767 ( $part_pkg->getfield('freq') ne '0' &&
2768 ( $cust_pkg->getfield('bill') || 0 ) <= $time
2770 || ( $part_pkg->plan eq 'voip_cdr'
2771 && $part_pkg->option('bill_every_call')
2775 # XXX should this be a package event? probably. events are called
2776 # at collection time at the moment, though...
2777 $part_pkg->reset_usage($cust_pkg, 'debug'=>$DEBUG)
2778 if $part_pkg->can('reset_usage');
2779 #don't want to reset usage just cause we want a line item??
2780 #&& $part_pkg->pkgpart == $real_pkgpart;
2782 warn " bill recur\n" if $DEBUG > 1;
2785 # XXX shared with $recur_prog
2786 $sdate = $cust_pkg->bill || $cust_pkg->setup || $time;
2788 #over two params! lets at least switch to a hashref for the rest...
2789 my $increment_next_bill = ( $part_pkg->freq ne '0'
2790 && ( $cust_pkg->getfield('bill') || 0 ) <= $time
2792 my %param = ( 'precommit_hooks' => $precommit_hooks,
2793 'increment_next_bill' => $increment_next_bill,
2796 $recur = eval { $cust_pkg->calc_recur( \$sdate, \@details, \%param ) };
2797 return "$@ running calc_recur for $cust_pkg\n"
2800 if ( $increment_next_bill ) {
2802 my $next_bill = $part_pkg->add_freq($sdate);
2803 return "unparsable frequency: ". $part_pkg->freq
2804 if $next_bill == -1;
2806 #pro-rating magic - if $recur_prog fiddled $sdate, want to use that
2807 # only for figuring next bill date, nothing else, so, reset $sdate again
2809 $sdate = $cust_pkg->bill || $cust_pkg->setup || $time;
2810 #no need, its in $hash{last_bill}# my $last_bill = $cust_pkg->last_bill;
2811 $cust_pkg->last_bill($sdate);
2813 $cust_pkg->setfield('bill', $next_bill );
2819 warn "\$setup is undefined" unless defined($setup);
2820 warn "\$recur is undefined" unless defined($recur);
2821 warn "\$cust_pkg->bill is undefined" unless defined($cust_pkg->bill);
2824 # If there's line items, create em cust_bill_pkg records
2825 # If $cust_pkg has been modified, update it (if we're a real pkgpart)
2830 if ( $cust_pkg->modified && $cust_pkg->pkgpart == $real_pkgpart ) {
2831 # hmm.. and if just the options are modified in some weird price plan?
2833 warn " package ". $cust_pkg->pkgnum. " modified; updating\n"
2836 my $error = $cust_pkg->replace( $old_cust_pkg,
2837 'options' => { $cust_pkg->options },
2839 return "Error modifying pkgnum ". $cust_pkg->pkgnum. ": $error"
2840 if $error; #just in case
2843 $setup = sprintf( "%.2f", $setup );
2844 $recur = sprintf( "%.2f", $recur );
2845 if ( $setup < 0 && ! $conf->exists('allow_negative_charges') ) {
2846 return "negative setup $setup for pkgnum ". $cust_pkg->pkgnum;
2848 if ( $recur < 0 && ! $conf->exists('allow_negative_charges') ) {
2849 return "negative recur $recur for pkgnum ". $cust_pkg->pkgnum;
2852 if ( $setup != 0 || $recur != 0 ) {
2854 warn " charges (setup=$setup, recur=$recur); adding line items\n"
2857 my @cust_pkg_detail = map { $_->detail } $cust_pkg->cust_pkg_detail('I');
2859 warn " adding customer package invoice detail: $_\n"
2860 foreach @cust_pkg_detail;
2862 push @details, @cust_pkg_detail;
2864 my $cust_bill_pkg = new FS::cust_bill_pkg {
2865 'pkgnum' => $cust_pkg->pkgnum,
2867 'unitsetup' => $unitsetup,
2869 'unitrecur' => $unitrecur,
2870 'quantity' => $cust_pkg->quantity,
2871 'details' => \@details,
2874 if ( $part_pkg->option('recur_temporality', 1) eq 'preceding' ) {
2875 $cust_bill_pkg->sdate( $hash{last_bill} );
2876 $cust_bill_pkg->edate( $sdate - 86399 ); #60s*60m*24h-1
2877 } else { #if ( $part_pkg->option('recur_temporality', 1) eq 'upcoming' ) {
2878 $cust_bill_pkg->sdate( $sdate );
2879 $cust_bill_pkg->edate( $cust_pkg->bill );
2882 $cust_bill_pkg->pkgpart_override($part_pkg->pkgpart)
2883 unless $part_pkg->pkgpart == $real_pkgpart;
2885 $$total_setup += $setup;
2886 $$total_recur += $recur;
2893 $self->_handle_taxes($part_pkg, $taxlisthash, $cust_bill_pkg, $cust_pkg, $options{invoice_time});
2894 return $error if $error;
2896 push @$cust_bill_pkgs, $cust_bill_pkg;
2898 } #if $setup != 0 || $recur != 0
2908 my $part_pkg = shift;
2909 my $taxlisthash = shift;
2910 my $cust_bill_pkg = shift;
2911 my $cust_pkg = shift;
2912 my $invoice_time = shift;
2914 my %cust_bill_pkg = ();
2918 #push @classes, $cust_bill_pkg->usage_classes if $cust_bill_pkg->type eq 'U';
2919 push @classes, $cust_bill_pkg->usage_classes if $cust_bill_pkg->usage;
2920 push @classes, 'setup' if $cust_bill_pkg->setup;
2921 push @classes, 'recur' if $cust_bill_pkg->recur;
2923 if ( $self->tax !~ /Y/i && $self->payby ne 'COMP' ) {
2925 if ( $conf->exists('enable_taxproducts')
2926 && ( scalar($part_pkg->part_pkg_taxoverride)
2927 || $part_pkg->has_taxproduct
2932 if ( $conf->exists('tax-pkg_address') && $cust_pkg->locationnum ) {
2933 return "fatal: Can't (yet) use tax-pkg_address with taxproducts";
2936 foreach my $class (@classes) {
2937 my $err_or_ref = $self->_gather_taxes( $part_pkg, $class );
2938 return $err_or_ref unless ref($err_or_ref);
2939 $taxes{$class} = $err_or_ref;
2942 unless (exists $taxes{''}) {
2943 my $err_or_ref = $self->_gather_taxes( $part_pkg, '' );
2944 return $err_or_ref unless ref($err_or_ref);
2945 $taxes{''} = $err_or_ref;
2950 my @loc_keys = qw( state county country );
2952 if ( $conf->exists('tax-pkg_address') && $cust_pkg->locationnum ) {
2953 my $cust_location = $cust_pkg->cust_location;
2954 %taxhash = map { $_ => $cust_location->$_() } @loc_keys;
2957 ( $conf->exists('tax-ship_address') && length($self->ship_last) )
2960 %taxhash = map { $_ => $self->get("$prefix$_") } @loc_keys;
2963 $taxhash{'taxclass'} = $part_pkg->taxclass;
2965 my @taxes = qsearch( 'cust_main_county', \%taxhash );
2967 my %taxhash_elim = %taxhash;
2969 my @elim = qw( taxclass county state );
2970 while ( !scalar(@taxes) && scalar(@elim) ) {
2971 $taxhash_elim{ shift(@elim) } = '';
2972 @taxes = qsearch( 'cust_main_county', \%taxhash_elim );
2975 @taxes = grep { ! $_->taxname or ! $self->tax_exemption($_->taxname) }
2977 if $self->cust_main_exemption; #just to be safe
2979 if ( $conf->exists('tax-pkg_address') && $cust_pkg->locationnum ) {
2981 $_->set('pkgnum', $cust_pkg->pkgnum );
2982 $_->set('locationnum', $cust_pkg->locationnum );
2986 $taxes{''} = [ @taxes ];
2987 $taxes{'setup'} = [ @taxes ];
2988 $taxes{'recur'} = [ @taxes ];
2989 $taxes{$_} = [ @taxes ] foreach (@classes);
2991 # # maybe eliminate this entirely, along with all the 0% records
2992 # unless ( @taxes ) {
2994 # "fatal: can't find tax rate for state/county/country/taxclass ".
2995 # join('/', map $taxhash{$_}, qw(state county country taxclass) );
2998 } #if $conf->exists('enable_taxproducts') ...
3003 if ( $conf->exists('separate_usage') ) {
3004 my $section = $cust_pkg->part_pkg->option('usage_section', 'Hush!');
3005 my $summary = $cust_pkg->part_pkg->option('summarize_usage', 'Hush!');
3006 push @display, new FS::cust_bill_pkg_display { type => 'S' };
3007 push @display, new FS::cust_bill_pkg_display { type => 'R' };
3008 push @display, new FS::cust_bill_pkg_display { type => 'U',
3011 if ($section && $summary) {
3012 $display[2]->post_total('Y');
3013 push @display, new FS::cust_bill_pkg_display { type => 'U',
3018 $cust_bill_pkg->set('display', \@display);
3020 my %tax_cust_bill_pkg = $cust_bill_pkg->disintegrate;
3021 foreach my $key (keys %tax_cust_bill_pkg) {
3022 my @taxes = @{ $taxes{$key} || [] };
3023 my $tax_cust_bill_pkg = $tax_cust_bill_pkg{$key};
3025 my %localtaxlisthash = ();
3026 foreach my $tax ( @taxes ) {
3028 my $taxname = ref( $tax ). ' '. $tax->taxnum;
3029 # $taxname .= ' pkgnum'. $cust_pkg->pkgnum.
3030 # ' locationnum'. $cust_pkg->locationnum
3031 # if $conf->exists('tax-pkg_address') && $cust_pkg->locationnum;
3033 $taxlisthash->{ $taxname } ||= [ $tax ];
3034 push @{ $taxlisthash->{ $taxname } }, $tax_cust_bill_pkg;
3036 $localtaxlisthash{ $taxname } ||= [ $tax ];
3037 push @{ $localtaxlisthash{ $taxname } }, $tax_cust_bill_pkg;
3041 warn "finding taxed taxes...\n" if $DEBUG > 2;
3042 foreach my $tax ( keys %localtaxlisthash ) {
3043 my $tax_object = shift @{ $localtaxlisthash{$tax} };
3044 warn "found possible taxed tax ". $tax_object->taxname. " we call $tax\n"
3046 next unless $tax_object->can('tax_on_tax');
3048 foreach my $tot ( $tax_object->tax_on_tax( $self ) ) {
3049 my $totname = ref( $tot ). ' '. $tot->taxnum;
3051 warn "checking $totname which we call ". $tot->taxname. " as applicable\n"
3053 next unless exists( $localtaxlisthash{ $totname } ); # only increase
3055 warn "adding $totname to taxed taxes\n" if $DEBUG > 2;
3056 my $hashref_or_error =
3057 $tax_object->taxline( $localtaxlisthash{$tax},
3058 'custnum' => $self->custnum,
3059 'invoice_time' => $invoice_time,
3061 return $hashref_or_error
3062 unless ref($hashref_or_error);
3064 $taxlisthash->{ $totname } ||= [ $tot ];
3065 push @{ $taxlisthash->{ $totname } }, $hashref_or_error->{amount};
3077 my $part_pkg = shift;
3081 my $geocode = $self->geocode('cch');
3083 my @taxclassnums = map { $_->taxclassnum }
3084 $part_pkg->part_pkg_taxoverride($class);
3086 unless (@taxclassnums) {
3087 @taxclassnums = map { $_->taxclassnum }
3088 grep { $_->taxable eq 'Y' }
3089 $part_pkg->part_pkg_taxrate('cch', $geocode, $class);
3091 warn "Found taxclassnum values of ". join(',', @taxclassnums)
3096 join(' OR ', map { "taxclassnum = $_" } @taxclassnums ). ")";
3098 @taxes = qsearch({ 'table' => 'tax_rate',
3099 'hashref' => { 'geocode' => $geocode, },
3100 'extra_sql' => $extra_sql,
3102 if scalar(@taxclassnums);
3104 warn "Found taxes ".
3105 join(',', map{ ref($_). " ". $_->get($_->primary_key) } @taxes). "\n"
3112 =item collect OPTIONS
3114 (Attempt to) collect money for this customer's outstanding invoices (see
3115 L<FS::cust_bill>). Usually used after the bill method.
3117 Actions are now triggered by billing events; see L<FS::part_event> and the
3118 billing events web interface. Old-style invoice events (see
3119 L<FS::part_bill_event>) have been deprecated.
3121 If there is an error, returns the error, otherwise returns false.
3123 Options are passed as name-value pairs.
3125 Currently available options are:
3131 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.
3135 Retry card/echeck/LEC transactions even when not scheduled by invoice events.
3139 set true to surpress email card/ACH decline notices.
3143 "1d" for the traditional, daily events (the default), or "1m" for the new monthly events (part_event.check_freq)
3147 allows for one time override of normal customer billing method
3151 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)
3159 my( $self, %options ) = @_;
3160 my $invoice_time = $options{'invoice_time'} || time;
3163 local $SIG{HUP} = 'IGNORE';
3164 local $SIG{INT} = 'IGNORE';
3165 local $SIG{QUIT} = 'IGNORE';
3166 local $SIG{TERM} = 'IGNORE';
3167 local $SIG{TSTP} = 'IGNORE';
3168 local $SIG{PIPE} = 'IGNORE';
3170 my $oldAutoCommit = $FS::UID::AutoCommit;
3171 local $FS::UID::AutoCommit = 0;
3174 $self->select_for_update; #mutex
3177 my $balance = $self->balance;
3178 warn "$me collect customer ". $self->custnum. ": balance $balance\n"
3181 if ( exists($options{'retry_card'}) ) {
3182 carp 'retry_card option passed to collect is deprecated; use retry';
3183 $options{'retry'} ||= $options{'retry_card'};
3185 if ( exists($options{'retry'}) && $options{'retry'} ) {
3186 my $error = $self->retry_realtime;
3188 $dbh->rollback if $oldAutoCommit;
3193 # false laziness w/pay_batch::import_results
3195 my $due_cust_event = $self->due_cust_event(
3196 'debug' => ( $options{'debug'} || 0 ),
3197 'time' => $invoice_time,
3198 'check_freq' => $options{'check_freq'},
3200 unless( ref($due_cust_event) ) {
3201 $dbh->rollback if $oldAutoCommit;
3202 return $due_cust_event;
3205 foreach my $cust_event ( @$due_cust_event ) {
3209 #re-eval event conditions (a previous event could have changed things)
3210 unless ( $cust_event->test_conditions( 'time' => $invoice_time ) ) {
3211 #don't leave stray "new/locked" records around
3212 my $error = $cust_event->delete;
3214 #gah, even with transactions
3215 $dbh->commit if $oldAutoCommit; #well.
3222 local $realtime_bop_decline_quiet = 1 if $options{'quiet'};
3223 warn " running cust_event ". $cust_event->eventnum. "\n"
3227 #if ( my $error = $cust_event->do_event(%options) ) { #XXX %options?
3228 if ( my $error = $cust_event->do_event() ) {
3229 #XXX wtf is this? figure out a proper dealio with return value
3231 # gah, even with transactions.
3232 $dbh->commit if $oldAutoCommit; #well.
3239 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
3244 =item due_cust_event [ HASHREF | OPTION => VALUE ... ]
3246 Inserts database records for and returns an ordered listref of new events due
3247 for this customer, as FS::cust_event objects (see L<FS::cust_event>). If no
3248 events are due, an empty listref is returned. If there is an error, returns a
3249 scalar error message.
3251 To actually run the events, call each event's test_condition method, and if
3252 still true, call the event's do_event method.
3254 Options are passed as a hashref or as a list of name-value pairs. Available
3261 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.
3265 "Current time" for the events.
3269 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)
3273 Only return events for the specified eventtable (by default, events of all eventtables are returned)
3277 Explicitly pass the objects to be tested (typically used with eventtable).
3281 Set to true to return the objects, but not actually insert them into the
3288 sub due_cust_event {
3290 my %opt = ref($_[0]) ? %{ $_[0] } : @_;
3293 #my $DEBUG = $opt{'debug'}
3294 local($DEBUG) = $opt{'debug'}
3295 if defined($opt{'debug'}) && $opt{'debug'} > $DEBUG;
3297 warn "$me due_cust_event called with options ".
3298 join(', ', map { "$_: $opt{$_}" } keys %opt). "\n"
3301 $opt{'time'} ||= time;
3303 local $SIG{HUP} = 'IGNORE';
3304 local $SIG{INT} = 'IGNORE';
3305 local $SIG{QUIT} = 'IGNORE';
3306 local $SIG{TERM} = 'IGNORE';
3307 local $SIG{TSTP} = 'IGNORE';
3308 local $SIG{PIPE} = 'IGNORE';
3310 my $oldAutoCommit = $FS::UID::AutoCommit;
3311 local $FS::UID::AutoCommit = 0;
3314 $self->select_for_update #mutex
3315 unless $opt{testonly};
3318 # 1: find possible events (initial search)
3321 my @cust_event = ();
3323 my @eventtable = $opt{'eventtable'}
3324 ? ( $opt{'eventtable'} )
3325 : FS::part_event->eventtables_runorder;
3327 foreach my $eventtable ( @eventtable ) {
3330 if ( $opt{'objects'} ) {
3332 @objects = @{ $opt{'objects'} };
3336 #my @objects = $self->eventtable(); # sub cust_main { @{ [ $self ] }; }
3337 @objects = ( $eventtable eq 'cust_main' )
3339 : ( $self->$eventtable() );
3343 my @e_cust_event = ();
3345 my $cross = "CROSS JOIN $eventtable";
3346 $cross .= ' LEFT JOIN cust_main USING ( custnum )'
3347 unless $eventtable eq 'cust_main';
3349 foreach my $object ( @objects ) {
3351 #this first search uses the condition_sql magic for optimization.
3352 #the more possible events we can eliminate in this step the better
3354 my $cross_where = '';
3355 my $pkey = $object->primary_key;
3356 $cross_where = "$eventtable.$pkey = ". $object->$pkey();
3358 my $join = FS::part_event_condition->join_conditions_sql( $eventtable );
3360 FS::part_event_condition->where_conditions_sql( $eventtable,
3361 'time'=>$opt{'time'}
3363 my $order = FS::part_event_condition->order_conditions_sql( $eventtable );
3365 $extra_sql = "AND $extra_sql" if $extra_sql;
3367 #here is the agent virtualization
3368 $extra_sql .= " AND ( part_event.agentnum IS NULL
3369 OR part_event.agentnum = ". $self->agentnum. ' )';
3371 $extra_sql .= " $order";
3373 warn "searching for events for $eventtable ". $object->$pkey. "\n"
3374 if $opt{'debug'} > 2;
3375 my @part_event = qsearch( {
3376 'debug' => ( $opt{'debug'} > 3 ? 1 : 0 ),
3377 'select' => 'part_event.*',
3378 'table' => 'part_event',
3379 'addl_from' => "$cross $join",
3380 'hashref' => { 'check_freq' => ( $opt{'check_freq'} || '1d' ),
3381 'eventtable' => $eventtable,
3384 'extra_sql' => "AND $cross_where $extra_sql",
3388 my $pkey = $object->primary_key;
3389 warn " ". scalar(@part_event).
3390 " possible events found for $eventtable ". $object->$pkey(). "\n";
3393 push @e_cust_event, map { $_->new_cust_event($object) } @part_event;
3397 warn " ". scalar(@e_cust_event).
3398 " subtotal possible cust events found for $eventtable\n"
3401 push @cust_event, @e_cust_event;
3405 warn " ". scalar(@cust_event).
3406 " total possible cust events found in initial search\n"
3410 # 2: test conditions
3415 @cust_event = grep $_->test_conditions( 'time' => $opt{'time'},
3416 'stats_hashref' => \%unsat ),
3419 warn " ". scalar(@cust_event). " cust events left satisfying conditions\n"
3422 warn " invalid conditions not eliminated with condition_sql:\n".
3423 join('', map " $_: ".$unsat{$_}."\n", keys %unsat )
3430 unless( $opt{testonly} ) {
3431 foreach my $cust_event ( @cust_event ) {
3433 my $error = $cust_event->insert();
3435 $dbh->rollback if $oldAutoCommit;
3442 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
3448 warn " returning events: ". Dumper(@cust_event). "\n"
3455 =item retry_realtime
3457 Schedules realtime / batch credit card / electronic check / LEC billing
3458 events for for retry. Useful if card information has changed or manual
3459 retry is desired. The 'collect' method must be called to actually retry
3462 Implementation details: For either this customer, or for each of this
3463 customer's open invoices, changes the status of the first "done" (with
3464 statustext error) realtime processing event to "failed".
3468 sub retry_realtime {
3471 local $SIG{HUP} = 'IGNORE';
3472 local $SIG{INT} = 'IGNORE';
3473 local $SIG{QUIT} = 'IGNORE';
3474 local $SIG{TERM} = 'IGNORE';
3475 local $SIG{TSTP} = 'IGNORE';
3476 local $SIG{PIPE} = 'IGNORE';
3478 my $oldAutoCommit = $FS::UID::AutoCommit;
3479 local $FS::UID::AutoCommit = 0;
3482 #a little false laziness w/due_cust_event (not too bad, really)
3484 my $join = FS::part_event_condition->join_conditions_sql;
3485 my $order = FS::part_event_condition->order_conditions_sql;
3488 . join ( ' OR ' , map {
3489 "( part_event.eventtable = " . dbh->quote($_)
3490 . " AND tablenum IN( SELECT " . dbdef->table($_)->primary_key . " from $_ where custnum = " . dbh->quote( $self->custnum ) . "))" ;
3491 } FS::part_event->eventtables)
3494 #here is the agent virtualization
3495 my $agent_virt = " ( part_event.agentnum IS NULL
3496 OR part_event.agentnum = ". $self->agentnum. ' )';
3498 #XXX this shouldn't be hardcoded, actions should declare it...
3499 my @realtime_events = qw(
3500 cust_bill_realtime_card
3501 cust_bill_realtime_check
3502 cust_bill_realtime_lec
3506 my $is_realtime_event = ' ( '. join(' OR ', map "part_event.action = '$_'",
3511 my @cust_event = qsearchs({
3512 'table' => 'cust_event',
3513 'select' => 'cust_event.*',
3514 'addl_from' => "LEFT JOIN part_event USING ( eventpart ) $join",
3515 'hashref' => { 'status' => 'done' },
3516 'extra_sql' => " AND statustext IS NOT NULL AND statustext != '' ".
3517 " AND $mine AND $is_realtime_event AND $agent_virt $order" # LIMIT 1"
3520 my %seen_invnum = ();
3521 foreach my $cust_event (@cust_event) {
3523 #max one for the customer, one for each open invoice
3524 my $cust_X = $cust_event->cust_X;
3525 next if $seen_invnum{ $cust_event->part_event->eventtable eq 'cust_bill'
3529 or $cust_event->part_event->eventtable eq 'cust_bill'
3532 my $error = $cust_event->retry;
3534 $dbh->rollback if $oldAutoCommit;
3535 return "error scheduling event for retry: $error";
3540 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
3545 # some horrid false laziness here to avoid refactor fallout
3546 # eventually realtime realtime_bop and realtime_refund_bop should go
3547 # away and be replaced by _new_realtime_bop and _new_realtime_refund_bop
3549 =item realtime_bop METHOD AMOUNT [ OPTION => VALUE ... ]
3551 Runs a realtime credit card, ACH (electronic check) or phone bill transaction
3552 via a Business::OnlinePayment realtime gateway. See
3553 L<http://420.am/business-onlinepayment> for supported gateways.
3555 Available methods are: I<CC>, I<ECHECK> and I<LEC>
3557 Available options are: I<description>, I<invnum>, I<quiet>, I<paynum_ref>, I<payunique>
3559 The additional options I<payname>, I<address1>, I<address2>, I<city>, I<state>,
3560 I<zip>, I<payinfo> and I<paydate> are also available. Any of these options,
3561 if set, will override the value from the customer record.
3563 I<description> is a free-text field passed to the gateway. It defaults to
3564 "Internet services".
3566 If an I<invnum> is specified, this payment (if successful) is applied to the
3567 specified invoice. If you don't specify an I<invnum> you might want to
3568 call the B<apply_payments> method.
3570 I<quiet> can be set true to surpress email decline notices.
3572 I<paynum_ref> can be set to a scalar reference. It will be filled in with the
3573 resulting paynum, if any.
3575 I<payunique> is a unique identifier for this payment.
3577 (moved from cust_bill) (probably should get realtime_{card,ach,lec} here too)
3584 return $self->_new_realtime_bop(@_)
3585 if $self->_new_bop_required();
3587 my( $method, $amount, %options ) = @_;
3589 warn "$me realtime_bop: $method $amount\n";
3590 warn " $_ => $options{$_}\n" foreach keys %options;
3593 $options{'description'} ||= 'Internet services';
3595 return $self->fake_bop($method, $amount, %options) if $options{'fake'};
3597 eval "use Business::OnlinePayment";
3600 my $payinfo = exists($options{'payinfo'})
3601 ? $options{'payinfo'}
3604 my %method2payby = (
3611 # check for banned credit card/ACH
3614 my $ban = qsearchs('banned_pay', {
3615 'payby' => $method2payby{$method},
3616 'payinfo' => md5_base64($payinfo),
3618 return "Banned credit card" if $ban;
3621 # set taxclass and trans_is_recur based on invnum if there is one
3625 my $trans_is_recur = 0;
3626 if ( $options{'invnum'} ) {
3628 my $cust_bill = qsearchs('cust_bill', { 'invnum' => $options{'invnum'} } );
3629 die "invnum ". $options{'invnum'}. " not found" unless $cust_bill;
3632 map { $_->part_pkg }
3634 map { $_->cust_pkg }
3635 $cust_bill->cust_bill_pkg;
3637 my @taxclasses = map $_->taxclass, @part_pkg;
3638 $taxclass = $taxclasses[0]
3639 unless grep { $taxclasses[0] ne $_ } @taxclasses; #unless there are
3640 #different taxclasses
3642 if grep { $_->freq ne '0' } @part_pkg;
3650 #look for an agent gateway override first
3652 if ( $method eq 'CC' ) {
3653 $cardtype = cardtype($payinfo);
3654 } elsif ( $method eq 'ECHECK' ) {
3657 $cardtype = $method;
3661 qsearchs('agent_payment_gateway', { agentnum => $self->agentnum,
3662 cardtype => $cardtype,
3663 taxclass => $taxclass, } )
3664 || qsearchs('agent_payment_gateway', { agentnum => $self->agentnum,
3666 taxclass => $taxclass, } )
3667 || qsearchs('agent_payment_gateway', { agentnum => $self->agentnum,
3668 cardtype => $cardtype,
3670 || qsearchs('agent_payment_gateway', { agentnum => $self->agentnum,
3672 taxclass => '', } );
3674 my $payment_gateway = '';
3675 my( $processor, $login, $password, $action, @bop_options );
3676 if ( $override ) { #use a payment gateway override
3678 $payment_gateway = $override->payment_gateway;
3680 $processor = $payment_gateway->gateway_module;
3681 $login = $payment_gateway->gateway_username;
3682 $password = $payment_gateway->gateway_password;
3683 $action = $payment_gateway->gateway_action;
3684 @bop_options = $payment_gateway->options;
3686 } else { #use the standard settings from the config
3688 ( $processor, $login, $password, $action, @bop_options ) =
3689 $self->default_payment_gateway($method);
3697 my $address = exists($options{'address1'})
3698 ? $options{'address1'}
3700 my $address2 = exists($options{'address2'})
3701 ? $options{'address2'}
3703 $address .= ", ". $address2 if length($address2);
3705 my $o_payname = exists($options{'payname'})
3706 ? $options{'payname'}
3708 my($payname, $payfirst, $paylast);
3709 if ( $o_payname && $method ne 'ECHECK' ) {
3710 ($payname = $o_payname) =~ /^\s*([\w \,\.\-\']*)?\s+([\w\,\.\-\']+)\s*$/
3711 or return "Illegal payname $payname";
3712 ($payfirst, $paylast) = ($1, $2);
3714 $payfirst = $self->getfield('first');
3715 $paylast = $self->getfield('last');
3716 $payname = "$payfirst $paylast";
3719 my @invoicing_list = $self->invoicing_list_emailonly;
3720 if ( $conf->exists('emailinvoiceautoalways')
3721 || $conf->exists('emailinvoiceauto') && ! @invoicing_list
3722 || ( $conf->exists('emailinvoiceonly') && ! @invoicing_list ) ) {
3723 push @invoicing_list, $self->all_emails;
3726 my $email = ($conf->exists('business-onlinepayment-email-override'))
3727 ? $conf->config('business-onlinepayment-email-override')
3728 : $invoicing_list[0];
3732 my $payip = exists($options{'payip'})
3735 $content{customer_ip} = $payip
3738 $content{invoice_number} = $options{'invnum'}
3739 if exists($options{'invnum'}) && length($options{'invnum'});
3741 $content{email_customer} =
3742 ( $conf->exists('business-onlinepayment-email_customer')
3743 || $conf->exists('business-onlinepayment-email-override') );
3746 if ( $method eq 'CC' ) {
3748 $content{card_number} = $payinfo;
3749 $paydate = exists($options{'paydate'})
3750 ? $options{'paydate'}
3752 $paydate =~ /^\d{2}(\d{2})[\/\-](\d+)[\/\-]\d+$/;
3753 $content{expiration} = "$2/$1";
3755 my $paycvv = exists($options{'paycvv'})
3756 ? $options{'paycvv'}
3758 $content{cvv2} = $paycvv
3761 my $paystart_month = exists($options{'paystart_month'})
3762 ? $options{'paystart_month'}
3763 : $self->paystart_month;
3765 my $paystart_year = exists($options{'paystart_year'})
3766 ? $options{'paystart_year'}
3767 : $self->paystart_year;
3769 $content{card_start} = "$paystart_month/$paystart_year"
3770 if $paystart_month && $paystart_year;
3772 my $payissue = exists($options{'payissue'})
3773 ? $options{'payissue'}
3775 $content{issue_number} = $payissue if $payissue;
3777 if ( $self->_bop_recurring_billing( 'payinfo' => $payinfo,
3778 'trans_is_recur' => $trans_is_recur,
3782 $content{recurring_billing} = 'YES';
3783 $content{acct_code} = 'rebill'
3784 if $conf->exists('credit_card-recurring_billing_acct_code');
3787 } elsif ( $method eq 'ECHECK' ) {
3788 ( $content{account_number}, $content{routing_code} ) =
3789 split('@', $payinfo);
3790 $content{bank_name} = $o_payname;
3791 $content{bank_state} = exists($options{'paystate'})
3792 ? $options{'paystate'}
3793 : $self->getfield('paystate');
3794 $content{account_type} = exists($options{'paytype'})
3795 ? uc($options{'paytype'}) || 'CHECKING'
3796 : uc($self->getfield('paytype')) || 'CHECKING';
3797 $content{account_name} = $payname;
3798 $content{customer_org} = $self->company ? 'B' : 'I';
3799 $content{state_id} = exists($options{'stateid'})
3800 ? $options{'stateid'}
3801 : $self->getfield('stateid');
3802 $content{state_id_state} = exists($options{'stateid_state'})
3803 ? $options{'stateid_state'}
3804 : $self->getfield('stateid_state');
3805 $content{customer_ssn} = exists($options{'ss'})
3808 } elsif ( $method eq 'LEC' ) {
3809 $content{phone} = $payinfo;
3813 # run transaction(s)
3816 my $balance = exists( $options{'balance'} )
3817 ? $options{'balance'}
3820 $self->select_for_update; #mutex ... just until we get our pending record in
3822 #the checks here are intended to catch concurrent payments
3823 #double-form-submission prevention is taken care of in cust_pay_pending::check
3826 return "The customer's balance has changed; $method transaction aborted."
3827 if $self->balance < $balance;
3828 #&& $self->balance < $amount; #might as well anyway?
3830 #also check and make sure there aren't *other* pending payments for this cust
3832 my @pending = qsearch('cust_pay_pending', {
3833 'custnum' => $self->custnum,
3834 'status' => { op=>'!=', value=>'done' }
3836 return "A payment is already being processed for this customer (".
3837 join(', ', map 'paypendingnum '. $_->paypendingnum, @pending ).
3838 "); $method transaction aborted."
3839 if scalar(@pending);
3841 #okay, good to go, if we're a duplicate, cust_pay_pending will kick us out
3843 my $cust_pay_pending = new FS::cust_pay_pending {
3844 'custnum' => $self->custnum,
3845 #'invnum' => $options{'invnum'},
3848 'payby' => $method2payby{$method},
3849 'payinfo' => $payinfo,
3850 'paydate' => $paydate,
3851 'recurring_billing' => $content{recurring_billing},
3853 'gatewaynum' => ( $payment_gateway ? $payment_gateway->gatewaynum : '' ),
3855 $cust_pay_pending->payunique( $options{payunique} )
3856 if defined($options{payunique}) && length($options{payunique});
3857 my $cpp_new_err = $cust_pay_pending->insert; #mutex lost when this is inserted
3858 return $cpp_new_err if $cpp_new_err;
3860 my( $action1, $action2 ) = split(/\s*\,\s*/, $action );
3862 my $transaction = new Business::OnlinePayment( $processor, @bop_options );
3863 $transaction->content(
3866 'password' => $password,
3867 'action' => $action1,
3868 'description' => $options{'description'},
3869 'amount' => $amount,
3870 #'invoice_number' => $options{'invnum'},
3871 'customer_id' => $self->custnum,
3872 'last_name' => $paylast,
3873 'first_name' => $payfirst,
3875 'address' => $address,
3876 'city' => ( exists($options{'city'})
3879 'state' => ( exists($options{'state'})
3882 'zip' => ( exists($options{'zip'})
3885 'country' => ( exists($options{'country'})
3886 ? $options{'country'}
3888 'referer' => 'http://cleanwhisker.420.am/', #XXX fix referer :/
3890 'phone' => $self->daytime || $self->night,
3894 $cust_pay_pending->status('pending');
3895 my $cpp_pending_err = $cust_pay_pending->replace;
3896 return $cpp_pending_err if $cpp_pending_err;
3899 my $BOP_TESTING = 0;
3900 my $BOP_TESTING_SUCCESS = 1;
3902 unless ( $BOP_TESTING ) {
3903 $transaction->submit();
3905 if ( $BOP_TESTING_SUCCESS ) {
3906 $transaction->is_success(1);
3907 $transaction->authorization('fake auth');
3909 $transaction->is_success(0);
3910 $transaction->error_message('fake failure');
3914 if ( $transaction->is_success() && $action2 ) {
3916 $cust_pay_pending->status('authorized');
3917 my $cpp_authorized_err = $cust_pay_pending->replace;
3918 return $cpp_authorized_err if $cpp_authorized_err;
3920 my $auth = $transaction->authorization;
3921 my $ordernum = $transaction->can('order_number')
3922 ? $transaction->order_number
3926 new Business::OnlinePayment( $processor, @bop_options );
3933 password => $password,
3934 order_number => $ordernum,
3936 authorization => $auth,
3937 description => $options{'description'},
3940 foreach my $field (qw( authorization_source_code returned_ACI
3941 transaction_identifier validation_code
3942 transaction_sequence_num local_transaction_date
3943 local_transaction_time AVS_result_code )) {
3944 $capture{$field} = $transaction->$field() if $transaction->can($field);
3947 $capture->content( %capture );
3951 unless ( $capture->is_success ) {
3952 my $e = "Authorization successful but capture failed, custnum #".
3953 $self->custnum. ': '. $capture->result_code.
3954 ": ". $capture->error_message;
3961 $cust_pay_pending->status($transaction->is_success() ? 'captured' : 'declined');
3962 my $cpp_captured_err = $cust_pay_pending->replace;
3963 return $cpp_captured_err if $cpp_captured_err;
3966 # remove paycvv after initial transaction
3969 #false laziness w/misc/process/payment.cgi - check both to make sure working
3971 if ( defined $self->dbdef_table->column('paycvv')
3972 && length($self->paycvv)
3973 && ! grep { $_ eq cardtype($payinfo) } $conf->config('cvv-save')
3975 my $error = $self->remove_cvv;
3977 warn "WARNING: error removing cvv: $error\n";
3985 if ( $transaction->is_success() ) {
3988 if ( $payment_gateway ) { # agent override
3989 $paybatch = $payment_gateway->gatewaynum. '-';
3992 $paybatch .= "$processor:". $transaction->authorization;
3994 $paybatch .= ':'. $transaction->order_number
3995 if $transaction->can('order_number')
3996 && length($transaction->order_number);
3998 my $cust_pay = new FS::cust_pay ( {
3999 'custnum' => $self->custnum,
4000 'invnum' => $options{'invnum'},
4003 'payby' => $method2payby{$method},
4004 'payinfo' => $payinfo,
4005 'paybatch' => $paybatch,
4006 'paydate' => $paydate,
4008 #doesn't hurt to know, even though the dup check is in cust_pay_pending now
4009 $cust_pay->payunique( $options{payunique} )
4010 if defined($options{payunique}) && length($options{payunique});
4012 my $oldAutoCommit = $FS::UID::AutoCommit;
4013 local $FS::UID::AutoCommit = 0;
4016 #start a transaction, insert the cust_pay and set cust_pay_pending.status to done in a single transction
4018 my $error = $cust_pay->insert($options{'manual'} ? ( 'manual' => 1 ) : () );
4021 $cust_pay->invnum(''); #try again with no specific invnum
4022 my $error2 = $cust_pay->insert( $options{'manual'} ?
4023 ( 'manual' => 1 ) : ()
4026 # gah. but at least we have a record of the state we had to abort in
4027 # from cust_pay_pending now.
4028 my $e = "WARNING: $method captured but payment not recorded - ".
4029 "error inserting payment ($processor): $error2".
4030 " (previously tried insert with invnum #$options{'invnum'}" .
4031 ": $error ) - pending payment saved as paypendingnum ".
4032 $cust_pay_pending->paypendingnum. "\n";
4038 if ( $options{'paynum_ref'} ) {
4039 ${ $options{'paynum_ref'} } = $cust_pay->paynum;
4042 $cust_pay_pending->status('done');
4043 $cust_pay_pending->statustext('captured');
4044 $cust_pay_pending->paynum($cust_pay->paynum);
4045 my $cpp_done_err = $cust_pay_pending->replace;
4047 if ( $cpp_done_err ) {
4049 $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
4050 my $e = "WARNING: $method captured but payment not recorded - ".
4051 "error updating status for paypendingnum ".
4052 $cust_pay_pending->paypendingnum. ": $cpp_done_err \n";
4058 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
4059 return ''; #no error
4065 my $perror = "$processor error: ". $transaction->error_message;
4067 unless ( $transaction->error_message ) {
4070 if ( $transaction->can('response_page') ) {
4072 'page' => ( $transaction->can('response_page')
4073 ? $transaction->response_page
4076 'code' => ( $transaction->can('response_code')
4077 ? $transaction->response_code
4080 'headers' => ( $transaction->can('response_headers')
4081 ? $transaction->response_headers
4087 "No additional debugging information available for $processor";
4090 $perror .= "No error_message returned from $processor -- ".
4091 ( ref($t_response) ? Dumper($t_response) : $t_response );
4095 if ( !$options{'quiet'} && !$realtime_bop_decline_quiet
4096 && $conf->exists('emaildecline')
4097 && grep { $_ ne 'POST' } $self->invoicing_list
4098 && ! grep { $transaction->error_message =~ /$_/ }
4099 $conf->config('emaildecline-exclude')
4101 my @templ = $conf->config('declinetemplate');
4102 my $template = new Text::Template (
4104 SOURCE => [ map "$_\n", @templ ],
4105 ) or return "($perror) can't create template: $Text::Template::ERROR";
4106 $template->compile()
4107 or return "($perror) can't compile template: $Text::Template::ERROR";
4109 my $templ_hash = { error => $transaction->error_message };
4111 my $error = send_email(
4112 'from' => $conf->config('invoice_from', $self->agentnum ),
4113 'to' => [ grep { $_ ne 'POST' } $self->invoicing_list ],
4114 'subject' => 'Your payment could not be processed',
4115 'body' => [ $template->fill_in(HASH => $templ_hash) ],
4118 $perror .= " (also received error sending decline notification: $error)"
4123 $cust_pay_pending->status('done');
4124 $cust_pay_pending->statustext("declined: $perror");
4125 my $cpp_done_err = $cust_pay_pending->replace;
4126 if ( $cpp_done_err ) {
4127 my $e = "WARNING: $method declined but pending payment not resolved - ".
4128 "error updating status for paypendingnum ".
4129 $cust_pay_pending->paypendingnum. ": $cpp_done_err \n";
4131 $perror = "$e ($perror)";
4139 sub _bop_recurring_billing {
4140 my( $self, %opt ) = @_;
4142 my $method = $conf->config('credit_card-recurring_billing_flag');
4144 if ( $method eq 'transaction_is_recur' ) {
4146 return 1 if $opt{'trans_is_recur'};
4150 my %hash = ( 'custnum' => $self->custnum,
4155 if qsearch('cust_pay', { %hash, 'payinfo' => $opt{'payinfo'} } )
4156 || qsearch('cust_pay', { %hash, 'paymask' => $self->mask_payinfo('CARD',
4167 =item realtime_refund_bop METHOD [ OPTION => VALUE ... ]
4169 Refunds a realtime credit card, ACH (electronic check) or phone bill transaction
4170 via a Business::OnlinePayment realtime gateway. See
4171 L<http://420.am/business-onlinepayment> for supported gateways.
4173 Available methods are: I<CC>, I<ECHECK> and I<LEC>
4175 Available options are: I<amount>, I<reason>, I<paynum>, I<paydate>
4177 Most gateways require a reference to an original payment transaction to refund,
4178 so you probably need to specify a I<paynum>.
4180 I<amount> defaults to the original amount of the payment if not specified.
4182 I<reason> specifies a reason for the refund.
4184 I<paydate> specifies the expiration date for a credit card overriding the
4185 value from the customer record or the payment record. Specified as yyyy-mm-dd
4187 Implementation note: If I<amount> is unspecified or equal to the amount of the
4188 orignal payment, first an attempt is made to "void" the transaction via
4189 the gateway (to cancel a not-yet settled transaction) and then if that fails,
4190 the normal attempt is made to "refund" ("credit") the transaction via the
4191 gateway is attempted.
4193 #The additional options I<payname>, I<address1>, I<address2>, I<city>, I<state>,
4194 #I<zip>, I<payinfo> and I<paydate> are also available. Any of these options,
4195 #if set, will override the value from the customer record.
4197 #If an I<invnum> is specified, this payment (if successful) is applied to the
4198 #specified invoice. If you don't specify an I<invnum> you might want to
4199 #call the B<apply_payments> method.
4203 #some false laziness w/realtime_bop, not enough to make it worth merging
4204 #but some useful small subs should be pulled out
4205 sub realtime_refund_bop {
4208 return $self->_new_realtime_refund_bop(@_)
4209 if $self->_new_bop_required();
4211 my( $method, %options ) = @_;
4213 warn "$me realtime_refund_bop: $method refund\n";
4214 warn " $_ => $options{$_}\n" foreach keys %options;
4217 eval "use Business::OnlinePayment";
4221 # look up the original payment and optionally a gateway for that payment
4225 my $amount = $options{'amount'};
4227 my( $processor, $login, $password, @bop_options ) ;
4228 my( $auth, $order_number ) = ( '', '', '' );
4230 if ( $options{'paynum'} ) {
4232 warn " paynum: $options{paynum}\n" if $DEBUG > 1;
4233 $cust_pay = qsearchs('cust_pay', { paynum=>$options{'paynum'} } )
4234 or return "Unknown paynum $options{'paynum'}";
4235 $amount ||= $cust_pay->paid;
4237 $cust_pay->paybatch =~ /^((\d+)\-)?(\w+):\s*([\w\-\/ ]*)(:([\w\-]+))?$/
4238 or return "Can't parse paybatch for paynum $options{'paynum'}: ".
4239 $cust_pay->paybatch;
4240 my $gatewaynum = '';
4241 ( $gatewaynum, $processor, $auth, $order_number ) = ( $2, $3, $4, $6 );
4243 if ( $gatewaynum ) { #gateway for the payment to be refunded
4245 my $payment_gateway =
4246 qsearchs('payment_gateway', { 'gatewaynum' => $gatewaynum } );
4247 die "payment gateway $gatewaynum not found"
4248 unless $payment_gateway;
4250 $processor = $payment_gateway->gateway_module;
4251 $login = $payment_gateway->gateway_username;
4252 $password = $payment_gateway->gateway_password;
4253 @bop_options = $payment_gateway->options;
4255 } else { #try the default gateway
4257 my( $conf_processor, $unused_action );
4258 ( $conf_processor, $login, $password, $unused_action, @bop_options ) =
4259 $self->default_payment_gateway($method);
4261 return "processor of payment $options{'paynum'} $processor does not".
4262 " match default processor $conf_processor"
4263 unless $processor eq $conf_processor;
4268 } else { # didn't specify a paynum, so look for agent gateway overrides
4269 # like a normal transaction
4272 if ( $method eq 'CC' ) {
4273 $cardtype = cardtype($self->payinfo);
4274 } elsif ( $method eq 'ECHECK' ) {
4277 $cardtype = $method;
4280 qsearchs('agent_payment_gateway', { agentnum => $self->agentnum,
4281 cardtype => $cardtype,
4283 || qsearchs('agent_payment_gateway', { agentnum => $self->agentnum,
4285 taxclass => '', } );
4287 if ( $override ) { #use a payment gateway override
4289 my $payment_gateway = $override->payment_gateway;
4291 $processor = $payment_gateway->gateway_module;
4292 $login = $payment_gateway->gateway_username;
4293 $password = $payment_gateway->gateway_password;
4294 #$action = $payment_gateway->gateway_action;
4295 @bop_options = $payment_gateway->options;
4297 } else { #use the standard settings from the config
4300 ( $processor, $login, $password, $unused_action, @bop_options ) =
4301 $self->default_payment_gateway($method);
4306 return "neither amount nor paynum specified" unless $amount;
4311 'password' => $password,
4312 'order_number' => $order_number,
4313 'amount' => $amount,
4314 'referer' => 'http://cleanwhisker.420.am/', #XXX fix referer :/
4316 $content{authorization} = $auth
4317 if length($auth); #echeck/ACH transactions have an order # but no auth
4318 #(at least with authorize.net)
4320 my $disable_void_after;
4321 if ($conf->exists('disable_void_after')
4322 && $conf->config('disable_void_after') =~ /^(\d+)$/) {
4323 $disable_void_after = $1;
4326 #first try void if applicable
4327 if ( $cust_pay && $cust_pay->paid == $amount
4329 ( not defined($disable_void_after) )
4330 || ( time < ($cust_pay->_date + $disable_void_after ) )
4333 warn " attempting void\n" if $DEBUG > 1;
4334 my $void = new Business::OnlinePayment( $processor, @bop_options );
4335 $void->content( 'action' => 'void', %content );
4337 if ( $void->is_success ) {
4338 my $error = $cust_pay->void($options{'reason'});
4340 # gah, even with transactions.
4341 my $e = 'WARNING: Card/ACH voided but database not updated - '.
4342 "error voiding payment: $error";
4346 warn " void successful\n" if $DEBUG > 1;
4351 warn " void unsuccessful, trying refund\n"
4355 my $address = $self->address1;
4356 $address .= ", ". $self->address2 if $self->address2;
4358 my($payname, $payfirst, $paylast);
4359 if ( $self->payname && $method ne 'ECHECK' ) {
4360 $payname = $self->payname;
4361 $payname =~ /^\s*([\w \,\.\-\']*)?\s+([\w\,\.\-\']+)\s*$/
4362 or return "Illegal payname $payname";
4363 ($payfirst, $paylast) = ($1, $2);
4365 $payfirst = $self->getfield('first');
4366 $paylast = $self->getfield('last');
4367 $payname = "$payfirst $paylast";
4370 my @invoicing_list = $self->invoicing_list_emailonly;
4371 if ( $conf->exists('emailinvoiceautoalways')
4372 || $conf->exists('emailinvoiceauto') && ! @invoicing_list
4373 || ( $conf->exists('emailinvoiceonly') && ! @invoicing_list ) ) {
4374 push @invoicing_list, $self->all_emails;
4377 my $email = ($conf->exists('business-onlinepayment-email-override'))
4378 ? $conf->config('business-onlinepayment-email-override')
4379 : $invoicing_list[0];
4381 my $payip = exists($options{'payip'})
4384 $content{customer_ip} = $payip
4388 if ( $method eq 'CC' ) {
4391 $content{card_number} = $payinfo = $cust_pay->payinfo;
4392 (exists($options{'paydate'}) ? $options{'paydate'} : $cust_pay->paydate)
4393 =~ /^\d{2}(\d{2})[\/\-](\d+)[\/\-]\d+$/ &&
4394 ($content{expiration} = "$2/$1"); # where available
4396 $content{card_number} = $payinfo = $self->payinfo;
4397 (exists($options{'paydate'}) ? $options{'paydate'} : $self->paydate)
4398 =~ /^\d{2}(\d{2})[\/\-](\d+)[\/\-]\d+$/;
4399 $content{expiration} = "$2/$1";
4402 } elsif ( $method eq 'ECHECK' ) {
4405 $payinfo = $cust_pay->payinfo;
4407 $payinfo = $self->payinfo;
4409 ( $content{account_number}, $content{routing_code} )= split('@', $payinfo );
4410 $content{bank_name} = $self->payname;
4411 $content{account_type} = 'CHECKING';
4412 $content{account_name} = $payname;
4413 $content{customer_org} = $self->company ? 'B' : 'I';
4414 $content{customer_ssn} = $self->ss;
4415 } elsif ( $method eq 'LEC' ) {
4416 $content{phone} = $payinfo = $self->payinfo;
4420 my $refund = new Business::OnlinePayment( $processor, @bop_options );
4421 my %sub_content = $refund->content(
4422 'action' => 'credit',
4423 'customer_id' => $self->custnum,
4424 'last_name' => $paylast,
4425 'first_name' => $payfirst,
4427 'address' => $address,
4428 'city' => $self->city,
4429 'state' => $self->state,
4430 'zip' => $self->zip,
4431 'country' => $self->country,
4433 'phone' => $self->daytime || $self->night,
4436 warn join('', map { " $_ => $sub_content{$_}\n" } keys %sub_content )
4440 return "$processor error: ". $refund->error_message
4441 unless $refund->is_success();
4443 my %method2payby = (
4449 my $paybatch = "$processor:". $refund->authorization;
4450 $paybatch .= ':'. $refund->order_number
4451 if $refund->can('order_number') && $refund->order_number;
4453 while ( $cust_pay && $cust_pay->unapplied < $amount ) {
4454 my @cust_bill_pay = $cust_pay->cust_bill_pay;
4455 last unless @cust_bill_pay;
4456 my $cust_bill_pay = pop @cust_bill_pay;
4457 my $error = $cust_bill_pay->delete;
4461 my $cust_refund = new FS::cust_refund ( {
4462 'custnum' => $self->custnum,
4463 'paynum' => $options{'paynum'},
4464 'refund' => $amount,
4466 'payby' => $method2payby{$method},
4467 'payinfo' => $payinfo,
4468 'paybatch' => $paybatch,
4469 'reason' => $options{'reason'} || 'card or ACH refund',
4471 my $error = $cust_refund->insert;
4473 $cust_refund->paynum(''); #try again with no specific paynum
4474 my $error2 = $cust_refund->insert;
4476 # gah, even with transactions.
4477 my $e = 'WARNING: Card/ACH refunded but database not updated - '.
4478 "error inserting refund ($processor): $error2".
4479 " (previously tried insert with paynum #$options{'paynum'}" .
4490 # does the configuration indicate the new bop routines are required?
4492 sub _new_bop_required {
4495 my $botpp = 'Business::OnlineThirdPartyPayment';
4498 if ( $conf->config('business-onlinepayment-namespace') eq $botpp ||
4499 scalar( grep { $_->gateway_namespace eq $botpp }
4500 qsearch( 'payment_gateway', { 'disabled' => '' } )
4509 =item realtime_collect [ OPTION => VALUE ... ]
4511 Runs a realtime credit card, ACH (electronic check) or phone bill transaction
4512 via a Business::OnlinePayment or Business::OnlineThirdPartyPayment realtime
4513 gateway. See L<http://420.am/business-onlinepayment> and
4514 L<http://420.am/business-onlinethirdpartypayment> for supported gateways.
4516 On failure returns an error message.
4518 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.
4520 Available options are: I<method>, I<amount>, I<description>, I<invnum>, I<quiet>, I<paynum_ref>, I<payunique>, I<session_id>
4522 I<method> is one of: I<CC>, I<ECHECK> and I<LEC>. If none is specified
4523 then it is deduced from the customer record.
4525 If no I<amount> is specified, then the customer balance is used.
4527 The additional options I<payname>, I<address1>, I<address2>, I<city>, I<state>,
4528 I<zip>, I<payinfo> and I<paydate> are also available. Any of these options,
4529 if set, will override the value from the customer record.
4531 I<description> is a free-text field passed to the gateway. It defaults to
4532 "Internet services".
4534 If an I<invnum> is specified, this payment (if successful) is applied to the
4535 specified invoice. If you don't specify an I<invnum> you might want to
4536 call the B<apply_payments> method.
4538 I<quiet> can be set true to surpress email decline notices.
4540 I<paynum_ref> can be set to a scalar reference. It will be filled in with the
4541 resulting paynum, if any.
4543 I<payunique> is a unique identifier for this payment.
4545 I<session_id> is a session identifier associated with this payment.
4547 I<depend_jobnum> allows payment capture to unlock export jobs
4551 sub realtime_collect {
4552 my( $self, %options ) = @_;
4555 warn "$me realtime_collect:\n";
4556 warn " $_ => $options{$_}\n" foreach keys %options;
4559 $options{amount} = $self->balance unless exists( $options{amount} );
4560 $options{method} = FS::payby->payby2bop($self->payby)
4561 unless exists( $options{method} );
4563 return $self->realtime_bop({%options});
4567 =item _realtime_bop { [ ARG => VALUE ... ] }
4569 Runs a realtime credit card, ACH (electronic check) or phone bill transaction
4570 via a Business::OnlinePayment realtime gateway. See
4571 L<http://420.am/business-onlinepayment> for supported gateways.
4573 Required arguments in the hashref are I<method>, and I<amount>
4575 Available methods are: I<CC>, I<ECHECK> and I<LEC>
4577 Available optional arguments are: I<description>, I<invnum>, I<quiet>, I<paynum_ref>, I<payunique>, I<session_id>
4579 The additional options I<payname>, I<address1>, I<address2>, I<city>, I<state>,
4580 I<zip>, I<payinfo> and I<paydate> are also available. Any of these options,
4581 if set, will override the value from the customer record.
4583 I<description> is a free-text field passed to the gateway. It defaults to
4584 "Internet services".
4586 If an I<invnum> is specified, this payment (if successful) is applied to the
4587 specified invoice. If you don't specify an I<invnum> you might want to
4588 call the B<apply_payments> method.
4590 I<quiet> can be set true to surpress email decline notices.
4592 I<paynum_ref> can be set to a scalar reference. It will be filled in with the
4593 resulting paynum, if any.
4595 I<payunique> is a unique identifier for this payment.
4597 I<session_id> is a session identifier associated with this payment.
4599 I<depend_jobnum> allows payment capture to unlock export jobs
4601 (moved from cust_bill) (probably should get realtime_{card,ach,lec} here too)
4605 # some helper routines
4606 sub _payment_gateway {
4607 my ($self, $options) = @_;
4609 $options->{payment_gateway} = $self->agent->payment_gateway( %$options )
4610 unless exists($options->{payment_gateway});
4612 $options->{payment_gateway};
4616 my ($self, $options) = @_;
4619 'login' => $options->{payment_gateway}->gateway_username,
4620 'password' => $options->{payment_gateway}->gateway_password,
4625 my ($self, $options) = @_;
4627 $options->{payment_gateway}->gatewaynum
4628 ? $options->{payment_gateway}->options
4629 : @{ $options->{payment_gateway}->get('options') };
4633 my ($self, $options) = @_;
4635 $options->{description} ||= 'Internet services';
4636 $options->{payinfo} = $self->payinfo unless exists( $options->{payinfo} );
4637 $options->{invnum} ||= '';
4638 $options->{payname} = $self->payname unless exists( $options->{payname} );
4642 my ($self, $options) = @_;
4645 $content{address} = exists($options->{'address1'})
4646 ? $options->{'address1'}
4648 my $address2 = exists($options->{'address2'})
4649 ? $options->{'address2'}
4651 $content{address} .= ", ". $address2 if length($address2);
4653 my $payip = exists($options->{'payip'}) ? $options->{'payip'} : $self->payip;
4654 $content{customer_ip} = $payip if length($payip);
4656 $content{invoice_number} = $options->{'invnum'}
4657 if exists($options->{'invnum'}) && length($options->{'invnum'});
4659 $content{email_customer} =
4660 ( $conf->exists('business-onlinepayment-email_customer')
4661 || $conf->exists('business-onlinepayment-email-override') );
4663 $content{payfirst} = $self->getfield('first');
4664 $content{paylast} = $self->getfield('last');
4666 $content{account_name} = "$content{payfirst} $content{paylast}"
4667 if $options->{method} eq 'ECHECK';
4669 $content{name} = $options->{payname};
4670 $content{name} = $content{account_name} if exists($content{account_name});
4672 $content{city} = exists($options->{city})
4675 $content{state} = exists($options->{state})
4678 $content{zip} = exists($options->{zip})
4681 $content{country} = exists($options->{country})
4682 ? $options->{country}
4684 $content{referer} = 'http://cleanwhisker.420.am/'; #XXX fix referer :/
4685 $content{phone} = $self->daytime || $self->night;
4690 my %bop_method2payby = (
4696 sub _new_realtime_bop {
4700 if (ref($_[0]) eq 'HASH') {
4701 %options = %{$_[0]};
4703 my ( $method, $amount ) = ( shift, shift );
4705 $options{method} = $method;
4706 $options{amount} = $amount;
4710 warn "$me realtime_bop (new): $options{method} $options{amount}\n";
4711 warn " $_ => $options{$_}\n" foreach keys %options;
4714 return $self->fake_bop(%options) if $options{'fake'};
4716 $self->_bop_defaults(\%options);
4719 # set trans_is_recur based on invnum if there is one
4722 my $trans_is_recur = 0;
4723 if ( $options{'invnum'} ) {
4725 my $cust_bill = qsearchs('cust_bill', { 'invnum' => $options{'invnum'} } );
4726 die "invnum ". $options{'invnum'}. " not found" unless $cust_bill;
4729 map { $_->part_pkg }
4731 map { $_->cust_pkg }
4732 $cust_bill->cust_bill_pkg;
4735 if grep { $_->freq ne '0' } @part_pkg;
4743 my $payment_gateway = $self->_payment_gateway( \%options );
4744 my $namespace = $payment_gateway->gateway_namespace;
4746 eval "use $namespace";
4750 # check for banned credit card/ACH
4753 my $ban = qsearchs('banned_pay', {
4754 'payby' => $bop_method2payby{$options{method}},
4755 'payinfo' => md5_base64($options{payinfo}),
4757 return "Banned credit card" if $ban;
4763 my (%bop_content) = $self->_bop_content(\%options);
4765 if ( $options{method} ne 'ECHECK' ) {
4766 $options{payname} =~ /^\s*([\w \,\.\-\']*)?\s+([\w\,\.\-\']+)\s*$/
4767 or return "Illegal payname $options{payname}";
4768 ($bop_content{payfirst}, $bop_content{paylast}) = ($1, $2);
4771 my @invoicing_list = $self->invoicing_list_emailonly;
4772 if ( $conf->exists('emailinvoiceautoalways')
4773 || $conf->exists('emailinvoiceauto') && ! @invoicing_list
4774 || ( $conf->exists('emailinvoiceonly') && ! @invoicing_list ) ) {
4775 push @invoicing_list, $self->all_emails;
4778 my $email = ($conf->exists('business-onlinepayment-email-override'))
4779 ? $conf->config('business-onlinepayment-email-override')
4780 : $invoicing_list[0];
4784 if ( $namespace eq 'Business::OnlinePayment' && $options{method} eq 'CC' ) {
4786 $content{card_number} = $options{payinfo};
4787 $paydate = exists($options{'paydate'})
4788 ? $options{'paydate'}
4790 $paydate =~ /^\d{2}(\d{2})[\/\-](\d+)[\/\-]\d+$/;
4791 $content{expiration} = "$2/$1";
4793 my $paycvv = exists($options{'paycvv'})
4794 ? $options{'paycvv'}
4796 $content{cvv2} = $paycvv
4799 my $paystart_month = exists($options{'paystart_month'})
4800 ? $options{'paystart_month'}
4801 : $self->paystart_month;
4803 my $paystart_year = exists($options{'paystart_year'})
4804 ? $options{'paystart_year'}
4805 : $self->paystart_year;
4807 $content{card_start} = "$paystart_month/$paystart_year"
4808 if $paystart_month && $paystart_year;
4810 my $payissue = exists($options{'payissue'})
4811 ? $options{'payissue'}
4813 $content{issue_number} = $payissue if $payissue;
4815 if ( $self->_bop_recurring_billing( 'payinfo' => $options{'payinfo'},
4816 'trans_is_recur' => $trans_is_recur,
4820 $content{recurring_billing} = 'YES';
4821 $content{acct_code} = 'rebill'
4822 if $conf->exists('credit_card-recurring_billing_acct_code');
4825 } elsif ( $namespace eq 'Business::OnlinePayment' && $options{method} eq 'ECHECK' ){
4826 ( $content{account_number}, $content{routing_code} ) =
4827 split('@', $options{payinfo});
4828 $content{bank_name} = $options{payname};
4829 $content{bank_state} = exists($options{'paystate'})
4830 ? $options{'paystate'}
4831 : $self->getfield('paystate');
4832 $content{account_type} = exists($options{'paytype'})
4833 ? uc($options{'paytype'}) || 'CHECKING'
4834 : uc($self->getfield('paytype')) || 'CHECKING';
4835 $content{customer_org} = $self->company ? 'B' : 'I';
4836 $content{state_id} = exists($options{'stateid'})
4837 ? $options{'stateid'}
4838 : $self->getfield('stateid');
4839 $content{state_id_state} = exists($options{'stateid_state'})
4840 ? $options{'stateid_state'}
4841 : $self->getfield('stateid_state');
4842 $content{customer_ssn} = exists($options{'ss'})
4845 } elsif ( $namespace eq 'Business::OnlinePayment' && $options{method} eq 'LEC' ) {
4846 $content{phone} = $options{payinfo};
4847 } elsif ( $namespace eq 'Business::OnlineThirdPartyPayment' ) {
4854 # run transaction(s)
4857 my $balance = exists( $options{'balance'} )
4858 ? $options{'balance'}
4861 $self->select_for_update; #mutex ... just until we get our pending record in
4863 #the checks here are intended to catch concurrent payments
4864 #double-form-submission prevention is taken care of in cust_pay_pending::check
4867 return "The customer's balance has changed; $options{method} transaction aborted."
4868 if $self->balance < $balance;
4869 #&& $self->balance < $options{amount}; #might as well anyway?
4871 #also check and make sure there aren't *other* pending payments for this cust
4873 my @pending = qsearch('cust_pay_pending', {
4874 'custnum' => $self->custnum,
4875 'status' => { op=>'!=', value=>'done' }
4877 return "A payment is already being processed for this customer (".
4878 join(', ', map 'paypendingnum '. $_->paypendingnum, @pending ).
4879 "); $options{method} transaction aborted."
4880 if scalar(@pending);
4882 #okay, good to go, if we're a duplicate, cust_pay_pending will kick us out
4884 my $cust_pay_pending = new FS::cust_pay_pending {
4885 'custnum' => $self->custnum,
4886 #'invnum' => $options{'invnum'},
4887 'paid' => $options{amount},
4889 'payby' => $bop_method2payby{$options{method}},
4890 'payinfo' => $options{payinfo},
4891 'paydate' => $paydate,
4892 'recurring_billing' => $content{recurring_billing},
4894 'gatewaynum' => $payment_gateway->gatewaynum || '',
4895 'session_id' => $options{session_id} || '',
4896 'jobnum' => $options{depend_jobnum} || '',
4898 $cust_pay_pending->payunique( $options{payunique} )
4899 if defined($options{payunique}) && length($options{payunique});
4900 my $cpp_new_err = $cust_pay_pending->insert; #mutex lost when this is inserted
4901 return $cpp_new_err if $cpp_new_err;
4903 my( $action1, $action2 ) =
4904 split( /\s*\,\s*/, $payment_gateway->gateway_action );
4906 my $transaction = new $namespace( $payment_gateway->gateway_module,
4907 $self->_bop_options(\%options),
4910 $transaction->content(
4911 'type' => $options{method},
4912 $self->_bop_auth(\%options),
4913 'action' => $action1,
4914 'description' => $options{'description'},
4915 'amount' => $options{amount},
4916 #'invoice_number' => $options{'invnum'},
4917 'customer_id' => $self->custnum,
4919 'reference' => $cust_pay_pending->paypendingnum, #for now
4924 $cust_pay_pending->status('pending');
4925 my $cpp_pending_err = $cust_pay_pending->replace;
4926 return $cpp_pending_err if $cpp_pending_err;
4929 my $BOP_TESTING = 0;
4930 my $BOP_TESTING_SUCCESS = 1;
4932 unless ( $BOP_TESTING ) {
4933 $transaction->submit();
4935 if ( $BOP_TESTING_SUCCESS ) {
4936 $transaction->is_success(1);
4937 $transaction->authorization('fake auth');
4939 $transaction->is_success(0);
4940 $transaction->error_message('fake failure');
4944 if ( $transaction->is_success() && $namespace eq 'Business::OnlineThirdPartyPayment' ) {
4946 return { reference => $cust_pay_pending->paypendingnum,
4947 map { $_ => $transaction->$_ } qw ( popup_url collectitems ) };
4949 } elsif ( $transaction->is_success() && $action2 ) {
4951 $cust_pay_pending->status('authorized');
4952 my $cpp_authorized_err = $cust_pay_pending->replace;
4953 return $cpp_authorized_err if $cpp_authorized_err;
4955 my $auth = $transaction->authorization;
4956 my $ordernum = $transaction->can('order_number')
4957 ? $transaction->order_number
4961 new Business::OnlinePayment( $payment_gateway->gateway_module,
4962 $self->_bop_options(\%options),
4967 type => $options{method},
4969 $self->_bop_auth(\%options),
4970 order_number => $ordernum,
4971 amount => $options{amount},
4972 authorization => $auth,
4973 description => $options{'description'},
4976 foreach my $field (qw( authorization_source_code returned_ACI
4977 transaction_identifier validation_code
4978 transaction_sequence_num local_transaction_date
4979 local_transaction_time AVS_result_code )) {
4980 $capture{$field} = $transaction->$field() if $transaction->can($field);
4983 $capture->content( %capture );
4987 unless ( $capture->is_success ) {
4988 my $e = "Authorization successful but capture failed, custnum #".
4989 $self->custnum. ': '. $capture->result_code.
4990 ": ". $capture->error_message;
4998 # remove paycvv after initial transaction
5001 #false laziness w/misc/process/payment.cgi - check both to make sure working
5003 if ( defined $self->dbdef_table->column('paycvv')
5004 && length($self->paycvv)
5005 && ! grep { $_ eq cardtype($options{payinfo}) } $conf->config('cvv-save')
5007 my $error = $self->remove_cvv;
5009 warn "WARNING: error removing cvv: $error\n";
5017 $self->_realtime_bop_result( $cust_pay_pending, $transaction, %options );
5029 if (ref($_[0]) eq 'HASH') {
5030 %options = %{$_[0]};
5032 my ( $method, $amount ) = ( shift, shift );
5034 $options{method} = $method;
5035 $options{amount} = $amount;
5038 if ( $options{'fake_failure'} ) {
5039 return "Error: No error; test failure requested with fake_failure";
5043 #if ( $payment_gateway->gatewaynum ) { # agent override
5044 # $paybatch = $payment_gateway->gatewaynum. '-';
5047 #$paybatch .= "$processor:". $transaction->authorization;
5049 #$paybatch .= ':'. $transaction->order_number
5050 # if $transaction->can('order_number')
5051 # && length($transaction->order_number);
5053 my $paybatch = 'FakeProcessor:54:32';
5055 my $cust_pay = new FS::cust_pay ( {
5056 'custnum' => $self->custnum,
5057 'invnum' => $options{'invnum'},
5058 'paid' => $options{amount},
5060 'payby' => $bop_method2payby{$options{method}},
5061 #'payinfo' => $payinfo,
5062 'payinfo' => '4111111111111111',
5063 'paybatch' => $paybatch,
5064 #'paydate' => $paydate,
5065 'paydate' => '2012-05-01',
5067 $cust_pay->payunique( $options{payunique} ) if length($options{payunique});
5069 my $error = $cust_pay->insert($options{'manual'} ? ( 'manual' => 1 ) : () );
5072 $cust_pay->invnum(''); #try again with no specific invnum
5073 my $error2 = $cust_pay->insert( $options{'manual'} ?
5074 ( 'manual' => 1 ) : ()
5077 # gah, even with transactions.
5078 my $e = 'WARNING: Card/ACH debited but database not updated - '.
5079 "error inserting (fake!) payment: $error2".
5080 " (previously tried insert with invnum #$options{'invnum'}" .
5087 if ( $options{'paynum_ref'} ) {
5088 ${ $options{'paynum_ref'} } = $cust_pay->paynum;
5091 return ''; #no error
5096 # item _realtime_bop_result CUST_PAY_PENDING, BOP_OBJECT [ OPTION => VALUE ... ]
5098 # Wraps up processing of a realtime credit card, ACH (electronic check) or
5099 # phone bill transaction.
5101 sub _realtime_bop_result {
5102 my( $self, $cust_pay_pending, $transaction, %options ) = @_;
5104 warn "$me _realtime_bop_result: pending transaction ".
5105 $cust_pay_pending->paypendingnum. "\n";
5106 warn " $_ => $options{$_}\n" foreach keys %options;
5109 my $payment_gateway = $options{payment_gateway}
5110 or return "no payment gateway in arguments to _realtime_bop_result";
5112 $cust_pay_pending->status($transaction->is_success() ? 'captured' : 'declined');
5113 my $cpp_captured_err = $cust_pay_pending->replace;
5114 return $cpp_captured_err if $cpp_captured_err;
5116 if ( $transaction->is_success() ) {
5119 if ( $payment_gateway->gatewaynum ) { # agent override
5120 $paybatch = $payment_gateway->gatewaynum. '-';
5123 $paybatch .= $payment_gateway->gateway_module. ":".
5124 $transaction->authorization;
5126 $paybatch .= ':'. $transaction->order_number
5127 if $transaction->can('order_number')
5128 && length($transaction->order_number);
5130 my $cust_pay = new FS::cust_pay ( {
5131 'custnum' => $self->custnum,
5132 'invnum' => $options{'invnum'},
5133 'paid' => $cust_pay_pending->paid,
5135 'payby' => $cust_pay_pending->payby,
5136 #'payinfo' => $payinfo,
5137 'paybatch' => $paybatch,
5138 'paydate' => $cust_pay_pending->paydate,
5140 #doesn't hurt to know, even though the dup check is in cust_pay_pending now
5141 $cust_pay->payunique( $options{payunique} )
5142 if defined($options{payunique}) && length($options{payunique});
5144 my $oldAutoCommit = $FS::UID::AutoCommit;
5145 local $FS::UID::AutoCommit = 0;
5148 #start a transaction, insert the cust_pay and set cust_pay_pending.status to done in a single transction
5150 my $error = $cust_pay->insert($options{'manual'} ? ( 'manual' => 1 ) : () );
5153 $cust_pay->invnum(''); #try again with no specific invnum
5154 my $error2 = $cust_pay->insert( $options{'manual'} ?
5155 ( 'manual' => 1 ) : ()
5158 # gah. but at least we have a record of the state we had to abort in
5159 # from cust_pay_pending now.
5160 my $e = "WARNING: $options{method} captured but payment not recorded -".
5161 " error inserting payment (". $payment_gateway->gateway_module.
5163 " (previously tried insert with invnum #$options{'invnum'}" .
5164 ": $error ) - pending payment saved as paypendingnum ".
5165 $cust_pay_pending->paypendingnum. "\n";
5171 my $jobnum = $cust_pay_pending->jobnum;
5173 my $placeholder = qsearchs( 'queue', { 'jobnum' => $jobnum } );
5175 unless ( $placeholder ) {
5176 $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
5177 my $e = "WARNING: $options{method} captured but job $jobnum not ".
5178 "found for paypendingnum ". $cust_pay_pending->paypendingnum. "\n";
5183 $error = $placeholder->delete;
5186 $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
5187 my $e = "WARNING: $options{method} captured but could not delete ".
5188 "job $jobnum for paypendingnum ".
5189 $cust_pay_pending->paypendingnum. ": $error\n";
5196 if ( $options{'paynum_ref'} ) {
5197 ${ $options{'paynum_ref'} } = $cust_pay->paynum;
5200 $cust_pay_pending->status('done');
5201 $cust_pay_pending->statustext('captured');
5202 $cust_pay_pending->paynum($cust_pay->paynum);
5203 my $cpp_done_err = $cust_pay_pending->replace;
5205 if ( $cpp_done_err ) {
5207 $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
5208 my $e = "WARNING: $options{method} captured but payment not recorded - ".
5209 "error updating status for paypendingnum ".
5210 $cust_pay_pending->paypendingnum. ": $cpp_done_err \n";
5216 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
5217 return ''; #no error
5223 my $perror = $payment_gateway->gateway_module. " error: ".
5224 $transaction->error_message;
5226 my $jobnum = $cust_pay_pending->jobnum;
5228 my $placeholder = qsearchs( 'queue', { 'jobnum' => $jobnum } );
5230 if ( $placeholder ) {
5231 my $error = $placeholder->depended_delete;
5232 $error ||= $placeholder->delete;
5233 warn "error removing provisioning jobs after declined paypendingnum ".
5234 $cust_pay_pending->paypendingnum. "\n";
5236 my $e = "error finding job $jobnum for declined paypendingnum ".
5237 $cust_pay_pending->paypendingnum. "\n";
5243 unless ( $transaction->error_message ) {
5246 if ( $transaction->can('response_page') ) {
5248 'page' => ( $transaction->can('response_page')
5249 ? $transaction->response_page
5252 'code' => ( $transaction->can('response_code')
5253 ? $transaction->response_code
5256 'headers' => ( $transaction->can('response_headers')
5257 ? $transaction->response_headers
5263 "No additional debugging information available for ".
5264 $payment_gateway->gateway_module;
5267 $perror .= "No error_message returned from ".
5268 $payment_gateway->gateway_module. " -- ".
5269 ( ref($t_response) ? Dumper($t_response) : $t_response );
5273 if ( !$options{'quiet'} && !$realtime_bop_decline_quiet
5274 && $conf->exists('emaildecline')
5275 && grep { $_ ne 'POST' } $self->invoicing_list
5276 && ! grep { $transaction->error_message =~ /$_/ }
5277 $conf->config('emaildecline-exclude')
5279 my @templ = $conf->config('declinetemplate');
5280 my $template = new Text::Template (
5282 SOURCE => [ map "$_\n", @templ ],
5283 ) or return "($perror) can't create template: $Text::Template::ERROR";
5284 $template->compile()
5285 or return "($perror) can't compile template: $Text::Template::ERROR";
5287 my $templ_hash = { error => $transaction->error_message };
5289 my $error = send_email(
5290 'from' => $conf->config('invoice_from', $self->agentnum ),
5291 'to' => [ grep { $_ ne 'POST' } $self->invoicing_list ],
5292 'subject' => 'Your payment could not be processed',
5293 'body' => [ $template->fill_in(HASH => $templ_hash) ],
5296 $perror .= " (also received error sending decline notification: $error)"
5301 $cust_pay_pending->status('done');
5302 $cust_pay_pending->statustext("declined: $perror");
5303 my $cpp_done_err = $cust_pay_pending->replace;
5304 if ( $cpp_done_err ) {
5305 my $e = "WARNING: $options{method} declined but pending payment not ".
5306 "resolved - error updating status for paypendingnum ".
5307 $cust_pay_pending->paypendingnum. ": $cpp_done_err \n";
5309 $perror = "$e ($perror)";
5317 =item realtime_botpp_capture CUST_PAY_PENDING [ OPTION => VALUE ... ]
5319 Verifies successful third party processing of a realtime credit card,
5320 ACH (electronic check) or phone bill transaction via a
5321 Business::OnlineThirdPartyPayment realtime gateway. See
5322 L<http://420.am/business-onlinethirdpartypayment> for supported gateways.
5324 Available options are: I<description>, I<invnum>, I<quiet>, I<paynum_ref>, I<payunique>
5326 The additional options I<payname>, I<city>, I<state>,
5327 I<zip>, I<payinfo> and I<paydate> are also available. Any of these options,
5328 if set, will override the value from the customer record.
5330 I<description> is a free-text field passed to the gateway. It defaults to
5331 "Internet services".
5333 If an I<invnum> is specified, this payment (if successful) is applied to the
5334 specified invoice. If you don't specify an I<invnum> you might want to
5335 call the B<apply_payments> method.
5337 I<quiet> can be set true to surpress email decline notices.
5339 I<paynum_ref> can be set to a scalar reference. It will be filled in with the
5340 resulting paynum, if any.
5342 I<payunique> is a unique identifier for this payment.
5344 Returns a hashref containing elements bill_error (which will be undefined
5345 upon success) and session_id of any associated session.
5349 sub realtime_botpp_capture {
5350 my( $self, $cust_pay_pending, %options ) = @_;
5352 warn "$me realtime_botpp_capture: pending transaction $cust_pay_pending\n";
5353 warn " $_ => $options{$_}\n" foreach keys %options;
5356 eval "use Business::OnlineThirdPartyPayment";
5360 # select the gateway
5363 my $method = FS::payby->payby2bop($cust_pay_pending->payby);
5365 my $payment_gateway = $cust_pay_pending->gatewaynum
5366 ? qsearchs( 'payment_gateway',
5367 { gatewaynum => $cust_pay_pending->gatewaynum }
5369 : $self->agent->payment_gateway( 'method' => $method,
5370 # 'invnum' => $cust_pay_pending->invnum,
5371 # 'payinfo' => $cust_pay_pending->payinfo,
5374 $options{payment_gateway} = $payment_gateway; # for the helper subs
5380 my @invoicing_list = $self->invoicing_list_emailonly;
5381 if ( $conf->exists('emailinvoiceautoalways')
5382 || $conf->exists('emailinvoiceauto') && ! @invoicing_list
5383 || ( $conf->exists('emailinvoiceonly') && ! @invoicing_list ) ) {
5384 push @invoicing_list, $self->all_emails;
5387 my $email = ($conf->exists('business-onlinepayment-email-override'))
5388 ? $conf->config('business-onlinepayment-email-override')
5389 : $invoicing_list[0];
5393 $content{email_customer} =
5394 ( $conf->exists('business-onlinepayment-email_customer')
5395 || $conf->exists('business-onlinepayment-email-override') );
5398 # run transaction(s)
5402 new Business::OnlineThirdPartyPayment( $payment_gateway->gateway_module,
5403 $self->_bop_options(\%options),
5406 $transaction->reference({ %options });
5408 $transaction->content(
5410 $self->_bop_auth(\%options),
5411 'action' => 'Post Authorization',
5412 'description' => $options{'description'},
5413 'amount' => $cust_pay_pending->paid,
5414 #'invoice_number' => $options{'invnum'},
5415 'customer_id' => $self->custnum,
5416 'referer' => 'http://cleanwhisker.420.am/',
5417 'reference' => $cust_pay_pending->paypendingnum,
5419 'phone' => $self->daytime || $self->night,
5421 # plus whatever is required for bogus capture avoidance
5424 $transaction->submit();
5427 $self->_realtime_bop_result( $cust_pay_pending, $transaction, %options );
5430 bill_error => $error,
5431 session_id => $cust_pay_pending->session_id,
5436 =item default_payment_gateway DEPRECATED -- use agent->payment_gateway
5440 sub default_payment_gateway {
5441 my( $self, $method ) = @_;
5443 die "Real-time processing not enabled\n"
5444 unless $conf->exists('business-onlinepayment');
5446 #warn "default_payment_gateway deprecated -- use agent->payment_gateway\n";
5449 my $bop_config = 'business-onlinepayment';
5450 $bop_config .= '-ach'
5451 if $method =~ /^(ECHECK|CHEK)$/ && $conf->exists($bop_config. '-ach');
5452 my ( $processor, $login, $password, $action, @bop_options ) =
5453 $conf->config($bop_config);
5454 $action ||= 'normal authorization';
5455 pop @bop_options if scalar(@bop_options) % 2 && $bop_options[-1] =~ /^\s*$/;
5456 die "No real-time processor is enabled - ".
5457 "did you set the business-onlinepayment configuration value?\n"
5460 ( $processor, $login, $password, $action, @bop_options )
5465 Removes the I<paycvv> field from the database directly.
5467 If there is an error, returns the error, otherwise returns false.
5473 my $sth = dbh->prepare("UPDATE cust_main SET paycvv = '' WHERE custnum = ?")
5474 or return dbh->errstr;
5475 $sth->execute($self->custnum)
5476 or return $sth->errstr;
5481 =item _new_realtime_refund_bop METHOD [ OPTION => VALUE ... ]
5483 Refunds a realtime credit card, ACH (electronic check) or phone bill transaction
5484 via a Business::OnlinePayment realtime gateway. See
5485 L<http://420.am/business-onlinepayment> for supported gateways.
5487 Available methods are: I<CC>, I<ECHECK> and I<LEC>
5489 Available options are: I<amount>, I<reason>, I<paynum>, I<paydate>
5491 Most gateways require a reference to an original payment transaction to refund,
5492 so you probably need to specify a I<paynum>.
5494 I<amount> defaults to the original amount of the payment if not specified.
5496 I<reason> specifies a reason for the refund.
5498 I<paydate> specifies the expiration date for a credit card overriding the
5499 value from the customer record or the payment record. Specified as yyyy-mm-dd
5501 Implementation note: If I<amount> is unspecified or equal to the amount of the
5502 orignal payment, first an attempt is made to "void" the transaction via
5503 the gateway (to cancel a not-yet settled transaction) and then if that fails,
5504 the normal attempt is made to "refund" ("credit") the transaction via the
5505 gateway is attempted.
5507 #The additional options I<payname>, I<address1>, I<address2>, I<city>, I<state>,
5508 #I<zip>, I<payinfo> and I<paydate> are also available. Any of these options,
5509 #if set, will override the value from the customer record.
5511 #If an I<invnum> is specified, this payment (if successful) is applied to the
5512 #specified invoice. If you don't specify an I<invnum> you might want to
5513 #call the B<apply_payments> method.
5517 #some false laziness w/realtime_bop, not enough to make it worth merging
5518 #but some useful small subs should be pulled out
5519 sub _new_realtime_refund_bop {
5523 if (ref($_[0]) ne 'HASH') {
5524 %options = %{$_[0]};
5528 $options{method} = $method;
5532 warn "$me realtime_refund_bop (new): $options{method} refund\n";
5533 warn " $_ => $options{$_}\n" foreach keys %options;
5537 # look up the original payment and optionally a gateway for that payment
5541 my $amount = $options{'amount'};
5543 my( $processor, $login, $password, @bop_options, $namespace ) ;
5544 my( $auth, $order_number ) = ( '', '', '' );
5546 if ( $options{'paynum'} ) {
5548 warn " paynum: $options{paynum}\n" if $DEBUG > 1;
5549 $cust_pay = qsearchs('cust_pay', { paynum=>$options{'paynum'} } )
5550 or return "Unknown paynum $options{'paynum'}";
5551 $amount ||= $cust_pay->paid;
5553 $cust_pay->paybatch =~ /^((\d+)\-)?(\w+):\s*([\w\-\/ ]*)(:([\w\-]+))?$/
5554 or return "Can't parse paybatch for paynum $options{'paynum'}: ".
5555 $cust_pay->paybatch;
5556 my $gatewaynum = '';
5557 ( $gatewaynum, $processor, $auth, $order_number ) = ( $2, $3, $4, $6 );
5559 if ( $gatewaynum ) { #gateway for the payment to be refunded
5561 my $payment_gateway =
5562 qsearchs('payment_gateway', { 'gatewaynum' => $gatewaynum } );
5563 die "payment gateway $gatewaynum not found"
5564 unless $payment_gateway;
5566 $processor = $payment_gateway->gateway_module;
5567 $login = $payment_gateway->gateway_username;
5568 $password = $payment_gateway->gateway_password;
5569 $namespace = $payment_gateway->gateway_namespace;
5570 @bop_options = $payment_gateway->options;
5572 } else { #try the default gateway
5575 my $payment_gateway =
5576 $self->agent->payment_gateway('method' => $options{method});
5578 ( $conf_processor, $login, $password, $namespace ) =
5579 map { my $method = "gateway_$_"; $payment_gateway->$method }
5580 qw( module username password namespace );
5582 @bop_options = $payment_gateway->gatewaynum
5583 ? $payment_gateway->options
5584 : @{ $payment_gateway->get('options') };
5586 return "processor of payment $options{'paynum'} $processor does not".
5587 " match default processor $conf_processor"
5588 unless $processor eq $conf_processor;
5593 } else { # didn't specify a paynum, so look for agent gateway overrides
5594 # like a normal transaction
5596 my $payment_gateway =
5597 $self->agent->payment_gateway( 'method' => $options{method},
5598 #'payinfo' => $payinfo,
5600 my( $processor, $login, $password, $namespace ) =
5601 map { my $method = "gateway_$_"; $payment_gateway->$method }
5602 qw( module username password namespace );
5604 my @bop_options = $payment_gateway->gatewaynum
5605 ? $payment_gateway->options
5606 : @{ $payment_gateway->get('options') };
5609 return "neither amount nor paynum specified" unless $amount;
5611 eval "use $namespace";
5615 'type' => $options{method},
5617 'password' => $password,
5618 'order_number' => $order_number,
5619 'amount' => $amount,
5620 'referer' => 'http://cleanwhisker.420.am/', #XXX fix referer :/
5622 $content{authorization} = $auth
5623 if length($auth); #echeck/ACH transactions have an order # but no auth
5624 #(at least with authorize.net)
5626 my $disable_void_after;
5627 if ($conf->exists('disable_void_after')
5628 && $conf->config('disable_void_after') =~ /^(\d+)$/) {
5629 $disable_void_after = $1;
5632 #first try void if applicable
5633 if ( $cust_pay && $cust_pay->paid == $amount
5635 ( not defined($disable_void_after) )
5636 || ( time < ($cust_pay->_date + $disable_void_after ) )
5639 warn " attempting void\n" if $DEBUG > 1;
5640 my $void = new Business::OnlinePayment( $processor, @bop_options );
5641 $void->content( 'action' => 'void', %content );
5643 if ( $void->is_success ) {
5644 my $error = $cust_pay->void($options{'reason'});
5646 # gah, even with transactions.
5647 my $e = 'WARNING: Card/ACH voided but database not updated - '.
5648 "error voiding payment: $error";
5652 warn " void successful\n" if $DEBUG > 1;
5657 warn " void unsuccessful, trying refund\n"
5661 my $address = $self->address1;
5662 $address .= ", ". $self->address2 if $self->address2;
5664 my($payname, $payfirst, $paylast);
5665 if ( $self->payname && $options{method} ne 'ECHECK' ) {
5666 $payname = $self->payname;
5667 $payname =~ /^\s*([\w \,\.\-\']*)?\s+([\w\,\.\-\']+)\s*$/
5668 or return "Illegal payname $payname";
5669 ($payfirst, $paylast) = ($1, $2);
5671 $payfirst = $self->getfield('first');
5672 $paylast = $self->getfield('last');
5673 $payname = "$payfirst $paylast";
5676 my @invoicing_list = $self->invoicing_list_emailonly;
5677 if ( $conf->exists('emailinvoiceautoalways')
5678 || $conf->exists('emailinvoiceauto') && ! @invoicing_list
5679 || ( $conf->exists('emailinvoiceonly') && ! @invoicing_list ) ) {
5680 push @invoicing_list, $self->all_emails;
5683 my $email = ($conf->exists('business-onlinepayment-email-override'))
5684 ? $conf->config('business-onlinepayment-email-override')
5685 : $invoicing_list[0];
5687 my $payip = exists($options{'payip'})
5690 $content{customer_ip} = $payip
5694 if ( $options{method} eq 'CC' ) {
5697 $content{card_number} = $payinfo = $cust_pay->payinfo;
5698 (exists($options{'paydate'}) ? $options{'paydate'} : $cust_pay->paydate)
5699 =~ /^\d{2}(\d{2})[\/\-](\d+)[\/\-]\d+$/ &&
5700 ($content{expiration} = "$2/$1"); # where available
5702 $content{card_number} = $payinfo = $self->payinfo;
5703 (exists($options{'paydate'}) ? $options{'paydate'} : $self->paydate)
5704 =~ /^\d{2}(\d{2})[\/\-](\d+)[\/\-]\d+$/;
5705 $content{expiration} = "$2/$1";
5708 } elsif ( $options{method} eq 'ECHECK' ) {
5711 $payinfo = $cust_pay->payinfo;
5713 $payinfo = $self->payinfo;
5715 ( $content{account_number}, $content{routing_code} )= split('@', $payinfo );
5716 $content{bank_name} = $self->payname;
5717 $content{account_type} = 'CHECKING';
5718 $content{account_name} = $payname;
5719 $content{customer_org} = $self->company ? 'B' : 'I';
5720 $content{customer_ssn} = $self->ss;
5721 } elsif ( $options{method} eq 'LEC' ) {
5722 $content{phone} = $payinfo = $self->payinfo;
5726 my $refund = new Business::OnlinePayment( $processor, @bop_options );
5727 my %sub_content = $refund->content(
5728 'action' => 'credit',
5729 'customer_id' => $self->custnum,
5730 'last_name' => $paylast,
5731 'first_name' => $payfirst,
5733 'address' => $address,
5734 'city' => $self->city,
5735 'state' => $self->state,
5736 'zip' => $self->zip,
5737 'country' => $self->country,
5739 'phone' => $self->daytime || $self->night,
5742 warn join('', map { " $_ => $sub_content{$_}\n" } keys %sub_content )
5746 return "$processor error: ". $refund->error_message
5747 unless $refund->is_success();
5749 my $paybatch = "$processor:". $refund->authorization;
5750 $paybatch .= ':'. $refund->order_number
5751 if $refund->can('order_number') && $refund->order_number;
5753 while ( $cust_pay && $cust_pay->unapplied < $amount ) {
5754 my @cust_bill_pay = $cust_pay->cust_bill_pay;
5755 last unless @cust_bill_pay;
5756 my $cust_bill_pay = pop @cust_bill_pay;
5757 my $error = $cust_bill_pay->delete;
5761 my $cust_refund = new FS::cust_refund ( {
5762 'custnum' => $self->custnum,
5763 'paynum' => $options{'paynum'},
5764 'refund' => $amount,
5766 'payby' => $bop_method2payby{$options{method}},
5767 'payinfo' => $payinfo,
5768 'paybatch' => $paybatch,
5769 'reason' => $options{'reason'} || 'card or ACH refund',
5771 my $error = $cust_refund->insert;
5773 $cust_refund->paynum(''); #try again with no specific paynum
5774 my $error2 = $cust_refund->insert;
5776 # gah, even with transactions.
5777 my $e = 'WARNING: Card/ACH refunded but database not updated - '.
5778 "error inserting refund ($processor): $error2".
5779 " (previously tried insert with paynum #$options{'paynum'}" .
5790 =item batch_card OPTION => VALUE...
5792 Adds a payment for this invoice to the pending credit card batch (see
5793 L<FS::cust_pay_batch>), or, if the B<realtime> option is set to a true value,
5794 runs the payment using a realtime gateway.
5799 my ($self, %options) = @_;
5802 if (exists($options{amount})) {
5803 $amount = $options{amount};
5805 $amount = sprintf("%.2f", $self->balance - $self->in_transit_payments);
5807 return '' unless $amount > 0;
5809 my $invnum = delete $options{invnum};
5810 my $payby = $options{invnum} || $self->payby; #dubious
5812 if ($options{'realtime'}) {
5813 return $self->realtime_bop( FS::payby->payby2bop($self->payby),
5819 my $oldAutoCommit = $FS::UID::AutoCommit;
5820 local $FS::UID::AutoCommit = 0;
5823 #this needs to handle mysql as well as Pg, like svc_acct.pm
5824 #(make it into a common function if folks need to do batching with mysql)
5825 $dbh->do("LOCK TABLE pay_batch IN SHARE ROW EXCLUSIVE MODE")
5826 or return "Cannot lock pay_batch: " . $dbh->errstr;
5830 'payby' => FS::payby->payby2payment($payby),
5833 my $pay_batch = qsearchs( 'pay_batch', \%pay_batch );
5835 unless ( $pay_batch ) {
5836 $pay_batch = new FS::pay_batch \%pay_batch;
5837 my $error = $pay_batch->insert;
5839 $dbh->rollback if $oldAutoCommit;
5840 die "error creating new batch: $error\n";
5844 my $old_cust_pay_batch = qsearchs('cust_pay_batch', {
5845 'batchnum' => $pay_batch->batchnum,
5846 'custnum' => $self->custnum,
5849 foreach (qw( address1 address2 city state zip country payby payinfo paydate
5851 $options{$_} = '' unless exists($options{$_});
5854 my $cust_pay_batch = new FS::cust_pay_batch ( {
5855 'batchnum' => $pay_batch->batchnum,
5856 'invnum' => $invnum || 0, # is there a better value?
5857 # this field should be
5859 # cust_bill_pay_batch now
5860 'custnum' => $self->custnum,
5861 'last' => $self->getfield('last'),
5862 'first' => $self->getfield('first'),
5863 'address1' => $options{address1} || $self->address1,
5864 'address2' => $options{address2} || $self->address2,
5865 'city' => $options{city} || $self->city,
5866 'state' => $options{state} || $self->state,
5867 'zip' => $options{zip} || $self->zip,
5868 'country' => $options{country} || $self->country,
5869 'payby' => $options{payby} || $self->payby,
5870 'payinfo' => $options{payinfo} || $self->payinfo,
5871 'exp' => $options{paydate} || $self->paydate,
5872 'payname' => $options{payname} || $self->payname,
5873 'amount' => $amount, # consolidating
5876 $cust_pay_batch->paybatchnum($old_cust_pay_batch->paybatchnum)
5877 if $old_cust_pay_batch;
5880 if ($old_cust_pay_batch) {
5881 $error = $cust_pay_batch->replace($old_cust_pay_batch)
5883 $error = $cust_pay_batch->insert;
5887 $dbh->rollback if $oldAutoCommit;
5891 my $unapplied = $self->total_unapplied_credits
5892 + $self->total_unapplied_payments
5893 + $self->in_transit_payments;
5894 foreach my $cust_bill ($self->open_cust_bill) {
5895 #$dbh->commit or die $dbh->errstr if $oldAutoCommit;
5896 my $cust_bill_pay_batch = new FS::cust_bill_pay_batch {
5897 'invnum' => $cust_bill->invnum,
5898 'paybatchnum' => $cust_pay_batch->paybatchnum,
5899 'amount' => $cust_bill->owed,
5902 if ($unapplied >= $cust_bill_pay_batch->amount){
5903 $unapplied -= $cust_bill_pay_batch->amount;
5906 $cust_bill_pay_batch->amount(sprintf ( "%.2f",
5907 $cust_bill_pay_batch->amount - $unapplied )); $unapplied = 0;
5909 $error = $cust_bill_pay_batch->insert;
5911 $dbh->rollback if $oldAutoCommit;
5916 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
5920 =item apply_payments_and_credits
5922 Applies unapplied payments and credits.
5924 In most cases, this new method should be used in place of sequential
5925 apply_payments and apply_credits methods.
5927 If there is an error, returns the error, otherwise returns false.
5931 sub apply_payments_and_credits {
5934 local $SIG{HUP} = 'IGNORE';
5935 local $SIG{INT} = 'IGNORE';
5936 local $SIG{QUIT} = 'IGNORE';
5937 local $SIG{TERM} = 'IGNORE';
5938 local $SIG{TSTP} = 'IGNORE';
5939 local $SIG{PIPE} = 'IGNORE';
5941 my $oldAutoCommit = $FS::UID::AutoCommit;
5942 local $FS::UID::AutoCommit = 0;
5945 $self->select_for_update; #mutex
5947 foreach my $cust_bill ( $self->open_cust_bill ) {
5948 my $error = $cust_bill->apply_payments_and_credits;
5950 $dbh->rollback if $oldAutoCommit;
5951 return "Error applying: $error";
5955 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
5960 =item apply_credits OPTION => VALUE ...
5962 Applies (see L<FS::cust_credit_bill>) unapplied credits (see L<FS::cust_credit>)
5963 to outstanding invoice balances in chronological order (or reverse
5964 chronological order if the I<order> option is set to B<newest>) and returns the
5965 value of any remaining unapplied credits available for refund (see
5966 L<FS::cust_refund>).
5968 Dies if there is an error.
5976 local $SIG{HUP} = 'IGNORE';
5977 local $SIG{INT} = 'IGNORE';
5978 local $SIG{QUIT} = 'IGNORE';
5979 local $SIG{TERM} = 'IGNORE';
5980 local $SIG{TSTP} = 'IGNORE';
5981 local $SIG{PIPE} = 'IGNORE';
5983 my $oldAutoCommit = $FS::UID::AutoCommit;
5984 local $FS::UID::AutoCommit = 0;
5987 $self->select_for_update; #mutex
5989 unless ( $self->total_unapplied_credits ) {
5990 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
5994 my @credits = sort { $b->_date <=> $a->_date} (grep { $_->credited > 0 }
5995 qsearch('cust_credit', { 'custnum' => $self->custnum } ) );
5997 my @invoices = $self->open_cust_bill;
5998 @invoices = sort { $b->_date <=> $a->_date } @invoices
5999 if defined($opt{'order'}) && $opt{'order'} eq 'newest';
6002 foreach my $cust_bill ( @invoices ) {
6005 if ( !defined($credit) || $credit->credited == 0) {
6006 $credit = pop @credits or last;
6009 if ($cust_bill->owed >= $credit->credited) {
6010 $amount=$credit->credited;
6012 $amount=$cust_bill->owed;
6015 my $cust_credit_bill = new FS::cust_credit_bill ( {
6016 'crednum' => $credit->crednum,
6017 'invnum' => $cust_bill->invnum,
6018 'amount' => $amount,
6020 my $error = $cust_credit_bill->insert;
6022 $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
6026 redo if ($cust_bill->owed > 0);
6030 my $total_unapplied_credits = $self->total_unapplied_credits;
6032 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
6034 return $total_unapplied_credits;
6037 =item apply_payments
6039 Applies (see L<FS::cust_bill_pay>) unapplied payments (see L<FS::cust_pay>)
6040 to outstanding invoice balances in chronological order.
6042 #and returns the value of any remaining unapplied payments.
6044 Dies if there is an error.
6048 sub apply_payments {
6051 local $SIG{HUP} = 'IGNORE';
6052 local $SIG{INT} = 'IGNORE';
6053 local $SIG{QUIT} = 'IGNORE';
6054 local $SIG{TERM} = 'IGNORE';
6055 local $SIG{TSTP} = 'IGNORE';
6056 local $SIG{PIPE} = 'IGNORE';
6058 my $oldAutoCommit = $FS::UID::AutoCommit;
6059 local $FS::UID::AutoCommit = 0;
6062 $self->select_for_update; #mutex
6066 my @payments = sort { $b->_date <=> $a->_date }
6067 grep { $_->unapplied > 0 }
6070 my @invoices = sort { $a->_date <=> $b->_date}
6071 grep { $_->owed > 0 }
6076 foreach my $cust_bill ( @invoices ) {
6079 if ( !defined($payment) || $payment->unapplied == 0 ) {
6080 $payment = pop @payments or last;
6083 if ( $cust_bill->owed >= $payment->unapplied ) {
6084 $amount = $payment->unapplied;
6086 $amount = $cust_bill->owed;
6089 my $cust_bill_pay = new FS::cust_bill_pay ( {
6090 'paynum' => $payment->paynum,
6091 'invnum' => $cust_bill->invnum,
6092 'amount' => $amount,
6094 my $error = $cust_bill_pay->insert;
6096 $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
6100 redo if ( $cust_bill->owed > 0);
6104 my $total_unapplied_payments = $self->total_unapplied_payments;
6106 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
6108 return $total_unapplied_payments;
6113 Returns the total owed for this customer on all invoices
6114 (see L<FS::cust_bill/owed>).
6120 $self->total_owed_date(2145859200); #12/31/2037
6123 =item total_owed_date TIME
6125 Returns the total owed for this customer on all invoices with date earlier than
6126 TIME. TIME is specified as a UNIX timestamp; see L<perlfunc/"time">). Also
6127 see L<Time::Local> and L<Date::Parse> for conversion functions.
6131 sub total_owed_date {
6135 # my $custnum = $self->custnum;
6137 # my $owed_sql = FS::cust_bill->owed_sql;
6140 # SELECT SUM($owed_sql) FROM cust_bill
6141 # WHERE custnum = $custnum
6142 # AND _date <= $time
6145 # my $sth = dbh->prepare($sql) or die dbh->errstr;
6146 # $sth->execute() or die $sth->errstr;
6148 # return sprintf( '%.2f', $sth->fetchrow_arrayref->[0] );
6151 foreach my $cust_bill (
6152 grep { $_->_date <= $time }
6153 qsearch('cust_bill', { 'custnum' => $self->custnum, } )
6155 $total_bill += $cust_bill->owed;
6157 sprintf( "%.2f", $total_bill );
6163 Returns the total amount of all payments.
6170 $total += $_->paid foreach $self->cust_pay;
6171 sprintf( "%.2f", $total );
6174 =item total_unapplied_credits
6176 Returns the total outstanding credit (see L<FS::cust_credit>) for this
6177 customer. See L<FS::cust_credit/credited>.
6179 =item total_credited
6181 Old name for total_unapplied_credits. Don't use.
6185 sub total_credited {
6186 #carp "total_credited deprecated, use total_unapplied_credits";
6187 shift->total_unapplied_credits(@_);
6190 sub total_unapplied_credits {
6192 my $total_credit = 0;
6193 $total_credit += $_->credited foreach $self->cust_credit;
6194 sprintf( "%.2f", $total_credit );
6197 =item total_unapplied_payments
6199 Returns the total unapplied payments (see L<FS::cust_pay>) for this customer.
6200 See L<FS::cust_pay/unapplied>.
6204 sub total_unapplied_payments {
6206 my $total_unapplied = 0;
6207 $total_unapplied += $_->unapplied foreach $self->cust_pay;
6208 sprintf( "%.2f", $total_unapplied );
6211 =item total_unapplied_refunds
6213 Returns the total unrefunded refunds (see L<FS::cust_refund>) for this
6214 customer. See L<FS::cust_refund/unapplied>.
6218 sub total_unapplied_refunds {
6220 my $total_unapplied = 0;
6221 $total_unapplied += $_->unapplied foreach $self->cust_refund;
6222 sprintf( "%.2f", $total_unapplied );
6227 Returns the balance for this customer (total_owed plus total_unrefunded, minus
6228 total_unapplied_credits minus total_unapplied_payments).
6236 + $self->total_unapplied_refunds
6237 - $self->total_unapplied_credits
6238 - $self->total_unapplied_payments
6242 =item balance_date TIME
6244 Returns the balance for this customer, only considering invoices with date
6245 earlier than TIME (total_owed_date minus total_credited minus
6246 total_unapplied_payments). TIME is specified as a UNIX timestamp; see
6247 L<perlfunc/"time">). Also see L<Time::Local> and L<Date::Parse> for conversion
6256 $self->total_owed_date($time)
6257 + $self->total_unapplied_refunds
6258 - $self->total_unapplied_credits
6259 - $self->total_unapplied_payments
6263 =item in_transit_payments
6265 Returns the total of requests for payments for this customer pending in
6266 batches in transit to the bank. See L<FS::pay_batch> and L<FS::cust_pay_batch>
6270 sub in_transit_payments {
6272 my $in_transit_payments = 0;
6273 foreach my $pay_batch ( qsearch('pay_batch', {
6276 foreach my $cust_pay_batch ( qsearch('cust_pay_batch', {
6277 'batchnum' => $pay_batch->batchnum,
6278 'custnum' => $self->custnum,
6280 $in_transit_payments += $cust_pay_batch->amount;
6283 sprintf( "%.2f", $in_transit_payments );
6288 Returns a hash of useful information for making a payment.
6298 'CARD' (credit card - automatic), 'DCRD' (credit card - on-demand),
6299 'CHEK' (electronic check - automatic), 'DCHK' (electronic check - on-demand),
6300 'LECB' (Phone bill billing), 'BILL' (billing), or 'COMP' (free).
6304 For credit card transactions:
6316 For electronic check transactions:
6331 $return{balance} = $self->balance;
6333 $return{payname} = $self->payname
6334 || ( $self->first. ' '. $self->get('last') );
6336 $return{$_} = $self->get($_) for qw(address1 address2 city state zip);
6338 $return{payby} = $self->payby;
6339 $return{stateid_state} = $self->stateid_state;
6341 if ( $self->payby =~ /^(CARD|DCRD)$/ ) {
6342 $return{card_type} = cardtype($self->payinfo);
6343 $return{payinfo} = $self->paymask;
6345 @return{'month', 'year'} = $self->paydate_monthyear;
6349 if ( $self->payby =~ /^(CHEK|DCHK)$/ ) {
6350 my ($payinfo1, $payinfo2) = split '@', $self->paymask;
6351 $return{payinfo1} = $payinfo1;
6352 $return{payinfo2} = $payinfo2;
6353 $return{paytype} = $self->paytype;
6354 $return{paystate} = $self->paystate;
6358 #doubleclick protection
6360 $return{paybatch} = "webui-MyAccount-$_date-$$-". rand() * 2**32;
6366 =item paydate_monthyear
6368 Returns a two-element list consisting of the month and year of this customer's
6369 paydate (credit card expiration date for CARD customers)
6373 sub paydate_monthyear {
6375 if ( $self->paydate =~ /^(\d{4})-(\d{1,2})-\d{1,2}$/ ) { #Pg date format
6377 } elsif ( $self->paydate =~ /^(\d{1,2})-(\d{1,2}-)?(\d{4}$)/ ) {
6384 =item tax_exemption TAXNAME
6389 my( $self, $taxname ) = @_;
6391 qsearchs( 'cust_main_exemption', { 'custnum' => $self->custnum,
6392 'taxname' => $taxname,
6397 =item cust_main_exemption
6401 sub cust_main_exemption {
6403 qsearch( 'cust_main_exemption', { 'custnum' => $self->custnum } );
6406 =item invoicing_list [ ARRAYREF ]
6408 If an arguement is given, sets these email addresses as invoice recipients
6409 (see L<FS::cust_main_invoice>). Errors are not fatal and are not reported
6410 (except as warnings), so use check_invoicing_list first.
6412 Returns a list of email addresses (with svcnum entries expanded).
6414 Note: You can clear the invoicing list by passing an empty ARRAYREF. You can
6415 check it without disturbing anything by passing nothing.
6417 This interface may change in the future.
6421 sub invoicing_list {
6422 my( $self, $arrayref ) = @_;
6425 my @cust_main_invoice;
6426 if ( $self->custnum ) {
6427 @cust_main_invoice =
6428 qsearch( 'cust_main_invoice', { 'custnum' => $self->custnum } );
6430 @cust_main_invoice = ();
6432 foreach my $cust_main_invoice ( @cust_main_invoice ) {
6433 #warn $cust_main_invoice->destnum;
6434 unless ( grep { $cust_main_invoice->address eq $_ } @{$arrayref} ) {
6435 #warn $cust_main_invoice->destnum;
6436 my $error = $cust_main_invoice->delete;
6437 warn $error if $error;
6440 if ( $self->custnum ) {
6441 @cust_main_invoice =
6442 qsearch( 'cust_main_invoice', { 'custnum' => $self->custnum } );
6444 @cust_main_invoice = ();
6446 my %seen = map { $_->address => 1 } @cust_main_invoice;
6447 foreach my $address ( @{$arrayref} ) {
6448 next if exists $seen{$address} && $seen{$address};
6449 $seen{$address} = 1;
6450 my $cust_main_invoice = new FS::cust_main_invoice ( {
6451 'custnum' => $self->custnum,
6454 my $error = $cust_main_invoice->insert;
6455 warn $error if $error;
6459 if ( $self->custnum ) {
6461 qsearch( 'cust_main_invoice', { 'custnum' => $self->custnum } );
6468 =item check_invoicing_list ARRAYREF
6470 Checks these arguements as valid input for the invoicing_list method. If there
6471 is an error, returns the error, otherwise returns false.
6475 sub check_invoicing_list {
6476 my( $self, $arrayref ) = @_;
6478 foreach my $address ( @$arrayref ) {
6480 if ($address eq 'FAX' and $self->getfield('fax') eq '') {
6481 return 'Can\'t add FAX invoice destination with a blank FAX number.';
6484 my $cust_main_invoice = new FS::cust_main_invoice ( {
6485 'custnum' => $self->custnum,
6488 my $error = $self->custnum
6489 ? $cust_main_invoice->check
6490 : $cust_main_invoice->checkdest
6492 return $error if $error;
6496 return "Email address required"
6497 if $conf->exists('cust_main-require_invoicing_list_email')
6498 && ! grep { $_ !~ /^([A-Z]+)$/ } @$arrayref;
6503 =item set_default_invoicing_list
6505 Sets the invoicing list to all accounts associated with this customer,
6506 overwriting any previous invoicing list.
6510 sub set_default_invoicing_list {
6512 $self->invoicing_list($self->all_emails);
6517 Returns the email addresses of all accounts provisioned for this customer.
6524 foreach my $cust_pkg ( $self->all_pkgs ) {
6525 my @cust_svc = qsearch('cust_svc', { 'pkgnum' => $cust_pkg->pkgnum } );
6527 map { qsearchs('svc_acct', { 'svcnum' => $_->svcnum } ) }
6528 grep { qsearchs('svc_acct', { 'svcnum' => $_->svcnum } ) }
6530 $list{$_}=1 foreach map { $_->email } @svc_acct;
6535 =item invoicing_list_addpost
6537 Adds postal invoicing to this customer. If this customer is already configured
6538 to receive postal invoices, does nothing.
6542 sub invoicing_list_addpost {
6544 return if grep { $_ eq 'POST' } $self->invoicing_list;
6545 my @invoicing_list = $self->invoicing_list;
6546 push @invoicing_list, 'POST';
6547 $self->invoicing_list(\@invoicing_list);
6550 =item invoicing_list_emailonly
6552 Returns the list of email invoice recipients (invoicing_list without non-email
6553 destinations such as POST and FAX).
6557 sub invoicing_list_emailonly {
6559 warn "$me invoicing_list_emailonly called"
6561 grep { $_ !~ /^([A-Z]+)$/ } $self->invoicing_list;
6564 =item invoicing_list_emailonly_scalar
6566 Returns the list of email invoice recipients (invoicing_list without non-email
6567 destinations such as POST and FAX) as a comma-separated scalar.
6571 sub invoicing_list_emailonly_scalar {
6573 warn "$me invoicing_list_emailonly_scalar called"
6575 join(', ', $self->invoicing_list_emailonly);
6578 =item referral_cust_main [ DEPTH [ EXCLUDE_HASHREF ] ]
6580 Returns an array of customers referred by this customer (referral_custnum set
6581 to this custnum). If DEPTH is given, recurses up to the given depth, returning
6582 customers referred by customers referred by this customer and so on, inclusive.
6583 The default behavior is DEPTH 1 (no recursion).
6587 sub referral_cust_main {
6589 my $depth = @_ ? shift : 1;
6590 my $exclude = @_ ? shift : {};
6593 map { $exclude->{$_->custnum}++; $_; }
6594 grep { ! $exclude->{ $_->custnum } }
6595 qsearch( 'cust_main', { 'referral_custnum' => $self->custnum } );
6599 map { $_->referral_cust_main($depth-1, $exclude) }
6606 =item referral_cust_main_ncancelled
6608 Same as referral_cust_main, except only returns customers with uncancelled
6613 sub referral_cust_main_ncancelled {
6615 grep { scalar($_->ncancelled_pkgs) } $self->referral_cust_main;
6618 =item referral_cust_pkg [ DEPTH ]
6620 Like referral_cust_main, except returns a flat list of all unsuspended (and
6621 uncancelled) packages for each customer. The number of items in this list may
6622 be useful for comission calculations (perhaps after a C<grep { my $pkgpart = $_->pkgpart; grep { $_ == $pkgpart } @commission_worthy_pkgparts> } $cust_main-> ).
6626 sub referral_cust_pkg {
6628 my $depth = @_ ? shift : 1;
6630 map { $_->unsuspended_pkgs }
6631 grep { $_->unsuspended_pkgs }
6632 $self->referral_cust_main($depth);
6635 =item referring_cust_main
6637 Returns the single cust_main record for the customer who referred this customer
6638 (referral_custnum), or false.
6642 sub referring_cust_main {
6644 return '' unless $self->referral_custnum;
6645 qsearchs('cust_main', { 'custnum' => $self->referral_custnum } );
6648 =item credit AMOUNT, REASON [ , OPTION => VALUE ... ]
6650 Applies a credit to this customer. If there is an error, returns the error,
6651 otherwise returns false.
6653 REASON can be a text string, an FS::reason object, or a scalar reference to
6654 a reasonnum. If a text string, it will be automatically inserted as a new
6655 reason, and a 'reason_type' option must be passed to indicate the
6656 FS::reason_type for the new reason.
6658 An I<addlinfo> option may be passed to set the credit's I<addlinfo> field.
6660 Any other options are passed to FS::cust_credit::insert.
6665 my( $self, $amount, $reason, %options ) = @_;
6667 my $cust_credit = new FS::cust_credit {
6668 'custnum' => $self->custnum,
6669 'amount' => $amount,
6672 if ( ref($reason) ) {
6674 if ( ref($reason) eq 'SCALAR' ) {
6675 $cust_credit->reasonnum( $$reason );
6677 $cust_credit->reasonnum( $reason->reasonnum );
6681 $cust_credit->set('reason', $reason)
6684 $cust_credit->addlinfo( delete $options{'addlinfo'} )
6685 if exists($options{'addlinfo'});
6687 $cust_credit->insert(%options);
6691 =item charge AMOUNT [ PKG [ COMMENT [ TAXCLASS ] ] ]
6693 Creates a one-time charge for this customer. If there is an error, returns
6694 the error, otherwise returns false.
6700 my ( $amount, $quantity, $pkg, $comment, $classnum, $additional );
6701 my ( $setuptax, $taxclass ); #internal taxes
6702 my ( $taxproduct, $override ); #vendor (CCH) taxes
6703 if ( ref( $_[0] ) ) {
6704 $amount = $_[0]->{amount};
6705 $quantity = exists($_[0]->{quantity}) ? $_[0]->{quantity} : 1;
6706 $pkg = exists($_[0]->{pkg}) ? $_[0]->{pkg} : 'One-time charge';
6707 $comment = exists($_[0]->{comment}) ? $_[0]->{comment}
6708 : '$'. sprintf("%.2f",$amount);
6709 $setuptax = exists($_[0]->{setuptax}) ? $_[0]->{setuptax} : '';
6710 $taxclass = exists($_[0]->{taxclass}) ? $_[0]->{taxclass} : '';
6711 $classnum = exists($_[0]->{classnum}) ? $_[0]->{classnum} : '';
6712 $additional = $_[0]->{additional};
6713 $taxproduct = $_[0]->{taxproductnum};
6714 $override = { '' => $_[0]->{tax_override} };
6718 $pkg = @_ ? shift : 'One-time charge';
6719 $comment = @_ ? shift : '$'. sprintf("%.2f",$amount);
6721 $taxclass = @_ ? shift : '';
6725 local $SIG{HUP} = 'IGNORE';
6726 local $SIG{INT} = 'IGNORE';
6727 local $SIG{QUIT} = 'IGNORE';
6728 local $SIG{TERM} = 'IGNORE';
6729 local $SIG{TSTP} = 'IGNORE';
6730 local $SIG{PIPE} = 'IGNORE';
6732 my $oldAutoCommit = $FS::UID::AutoCommit;
6733 local $FS::UID::AutoCommit = 0;
6736 my $part_pkg = new FS::part_pkg ( {
6738 'comment' => $comment,
6742 'classnum' => $classnum ? $classnum : '',
6743 'setuptax' => $setuptax,
6744 'taxclass' => $taxclass,
6745 'taxproductnum' => $taxproduct,
6748 my %options = ( ( map { ("additional_info$_" => $additional->[$_] ) }
6749 ( 0 .. @$additional - 1 )
6751 'additional_count' => scalar(@$additional),
6752 'setup_fee' => $amount,
6755 my $error = $part_pkg->insert( options => \%options,
6756 tax_overrides => $override,
6759 $dbh->rollback if $oldAutoCommit;
6763 my $pkgpart = $part_pkg->pkgpart;
6764 my %type_pkgs = ( 'typenum' => $self->agent->typenum, 'pkgpart' => $pkgpart );
6765 unless ( qsearchs('type_pkgs', \%type_pkgs ) ) {
6766 my $type_pkgs = new FS::type_pkgs \%type_pkgs;
6767 $error = $type_pkgs->insert;
6769 $dbh->rollback if $oldAutoCommit;
6774 my $cust_pkg = new FS::cust_pkg ( {
6775 'custnum' => $self->custnum,
6776 'pkgpart' => $pkgpart,
6777 'quantity' => $quantity,
6780 $error = $cust_pkg->insert;
6782 $dbh->rollback if $oldAutoCommit;
6786 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
6791 #=item charge_postal_fee
6793 #Applies a one time charge this customer. If there is an error,
6794 #returns the error, returns the cust_pkg charge object or false
6795 #if there was no charge.
6799 # This should be a customer event. For that to work requires that bill
6800 # also be a customer event.
6802 sub charge_postal_fee {
6805 my $pkgpart = $conf->config('postal_invoice-fee_pkgpart');
6806 return '' unless ($pkgpart && grep { $_ eq 'POST' } $self->invoicing_list);
6808 my $cust_pkg = new FS::cust_pkg ( {
6809 'custnum' => $self->custnum,
6810 'pkgpart' => $pkgpart,
6814 my $error = $cust_pkg->insert;
6815 $error ? $error : $cust_pkg;
6820 Returns all the invoices (see L<FS::cust_bill>) for this customer.
6826 sort { $a->_date <=> $b->_date }
6827 qsearch('cust_bill', { 'custnum' => $self->custnum, } )
6830 =item open_cust_bill
6832 Returns all the open (owed > 0) invoices (see L<FS::cust_bill>) for this
6837 sub open_cust_bill {
6841 'table' => 'cust_bill',
6842 'hashref' => { 'custnum' => $self->custnum, },
6843 'extra_sql' => ' AND '. FS::cust_bill->owed_sql. ' > 0',
6844 'order_by' => 'ORDER BY _date ASC',
6851 Returns all the credits (see L<FS::cust_credit>) for this customer.
6857 sort { $a->_date <=> $b->_date }
6858 qsearch( 'cust_credit', { 'custnum' => $self->custnum } )
6863 Returns all the payments (see L<FS::cust_pay>) for this customer.
6869 sort { $a->_date <=> $b->_date }
6870 qsearch( 'cust_pay', { 'custnum' => $self->custnum } )
6875 Returns all voided payments (see L<FS::cust_pay_void>) for this customer.
6881 sort { $a->_date <=> $b->_date }
6882 qsearch( 'cust_pay_void', { 'custnum' => $self->custnum } )
6885 =item cust_pay_batch
6887 Returns all batched payments (see L<FS::cust_pay_void>) for this customer.
6891 sub cust_pay_batch {
6893 sort { $a->paybatchnum <=> $b->paybatchnum }
6894 qsearch( 'cust_pay_batch', { 'custnum' => $self->custnum } )
6897 =item cust_pay_pending
6899 Returns all pending payments (see L<FS::cust_pay_pending>) for this customer
6900 (without status "done").
6904 sub cust_pay_pending {
6906 return $self->num_cust_pay_pending unless wantarray;
6907 sort { $a->_date <=> $b->_date }
6908 qsearch( 'cust_pay_pending', {
6909 'custnum' => $self->custnum,
6910 'status' => { op=>'!=', value=>'done' },
6915 =item num_cust_pay_pending
6917 Returns the number of pending payments (see L<FS::cust_pay_pending>) for this
6918 customer (without status "done"). Also called automatically when the
6919 cust_pay_pending method is used in a scalar context.
6923 sub num_cust_pay_pending {
6925 my $sql = " SELECT COUNT(*) FROM cust_pay_pending ".
6926 " WHERE custnum = ? AND status != 'done' ";
6927 my $sth = dbh->prepare($sql) or die dbh->errstr;
6928 $sth->execute($self->custnum) or die $sth->errstr;
6929 $sth->fetchrow_arrayref->[0];
6934 Returns all the refunds (see L<FS::cust_refund>) for this customer.
6940 sort { $a->_date <=> $b->_date }
6941 qsearch( 'cust_refund', { 'custnum' => $self->custnum } )
6944 =item display_custnum
6946 Returns the displayed customer number for this customer: agent_custid if
6947 cust_main-default_agent_custid is set and it has a value, custnum otherwise.
6951 sub display_custnum {
6953 if ( $conf->exists('cust_main-default_agent_custid') && $self->agent_custid ){
6954 return $self->agent_custid;
6956 return $self->custnum;
6962 Returns a name string for this customer, either "Company (Last, First)" or
6969 my $name = $self->contact;
6970 $name = $self->company. " ($name)" if $self->company;
6976 Returns a name string for this (service/shipping) contact, either
6977 "Company (Last, First)" or "Last, First".
6983 if ( $self->get('ship_last') ) {
6984 my $name = $self->ship_contact;
6985 $name = $self->ship_company. " ($name)" if $self->ship_company;
6994 Returns a name string for this customer, either "Company" or "First Last".
7000 $self->company !~ /^\s*$/ ? $self->company : $self->contact_firstlast;
7003 =item ship_name_short
7005 Returns a name string for this (service/shipping) contact, either "Company"
7010 sub ship_name_short {
7012 if ( $self->get('ship_last') ) {
7013 $self->ship_company !~ /^\s*$/
7014 ? $self->ship_company
7015 : $self->ship_contact_firstlast;
7017 $self->name_company_or_firstlast;
7023 Returns this customer's full (billing) contact name only, "Last, First"
7029 $self->get('last'). ', '. $self->first;
7034 Returns this customer's full (shipping) contact name only, "Last, First"
7040 $self->get('ship_last')
7041 ? $self->get('ship_last'). ', '. $self->ship_first
7045 =item contact_firstlast
7047 Returns this customers full (billing) contact name only, "First Last".
7051 sub contact_firstlast {
7053 $self->first. ' '. $self->get('last');
7056 =item ship_contact_firstlast
7058 Returns this customer's full (shipping) contact name only, "First Last".
7062 sub ship_contact_firstlast {
7064 $self->get('ship_last')
7065 ? $self->first. ' '. $self->get('ship_last')
7066 : $self->contact_firstlast;
7071 Returns this customer's full country name
7077 code2country($self->country);
7080 =item geocode DATA_VENDOR
7082 Returns a value for the customer location as encoded by DATA_VENDOR.
7083 Currently this only makes sense for "CCH" as DATA_VENDOR.
7088 my ($self, $data_vendor) = (shift, shift); #always cch for now
7090 my $geocode = $self->get('geocode'); #XXX only one data_vendor for geocode
7091 return $geocode if $geocode;
7093 my $prefix = ( $conf->exists('tax-ship_address') && length($self->ship_last) )
7097 my ($zip,$plus4) = split /-/, $self->get("${prefix}zip")
7098 if $self->country eq 'US';
7100 #CCH specific location stuff
7101 my $extra_sql = "AND plus4lo <= '$plus4' AND plus4hi >= '$plus4'";
7103 my @cust_tax_location =
7105 'table' => 'cust_tax_location',
7106 'hashref' => { 'zip' => $zip, 'data_vendor' => $data_vendor },
7107 'extra_sql' => $extra_sql,
7108 'order_by' => 'ORDER BY plus4hi',#overlapping with distinct ends
7111 $geocode = $cust_tax_location[0]->geocode
7112 if scalar(@cust_tax_location);
7121 Returns a status string for this customer, currently:
7125 =item prospect - No packages have ever been ordered
7127 =item active - One or more recurring packages is active
7129 =item inactive - No active recurring packages, but otherwise unsuspended/uncancelled (the inactive status is new - previously inactive customers were mis-identified as cancelled)
7131 =item suspended - All non-cancelled recurring packages are suspended
7133 =item cancelled - All recurring packages are cancelled
7139 sub status { shift->cust_status(@_); }
7143 for my $status (qw( prospect active inactive suspended cancelled )) {
7144 my $method = $status.'_sql';
7145 my $numnum = ( my $sql = $self->$method() ) =~ s/cust_main\.custnum/?/g;
7146 my $sth = dbh->prepare("SELECT $sql") or die dbh->errstr;
7147 $sth->execute( ($self->custnum) x $numnum )
7148 or die "Error executing 'SELECT $sql': ". $sth->errstr;
7149 return $status if $sth->fetchrow_arrayref->[0];
7153 =item ucfirst_cust_status
7155 =item ucfirst_status
7157 Returns the status with the first character capitalized.
7161 sub ucfirst_status { shift->ucfirst_cust_status(@_); }
7163 sub ucfirst_cust_status {
7165 ucfirst($self->cust_status);
7170 Returns a hex triplet color string for this customer's status.
7174 use vars qw(%statuscolor);
7175 tie %statuscolor, 'Tie::IxHash',
7176 'prospect' => '7e0079', #'000000', #black? naw, purple
7177 'active' => '00CC00', #green
7178 'inactive' => '0000CC', #blue
7179 'suspended' => 'FF9900', #yellow
7180 'cancelled' => 'FF0000', #red
7183 sub statuscolor { shift->cust_statuscolor(@_); }
7185 sub cust_statuscolor {
7187 $statuscolor{$self->cust_status};
7192 Returns an array of hashes representing the customer's RT tickets.
7199 my $num = $conf->config('cust_main-max_tickets') || 10;
7202 if ( $conf->config('ticket_system') ) {
7203 unless ( $conf->config('ticket_system-custom_priority_field') ) {
7205 @tickets = @{ FS::TicketSystem->customer_tickets($self->custnum, $num) };
7209 foreach my $priority (
7210 $conf->config('ticket_system-custom_priority_field-values'), ''
7212 last if scalar(@tickets) >= $num;
7214 @{ FS::TicketSystem->customer_tickets( $self->custnum,
7215 $num - scalar(@tickets),
7225 # Return services representing svc_accts in customer support packages
7226 sub support_services {
7228 my %packages = map { $_ => 1 } $conf->config('support_packages');
7230 grep { $_->pkg_svc && $_->pkg_svc->primary_svc eq 'Y' }
7231 grep { $_->part_svc->svcdb eq 'svc_acct' }
7232 map { $_->cust_svc }
7233 grep { exists $packages{ $_->pkgpart } }
7234 $self->ncancelled_pkgs;
7240 =head1 CLASS METHODS
7246 Class method that returns the list of possible status strings for customers
7247 (see L<the status method|/status>). For example:
7249 @statuses = FS::cust_main->statuses();
7254 #my $self = shift; #could be class...
7260 Returns an SQL expression identifying prospective cust_main records (customers
7261 with no packages ever ordered)
7265 use vars qw($select_count_pkgs);
7266 $select_count_pkgs =
7267 "SELECT COUNT(*) FROM cust_pkg
7268 WHERE cust_pkg.custnum = cust_main.custnum";
7270 sub select_count_pkgs_sql {
7274 sub prospect_sql { "
7275 0 = ( $select_count_pkgs )
7280 Returns an SQL expression identifying active cust_main records (customers with
7281 active recurring packages).
7286 0 < ( $select_count_pkgs AND ". FS::cust_pkg->active_sql. "
7292 Returns an SQL expression identifying inactive cust_main records (customers with
7293 no active recurring packages, but otherwise unsuspended/uncancelled).
7297 sub inactive_sql { "
7298 0 = ( $select_count_pkgs AND ". FS::cust_pkg->active_sql. " )
7300 0 < ( $select_count_pkgs AND ". FS::cust_pkg->inactive_sql. " )
7306 Returns an SQL expression identifying suspended cust_main records.
7311 sub suspended_sql { susp_sql(@_); }
7313 0 < ( $select_count_pkgs AND ". FS::cust_pkg->suspended_sql. " )
7315 0 = ( $select_count_pkgs AND ". FS::cust_pkg->active_sql. " )
7321 Returns an SQL expression identifying cancelled cust_main records.
7325 sub cancelled_sql { cancel_sql(@_); }
7328 my $recurring_sql = FS::cust_pkg->recurring_sql;
7329 my $cancelled_sql = FS::cust_pkg->cancelled_sql;
7332 0 < ( $select_count_pkgs )
7333 AND 0 < ( $select_count_pkgs AND $recurring_sql AND $cancelled_sql )
7334 AND 0 = ( $select_count_pkgs AND $recurring_sql
7335 AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
7337 AND 0 = ( $select_count_pkgs AND ". FS::cust_pkg->inactive_sql. " )
7343 =item uncancelled_sql
7345 Returns an SQL expression identifying un-cancelled cust_main records.
7349 sub uncancelled_sql { uncancel_sql(@_); }
7350 sub uncancel_sql { "
7351 ( 0 < ( $select_count_pkgs
7352 AND ( cust_pkg.cancel IS NULL
7353 OR cust_pkg.cancel = 0
7356 OR 0 = ( $select_count_pkgs )
7362 Returns an SQL fragment to retreive the balance.
7367 ( SELECT COALESCE( SUM(charged), 0 ) FROM cust_bill
7368 WHERE cust_bill.custnum = cust_main.custnum )
7369 - ( SELECT COALESCE( SUM(paid), 0 ) FROM cust_pay
7370 WHERE cust_pay.custnum = cust_main.custnum )
7371 - ( SELECT COALESCE( SUM(amount), 0 ) FROM cust_credit
7372 WHERE cust_credit.custnum = cust_main.custnum )
7373 + ( SELECT COALESCE( SUM(refund), 0 ) FROM cust_refund
7374 WHERE cust_refund.custnum = cust_main.custnum )
7377 =item balance_date_sql START_TIME [ END_TIME [ OPTION => VALUE ... ] ]
7379 Returns an SQL fragment to retreive the balance for this customer, only
7380 considering invoices with date earlier than START_TIME, and optionally not
7381 later than END_TIME (total_owed_date minus total_unapplied_credits minus
7382 total_unapplied_payments).
7384 Times are specified as SQL fragments or numeric
7385 UNIX timestamps; see L<perlfunc/"time">). Also see L<Time::Local> and
7386 L<Date::Parse> for conversion functions. The empty string can be passed
7387 to disable that time constraint completely.
7389 Available options are:
7393 =item unapplied_date
7395 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)
7400 set to true to remove all customer comparison clauses, for totals
7405 WHERE clause hashref (elements "AND"ed together) (typically used with the total option)
7410 JOIN clause (typically used with the total option)
7416 sub balance_date_sql {
7417 my( $class, $start, $end, %opt ) = @_;
7419 my $owed = FS::cust_bill->owed_sql;
7420 my $unapp_refund = FS::cust_refund->unapplied_sql;
7421 my $unapp_credit = FS::cust_credit->unapplied_sql;
7422 my $unapp_pay = FS::cust_pay->unapplied_sql;
7424 my $j = $opt{'join'} || '';
7426 my $owed_wh = $class->_money_table_where( 'cust_bill', $start,$end,%opt );
7427 my $refund_wh = $class->_money_table_where( 'cust_refund', $start,$end,%opt );
7428 my $credit_wh = $class->_money_table_where( 'cust_credit', $start,$end,%opt );
7429 my $pay_wh = $class->_money_table_where( 'cust_pay', $start,$end,%opt );
7431 " ( SELECT COALESCE(SUM($owed), 0) FROM cust_bill $j $owed_wh )
7432 + ( SELECT COALESCE(SUM($unapp_refund), 0) FROM cust_refund $j $refund_wh )
7433 - ( SELECT COALESCE(SUM($unapp_credit), 0) FROM cust_credit $j $credit_wh )
7434 - ( SELECT COALESCE(SUM($unapp_pay), 0) FROM cust_pay $j $pay_wh )
7439 =item _money_table_where TABLE START_TIME [ END_TIME [ OPTION => VALUE ... ] ]
7441 Helper method for balance_date_sql; name (and usage) subject to change
7442 (suggestions welcome).
7444 Returns a WHERE clause for the specified monetary TABLE (cust_bill,
7445 cust_refund, cust_credit or cust_pay).
7447 If TABLE is "cust_bill" or the unapplied_date option is true, only
7448 considers records with date earlier than START_TIME, and optionally not
7449 later than END_TIME .
7453 sub _money_table_where {
7454 my( $class, $table, $start, $end, %opt ) = @_;
7457 push @where, "cust_main.custnum = $table.custnum" unless $opt{'total'};
7458 if ( $table eq 'cust_bill' || $opt{'unapplied_date'} ) {
7459 push @where, "$table._date <= $start" if defined($start) && length($start);
7460 push @where, "$table._date > $end" if defined($end) && length($end);
7462 push @where, @{$opt{'where'}} if $opt{'where'};
7463 my $where = scalar(@where) ? 'WHERE '. join(' AND ', @where ) : '';
7469 =item search_sql HASHREF
7473 Returns a qsearch hash expression to search for parameters specified in HREF.
7474 Valid parameters are
7482 =item cancelled_pkgs
7488 listref of start date, end date
7494 =item current_balance
7496 listref (list returned by FS::UI::Web::parse_lt_gt($cgi, 'current_balance'))
7500 =item flattened_pkgs
7509 my ($class, $params) = @_;
7520 if ( $params->{'agentnum'} =~ /^(\d+)$/ and $1 ) {
7522 "cust_main.agentnum = $1";
7529 #prospect active inactive suspended cancelled
7530 if ( grep { $params->{'status'} eq $_ } FS::cust_main->statuses() ) {
7531 my $method = $params->{'status'}. '_sql';
7532 #push @where, $class->$method();
7533 push @where, FS::cust_main->$method();
7537 # parse cancelled package checkbox
7542 $pkgwhere .= "AND (cancel = 0 or cancel is null)"
7543 unless $params->{'cancelled_pkgs'};
7549 foreach my $field (qw( signupdate )) {
7551 next unless exists($params->{$field});
7553 my($beginning, $ending) = @{$params->{$field}};
7556 "cust_main.$field IS NOT NULL",
7557 "cust_main.$field >= $beginning",
7558 "cust_main.$field <= $ending";
7560 $orderby ||= "ORDER BY cust_main.$field";
7568 my @payby = grep /^([A-Z]{4})$/, @{ $params->{'payby'} };
7570 push @where, '( '. join(' OR ', map "cust_main.payby = '$_'", @payby). ' )';
7577 #my $balance_sql = $class->balance_sql();
7578 my $balance_sql = FS::cust_main->balance_sql();
7580 push @where, map { s/current_balance/$balance_sql/; $_ }
7581 @{ $params->{'current_balance'} };
7587 if ( $params->{'custbatch'} =~ /^([\w\/\-\:\.]+)$/ and $1 ) {
7589 "cust_main.custbatch = '$1'";
7593 # setup queries, subs, etc. for the search
7596 $orderby ||= 'ORDER BY custnum';
7598 # here is the agent virtualization
7599 push @where, $FS::CurrentUser::CurrentUser->agentnums_sql;
7601 my $extra_sql = scalar(@where) ? ' WHERE '. join(' AND ', @where) : '';
7603 my $addl_from = 'LEFT JOIN cust_pkg USING ( custnum ) ';
7605 my $count_query = "SELECT COUNT(*) FROM cust_main $extra_sql";
7607 my $select = join(', ',
7608 'cust_main.custnum',
7609 FS::UI::Web::cust_sql_fields($params->{'cust_fields'}),
7612 my(@extra_headers) = ();
7613 my(@extra_fields) = ();
7615 if ($params->{'flattened_pkgs'}) {
7617 if ($dbh->{Driver}->{Name} eq 'Pg') {
7619 $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";
7621 }elsif ($dbh->{Driver}->{Name} =~ /^mysql/i) {
7622 $select .= ", GROUP_CONCAT(pkg SEPARATOR '|') as magic";
7623 $addl_from .= " LEFT JOIN part_pkg using ( pkgpart )";
7625 warn "warning: unknown database type ". $dbh->{Driver}->{Name}.
7626 "omitting packing information from report.";
7629 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";
7631 my $sth = dbh->prepare($header_query) or die dbh->errstr;
7632 $sth->execute() or die $sth->errstr;
7633 my $headerrow = $sth->fetchrow_arrayref;
7634 my $headercount = $headerrow ? $headerrow->[0] : 0;
7635 while($headercount) {
7636 unshift @extra_headers, "Package ". $headercount;
7637 unshift @extra_fields, eval q!sub {my $c = shift;
7638 my @a = split '\|', $c->magic;
7639 my $p = $a[!.--$headercount. q!];
7647 'table' => 'cust_main',
7648 'select' => $select,
7650 'extra_sql' => $extra_sql,
7651 'order_by' => $orderby,
7652 'count_query' => $count_query,
7653 'extra_headers' => \@extra_headers,
7654 'extra_fields' => \@extra_fields,
7659 =item email_search_sql HASHREF
7663 Emails a notice to the specified customers.
7665 Valid parameters are those of the L<search_sql> method, plus the following:
7687 Optional job queue job for status updates.
7691 Returns an error message, or false for success.
7693 If an error occurs during any email, stops the enture send and returns that
7694 error. Presumably if you're getting SMTP errors aborting is better than
7695 retrying everything.
7699 sub email_search_sql {
7700 my($class, $params) = @_;
7702 my $from = delete $params->{from};
7703 my $subject = delete $params->{subject};
7704 my $html_body = delete $params->{html_body};
7705 my $text_body = delete $params->{text_body};
7707 my $job = delete $params->{'job'};
7709 my $sql_query = $class->search_sql($params);
7711 my $count_query = delete($sql_query->{'count_query'});
7712 my $count_sth = dbh->prepare($count_query)
7713 or die "Error preparing $count_query: ". dbh->errstr;
7715 or die "Error executing $count_query: ". $count_sth->errstr;
7716 my $count_arrayref = $count_sth->fetchrow_arrayref;
7717 my $num_cust = $count_arrayref->[0];
7719 #my @extra_headers = @{ delete($sql_query->{'extra_headers'}) };
7720 #my @extra_fields = @{ delete($sql_query->{'extra_fields'}) };
7723 my( $num, $last, $min_sec ) = (0, time, 5); #progresbar foo
7725 #eventually order+limit magic to reduce memory use?
7726 foreach my $cust_main ( qsearch($sql_query) ) {
7728 my $to = $cust_main->invoicing_list_emailonly_scalar;
7731 my $error = send_email(
7735 'subject' => $subject,
7736 'html_body' => $html_body,
7737 'text_body' => $text_body,
7740 return $error if $error;
7742 if ( $job ) { #progressbar foo
7744 if ( time - $min_sec > $last ) {
7745 my $error = $job->update_statustext(
7746 int( 100 * $num / $num_cust )
7748 die $error if $error;
7758 use Storable qw(thaw);
7761 sub process_email_search_sql {
7763 #warn "$me process_re_X $method for job $job\n" if $DEBUG;
7765 my $param = thaw(decode_base64(shift));
7766 warn Dumper($param) if $DEBUG;
7768 $param->{'job'} = $job;
7770 my $error = FS::cust_main->email_search_sql( $param );
7771 die $error if $error;
7775 =item fuzzy_search FUZZY_HASHREF [ HASHREF, SELECT, EXTRA_SQL, CACHE_OBJ ]
7777 Performs a fuzzy (approximate) search and returns the matching FS::cust_main
7778 records. Currently, I<first>, I<last> and/or I<company> may be specified (the
7779 appropriate ship_ field is also searched).
7781 Additional options are the same as FS::Record::qsearch
7786 my( $self, $fuzzy, $hash, @opt) = @_;
7791 check_and_rebuild_fuzzyfiles();
7792 foreach my $field ( keys %$fuzzy ) {
7794 my $all = $self->all_X($field);
7795 next unless scalar(@$all);
7798 $match{$_}=1 foreach ( amatch( $fuzzy->{$field}, ['i'], @$all ) );
7801 foreach ( keys %match ) {
7802 push @fcust, qsearch('cust_main', { %$hash, $field=>$_}, @opt);
7803 push @fcust, qsearch('cust_main', { %$hash, "ship_$field"=>$_}, @opt);
7806 push @cust_main, grep { ! $fsaw{$_->custnum}++ } @fcust;
7809 # we want the components of $fuzzy ANDed, not ORed, but still don't want dupes
7811 @cust_main = grep { ++$saw{$_->custnum} == scalar(keys %$fuzzy) } @cust_main;
7819 Returns a masked version of the named field
7824 my ($self,$field) = @_;
7828 'x'x(length($self->getfield($field))-4).
7829 substr($self->getfield($field), (length($self->getfield($field))-4));
7839 =item smart_search OPTION => VALUE ...
7841 Accepts the following options: I<search>, the string to search for. The string
7842 will be searched for as a customer number, phone number, name or company name,
7843 as an exact, or, in some cases, a substring or fuzzy match (see the source code
7844 for the exact heuristics used); I<no_fuzzy_on_exact>, causes smart_search to
7845 skip fuzzy matching when an exact match is found.
7847 Any additional options are treated as an additional qualifier on the search
7850 Returns a (possibly empty) array of FS::cust_main objects.
7857 #here is the agent virtualization
7858 my $agentnums_sql = $FS::CurrentUser::CurrentUser->agentnums_sql;
7862 my $skip_fuzzy = delete $options{'no_fuzzy_on_exact'};
7863 my $search = delete $options{'search'};
7864 ( my $alphanum_search = $search ) =~ s/\W//g;
7866 if ( $alphanum_search =~ /^1?(\d{3})(\d{3})(\d{4})(\d*)$/ ) { #phone# search
7868 #false laziness w/Record::ut_phone
7869 my $phonen = "$1-$2-$3";
7870 $phonen .= " x$4" if $4;
7872 push @cust_main, qsearch( {
7873 'table' => 'cust_main',
7874 'hashref' => { %options },
7875 'extra_sql' => ( scalar(keys %options) ? ' AND ' : ' WHERE ' ).
7877 join(' OR ', map "$_ = '$phonen'",
7878 qw( daytime night fax
7879 ship_daytime ship_night ship_fax )
7882 " AND $agentnums_sql", #agent virtualization
7885 unless ( @cust_main || $phonen =~ /x\d+$/ ) { #no exact match
7886 #try looking for matches with extensions unless one was specified
7888 push @cust_main, qsearch( {
7889 'table' => 'cust_main',
7890 'hashref' => { %options },
7891 'extra_sql' => ( scalar(keys %options) ? ' AND ' : ' WHERE ' ).
7893 join(' OR ', map "$_ LIKE '$phonen\%'",
7895 ship_daytime ship_night )
7898 " AND $agentnums_sql", #agent virtualization
7903 # custnum search (also try agent_custid), with some tweaking options if your
7904 # legacy cust "numbers" have letters
7907 if ( $search =~ /^\s*(\d+)\s*$/
7908 || ( $conf->config('cust_main-agent_custid-format') eq 'ww?d+'
7909 && $search =~ /^\s*(\w\w?\d+)\s*$/
7916 if ( $num <= 2147483647 ) { #need a bigint custnum? wow.
7917 push @cust_main, qsearch( {
7918 'table' => 'cust_main',
7919 'hashref' => { 'custnum' => $num, %options },
7920 'extra_sql' => " AND $agentnums_sql", #agent virtualization
7924 push @cust_main, qsearch( {
7925 'table' => 'cust_main',
7926 'hashref' => { 'agent_custid' => $num, %options },
7927 'extra_sql' => " AND $agentnums_sql", #agent virtualization
7930 } elsif ( $search =~ /^\s*(\S.*\S)\s+\((.+), ([^,]+)\)\s*$/ ) {
7932 my($company, $last, $first) = ( $1, $2, $3 );
7934 # "Company (Last, First)"
7935 #this is probably something a browser remembered,
7936 #so just do an exact search
7938 foreach my $prefix ( '', 'ship_' ) {
7939 push @cust_main, qsearch( {
7940 'table' => 'cust_main',
7941 'hashref' => { $prefix.'first' => $first,
7942 $prefix.'last' => $last,
7943 $prefix.'company' => $company,
7946 'extra_sql' => " AND $agentnums_sql",
7950 } elsif ( $search =~ /^\s*(\S.*\S)\s*$/ ) { # value search
7951 # try (ship_){last,company}
7955 # # remove "(Last, First)" in "Company (Last, First)", otherwise the
7956 # # full strings the browser remembers won't work
7957 # $value =~ s/\([\w \,\.\-\']*\)$//; #false laziness w/Record::ut_name
7959 use Lingua::EN::NameParse;
7960 my $NameParse = new Lingua::EN::NameParse(
7962 allow_reversed => 1,
7965 my($last, $first) = ( '', '' );
7966 #maybe disable this too and just rely on NameParse?
7967 if ( $value =~ /^(.+),\s*([^,]+)$/ ) { # Last, First
7969 ($last, $first) = ( $1, $2 );
7971 #} elsif ( $value =~ /^(.+)\s+(.+)$/ ) {
7972 } elsif ( ! $NameParse->parse($value) ) {
7974 my %name = $NameParse->components;
7975 $first = $name{'given_name_1'};
7976 $last = $name{'surname_1'};
7980 if ( $first && $last ) {
7982 my($q_last, $q_first) = ( dbh->quote($last), dbh->quote($first) );
7985 my $sql = scalar(keys %options) ? ' AND ' : ' WHERE ';
7987 ( ( LOWER(last) = $q_last AND LOWER(first) = $q_first )
7988 OR ( LOWER(ship_last) = $q_last AND LOWER(ship_first) = $q_first )
7991 push @cust_main, qsearch( {
7992 'table' => 'cust_main',
7993 'hashref' => \%options,
7994 'extra_sql' => "$sql AND $agentnums_sql", #agent virtualization
7997 # or it just be something that was typed in... (try that in a sec)
8001 my $q_value = dbh->quote($value);
8004 my $sql = scalar(keys %options) ? ' AND ' : ' WHERE ';
8005 $sql .= " ( LOWER(last) = $q_value
8006 OR LOWER(company) = $q_value
8007 OR LOWER(ship_last) = $q_value
8008 OR LOWER(ship_company) = $q_value
8011 push @cust_main, qsearch( {
8012 'table' => 'cust_main',
8013 'hashref' => \%options,
8014 'extra_sql' => "$sql AND $agentnums_sql", #agent virtualization
8017 #no exact match, trying substring/fuzzy
8018 #always do substring & fuzzy (unless they're explicity config'ed off)
8019 #getting complaints searches are not returning enough
8020 unless ( @cust_main && $skip_fuzzy || $conf->exists('disable-fuzzy') ) {
8022 #still some false laziness w/search_sql (was search/cust_main.cgi)
8027 { 'company' => { op=>'ILIKE', value=>"%$value%" }, },
8028 { 'ship_company' => { op=>'ILIKE', value=>"%$value%" }, },
8031 if ( $first && $last ) {
8034 { 'first' => { op=>'ILIKE', value=>"%$first%" },
8035 'last' => { op=>'ILIKE', value=>"%$last%" },
8037 { 'ship_first' => { op=>'ILIKE', value=>"%$first%" },
8038 'ship_last' => { op=>'ILIKE', value=>"%$last%" },
8045 { 'last' => { op=>'ILIKE', value=>"%$value%" }, },
8046 { 'ship_last' => { op=>'ILIKE', value=>"%$value%" }, },
8050 foreach my $hashref ( @hashrefs ) {
8052 push @cust_main, qsearch( {
8053 'table' => 'cust_main',
8054 'hashref' => { %$hashref,
8057 'extra_sql' => " AND $agentnums_sql", #agent virtualizaiton
8066 " AND $agentnums_sql", #extra_sql #agent virtualization
8069 if ( $first && $last ) {
8070 push @cust_main, FS::cust_main->fuzzy_search(
8071 { 'last' => $last, #fuzzy hashref
8072 'first' => $first }, #
8076 foreach my $field ( 'last', 'company' ) {
8078 FS::cust_main->fuzzy_search( { $field => $value }, @fuzopts );
8083 #eliminate duplicates
8085 @cust_main = grep { !$saw{$_->custnum}++ } @cust_main;
8095 Accepts the following options: I<email>, the email address to search for. The
8096 email address will be searched for as an email invoice destination and as an
8099 #Any additional options are treated as an additional qualifier on the search
8100 #(i.e. I<agentnum>).
8102 Returns a (possibly empty) array of FS::cust_main objects (but usually just
8112 my $email = delete $options{'email'};
8114 #we're only being used by RT at the moment... no agent virtualization yet
8115 #my $agentnums_sql = $FS::CurrentUser::CurrentUser->agentnums_sql;
8119 if ( $email =~ /([^@]+)\@([^@]+)/ ) {
8121 my ( $user, $domain ) = ( $1, $2 );
8123 warn "$me smart_search: searching for $user in domain $domain"
8129 'table' => 'cust_main_invoice',
8130 'hashref' => { 'dest' => $email },
8137 map $_->cust_svc->cust_pkg,
8139 'table' => 'svc_acct',
8140 'hashref' => { 'username' => $user, },
8142 'AND ( SELECT domain FROM svc_domain
8143 WHERE svc_acct.domsvc = svc_domain.svcnum
8144 ) = '. dbh->quote($domain),
8150 @cust_main = grep { !$saw{$_->custnum}++ } @cust_main;
8152 warn "$me smart_search: found ". scalar(@cust_main). " unique customers"
8159 =item check_and_rebuild_fuzzyfiles
8163 use vars qw(@fuzzyfields);
8164 @fuzzyfields = ( 'last', 'first', 'company' );
8166 sub check_and_rebuild_fuzzyfiles {
8167 my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
8168 rebuild_fuzzyfiles() if grep { ! -e "$dir/cust_main.$_" } @fuzzyfields
8171 =item rebuild_fuzzyfiles
8175 sub rebuild_fuzzyfiles {
8177 use Fcntl qw(:flock);
8179 my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
8180 mkdir $dir, 0700 unless -d $dir;
8182 foreach my $fuzzy ( @fuzzyfields ) {
8184 open(LOCK,">>$dir/cust_main.$fuzzy")
8185 or die "can't open $dir/cust_main.$fuzzy: $!";
8187 or die "can't lock $dir/cust_main.$fuzzy: $!";
8189 open (CACHE,">$dir/cust_main.$fuzzy.tmp")
8190 or die "can't open $dir/cust_main.$fuzzy.tmp: $!";
8192 foreach my $field ( $fuzzy, "ship_$fuzzy" ) {
8193 my $sth = dbh->prepare("SELECT $field FROM cust_main".
8194 " WHERE $field != '' AND $field IS NOT NULL");
8195 $sth->execute or die $sth->errstr;
8197 while ( my $row = $sth->fetchrow_arrayref ) {
8198 print CACHE $row->[0]. "\n";
8203 close CACHE or die "can't close $dir/cust_main.$fuzzy.tmp: $!";
8205 rename "$dir/cust_main.$fuzzy.tmp", "$dir/cust_main.$fuzzy";
8216 my( $self, $field ) = @_;
8217 my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
8218 open(CACHE,"<$dir/cust_main.$field")
8219 or die "can't open $dir/cust_main.$field: $!";
8220 my @array = map { chomp; $_; } <CACHE>;
8225 =item append_fuzzyfiles LASTNAME COMPANY
8229 sub append_fuzzyfiles {
8230 #my( $first, $last, $company ) = @_;
8232 &check_and_rebuild_fuzzyfiles;
8234 use Fcntl qw(:flock);
8236 my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
8238 foreach my $field (qw( first last company )) {
8243 open(CACHE,">>$dir/cust_main.$field")
8244 or die "can't open $dir/cust_main.$field: $!";
8245 flock(CACHE,LOCK_EX)
8246 or die "can't lock $dir/cust_main.$field: $!";
8248 print CACHE "$value\n";
8250 flock(CACHE,LOCK_UN)
8251 or die "can't unlock $dir/cust_main.$field: $!";
8266 #warn join('-',keys %$param);
8267 my $fh = $param->{filehandle};
8268 my @fields = @{$param->{fields}};
8270 eval "use Text::CSV_XS;";
8273 my $csv = new Text::CSV_XS;
8280 local $SIG{HUP} = 'IGNORE';
8281 local $SIG{INT} = 'IGNORE';
8282 local $SIG{QUIT} = 'IGNORE';
8283 local $SIG{TERM} = 'IGNORE';
8284 local $SIG{TSTP} = 'IGNORE';
8285 local $SIG{PIPE} = 'IGNORE';
8287 my $oldAutoCommit = $FS::UID::AutoCommit;
8288 local $FS::UID::AutoCommit = 0;
8291 #while ( $columns = $csv->getline($fh) ) {
8293 while ( defined($line=<$fh>) ) {
8295 $csv->parse($line) or do {
8296 $dbh->rollback if $oldAutoCommit;
8297 return "can't parse: ". $csv->error_input();
8300 my @columns = $csv->fields();
8301 #warn join('-',@columns);
8304 foreach my $field ( @fields ) {
8305 $row{$field} = shift @columns;
8308 my $cust_main = qsearchs('cust_main', { 'custnum' => $row{'custnum'} } );
8309 unless ( $cust_main ) {
8310 $dbh->rollback if $oldAutoCommit;
8311 return "unknown custnum $row{'custnum'}";
8314 if ( $row{'amount'} > 0 ) {
8315 my $error = $cust_main->charge($row{'amount'}, $row{'pkg'});
8317 $dbh->rollback if $oldAutoCommit;
8321 } elsif ( $row{'amount'} < 0 ) {
8322 my $error = $cust_main->credit( sprintf( "%.2f", 0-$row{'amount'} ),
8325 $dbh->rollback if $oldAutoCommit;
8335 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
8337 return "Empty file!" unless $imported;
8343 =item notify CUSTOMER_OBJECT TEMPLATE_NAME OPTIONS
8345 Sends a templated email notification to the customer (see L<Text::Template>).
8347 OPTIONS is a hash and may include
8349 I<from> - the email sender (default is invoice_from)
8351 I<to> - comma-separated scalar or arrayref of recipients
8352 (default is invoicing_list)
8354 I<subject> - The subject line of the sent email notification
8355 (default is "Notice from company_name")
8357 I<extra_fields> - a hashref of name/value pairs which will be substituted
8360 The following variables are vavailable in the template.
8362 I<$first> - the customer first name
8363 I<$last> - the customer last name
8364 I<$company> - the customer company
8365 I<$payby> - a description of the method of payment for the customer
8366 # would be nice to use FS::payby::shortname
8367 I<$payinfo> - the account information used to collect for this customer
8368 I<$expdate> - the expiration of the customer payment in seconds from epoch
8373 my ($self, $template, %options) = @_;
8375 return unless $conf->exists($template);
8377 my $from = $conf->config('invoice_from', $self->agentnum)
8378 if $conf->exists('invoice_from', $self->agentnum);
8379 $from = $options{from} if exists($options{from});
8381 my $to = join(',', $self->invoicing_list_emailonly);
8382 $to = $options{to} if exists($options{to});
8384 my $subject = "Notice from " . $conf->config('company_name', $self->agentnum)
8385 if $conf->exists('company_name', $self->agentnum);
8386 $subject = $options{subject} if exists($options{subject});
8388 my $notify_template = new Text::Template (TYPE => 'ARRAY',
8389 SOURCE => [ map "$_\n",
8390 $conf->config($template)]
8392 or die "can't create new Text::Template object: Text::Template::ERROR";
8393 $notify_template->compile()
8394 or die "can't compile template: Text::Template::ERROR";
8396 $FS::notify_template::_template::company_name =
8397 $conf->config('company_name', $self->agentnum);
8398 $FS::notify_template::_template::company_address =
8399 join("\n", $conf->config('company_address', $self->agentnum) ). "\n";
8401 my $paydate = $self->paydate || '2037-12-31';
8402 $FS::notify_template::_template::first = $self->first;
8403 $FS::notify_template::_template::last = $self->last;
8404 $FS::notify_template::_template::company = $self->company;
8405 $FS::notify_template::_template::payinfo = $self->mask_payinfo;
8406 my $payby = $self->payby;
8407 my ($payyear,$paymonth,$payday) = split (/-/,$paydate);
8408 my $expire_time = timelocal(0,0,0,$payday,--$paymonth,$payyear);
8410 #credit cards expire at the end of the month/year of their exp date
8411 if ($payby eq 'CARD' || $payby eq 'DCRD') {
8412 $FS::notify_template::_template::payby = 'credit card';
8413 ($paymonth < 11) ? $paymonth++ : ($paymonth=0, $payyear++);
8414 $expire_time = timelocal(0,0,0,$payday,$paymonth,$payyear);
8416 }elsif ($payby eq 'COMP') {
8417 $FS::notify_template::_template::payby = 'complimentary account';
8419 $FS::notify_template::_template::payby = 'current method';
8421 $FS::notify_template::_template::expdate = $expire_time;
8423 for (keys %{$options{extra_fields}}){
8425 ${"FS::notify_template::_template::$_"} = $options{extra_fields}->{$_};
8428 send_email(from => $from,
8430 subject => $subject,
8431 body => $notify_template->fill_in( PACKAGE =>
8432 'FS::notify_template::_template' ),
8437 =item generate_letter CUSTOMER_OBJECT TEMPLATE_NAME OPTIONS
8439 Generates a templated notification to the customer (see L<Text::Template>).
8441 OPTIONS is a hash and may include
8443 I<extra_fields> - a hashref of name/value pairs which will be substituted
8444 into the template. These values may override values mentioned below
8445 and those from the customer record.
8447 The following variables are available in the template instead of or in addition
8448 to the fields of the customer record.
8450 I<$payby> - a description of the method of payment for the customer
8451 # would be nice to use FS::payby::shortname
8452 I<$payinfo> - the masked account information used to collect for this customer
8453 I<$expdate> - the expiration of the customer payment method in seconds from epoch
8454 I<$returnaddress> - the return address defaults to invoice_latexreturnaddress or company_address
8458 sub generate_letter {
8459 my ($self, $template, %options) = @_;
8461 return unless $conf->exists($template);
8463 my $letter_template = new Text::Template
8465 SOURCE => [ map "$_\n", $conf->config($template)],
8466 DELIMITERS => [ '[@--', '--@]' ],
8468 or die "can't create new Text::Template object: Text::Template::ERROR";
8470 $letter_template->compile()
8471 or die "can't compile template: Text::Template::ERROR";
8473 my %letter_data = map { $_ => $self->$_ } $self->fields;
8474 $letter_data{payinfo} = $self->mask_payinfo;
8476 #my $paydate = $self->paydate || '2037-12-31';
8477 my $paydate = $self->paydate =~ /^\S+$/ ? $self->paydate : '2037-12-31';
8479 my $payby = $self->payby;
8480 my ($payyear,$paymonth,$payday) = split (/-/,$paydate);
8481 my $expire_time = timelocal(0,0,0,$payday,--$paymonth,$payyear);
8483 #credit cards expire at the end of the month/year of their exp date
8484 if ($payby eq 'CARD' || $payby eq 'DCRD') {
8485 $letter_data{payby} = 'credit card';
8486 ($paymonth < 11) ? $paymonth++ : ($paymonth=0, $payyear++);
8487 $expire_time = timelocal(0,0,0,$payday,$paymonth,$payyear);
8489 }elsif ($payby eq 'COMP') {
8490 $letter_data{payby} = 'complimentary account';
8492 $letter_data{payby} = 'current method';
8494 $letter_data{expdate} = $expire_time;
8496 for (keys %{$options{extra_fields}}){
8497 $letter_data{$_} = $options{extra_fields}->{$_};
8500 unless(exists($letter_data{returnaddress})){
8501 my $retadd = join("\n", $conf->config_orbase( 'invoice_latexreturnaddress',
8502 $self->agent_template)
8504 if ( length($retadd) ) {
8505 $letter_data{returnaddress} = $retadd;
8506 } elsif ( grep /\S/, $conf->config('company_address', $self->agentnum) ) {
8507 $letter_data{returnaddress} =
8508 join( '\\*'."\n", map s/( {2,})/'~' x length($1)/eg,
8509 $conf->config('company_address', $self->agentnum)
8512 $letter_data{returnaddress} = '~';
8516 $letter_data{conf_dir} = "$FS::UID::conf_dir/conf.$FS::UID::datasrc";
8518 $letter_data{company_name} = $conf->config('company_name', $self->agentnum);
8520 my $dir = $FS::UID::conf_dir."/cache.". $FS::UID::datasrc;
8521 my $fh = new File::Temp( TEMPLATE => 'letter.'. $self->custnum. '.XXXXXXXX',
8525 ) or die "can't open temp file: $!\n";
8527 $letter_template->fill_in( OUTPUT => $fh, HASH => \%letter_data );
8529 $fh->filename =~ /^(.*).tex$/ or die "unparsable filename: ". $fh->filename;
8533 =item print_ps TEMPLATE
8535 Returns an postscript letter filled in from TEMPLATE, as a scalar.
8541 my $file = $self->generate_letter(@_);
8542 FS::Misc::generate_ps($file);
8545 =item print TEMPLATE
8547 Prints the filled in template.
8549 TEMPLATE is the name of a L<Text::Template> to fill in and print.
8553 sub queueable_print {
8556 my $self = qsearchs('cust_main', { 'custnum' => $opt{custnum} } )
8557 or die "invalid customer number: " . $opt{custvnum};
8559 my $error = $self->print( $opt{template} );
8560 die $error if $error;
8564 my ($self, $template) = (shift, shift);
8565 do_print [ $self->print_ps($template) ];
8568 #these three subs should just go away once agent stuff is all config overrides
8570 sub agent_template {
8572 $self->_agent_plandata('agent_templatename');
8575 sub agent_invoice_from {
8577 $self->_agent_plandata('agent_invoice_from');
8580 sub _agent_plandata {
8581 my( $self, $option ) = @_;
8583 #yuck. this whole thing needs to be reconciled better with 1.9's idea of
8584 #agent-specific Conf
8586 use FS::part_event::Condition;
8588 my $agentnum = $self->agentnum;
8591 if ( driver_name =~ /^Pg/i ) {
8593 } elsif ( driver_name =~ /^mysql/i ) {
8596 die "don't know how to use regular expressions in ". driver_name. " databases";
8599 my $part_event_option =
8601 'select' => 'part_event_option.*',
8602 'table' => 'part_event_option',
8604 LEFT JOIN part_event USING ( eventpart )
8605 LEFT JOIN part_event_option AS peo_agentnum
8606 ON ( part_event.eventpart = peo_agentnum.eventpart
8607 AND peo_agentnum.optionname = 'agentnum'
8608 AND peo_agentnum.optionvalue }. $regexp. q{ '(^|,)}. $agentnum. q{(,|$)'
8610 LEFT JOIN part_event_condition
8611 ON ( part_event.eventpart = part_event_condition.eventpart
8612 AND part_event_condition.conditionname = 'cust_bill_age'
8614 LEFT JOIN part_event_condition_option
8615 ON ( part_event_condition.eventconditionnum = part_event_condition_option.eventconditionnum
8616 AND part_event_condition_option.optionname = 'age'
8619 #'hashref' => { 'optionname' => $option },
8620 #'hashref' => { 'part_event_option.optionname' => $option },
8622 " WHERE part_event_option.optionname = ". dbh->quote($option).
8623 " AND action = 'cust_bill_send_agent' ".
8624 " AND ( disabled IS NULL OR disabled != 'Y' ) ".
8625 " AND peo_agentnum.optionname = 'agentnum' ".
8626 " AND ( agentnum IS NULL OR agentnum = $agentnum ) ".
8628 CASE WHEN part_event_condition_option.optionname IS NULL
8630 ELSE ". FS::part_event::Condition->age2seconds_sql('part_event_condition_option.optionvalue').
8632 , part_event.weight".
8636 unless ( $part_event_option ) {
8637 return $self->agent->invoice_template || ''
8638 if $option eq 'agent_templatename';
8642 $part_event_option->optionvalue;
8647 ## actual sub, not a method, designed to be called from the queue.
8648 ## sets up the customer, and calls the bill_and_collect
8649 my (%args) = @_; #, ($time, $invoice_time, $check_freq, $resetup) = @_;
8650 my $cust_main = qsearchs( 'cust_main', { custnum => $args{'custnum'} } );
8651 $cust_main->bill_and_collect(
8656 sub _upgrade_data { #class method
8657 my ($class, %opts) = @_;
8659 my $sql = 'UPDATE h_cust_main SET paycvv = NULL WHERE paycvv IS NOT NULL';
8660 my $sth = dbh->prepare($sql) or die dbh->errstr;
8661 $sth->execute or die $sth->errstr;
8671 The delete method should possibly take an FS::cust_main object reference
8672 instead of a scalar customer number.
8674 Bill and collect options should probably be passed as references instead of a
8677 There should probably be a configuration file with a list of allowed credit
8680 No multiple currency support (probably a larger project than just this module).
8682 payinfo_masked false laziness with cust_pay.pm and cust_refund.pm
8684 Birthdates rely on negative epoch values.
8686 The payby for card/check batches is broken. With mixed batching, bad
8689 B<collect> I<invoice_time> should be renamed I<time>, like B<bill>.
8693 L<FS::Record>, L<FS::cust_pkg>, L<FS::cust_bill>, L<FS::cust_credit>
8694 L<FS::agent>, L<FS::part_referral>, L<FS::cust_main_county>,
8695 L<FS::cust_main_invoice>, L<FS::UID>, schema.html from the base documentation.