4 use vars qw( @ISA @EXPORT_OK $DEBUG $me $conf @encrypted_fields
5 $import $skip_fuzzyfiles $ignore_expired_card @paytypes);
6 use vars qw( $realtime_bop_decline_quiet ); #ugh
11 eval "use Time::Local;";
12 die "Time::Local minimum version 1.05 required with Perl versions before 5.6"
13 if $] < 5.006 && !defined($Time::Local::VERSION);
14 #eval "use Time::Local qw(timelocal timelocal_nocheck);";
15 eval "use Time::Local qw(timelocal_nocheck);";
17 use Digest::MD5 qw(md5_base64);
21 use File::Slurp qw( slurp );
22 use File::Temp qw( tempfile );
23 use String::Approx qw(amatch);
24 use Business::CreditCard 0.28;
27 use FS::UID qw( getotaker dbh driver_name );
28 use FS::Record qw( qsearchs qsearch dbdef );
29 use FS::Misc qw( generate_email send_email generate_ps do_print );
30 use FS::Msgcat qw(gettext);
34 use FS::cust_bill_pkg;
36 use FS::cust_pay_pending;
37 use FS::cust_pay_void;
40 use FS::part_referral;
41 use FS::cust_main_county;
43 use FS::cust_main_invoice;
44 use FS::cust_credit_bill;
45 use FS::cust_bill_pay;
46 use FS::prepay_credit;
49 use FS::part_bill_event qw(due_events);
50 use FS::cust_bill_event;
51 use FS::cust_tax_exempt;
52 use FS::cust_tax_exempt_pkg;
54 use FS::payment_gateway;
55 use FS::agent_payment_gateway;
57 use FS::payinfo_Mixin;
59 @ISA = qw( FS::Record FS::payinfo_Mixin );
61 @EXPORT_OK = qw( smart_search );
63 $realtime_bop_decline_quiet = 0;
65 # 1 is mostly method/subroutine entry and options
66 # 2 traces progress of some operations
67 # 3 is even more information including possibly sensitive data
69 $me = '[FS::cust_main]';
73 $ignore_expired_card = 0;
75 @encrypted_fields = ('payinfo', 'paycvv');
76 @paytypes = ('', 'Personal checking', 'Personal savings', 'Business checking', 'Business savings');
78 #ask FS::UID to run this stuff for us later
79 #$FS::UID::callback{'FS::cust_main'} = sub {
80 install_callback FS::UID sub {
82 #yes, need it for stuff below (prolly should be cached)
87 my ( $hashref, $cache ) = @_;
88 if ( exists $hashref->{'pkgnum'} ) {
89 #@{ $self->{'_pkgnum'} } = ();
90 my $subcache = $cache->subcache( 'pkgnum', 'cust_pkg', $hashref->{custnum});
91 $self->{'_pkgnum'} = $subcache;
92 #push @{ $self->{'_pkgnum'} },
93 FS::cust_pkg->new_or_cached($hashref, $subcache) if $hashref->{pkgnum};
99 FS::cust_main - Object methods for cust_main records
105 $record = new FS::cust_main \%hash;
106 $record = new FS::cust_main { 'column' => 'value' };
108 $error = $record->insert;
110 $error = $new_record->replace($old_record);
112 $error = $record->delete;
114 $error = $record->check;
116 @cust_pkg = $record->all_pkgs;
118 @cust_pkg = $record->ncancelled_pkgs;
120 @cust_pkg = $record->suspended_pkgs;
122 $error = $record->bill;
123 $error = $record->bill %options;
124 $error = $record->bill 'time' => $time;
126 $error = $record->collect;
127 $error = $record->collect %options;
128 $error = $record->collect 'invoice_time' => $time,
133 An FS::cust_main object represents a customer. FS::cust_main inherits from
134 FS::Record. The following fields are currently supported:
138 =item custnum - primary key (assigned automatically for new customers)
140 =item agentnum - agent (see L<FS::agent>)
142 =item refnum - Advertising source (see L<FS::part_referral>)
148 =item ss - social security number (optional)
150 =item company - (optional)
154 =item address2 - (optional)
158 =item county - (optional, see L<FS::cust_main_county>)
160 =item state - (see L<FS::cust_main_county>)
164 =item country - (see L<FS::cust_main_county>)
166 =item daytime - phone (optional)
168 =item night - phone (optional)
170 =item fax - phone (optional)
172 =item ship_first - name
174 =item ship_last - name
176 =item ship_company - (optional)
180 =item ship_address2 - (optional)
184 =item ship_county - (optional, see L<FS::cust_main_county>)
186 =item ship_state - (see L<FS::cust_main_county>)
190 =item ship_country - (see L<FS::cust_main_county>)
192 =item ship_daytime - phone (optional)
194 =item ship_night - phone (optional)
196 =item ship_fax - phone (optional)
198 =item payby - Payment Type (See L<FS::payinfo_Mixin> for valid payby values)
200 =item payinfo - Payment Information (See L<FS::payinfo_Mixin> for data format)
202 =item paymask - Masked payinfo (See L<FS::payinfo_Mixin> for how this works)
206 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
208 =item paydate - expiration date, mm/yyyy, m/yyyy, mm/yy or m/yy
210 =item paystart_month - start date month (maestro/solo cards only)
212 =item paystart_year - start date year (maestro/solo cards only)
214 =item payissue - issue number (maestro/solo cards only)
216 =item payname - name on card or billing name
218 =item payip - IP address from which payment information was received
220 =item tax - tax exempt, empty or `Y'
222 =item otaker - order taker (assigned automatically, see L<FS::UID>)
224 =item comments - comments (optional)
226 =item referral_custnum - referring customer number
228 =item spool_cdr - Enable individual CDR spooling, empty or `Y'
230 =item dundate - a suggestion to events (see L<FS::part_bill_event">) to delay until this unix timestamp
240 Creates a new customer. To add the customer to the database, see L<"insert">.
242 Note that this stores the hash reference, not a distinct copy of the hash it
243 points to. You can ask the object for a copy with the I<hash> method.
247 sub table { 'cust_main'; }
249 =item insert [ CUST_PKG_HASHREF [ , INVOICING_LIST_ARYREF ] [ , OPTION => VALUE ... ] ]
251 Adds this customer to the database. If there is an error, returns the error,
252 otherwise returns false.
254 CUST_PKG_HASHREF: If you pass a Tie::RefHash data structure to the insert
255 method containing FS::cust_pkg and FS::svc_I<tablename> objects, all records
256 are inserted atomicly, or the transaction is rolled back. Passing an empty
257 hash reference is equivalent to not supplying this parameter. There should be
258 a better explanation of this, but until then, here's an example:
261 tie %hash, 'Tie::RefHash'; #this part is important
263 $cust_pkg => [ $svc_acct ],
266 $cust_main->insert( \%hash );
268 INVOICING_LIST_ARYREF: If you pass an arrarref to the insert method, it will
269 be set as the invoicing list (see L<"invoicing_list">). Errors return as
270 expected and rollback the entire transaction; it is not necessary to call
271 check_invoicing_list first. The invoicing_list is set after the records in the
272 CUST_PKG_HASHREF above are inserted, so it is now possible to set an
273 invoicing_list destination to the newly-created svc_acct. Here's an example:
275 $cust_main->insert( {}, [ $email, 'POST' ] );
277 Currently available options are: I<depend_jobnum> and I<noexport>.
279 If I<depend_jobnum> is set, all provisioning jobs will have a dependancy
280 on the supplied jobnum (they will not run until the specific job completes).
281 This can be used to defer provisioning until some action completes (such
282 as running the customer's credit card successfully).
284 The I<noexport> option is deprecated. If I<noexport> is set true, no
285 provisioning jobs (exports) are scheduled. (You can schedule them later with
286 the B<reexport> method.)
292 my $cust_pkgs = @_ ? shift : {};
293 my $invoicing_list = @_ ? shift : '';
295 warn "$me insert called with options ".
296 join(', ', map { "$_: $options{$_}" } keys %options ). "\n"
299 local $SIG{HUP} = 'IGNORE';
300 local $SIG{INT} = 'IGNORE';
301 local $SIG{QUIT} = 'IGNORE';
302 local $SIG{TERM} = 'IGNORE';
303 local $SIG{TSTP} = 'IGNORE';
304 local $SIG{PIPE} = 'IGNORE';
306 my $oldAutoCommit = $FS::UID::AutoCommit;
307 local $FS::UID::AutoCommit = 0;
310 my $prepay_identifier = '';
311 my( $amount, $seconds ) = ( 0, 0 );
313 if ( $self->payby eq 'PREPAY' ) {
315 $self->payby('BILL');
316 $prepay_identifier = $self->payinfo;
319 warn " looking up prepaid card $prepay_identifier\n"
322 my $error = $self->get_prepay($prepay_identifier, \$amount, \$seconds);
324 $dbh->rollback if $oldAutoCommit;
325 #return "error applying prepaid card (transaction rolled back): $error";
329 $payby = 'PREP' if $amount;
331 } elsif ( $self->payby =~ /^(CASH|WEST|MCRD)$/ ) {
334 $self->payby('BILL');
335 $amount = $self->paid;
339 warn " inserting $self\n"
342 $self->signupdate(time) unless $self->signupdate;
344 my $error = $self->SUPER::insert;
346 $dbh->rollback if $oldAutoCommit;
347 #return "inserting cust_main record (transaction rolled back): $error";
351 warn " setting invoicing list\n"
354 if ( $invoicing_list ) {
355 $error = $self->check_invoicing_list( $invoicing_list );
357 $dbh->rollback if $oldAutoCommit;
358 #return "checking invoicing_list (transaction rolled back): $error";
361 $self->invoicing_list( $invoicing_list );
364 if ( $conf->config('cust_main-skeleton_tables')
365 && $conf->config('cust_main-skeleton_custnum') ) {
367 warn " inserting skeleton records\n"
370 my $error = $self->start_copy_skel;
372 $dbh->rollback if $oldAutoCommit;
378 warn " ordering packages\n"
381 $error = $self->order_pkgs($cust_pkgs, \$seconds, %options);
383 $dbh->rollback if $oldAutoCommit;
388 $dbh->rollback if $oldAutoCommit;
389 return "No svc_acct record to apply pre-paid time";
393 warn " inserting initial $payby payment of $amount\n"
395 $error = $self->insert_cust_pay($payby, $amount, $prepay_identifier);
397 $dbh->rollback if $oldAutoCommit;
398 return "inserting payment (transaction rolled back): $error";
402 unless ( $import || $skip_fuzzyfiles ) {
403 warn " queueing fuzzyfiles update\n"
405 $error = $self->queue_fuzzyfiles_update;
407 $dbh->rollback if $oldAutoCommit;
408 return "updating fuzzy search cache: $error";
412 warn " insert complete; committing transaction\n"
415 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
420 sub start_copy_skel {
423 #'mg_user_preference' => {},
424 #'mg_user_indicator_profile.user_indicator_profile_id' => { 'mg_profile_indicator.profile_indicator_id' => { 'mg_profile_details.profile_detail_id' }, },
425 #'mg_watchlist_header.watchlist_header_id' => { 'mg_watchlist_details.watchlist_details_id' },
426 #'mg_user_grid_header.grid_header_id' => { 'mg_user_grid_details.user_grid_details_id' },
427 #'mg_portfolio_header.portfolio_header_id' => { 'mg_portfolio_trades.portfolio_trades_id' => { 'mg_portfolio_trades_positions.portfolio_trades_positions_id' } },
428 my @tables = eval($conf->config_binary('cust_main-skeleton_tables'));
431 _copy_skel( 'cust_main', #tablename
432 $conf->config('cust_main-skeleton_custnum'), #sourceid
433 $self->custnum, #destid
434 @tables, #child tables
438 #recursive subroutine, not a method
440 my( $table, $sourceid, $destid, %child_tables ) = @_;
443 if ( $table =~ /^(\w+)\.(\w+)$/ ) {
444 ( $table, $primary_key ) = ( $1, $2 );
446 my $dbdef_table = dbdef->table($table);
447 $primary_key = $dbdef_table->primary_key
448 or return "$table has no primary key".
449 " (or do you need to run dbdef-create?)";
452 warn " _copy_skel: $table.$primary_key $sourceid to $destid for ".
453 join (', ', keys %child_tables). "\n"
456 foreach my $child_table_def ( keys %child_tables ) {
460 if ( $child_table_def =~ /^(\w+)\.(\w+)$/ ) {
461 ( $child_table, $child_pkey ) = ( $1, $2 );
463 $child_table = $child_table_def;
465 $child_pkey = dbdef->table($child_table)->primary_key;
466 # or return "$table has no primary key".
467 # " (or do you need to run dbdef-create?)\n";
471 if ( keys %{ $child_tables{$child_table_def} } ) {
473 return "$child_table has no primary key".
474 " (run dbdef-create or try specifying it?)\n"
477 #false laziness w/Record::insert and only works on Pg
478 #refactor the proper last-inserted-id stuff out of Record::insert if this
479 # ever gets use for anything besides a quick kludge for one customer
480 my $default = dbdef->table($child_table)->column($child_pkey)->default;
481 $default =~ /^nextval\(\(?'"?([\w\.]+)"?'/i
482 or return "can't parse $child_table.$child_pkey default value ".
483 " for sequence name: $default";
488 my @sel_columns = grep { $_ ne $primary_key }
489 dbdef->table($child_table)->columns;
490 my $sel_columns = join(', ', @sel_columns );
492 my @ins_columns = grep { $_ ne $child_pkey } @sel_columns;
493 my $ins_columns = ' ( '. join(', ', $primary_key, @ins_columns ). ' ) ';
494 my $placeholders = ' ( ?, '. join(', ', map '?', @ins_columns ). ' ) ';
496 my $sel_st = "SELECT $sel_columns FROM $child_table".
497 " WHERE $primary_key = $sourceid";
500 my $sel_sth = dbh->prepare( $sel_st )
501 or return dbh->errstr;
503 $sel_sth->execute or return $sel_sth->errstr;
505 while ( my $row = $sel_sth->fetchrow_hashref ) {
507 warn " selected row: ".
508 join(', ', map { "$_=".$row->{$_} } keys %$row ). "\n"
512 "INSERT INTO $child_table $ins_columns VALUES $placeholders";
513 my $ins_sth =dbh->prepare($statement)
514 or return dbh->errstr;
515 my @param = ( $destid, map $row->{$_}, @ins_columns );
516 warn " $statement: [ ". join(', ', @param). " ]\n"
518 $ins_sth->execute( @param )
519 or return $ins_sth->errstr;
521 #next unless keys %{ $child_tables{$child_table} };
522 next unless $sequence;
524 #another section of that laziness
525 my $seq_sql = "SELECT currval('$sequence')";
526 my $seq_sth = dbh->prepare($seq_sql) or return dbh->errstr;
527 $seq_sth->execute or return $seq_sth->errstr;
528 my $insertid = $seq_sth->fetchrow_arrayref->[0];
530 # don't drink soap! recurse! recurse! okay!
532 _copy_skel( $child_table_def,
533 $row->{$child_pkey}, #sourceid
535 %{ $child_tables{$child_table_def} },
537 return $error if $error;
547 =item order_pkgs HASHREF, [ SECONDSREF, [ , OPTION => VALUE ... ] ]
549 Like the insert method on an existing record, this method orders a package
550 and included services atomicaly. Pass a Tie::RefHash data structure to this
551 method containing FS::cust_pkg and FS::svc_I<tablename> objects. There should
552 be a better explanation of this, but until then, here's an example:
555 tie %hash, 'Tie::RefHash'; #this part is important
557 $cust_pkg => [ $svc_acct ],
560 $cust_main->order_pkgs( \%hash, \'0', 'noexport'=>1 );
562 Services can be new, in which case they are inserted, or existing unaudited
563 services, in which case they are linked to the newly-created package.
565 Currently available options are: I<depend_jobnum> and I<noexport>.
567 If I<depend_jobnum> is set, all provisioning jobs will have a dependancy
568 on the supplied jobnum (they will not run until the specific job completes).
569 This can be used to defer provisioning until some action completes (such
570 as running the customer's credit card successfully).
572 The I<noexport> option is deprecated. If I<noexport> is set true, no
573 provisioning jobs (exports) are scheduled. (You can schedule them later with
574 the B<reexport> method for each cust_pkg object. Using the B<reexport> method
575 on the cust_main object is not recommended, as existing services will also be
582 my $cust_pkgs = shift;
585 my %svc_options = ();
586 $svc_options{'depend_jobnum'} = $options{'depend_jobnum'}
587 if exists $options{'depend_jobnum'};
588 warn "$me order_pkgs called with options ".
589 join(', ', map { "$_: $options{$_}" } keys %options ). "\n"
592 local $SIG{HUP} = 'IGNORE';
593 local $SIG{INT} = 'IGNORE';
594 local $SIG{QUIT} = 'IGNORE';
595 local $SIG{TERM} = 'IGNORE';
596 local $SIG{TSTP} = 'IGNORE';
597 local $SIG{PIPE} = 'IGNORE';
599 my $oldAutoCommit = $FS::UID::AutoCommit;
600 local $FS::UID::AutoCommit = 0;
603 local $FS::svc_Common::noexport_hack = 1 if $options{'noexport'};
605 foreach my $cust_pkg ( keys %$cust_pkgs ) {
606 $cust_pkg->custnum( $self->custnum );
607 my $error = $cust_pkg->insert;
609 $dbh->rollback if $oldAutoCommit;
610 return "inserting cust_pkg (transaction rolled back): $error";
612 foreach my $svc_something ( @{$cust_pkgs->{$cust_pkg}} ) {
613 if ( $svc_something->svcnum ) {
614 my $old_cust_svc = $svc_something->cust_svc;
615 my $new_cust_svc = new FS::cust_svc { $old_cust_svc->hash };
616 $new_cust_svc->pkgnum( $cust_pkg->pkgnum);
617 $error = $new_cust_svc->replace($old_cust_svc);
619 $svc_something->pkgnum( $cust_pkg->pkgnum );
620 if ( $seconds && $$seconds && $svc_something->isa('FS::svc_acct') ) {
621 $svc_something->seconds( $svc_something->seconds + $$seconds );
624 $error = $svc_something->insert(%svc_options);
627 $dbh->rollback if $oldAutoCommit;
628 #return "inserting svc_ (transaction rolled back): $error";
634 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
638 =item recharge_prepay IDENTIFIER | PREPAY_CREDIT_OBJ [ , AMOUNTREF, SECONDSREF, UPBYTEREF, DOWNBYTEREF ]
640 Recharges this (existing) customer with the specified prepaid card (see
641 L<FS::prepay_credit>), specified either by I<identifier> or as an
642 FS::prepay_credit object. If there is an error, returns the error, otherwise
645 Optionally, four scalar references can be passed as well. They will have their
646 values filled in with the amount, number of seconds, and number of upload and
647 download bytes applied by this prepaid
652 sub recharge_prepay {
653 my( $self, $prepay_credit, $amountref, $secondsref,
654 $upbytesref, $downbytesref, $totalbytesref ) = @_;
656 local $SIG{HUP} = 'IGNORE';
657 local $SIG{INT} = 'IGNORE';
658 local $SIG{QUIT} = 'IGNORE';
659 local $SIG{TERM} = 'IGNORE';
660 local $SIG{TSTP} = 'IGNORE';
661 local $SIG{PIPE} = 'IGNORE';
663 my $oldAutoCommit = $FS::UID::AutoCommit;
664 local $FS::UID::AutoCommit = 0;
667 my( $amount, $seconds, $upbytes, $downbytes, $totalbytes) = ( 0, 0, 0, 0, 0 );
669 my $error = $self->get_prepay($prepay_credit, \$amount,
670 \$seconds, \$upbytes, \$downbytes, \$totalbytes)
671 || $self->increment_seconds($seconds)
672 || $self->increment_upbytes($upbytes)
673 || $self->increment_downbytes($downbytes)
674 || $self->increment_totalbytes($totalbytes)
675 || $self->insert_cust_pay_prepay( $amount,
677 ? $prepay_credit->identifier
682 $dbh->rollback if $oldAutoCommit;
686 if ( defined($amountref) ) { $$amountref = $amount; }
687 if ( defined($secondsref) ) { $$secondsref = $seconds; }
688 if ( defined($upbytesref) ) { $$upbytesref = $upbytes; }
689 if ( defined($downbytesref) ) { $$downbytesref = $downbytes; }
690 if ( defined($totalbytesref) ) { $$totalbytesref = $totalbytes; }
692 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
697 =item get_prepay IDENTIFIER | PREPAY_CREDIT_OBJ , AMOUNTREF, SECONDSREF
699 Looks up and deletes a prepaid card (see L<FS::prepay_credit>),
700 specified either by I<identifier> or as an FS::prepay_credit object.
702 References to I<amount> and I<seconds> scalars should be passed as arguments
703 and will be incremented by the values of the prepaid card.
705 If the prepaid card specifies an I<agentnum> (see L<FS::agent>), it is used to
706 check or set this customer's I<agentnum>.
708 If there is an error, returns the error, otherwise returns false.
714 my( $self, $prepay_credit, $amountref, $secondsref,
715 $upref, $downref, $totalref) = @_;
717 local $SIG{HUP} = 'IGNORE';
718 local $SIG{INT} = 'IGNORE';
719 local $SIG{QUIT} = 'IGNORE';
720 local $SIG{TERM} = 'IGNORE';
721 local $SIG{TSTP} = 'IGNORE';
722 local $SIG{PIPE} = 'IGNORE';
724 my $oldAutoCommit = $FS::UID::AutoCommit;
725 local $FS::UID::AutoCommit = 0;
728 unless ( ref($prepay_credit) ) {
730 my $identifier = $prepay_credit;
732 $prepay_credit = qsearchs(
734 { 'identifier' => $prepay_credit },
739 unless ( $prepay_credit ) {
740 $dbh->rollback if $oldAutoCommit;
741 return "Invalid prepaid card: ". $identifier;
746 if ( $prepay_credit->agentnum ) {
747 if ( $self->agentnum && $self->agentnum != $prepay_credit->agentnum ) {
748 $dbh->rollback if $oldAutoCommit;
749 return "prepaid card not valid for agent ". $self->agentnum;
751 $self->agentnum($prepay_credit->agentnum);
754 my $error = $prepay_credit->delete;
756 $dbh->rollback if $oldAutoCommit;
757 return "removing prepay_credit (transaction rolled back): $error";
760 $$amountref += $prepay_credit->amount;
761 $$secondsref += $prepay_credit->seconds;
762 $$upref += $prepay_credit->upbytes;
763 $$downref += $prepay_credit->downbytes;
764 $$totalref += $prepay_credit->totalbytes;
766 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
771 =item increment_upbytes SECONDS
773 Updates this customer's single or primary account (see L<FS::svc_acct>) by
774 the specified number of upbytes. If there is an error, returns the error,
775 otherwise returns false.
779 sub increment_upbytes {
780 _increment_column( shift, 'upbytes', @_);
783 =item increment_downbytes SECONDS
785 Updates this customer's single or primary account (see L<FS::svc_acct>) by
786 the specified number of downbytes. If there is an error, returns the error,
787 otherwise returns false.
791 sub increment_downbytes {
792 _increment_column( shift, 'downbytes', @_);
795 =item increment_totalbytes SECONDS
797 Updates this customer's single or primary account (see L<FS::svc_acct>) by
798 the specified number of totalbytes. If there is an error, returns the error,
799 otherwise returns false.
803 sub increment_totalbytes {
804 _increment_column( shift, 'totalbytes', @_);
807 =item increment_seconds SECONDS
809 Updates this customer's single or primary account (see L<FS::svc_acct>) by
810 the specified number of seconds. If there is an error, returns the error,
811 otherwise returns false.
815 sub increment_seconds {
816 _increment_column( shift, 'seconds', @_);
819 =item _increment_column AMOUNT
821 Updates this customer's single or primary account (see L<FS::svc_acct>) by
822 the specified number of seconds or bytes. If there is an error, returns
823 the error, otherwise returns false.
827 sub _increment_column {
828 my( $self, $column, $amount ) = @_;
829 warn "$me increment_column called: $column, $amount\n"
832 return '' unless $amount;
834 my @cust_pkg = grep { $_->part_pkg->svcpart('svc_acct') }
835 $self->ncancelled_pkgs;
838 return 'No packages with primary or single services found'.
839 ' to apply pre-paid time';
840 } elsif ( scalar(@cust_pkg) > 1 ) {
841 #maybe have a way to specify the package/account?
842 return 'Multiple packages found to apply pre-paid time';
845 my $cust_pkg = $cust_pkg[0];
846 warn " found package pkgnum ". $cust_pkg->pkgnum. "\n"
850 $cust_pkg->cust_svc( $cust_pkg->part_pkg->svcpart('svc_acct') );
853 return 'No account found to apply pre-paid time';
854 } elsif ( scalar(@cust_svc) > 1 ) {
855 return 'Multiple accounts found to apply pre-paid time';
858 my $svc_acct = $cust_svc[0]->svc_x;
859 warn " found service svcnum ". $svc_acct->pkgnum.
860 ' ('. $svc_acct->email. ")\n"
863 $column = "increment_$column";
864 $svc_acct->$column($amount);
868 =item insert_cust_pay_prepay AMOUNT [ PAYINFO ]
870 Inserts a prepayment in the specified amount for this customer. An optional
871 second argument can specify the prepayment identifier for tracking purposes.
872 If there is an error, returns the error, otherwise returns false.
876 sub insert_cust_pay_prepay {
877 shift->insert_cust_pay('PREP', @_);
880 =item insert_cust_pay_cash AMOUNT [ PAYINFO ]
882 Inserts a cash payment in the specified amount for this customer. An optional
883 second argument can specify the payment identifier for tracking purposes.
884 If there is an error, returns the error, otherwise returns false.
888 sub insert_cust_pay_cash {
889 shift->insert_cust_pay('CASH', @_);
892 =item insert_cust_pay_west AMOUNT [ PAYINFO ]
894 Inserts a Western Union payment in the specified amount for this customer. An
895 optional second argument can specify the prepayment identifier for tracking
896 purposes. If there is an error, returns the error, otherwise returns false.
900 sub insert_cust_pay_west {
901 shift->insert_cust_pay('WEST', @_);
904 sub insert_cust_pay {
905 my( $self, $payby, $amount ) = splice(@_, 0, 3);
906 my $payinfo = scalar(@_) ? shift : '';
908 my $cust_pay = new FS::cust_pay {
909 'custnum' => $self->custnum,
910 'paid' => sprintf('%.2f', $amount),
911 #'_date' => #date the prepaid card was purchased???
913 'payinfo' => $payinfo,
921 This method is deprecated. See the I<depend_jobnum> option to the insert and
922 order_pkgs methods for a better way to defer provisioning.
924 Re-schedules all exports by calling the B<reexport> method of all associated
925 packages (see L<FS::cust_pkg>). If there is an error, returns the error;
926 otherwise returns false.
933 carp "WARNING: FS::cust_main::reexport is deprectated; ".
934 "use the depend_jobnum option to insert or order_pkgs to delay export";
936 local $SIG{HUP} = 'IGNORE';
937 local $SIG{INT} = 'IGNORE';
938 local $SIG{QUIT} = 'IGNORE';
939 local $SIG{TERM} = 'IGNORE';
940 local $SIG{TSTP} = 'IGNORE';
941 local $SIG{PIPE} = 'IGNORE';
943 my $oldAutoCommit = $FS::UID::AutoCommit;
944 local $FS::UID::AutoCommit = 0;
947 foreach my $cust_pkg ( $self->ncancelled_pkgs ) {
948 my $error = $cust_pkg->reexport;
950 $dbh->rollback if $oldAutoCommit;
955 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
960 =item delete NEW_CUSTNUM
962 This deletes the customer. If there is an error, returns the error, otherwise
965 This will completely remove all traces of the customer record. This is not
966 what you want when a customer cancels service; for that, cancel all of the
967 customer's packages (see L</cancel>).
969 If the customer has any uncancelled packages, you need to pass a new (valid)
970 customer number for those packages to be transferred to. Cancelled packages
971 will be deleted. Did I mention that this is NOT what you want when a customer
972 cancels service and that you really should be looking see L<FS::cust_pkg/cancel>?
974 You can't delete a customer with invoices (see L<FS::cust_bill>),
975 or credits (see L<FS::cust_credit>), payments (see L<FS::cust_pay>) or
976 refunds (see L<FS::cust_refund>).
983 local $SIG{HUP} = 'IGNORE';
984 local $SIG{INT} = 'IGNORE';
985 local $SIG{QUIT} = 'IGNORE';
986 local $SIG{TERM} = 'IGNORE';
987 local $SIG{TSTP} = 'IGNORE';
988 local $SIG{PIPE} = 'IGNORE';
990 my $oldAutoCommit = $FS::UID::AutoCommit;
991 local $FS::UID::AutoCommit = 0;
994 if ( $self->cust_bill ) {
995 $dbh->rollback if $oldAutoCommit;
996 return "Can't delete a customer with invoices";
998 if ( $self->cust_credit ) {
999 $dbh->rollback if $oldAutoCommit;
1000 return "Can't delete a customer with credits";
1002 if ( $self->cust_pay ) {
1003 $dbh->rollback if $oldAutoCommit;
1004 return "Can't delete a customer with payments";
1006 if ( $self->cust_refund ) {
1007 $dbh->rollback if $oldAutoCommit;
1008 return "Can't delete a customer with refunds";
1011 my @cust_pkg = $self->ncancelled_pkgs;
1013 my $new_custnum = shift;
1014 unless ( qsearchs( 'cust_main', { 'custnum' => $new_custnum } ) ) {
1015 $dbh->rollback if $oldAutoCommit;
1016 return "Invalid new customer number: $new_custnum";
1018 foreach my $cust_pkg ( @cust_pkg ) {
1019 my %hash = $cust_pkg->hash;
1020 $hash{'custnum'} = $new_custnum;
1021 my $new_cust_pkg = new FS::cust_pkg ( \%hash );
1022 my $error = $new_cust_pkg->replace($cust_pkg,
1023 options => { $cust_pkg->options },
1026 $dbh->rollback if $oldAutoCommit;
1031 my @cancelled_cust_pkg = $self->all_pkgs;
1032 foreach my $cust_pkg ( @cancelled_cust_pkg ) {
1033 my $error = $cust_pkg->delete;
1035 $dbh->rollback if $oldAutoCommit;
1040 foreach my $cust_main_invoice ( #(email invoice destinations, not invoices)
1041 qsearch( 'cust_main_invoice', { 'custnum' => $self->custnum } )
1043 my $error = $cust_main_invoice->delete;
1045 $dbh->rollback if $oldAutoCommit;
1050 my $error = $self->SUPER::delete;
1052 $dbh->rollback if $oldAutoCommit;
1056 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1061 =item replace OLD_RECORD [ INVOICING_LIST_ARYREF ]
1063 Replaces the OLD_RECORD with this one in the database. If there is an error,
1064 returns the error, otherwise returns false.
1066 INVOICING_LIST_ARYREF: If you pass an arrarref to the insert method, it will
1067 be set as the invoicing list (see L<"invoicing_list">). Errors return as
1068 expected and rollback the entire transaction; it is not necessary to call
1069 check_invoicing_list first. Here's an example:
1071 $new_cust_main->replace( $old_cust_main, [ $email, 'POST' ] );
1079 warn "$me replace called\n"
1082 local $SIG{HUP} = 'IGNORE';
1083 local $SIG{INT} = 'IGNORE';
1084 local $SIG{QUIT} = 'IGNORE';
1085 local $SIG{TERM} = 'IGNORE';
1086 local $SIG{TSTP} = 'IGNORE';
1087 local $SIG{PIPE} = 'IGNORE';
1089 # We absolutely have to have an old vs. new record to make this work.
1090 if (!defined($old)) {
1091 $old = qsearchs( 'cust_main', { 'custnum' => $self->custnum } );
1094 my $curuser = $FS::CurrentUser::CurrentUser;
1095 if ( $self->payby eq 'COMP'
1096 && $self->payby ne $old->payby
1097 && ! $curuser->access_right('Complimentary customer')
1100 return "You are not permitted to create complimentary accounts.";
1103 local($ignore_expired_card) = 1
1104 if $old->payby =~ /^(CARD|DCRD)$/
1105 && $self->payby =~ /^(CARD|DCRD)$/
1106 && ( $old->payinfo eq $self->payinfo || $old->paymask eq $self->paymask );
1108 my $oldAutoCommit = $FS::UID::AutoCommit;
1109 local $FS::UID::AutoCommit = 0;
1112 my $error = $self->SUPER::replace($old);
1115 $dbh->rollback if $oldAutoCommit;
1119 if ( @param ) { # INVOICING_LIST_ARYREF
1120 my $invoicing_list = shift @param;
1121 $error = $self->check_invoicing_list( $invoicing_list );
1123 $dbh->rollback if $oldAutoCommit;
1126 $self->invoicing_list( $invoicing_list );
1129 if ( $self->payby =~ /^(CARD|CHEK|LECB)$/ &&
1130 grep { $self->get($_) ne $old->get($_) } qw(payinfo paydate payname) ) {
1131 # card/check/lec info has changed, want to retry realtime_ invoice events
1132 my $error = $self->retry_realtime;
1134 $dbh->rollback if $oldAutoCommit;
1139 unless ( $import || $skip_fuzzyfiles ) {
1140 $error = $self->queue_fuzzyfiles_update;
1142 $dbh->rollback if $oldAutoCommit;
1143 return "updating fuzzy search cache: $error";
1147 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1152 =item queue_fuzzyfiles_update
1154 Used by insert & replace to update the fuzzy search cache
1158 sub queue_fuzzyfiles_update {
1161 local $SIG{HUP} = 'IGNORE';
1162 local $SIG{INT} = 'IGNORE';
1163 local $SIG{QUIT} = 'IGNORE';
1164 local $SIG{TERM} = 'IGNORE';
1165 local $SIG{TSTP} = 'IGNORE';
1166 local $SIG{PIPE} = 'IGNORE';
1168 my $oldAutoCommit = $FS::UID::AutoCommit;
1169 local $FS::UID::AutoCommit = 0;
1172 my $queue = new FS::queue { 'job' => 'FS::cust_main::append_fuzzyfiles' };
1173 my $error = $queue->insert( map $self->getfield($_),
1174 qw(first last company)
1177 $dbh->rollback if $oldAutoCommit;
1178 return "queueing job (transaction rolled back): $error";
1181 if ( $self->ship_last ) {
1182 $queue = new FS::queue { 'job' => 'FS::cust_main::append_fuzzyfiles' };
1183 $error = $queue->insert( map $self->getfield("ship_$_"),
1184 qw(first last company)
1187 $dbh->rollback if $oldAutoCommit;
1188 return "queueing job (transaction rolled back): $error";
1192 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1199 Checks all fields to make sure this is a valid customer record. If there is
1200 an error, returns the error, otherwise returns false. Called by the insert
1201 and replace methods.
1208 warn "$me check BEFORE: \n". $self->_dump
1212 $self->ut_numbern('custnum')
1213 || $self->ut_number('agentnum')
1214 || $self->ut_textn('agent_custid')
1215 || $self->ut_number('refnum')
1216 || $self->ut_textn('custbatch')
1217 || $self->ut_name('last')
1218 || $self->ut_name('first')
1219 || $self->ut_snumbern('birthdate')
1220 || $self->ut_snumbern('signupdate')
1221 || $self->ut_textn('company')
1222 || $self->ut_text('address1')
1223 || $self->ut_textn('address2')
1224 || $self->ut_text('city')
1225 || $self->ut_textn('county')
1226 || $self->ut_textn('state')
1227 || $self->ut_country('country')
1228 || $self->ut_anything('comments')
1229 || $self->ut_numbern('referral_custnum')
1230 || $self->ut_textn('stateid')
1231 || $self->ut_textn('stateid_state')
1233 #barf. need message catalogs. i18n. etc.
1234 $error .= "Please select an advertising source."
1235 if $error =~ /^Illegal or empty \(numeric\) refnum: /;
1236 return $error if $error;
1238 return "Unknown agent"
1239 unless qsearchs( 'agent', { 'agentnum' => $self->agentnum } );
1241 return "Unknown refnum"
1242 unless qsearchs( 'part_referral', { 'refnum' => $self->refnum } );
1244 return "Unknown referring custnum: ". $self->referral_custnum
1245 unless ! $self->referral_custnum
1246 || qsearchs( 'cust_main', { 'custnum' => $self->referral_custnum } );
1248 if ( $self->ss eq '' ) {
1253 $ss =~ /^(\d{3})(\d{2})(\d{4})$/
1254 or return "Illegal social security number: ". $self->ss;
1255 $self->ss("$1-$2-$3");
1259 # bad idea to disable, causes billing to fail because of no tax rates later
1260 # unless ( $import ) {
1261 unless ( qsearch('cust_main_county', {
1262 'country' => $self->country,
1265 return "Unknown state/county/country: ".
1266 $self->state. "/". $self->county. "/". $self->country
1267 unless qsearch('cust_main_county',{
1268 'state' => $self->state,
1269 'county' => $self->county,
1270 'country' => $self->country,
1276 $self->ut_phonen('daytime', $self->country)
1277 || $self->ut_phonen('night', $self->country)
1278 || $self->ut_phonen('fax', $self->country)
1279 || $self->ut_zip('zip', $self->country)
1281 return $error if $error;
1283 if ( $conf->exists('cust_main-require_phone')
1284 && ! length($self->daytime) && ! length($self->night)
1287 my $daytime_label = FS::Msgcat::_gettext('daytime') =~ /^(daytime)?$/
1289 : FS::Msgcat::_gettext('daytime');
1290 my $night_label = FS::Msgcat::_gettext('night') =~ /^(night)?$/
1292 : FS::Msgcat::_gettext('night');
1294 return "$daytime_label or $night_label is required"
1298 if ( $self->has_ship_address
1299 && scalar ( grep { $self->getfield($_) ne $self->getfield("ship_$_") }
1300 $self->addr_fields )
1304 $self->ut_name('ship_last')
1305 || $self->ut_name('ship_first')
1306 || $self->ut_textn('ship_company')
1307 || $self->ut_text('ship_address1')
1308 || $self->ut_textn('ship_address2')
1309 || $self->ut_text('ship_city')
1310 || $self->ut_textn('ship_county')
1311 || $self->ut_textn('ship_state')
1312 || $self->ut_country('ship_country')
1314 return $error if $error;
1316 #false laziness with above
1317 unless ( qsearchs('cust_main_county', {
1318 'country' => $self->ship_country,
1321 return "Unknown ship_state/ship_county/ship_country: ".
1322 $self->ship_state. "/". $self->ship_county. "/". $self->ship_country
1323 unless qsearch('cust_main_county',{
1324 'state' => $self->ship_state,
1325 'county' => $self->ship_county,
1326 'country' => $self->ship_country,
1332 $self->ut_phonen('ship_daytime', $self->ship_country)
1333 || $self->ut_phonen('ship_night', $self->ship_country)
1334 || $self->ut_phonen('ship_fax', $self->ship_country)
1335 || $self->ut_zip('ship_zip', $self->ship_country)
1337 return $error if $error;
1339 return "Unit # is required."
1340 if $self->ship_address2 =~ /^\s*$/
1341 && $conf->exists('cust_main-require_address2');
1343 } else { # ship_ info eq billing info, so don't store dup info in database
1345 $self->setfield("ship_$_", '')
1346 foreach $self->addr_fields;
1348 return "Unit # is required."
1349 if $self->address2 =~ /^\s*$/
1350 && $conf->exists('cust_main-require_address2');
1354 #$self->payby =~ /^(CARD|DCRD|CHEK|DCHK|LECB|BILL|COMP|PREPAY|CASH|WEST|MCRD)$/
1355 # or return "Illegal payby: ". $self->payby;
1357 FS::payby->can_payby($self->table, $self->payby)
1358 or return "Illegal payby: ". $self->payby;
1360 $error = $self->ut_numbern('paystart_month')
1361 || $self->ut_numbern('paystart_year')
1362 || $self->ut_numbern('payissue')
1363 || $self->ut_textn('paytype')
1365 return $error if $error;
1367 if ( $self->payip eq '' ) {
1370 $error = $self->ut_ip('payip');
1371 return $error if $error;
1374 # If it is encrypted and the private key is not availaible then we can't
1375 # check the credit card.
1377 my $check_payinfo = 1;
1379 if ($self->is_encrypted($self->payinfo)) {
1383 if ( $check_payinfo && $self->payby =~ /^(CARD|DCRD)$/ ) {
1385 my $payinfo = $self->payinfo;
1386 $payinfo =~ s/\D//g;
1387 $payinfo =~ /^(\d{13,16})$/
1388 or return gettext('invalid_card'); # . ": ". $self->payinfo;
1390 $self->payinfo($payinfo);
1392 or return gettext('invalid_card'); # . ": ". $self->payinfo;
1394 return gettext('unknown_card_type')
1395 if cardtype($self->payinfo) eq "Unknown";
1397 my $ban = qsearchs('banned_pay', $self->_banned_pay_hashref);
1399 return 'Banned credit card: banned on '.
1400 time2str('%a %h %o at %r', $ban->_date).
1401 ' by '. $ban->otaker.
1402 ' (ban# '. $ban->bannum. ')';
1405 if (length($self->paycvv) && !$self->is_encrypted($self->paycvv)) {
1406 if ( cardtype($self->payinfo) eq 'American Express card' ) {
1407 $self->paycvv =~ /^(\d{4})$/
1408 or return "CVV2 (CID) for American Express cards is four digits.";
1411 $self->paycvv =~ /^(\d{3})$/
1412 or return "CVV2 (CVC2/CID) is three digits.";
1419 my $cardtype = cardtype($payinfo);
1420 if ( $cardtype =~ /^(Switch|Solo)$/i ) {
1422 return "Start date or issue number is required for $cardtype cards"
1423 unless $self->paystart_month && $self->paystart_year or $self->payissue;
1425 return "Start month must be between 1 and 12"
1426 if $self->paystart_month
1427 and $self->paystart_month < 1 || $self->paystart_month > 12;
1429 return "Start year must be 1990 or later"
1430 if $self->paystart_year
1431 and $self->paystart_year < 1990;
1433 return "Issue number must be beween 1 and 99"
1435 and $self->payissue < 1 || $self->payissue > 99;
1438 $self->paystart_month('');
1439 $self->paystart_year('');
1440 $self->payissue('');
1443 } elsif ( $check_payinfo && $self->payby =~ /^(CHEK|DCHK)$/ ) {
1445 my $payinfo = $self->payinfo;
1446 $payinfo =~ s/[^\d\@]//g;
1447 if ( $conf->exists('echeck-nonus') ) {
1448 $payinfo =~ /^(\d+)\@(\d+)$/ or return 'invalid echeck account@aba';
1449 $payinfo = "$1\@$2";
1451 $payinfo =~ /^(\d+)\@(\d{9})$/ or return 'invalid echeck account@aba';
1452 $payinfo = "$1\@$2";
1454 $self->payinfo($payinfo);
1457 my $ban = qsearchs('banned_pay', $self->_banned_pay_hashref);
1459 return 'Banned ACH account: banned on '.
1460 time2str('%a %h %o at %r', $ban->_date).
1461 ' by '. $ban->otaker.
1462 ' (ban# '. $ban->bannum. ')';
1465 } elsif ( $self->payby eq 'LECB' ) {
1467 my $payinfo = $self->payinfo;
1468 $payinfo =~ s/\D//g;
1469 $payinfo =~ /^1?(\d{10})$/ or return 'invalid btn billing telephone number';
1471 $self->payinfo($payinfo);
1474 } elsif ( $self->payby eq 'BILL' ) {
1476 $error = $self->ut_textn('payinfo');
1477 return "Illegal P.O. number: ". $self->payinfo if $error;
1480 } elsif ( $self->payby eq 'COMP' ) {
1482 my $curuser = $FS::CurrentUser::CurrentUser;
1483 if ( ! $self->custnum
1484 && ! $curuser->access_right('Complimentary customer')
1487 return "You are not permitted to create complimentary accounts."
1490 $error = $self->ut_textn('payinfo');
1491 return "Illegal comp account issuer: ". $self->payinfo if $error;
1494 } elsif ( $self->payby eq 'PREPAY' ) {
1496 my $payinfo = $self->payinfo;
1497 $payinfo =~ s/\W//g; #anything else would just confuse things
1498 $self->payinfo($payinfo);
1499 $error = $self->ut_alpha('payinfo');
1500 return "Illegal prepayment identifier: ". $self->payinfo if $error;
1501 return "Unknown prepayment identifier"
1502 unless qsearchs('prepay_credit', { 'identifier' => $self->payinfo } );
1507 if ( $self->paydate eq '' || $self->paydate eq '-' ) {
1508 return "Expiration date required"
1509 unless $self->payby =~ /^(BILL|PREPAY|CHEK|DCHK|LECB|CASH|WEST|MCRD)$/;
1513 if ( $self->paydate =~ /^(\d{1,2})[\/\-](\d{2}(\d{2})?)$/ ) {
1514 ( $m, $y ) = ( $1, length($2) == 4 ? $2 : "20$2" );
1515 } elsif ( $self->paydate =~ /^(20)?(\d{2})[\/\-](\d{1,2})[\/\-]\d+$/ ) {
1516 ( $m, $y ) = ( $3, "20$2" );
1518 return "Illegal expiration date: ". $self->paydate;
1520 $self->paydate("$y-$m-01");
1521 my($nowm,$nowy)=(localtime(time))[4,5]; $nowm++; $nowy+=1900;
1522 return gettext('expired_card')
1524 && !$ignore_expired_card
1525 && ( $y<$nowy || ( $y==$nowy && $1<$nowm ) );
1528 if ( $self->payname eq '' && $self->payby !~ /^(CHEK|DCHK)$/ &&
1529 ( ! $conf->exists('require_cardname')
1530 || $self->payby !~ /^(CARD|DCRD)$/ )
1532 $self->payname( $self->first. " ". $self->getfield('last') );
1534 $self->payname =~ /^([\w \,\.\-\'\&]+)$/
1535 or return gettext('illegal_name'). " payname: ". $self->payname;
1539 foreach my $flag (qw( tax spool_cdr )) {
1540 $self->$flag() =~ /^(Y?)$/ or return "Illegal $flag: ". $self->$flag();
1544 $self->otaker(getotaker) unless $self->otaker;
1546 warn "$me check AFTER: \n". $self->_dump
1549 $self->SUPER::check;
1554 Returns a list of fields which have ship_ duplicates.
1559 qw( last first company
1560 address1 address2 city county state zip country
1565 =item has_ship_address
1567 Returns true if this customer record has a separate shipping address.
1571 sub has_ship_address {
1573 scalar( grep { $self->getfield("ship_$_") ne '' } $self->addr_fields );
1578 Returns all packages (see L<FS::cust_pkg>) for this customer.
1585 return $self->num_pkgs unless wantarray;
1588 if ( $self->{'_pkgnum'} ) {
1589 @cust_pkg = values %{ $self->{'_pkgnum'}->cache };
1591 @cust_pkg = qsearch( 'cust_pkg', { 'custnum' => $self->custnum });
1594 sort sort_packages @cust_pkg;
1597 =item ncancelled_pkgs
1599 Returns all non-cancelled packages (see L<FS::cust_pkg>) for this customer.
1603 sub ncancelled_pkgs {
1606 return $self->num_ncancelled_pkgs unless wantarray;
1609 if ( $self->{'_pkgnum'} ) {
1611 @cust_pkg = grep { ! $_->getfield('cancel') }
1612 values %{ $self->{'_pkgnum'}->cache };
1617 qsearch( 'cust_pkg', {
1618 'custnum' => $self->custnum,
1622 qsearch( 'cust_pkg', {
1623 'custnum' => $self->custnum,
1628 sort sort_packages @cust_pkg;
1632 # This should be generalized to use config options to determine order.
1634 if ( $a->get('cancel') and $b->get('cancel') ) {
1635 $a->pkgnum <=> $b->pkgnum;
1636 } elsif ( $a->get('cancel') or $b->get('cancel') ) {
1637 return -1 if $b->get('cancel');
1638 return 1 if $a->get('cancel');
1641 $a->pkgnum <=> $b->pkgnum;
1645 =item suspended_pkgs
1647 Returns all suspended packages (see L<FS::cust_pkg>) for this customer.
1651 sub suspended_pkgs {
1653 grep { $_->susp } $self->ncancelled_pkgs;
1656 =item unflagged_suspended_pkgs
1658 Returns all unflagged suspended packages (see L<FS::cust_pkg>) for this
1659 customer (thouse packages without the `manual_flag' set).
1663 sub unflagged_suspended_pkgs {
1665 return $self->suspended_pkgs
1666 unless dbdef->table('cust_pkg')->column('manual_flag');
1667 grep { ! $_->manual_flag } $self->suspended_pkgs;
1670 =item unsuspended_pkgs
1672 Returns all unsuspended (and uncancelled) packages (see L<FS::cust_pkg>) for
1677 sub unsuspended_pkgs {
1679 grep { ! $_->susp } $self->ncancelled_pkgs;
1682 =item num_cancelled_pkgs
1684 Returns the number of cancelled packages (see L<FS::cust_pkg>) for this
1689 sub num_cancelled_pkgs {
1690 shift->num_pkgs("cust_pkg.cancel IS NOT NULL AND cust_pkg.cancel != 0");
1693 sub num_ncancelled_pkgs {
1694 shift->num_pkgs("( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )");
1698 my( $self ) = shift;
1699 my $sql = scalar(@_) ? shift : '';
1700 $sql = "AND $sql" if $sql && $sql !~ /^\s*$/ && $sql !~ /^\s*AND/i;
1701 my $sth = dbh->prepare(
1702 "SELECT COUNT(*) FROM cust_pkg WHERE custnum = ? $sql"
1703 ) or die dbh->errstr;
1704 $sth->execute($self->custnum) or die $sth->errstr;
1705 $sth->fetchrow_arrayref->[0];
1710 Unsuspends all unflagged suspended packages (see L</unflagged_suspended_pkgs>
1711 and L<FS::cust_pkg>) for this customer. Always returns a list: an empty list
1712 on success or a list of errors.
1718 grep { $_->unsuspend } $self->suspended_pkgs;
1723 Suspends all unsuspended packages (see L<FS::cust_pkg>) for this customer.
1725 Returns a list: an empty list on success or a list of errors.
1731 grep { $_->suspend(@_) } $self->unsuspended_pkgs;
1734 =item suspend_if_pkgpart PKGPART [ , PKGPART ... ]
1736 Suspends all unsuspended packages (see L<FS::cust_pkg>) matching the listed
1737 PKGPARTs (see L<FS::part_pkg>).
1739 Returns a list: an empty list on success or a list of errors.
1743 sub suspend_if_pkgpart {
1745 my (@pkgparts, %opt);
1746 if (ref($_[0]) eq 'HASH'){
1747 @pkgparts = @{$_[0]{pkgparts}};
1752 grep { $_->suspend(%opt) }
1753 grep { my $pkgpart = $_->pkgpart; grep { $pkgpart eq $_ } @pkgparts }
1754 $self->unsuspended_pkgs;
1757 =item suspend_unless_pkgpart PKGPART [ , PKGPART ... ]
1759 Suspends all unsuspended packages (see L<FS::cust_pkg>) unless they match the
1760 listed PKGPARTs (see L<FS::part_pkg>).
1762 Returns a list: an empty list on success or a list of errors.
1766 sub suspend_unless_pkgpart {
1768 my (@pkgparts, %opt);
1769 if (ref($_[0]) eq 'HASH'){
1770 @pkgparts = @{$_[0]{pkgparts}};
1775 grep { $_->suspend(%opt) }
1776 grep { my $pkgpart = $_->pkgpart; ! grep { $pkgpart eq $_ } @pkgparts }
1777 $self->unsuspended_pkgs;
1780 =item cancel [ OPTION => VALUE ... ]
1782 Cancels all uncancelled packages (see L<FS::cust_pkg>) for this customer.
1784 Available options are: I<quiet>, I<reasonnum>, and I<ban>
1786 I<quiet> can be set true to supress email cancellation notices.
1788 # I<reasonnum> can be set to a cancellation reason (see L<FS::cancel_reason>)
1790 I<ban> can be set true to ban this customer's credit card or ACH information,
1793 Always returns a list: an empty list on success or a list of errors.
1801 if ( $opt{'ban'} && $self->payby =~ /^(CARD|DCRD|CHEK|DCHK)$/ ) {
1803 #should try decryption (we might have the private key)
1804 # and if not maybe queue a job for the server that does?
1805 return ( "Can't (yet) ban encrypted credit cards" )
1806 if $self->is_encrypted($self->payinfo);
1808 my $ban = new FS::banned_pay $self->_banned_pay_hashref;
1809 my $error = $ban->insert;
1810 return ( $error ) if $error;
1814 grep { $_ } map { $_->cancel(@_) } $self->ncancelled_pkgs;
1817 sub _banned_pay_hashref {
1828 'payby' => $payby2ban{$self->payby},
1829 'payinfo' => md5_base64($self->payinfo),
1830 #don't ever *search* on reason! #'reason' =>
1836 Returns all notes (see L<FS::cust_main_note>) for this customer.
1843 qsearch( 'cust_main_note',
1844 { 'custnum' => $self->custnum },
1846 'ORDER BY _DATE DESC'
1852 Returns the agent (see L<FS::agent>) for this customer.
1858 qsearchs( 'agent', { 'agentnum' => $self->agentnum } );
1863 Generates invoices (see L<FS::cust_bill>) for this customer. Usually used in
1864 conjunction with the collect method.
1866 If there is an error, returns the error, otherwise returns false.
1868 Options are passed as name-value pairs. Currently available options are:
1872 =item resetup - if set true, re-charges setup fees.
1874 =item time - 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:
1878 $cust_main->bill( 'time' => str2time('April 20th, 2001') );
1880 =item invoice_time - 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.
1887 my( $self, %options ) = @_;
1888 return '' if $self->payby eq 'COMP';
1889 warn "$me bill customer ". $self->custnum. "\n"
1892 my $time = $options{'time'} || time;
1897 local $SIG{HUP} = 'IGNORE';
1898 local $SIG{INT} = 'IGNORE';
1899 local $SIG{QUIT} = 'IGNORE';
1900 local $SIG{TERM} = 'IGNORE';
1901 local $SIG{TSTP} = 'IGNORE';
1902 local $SIG{PIPE} = 'IGNORE';
1904 my $oldAutoCommit = $FS::UID::AutoCommit;
1905 local $FS::UID::AutoCommit = 0;
1908 $self->select_for_update; #mutex
1910 #create a new invoice
1911 #(we'll remove it later if it doesn't actually need to be generated [contains
1912 # no line items] and we're inside a transaciton so nothing else will see it)
1913 my $cust_bill = new FS::cust_bill ( {
1914 'custnum' => $self->custnum,
1915 '_date' => ( $options{'invoice_time'} || $time ),
1916 #'charged' => $charged,
1919 $error = $cust_bill->insert;
1921 $dbh->rollback if $oldAutoCommit;
1922 return "can't create invoice for customer #". $self->custnum. ": $error";
1924 my $invnum = $cust_bill->invnum;
1927 # find the packages which are due for billing, find out how much they are
1928 # & generate invoice database.
1931 my( $total_setup, $total_recur, $postal_charge ) = ( 0, 0, 0 );
1933 my @precommit_hooks = ();
1935 my @cust_pkgs = qsearch('cust_pkg', { 'custnum' => $self->custnum } );
1936 foreach my $cust_pkg (@cust_pkgs) {
1938 #NO!! next if $cust_pkg->cancel;
1939 next if $cust_pkg->getfield('cancel');
1941 warn " bill package ". $cust_pkg->pkgnum. "\n" if $DEBUG > 1;
1943 #? to avoid use of uninitialized value errors... ?
1944 $cust_pkg->setfield('bill', '')
1945 unless defined($cust_pkg->bill);
1947 my $part_pkg = $cust_pkg->part_pkg;
1949 my %hash = $cust_pkg->hash;
1950 my $old_cust_pkg = new FS::cust_pkg \%hash;
1960 if ( ! $cust_pkg->setup &&
1962 ( $conf->exists('disable_setup_suspended_pkgs') &&
1963 ! $cust_pkg->getfield('susp')
1964 ) || ! $conf->exists('disable_setup_suspended_pkgs')
1966 || $options{'resetup'}
1969 warn " bill setup\n" if $DEBUG > 1;
1971 $setup = eval { $cust_pkg->calc_setup( $time, \@details ) };
1973 $dbh->rollback if $oldAutoCommit;
1974 return "$@ running calc_setup for $cust_pkg\n";
1977 $unitsetup = $cust_pkg->part_pkg->unit_setup || $setup; #XXX uuh
1979 $cust_pkg->setfield('setup', $time) unless $cust_pkg->setup;
1983 # bill recurring fee
1986 #XXX unit stuff here too
1990 if ( $part_pkg->getfield('freq') ne '0' &&
1991 ! $cust_pkg->getfield('susp') &&
1992 ( $cust_pkg->getfield('bill') || 0 ) <= $time
1995 # XXX should this be a package event? probably. events are called
1996 # at collection time at the moment, though...
1997 if ( $part_pkg->can('reset_usage') ) {
1998 warn " resetting usage counters" if $DEBUG > 1;
1999 $part_pkg->reset_usage($cust_pkg);
2002 warn " bill recur\n" if $DEBUG > 1;
2004 # XXX shared with $recur_prog
2005 $sdate = $cust_pkg->bill || $cust_pkg->setup || $time;
2007 #over two params! lets at least switch to a hashref for the rest...
2008 my %param = ( 'precommit_hooks' => \@precommit_hooks, );
2010 $recur = eval { $cust_pkg->calc_recur( \$sdate, \@details, \%param ) };
2012 $dbh->rollback if $oldAutoCommit;
2013 return "$@ running calc_recur for $cust_pkg\n";
2016 #change this bit to use Date::Manip? CAREFUL with timezones (see
2017 # mailing list archive)
2018 my ($sec,$min,$hour,$mday,$mon,$year) =
2019 (localtime($sdate) )[0,1,2,3,4,5];
2021 #pro-rating magic - if $recur_prog fiddles $sdate, want to use that
2022 # only for figuring next bill date, nothing else, so, reset $sdate again
2024 $sdate = $cust_pkg->bill || $cust_pkg->setup || $time;
2025 #no need, its in $hash{last_bill}# my $last_bill = $cust_pkg->last_bill;
2026 $cust_pkg->last_bill($sdate);
2028 if ( $part_pkg->freq =~ /^\d+$/ ) {
2029 $mon += $part_pkg->freq;
2030 until ( $mon < 12 ) { $mon -= 12; $year++; }
2031 } elsif ( $part_pkg->freq =~ /^(\d+)w$/ ) {
2033 $mday += $weeks * 7;
2034 } elsif ( $part_pkg->freq =~ /^(\d+)d$/ ) {
2037 } elsif ( $part_pkg->freq =~ /^(\d+)h$/ ) {
2041 $dbh->rollback if $oldAutoCommit;
2042 return "unparsable frequency: ". $part_pkg->freq;
2044 $cust_pkg->setfield('bill',
2045 timelocal_nocheck($sec,$min,$hour,$mday,$mon,$year));
2048 warn "\$setup is undefined" unless defined($setup);
2049 warn "\$recur is undefined" unless defined($recur);
2050 warn "\$cust_pkg->bill is undefined" unless defined($cust_pkg->bill);
2053 # If $cust_pkg has been modified, update it and create cust_bill_pkg records
2056 if ( $cust_pkg->modified ) { # hmmm.. and if the options are modified?
2058 warn " package ". $cust_pkg->pkgnum. " modified; updating\n"
2061 $error=$cust_pkg->replace($old_cust_pkg,
2062 options => { $cust_pkg->options },
2064 if ( $error ) { #just in case
2065 $dbh->rollback if $oldAutoCommit;
2066 return "Error modifying pkgnum ". $cust_pkg->pkgnum. ": $error";
2069 $setup = sprintf( "%.2f", $setup );
2070 $recur = sprintf( "%.2f", $recur );
2071 if ( $setup < 0 && ! $conf->exists('allow_negative_charges') ) {
2072 $dbh->rollback if $oldAutoCommit;
2073 return "negative setup $setup for pkgnum ". $cust_pkg->pkgnum;
2075 if ( $recur < 0 && ! $conf->exists('allow_negative_charges') ) {
2076 $dbh->rollback if $oldAutoCommit;
2077 return "negative recur $recur for pkgnum ". $cust_pkg->pkgnum;
2080 if ( $setup != 0 || $recur != 0 ) {
2082 unless ($postal_charge) {
2083 $postal_charge = 1; # try only once
2084 my $postal_pkg = $self->charge_postal_fee();
2085 if ( $postal_pkg && !ref( $postal_pkg ) ) {
2086 $dbh->rollback if $oldAutoCommit;
2087 return "can't charge postal invoice fee for customer ".
2088 $self->custnum. ": $postal_pkg";
2090 push @cust_pkgs, $postal_pkg if $postal_pkg;
2093 warn " charges (setup=$setup, recur=$recur); adding line items\n"
2096 push @details, map { $_->detail } $cust_pkg->cust_pkg_detail('I');
2098 my $cust_bill_pkg = new FS::cust_bill_pkg ({
2099 'invnum' => $invnum,
2100 'pkgnum' => $cust_pkg->pkgnum,
2102 'unitsetup' => $unitsetup,
2104 'unitrecur' => $unitrecur,
2105 'quantity' => $cust_pkg->quantity,
2106 'details' => \@details,
2109 if ( $part_pkg->option('recur_temporality', 1) eq 'preceding' ) {
2110 $cust_bill_pkg->sdate( $hash{last_bill} );
2111 $cust_bill_pkg->edate( $sdate - 86399 ); #60s*60m*24h-1
2112 } else { #if ( $part_pkg->option('recur_temporality', 1) eq 'upcoming' ) {
2113 $cust_bill_pkg->sdate( $sdate );
2114 $cust_bill_pkg->edate( $cust_pkg->bill );
2117 $error = $cust_bill_pkg->insert;
2119 $dbh->rollback if $oldAutoCommit;
2120 return "can't create invoice line item for invoice #$invnum: $error";
2122 $total_setup += $setup;
2123 $total_recur += $recur;
2129 unless ( $self->tax =~ /Y/i || $self->payby eq 'COMP' ) {
2132 ( $conf->exists('tax-ship_address') && length($self->ship_last) )
2135 my %taxhash = map { $_ => $self->get("$prefix$_") }
2136 qw( state county country );
2138 $taxhash{'taxclass'} = $part_pkg->taxclass;
2140 my @taxes = qsearch( 'cust_main_county', \%taxhash );
2143 $taxhash{'taxclass'} = '';
2144 @taxes = qsearch( 'cust_main_county', \%taxhash );
2147 #one more try at a whole-country tax rate
2149 $taxhash{$_} = '' foreach qw( state county );
2150 @taxes = qsearch( 'cust_main_county', \%taxhash );
2153 # maybe eliminate this entirely, along with all the 0% records
2155 $dbh->rollback if $oldAutoCommit;
2157 "fatal: can't find tax rate for state/county/country/taxclass ".
2158 join('/', ( map $self->get("$prefix$_"),
2159 qw(state county country)
2161 $part_pkg->taxclass ). "\n";
2164 foreach my $tax ( @taxes ) {
2166 my $taxable_charged = 0;
2167 $taxable_charged += $setup
2168 unless $part_pkg->setuptax =~ /^Y$/i
2169 || $tax->setuptax =~ /^Y$/i;
2170 $taxable_charged += $recur
2171 unless $part_pkg->recurtax =~ /^Y$/i
2172 || $tax->recurtax =~ /^Y$/i;
2173 next unless $taxable_charged;
2175 if ( $tax->exempt_amount && $tax->exempt_amount > 0 ) {
2176 #my ($mon,$year) = (localtime($sdate) )[4,5];
2177 my ($mon,$year) = (localtime( $sdate || $cust_bill->_date ) )[4,5];
2179 my $freq = $part_pkg->freq || 1;
2180 if ( $freq !~ /(\d+)$/ ) {
2181 $dbh->rollback if $oldAutoCommit;
2182 return "daily/weekly package definitions not (yet?)".
2183 " compatible with monthly tax exemptions";
2185 my $taxable_per_month =
2186 sprintf("%.2f", $taxable_charged / $freq );
2188 #call the whole thing off if this customer has any old
2189 #exemption records...
2190 my @cust_tax_exempt =
2191 qsearch( 'cust_tax_exempt' => { custnum=> $self->custnum } );
2192 if ( @cust_tax_exempt ) {
2193 $dbh->rollback if $oldAutoCommit;
2195 'this customer still has old-style tax exemption records; '.
2196 'run bin/fs-migrate-cust_tax_exempt?';
2199 foreach my $which_month ( 1 .. $freq ) {
2201 #maintain the new exemption table now
2204 FROM cust_tax_exempt_pkg
2205 LEFT JOIN cust_bill_pkg USING ( billpkgnum )
2206 LEFT JOIN cust_bill USING ( invnum )
2212 my $sth = dbh->prepare($sql) or do {
2213 $dbh->rollback if $oldAutoCommit;
2214 return "fatal: can't lookup exising exemption: ". dbh->errstr;
2222 $dbh->rollback if $oldAutoCommit;
2223 return "fatal: can't lookup exising exemption: ". dbh->errstr;
2225 my $existing_exemption = $sth->fetchrow_arrayref->[0] || 0;
2227 my $remaining_exemption =
2228 $tax->exempt_amount - $existing_exemption;
2229 if ( $remaining_exemption > 0 ) {
2230 my $addl = $remaining_exemption > $taxable_per_month
2231 ? $taxable_per_month
2232 : $remaining_exemption;
2233 $taxable_charged -= $addl;
2235 my $cust_tax_exempt_pkg = new FS::cust_tax_exempt_pkg ( {
2236 'billpkgnum' => $cust_bill_pkg->billpkgnum,
2237 'taxnum' => $tax->taxnum,
2238 'year' => 1900+$year,
2240 'amount' => sprintf("%.2f", $addl ),
2242 $error = $cust_tax_exempt_pkg->insert;
2244 $dbh->rollback if $oldAutoCommit;
2245 return "fatal: can't insert cust_tax_exempt_pkg: $error";
2247 } # if $remaining_exemption > 0
2251 #until ( $mon < 12 ) { $mon -= 12; $year++; }
2252 until ( $mon < 13 ) { $mon -= 12; $year++; }
2254 } #foreach $which_month
2256 } #if $tax->exempt_amount
2258 $taxable_charged = sprintf( "%.2f", $taxable_charged);
2260 #$tax += $taxable_charged * $cust_main_county->tax / 100
2261 $tax{ $tax->taxname || 'Tax' } +=
2262 $taxable_charged * $tax->tax / 100
2264 } #foreach my $tax ( @taxes )
2266 } #unless $self->tax =~ /Y/i || $self->payby eq 'COMP'
2268 } #if $setup != 0 || $recur != 0
2270 } #if $cust_pkg->modified
2272 } #foreach my $cust_pkg
2274 unless ( $cust_bill->cust_bill_pkg ) {
2275 $cust_bill->delete; #don't create an invoice w/o line items
2276 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2280 my $charged = sprintf( "%.2f", $total_setup + $total_recur );
2282 foreach my $taxname ( grep { $tax{$_} > 0 } keys %tax ) {
2283 my $tax = sprintf("%.2f", $tax{$taxname} );
2284 $charged = sprintf( "%.2f", $charged+$tax );
2286 my $cust_bill_pkg = new FS::cust_bill_pkg ({
2287 'invnum' => $invnum,
2293 'itemdesc' => $taxname,
2295 $error = $cust_bill_pkg->insert;
2297 $dbh->rollback if $oldAutoCommit;
2298 return "can't create invoice line item for invoice #$invnum: $error";
2300 $total_setup += $tax;
2304 $cust_bill->charged( sprintf( "%.2f", $total_setup + $total_recur ) );
2305 $error = $cust_bill->replace;
2307 $dbh->rollback if $oldAutoCommit;
2308 return "can't update charged for invoice #$invnum: $error";
2311 foreach my $hook ( @precommit_hooks ) {
2313 &{$hook}; #($self) ?
2316 $dbh->rollback if $oldAutoCommit;
2317 return "$@ running precommit hook $hook\n";
2321 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2325 =item collect OPTIONS
2327 (Attempt to) collect money for this customer's outstanding invoices (see
2328 L<FS::cust_bill>). Usually used after the bill method.
2330 Depending on the value of `payby', this may print or email an invoice (I<BILL>,
2331 I<DCRD>, or I<DCHK>), charge a credit card (I<CARD>), charge via electronic
2332 check/ACH (I<CHEK>), or just add any necessary (pseudo-)payment (I<COMP>).
2334 Most actions are now triggered by invoice events; see L<FS::part_bill_event>
2335 and the invoice events web interface.
2337 If there is an error, returns the error, otherwise returns false.
2339 Options are passed as name-value pairs.
2341 Currently available options are:
2343 invoice_time - Use this time when deciding when to print invoices and
2344 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>
2345 for conversion functions.
2347 retry - Retry card/echeck/LEC transactions even when not scheduled by invoice
2350 quiet - set true to surpress email card/ACH decline notices.
2352 freq - "1d" for the traditional, daily events (the default), or "1m" for the
2355 payby - allows for one time override of normal customer billing method
2360 my( $self, %options ) = @_;
2361 my $invoice_time = $options{'invoice_time'} || time;
2364 local $SIG{HUP} = 'IGNORE';
2365 local $SIG{INT} = 'IGNORE';
2366 local $SIG{QUIT} = 'IGNORE';
2367 local $SIG{TERM} = 'IGNORE';
2368 local $SIG{TSTP} = 'IGNORE';
2369 local $SIG{PIPE} = 'IGNORE';
2371 my $oldAutoCommit = $FS::UID::AutoCommit;
2372 local $FS::UID::AutoCommit = 0;
2375 $self->select_for_update; #mutex
2377 my $balance = $self->balance;
2378 warn "$me collect customer ". $self->custnum. ": balance $balance\n"
2380 unless ( $balance > 0 ) { #redundant?????
2381 $dbh->rollback if $oldAutoCommit; #hmm
2385 if ( exists($options{'retry_card'}) ) {
2386 carp 'retry_card option passed to collect is deprecated; use retry';
2387 $options{'retry'} ||= $options{'retry_card'};
2389 if ( exists($options{'retry'}) && $options{'retry'} ) {
2390 my $error = $self->retry_realtime;
2392 $dbh->rollback if $oldAutoCommit;
2398 if ( defined $options{'freq'} && $options{'freq'} eq '1m' ) {
2399 $extra_sql = " AND freq = '1m' ";
2401 $extra_sql = " AND ( freq = '1d' OR freq IS NULL OR freq = '' ) ";
2404 foreach my $cust_bill ( $self->open_cust_bill ) {
2406 # don't try to charge for the same invoice if it's already in a batch
2407 #next if qsearchs( 'cust_pay_batch', { 'invnum' => $cust_bill->invnum } );
2409 last if $self->balance <= 0;
2411 warn " invnum ". $cust_bill->invnum. " (owed ". $cust_bill->owed. ")\n"
2414 foreach my $part_bill_event ( due_events ( $cust_bill,
2415 exists($options{'payby'})
2421 last if $cust_bill->owed <= 0 # don't run subsequent events if owed<=0
2422 || $self->balance <= 0; # or if balance<=0
2425 local $realtime_bop_decline_quiet = 1 if $options{'quiet'};
2426 warn " do_event " . $cust_bill . " ". (%options) . "\n"
2429 if (my $error = $part_bill_event->do_event($cust_bill, %options)) {
2430 # gah, even with transactions.
2431 $dbh->commit if $oldAutoCommit; #well.
2440 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2445 =item retry_realtime
2447 Schedules realtime / batch credit card / electronic check / LEC billing
2448 events for for retry. Useful if card information has changed or manual
2449 retry is desired. The 'collect' method must be called to actually retry
2452 Implementation details: For each of this customer's open invoices, changes
2453 the status of the first "done" (with statustext error) realtime processing
2458 sub retry_realtime {
2461 local $SIG{HUP} = 'IGNORE';
2462 local $SIG{INT} = 'IGNORE';
2463 local $SIG{QUIT} = 'IGNORE';
2464 local $SIG{TERM} = 'IGNORE';
2465 local $SIG{TSTP} = 'IGNORE';
2466 local $SIG{PIPE} = 'IGNORE';
2468 my $oldAutoCommit = $FS::UID::AutoCommit;
2469 local $FS::UID::AutoCommit = 0;
2472 foreach my $cust_bill (
2473 grep { $_->cust_bill_event }
2474 $self->open_cust_bill
2476 my @cust_bill_event =
2477 sort { $a->part_bill_event->seconds <=> $b->part_bill_event->seconds }
2479 #$_->part_bill_event->plan eq 'realtime-card'
2480 $_->part_bill_event->eventcode =~
2481 /\$cust_bill\->(batch|realtime)_(card|ach|lec)/
2482 && $_->status eq 'done'
2485 $cust_bill->cust_bill_event;
2486 next unless @cust_bill_event;
2487 my $error = $cust_bill_event[0]->retry;
2489 $dbh->rollback if $oldAutoCommit;
2490 return "error scheduling invoice event for retry: $error";
2495 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2500 =item realtime_bop METHOD AMOUNT [ OPTION => VALUE ... ]
2502 Runs a realtime credit card, ACH (electronic check) or phone bill transaction
2503 via a Business::OnlinePayment realtime gateway. See
2504 L<http://420.am/business-onlinepayment> for supported gateways.
2506 Available methods are: I<CC>, I<ECHECK> and I<LEC>
2508 Available options are: I<description>, I<invnum>, I<quiet>, I<paynum_ref>, I<payunique>
2510 The additional options I<payname>, I<address1>, I<address2>, I<city>, I<state>,
2511 I<zip>, I<payinfo> and I<paydate> are also available. Any of these options,
2512 if set, will override the value from the customer record.
2514 I<description> is a free-text field passed to the gateway. It defaults to
2515 "Internet services".
2517 If an I<invnum> is specified, this payment (if successful) is applied to the
2518 specified invoice. If you don't specify an I<invnum> you might want to
2519 call the B<apply_payments> method.
2521 I<quiet> can be set true to surpress email decline notices.
2523 I<paynum_ref> can be set to a scalar reference. It will be filled in with the
2524 resulting paynum, if any.
2526 I<payunique> is a unique identifier for this payment.
2528 (moved from cust_bill) (probably should get realtime_{card,ach,lec} here too)
2535 my( $self, $method, $amount, %options ) = @_;
2537 warn "$me realtime_bop: $method $amount\n";
2538 warn " $_ => $options{$_}\n" foreach keys %options;
2541 $options{'description'} ||= 'Internet services';
2543 eval "use Business::OnlinePayment";
2546 my $payinfo = exists($options{'payinfo'})
2547 ? $options{'payinfo'}
2550 my %method2payby = (
2561 if ( $options{'invnum'} ) {
2562 my $cust_bill = qsearchs('cust_bill', { 'invnum' => $options{'invnum'} } );
2563 die "invnum ". $options{'invnum'}. " not found" unless $cust_bill;
2565 map { $_->part_pkg->taxclass }
2567 map { $_->cust_pkg }
2568 $cust_bill->cust_bill_pkg;
2569 unless ( grep { $taxclasses[0] ne $_ } @taxclasses ) { #unless there are
2570 #different taxclasses
2571 $taxclass = $taxclasses[0];
2575 #look for an agent gateway override first
2577 if ( $method eq 'CC' ) {
2578 $cardtype = cardtype($payinfo);
2579 } elsif ( $method eq 'ECHECK' ) {
2582 $cardtype = $method;
2586 qsearchs('agent_payment_gateway', { agentnum => $self->agentnum,
2587 cardtype => $cardtype,
2588 taxclass => $taxclass, } )
2589 || qsearchs('agent_payment_gateway', { agentnum => $self->agentnum,
2591 taxclass => $taxclass, } )
2592 || qsearchs('agent_payment_gateway', { agentnum => $self->agentnum,
2593 cardtype => $cardtype,
2595 || qsearchs('agent_payment_gateway', { agentnum => $self->agentnum,
2597 taxclass => '', } );
2599 my $payment_gateway = '';
2600 my( $processor, $login, $password, $action, @bop_options );
2601 if ( $override ) { #use a payment gateway override
2603 $payment_gateway = $override->payment_gateway;
2605 $processor = $payment_gateway->gateway_module;
2606 $login = $payment_gateway->gateway_username;
2607 $password = $payment_gateway->gateway_password;
2608 $action = $payment_gateway->gateway_action;
2609 @bop_options = $payment_gateway->options;
2611 } else { #use the standard settings from the config
2613 ( $processor, $login, $password, $action, @bop_options ) =
2614 $self->default_payment_gateway($method);
2622 my $address = exists($options{'address1'})
2623 ? $options{'address1'}
2625 my $address2 = exists($options{'address2'})
2626 ? $options{'address2'}
2628 $address .= ", ". $address2 if length($address2);
2630 my $o_payname = exists($options{'payname'})
2631 ? $options{'payname'}
2633 my($payname, $payfirst, $paylast);
2634 if ( $o_payname && $method ne 'ECHECK' ) {
2635 ($payname = $o_payname) =~ /^\s*([\w \,\.\-\']*)?\s+([\w\,\.\-\']+)\s*$/
2636 or return "Illegal payname $payname";
2637 ($payfirst, $paylast) = ($1, $2);
2639 $payfirst = $self->getfield('first');
2640 $paylast = $self->getfield('last');
2641 $payname = "$payfirst $paylast";
2644 my @invoicing_list = $self->invoicing_list_emailonly;
2645 if ( $conf->exists('emailinvoiceautoalways')
2646 || $conf->exists('emailinvoiceauto') && ! @invoicing_list
2647 || ( $conf->exists('emailinvoiceonly') && ! @invoicing_list ) ) {
2648 push @invoicing_list, $self->all_emails;
2651 my $email = ($conf->exists('business-onlinepayment-email-override'))
2652 ? $conf->config('business-onlinepayment-email-override')
2653 : $invoicing_list[0];
2657 my $payip = exists($options{'payip'})
2660 $content{customer_ip} = $payip
2663 $content{invoice_number} = $options{'invnum'}
2664 if exists($options{'invnum'}) && length($options{'invnum'});
2666 $content{email_customer} =
2667 ( $conf->exists('business-onlinepayment-email_customer')
2668 || $conf->exists('business-onlinepayment-email-override') );
2671 if ( $method eq 'CC' ) {
2673 $content{card_number} = $payinfo;
2674 $paydate = exists($options{'paydate'})
2675 ? $options{'paydate'}
2677 $paydate =~ /^\d{2}(\d{2})[\/\-](\d+)[\/\-]\d+$/;
2678 $content{expiration} = "$2/$1";
2680 my $paycvv = exists($options{'paycvv'})
2681 ? $options{'paycvv'}
2683 $content{cvv2} = $paycvv
2686 my $paystart_month = exists($options{'paystart_month'})
2687 ? $options{'paystart_month'}
2688 : $self->paystart_month;
2690 my $paystart_year = exists($options{'paystart_year'})
2691 ? $options{'paystart_year'}
2692 : $self->paystart_year;
2694 $content{card_start} = "$paystart_month/$paystart_year"
2695 if $paystart_month && $paystart_year;
2697 my $payissue = exists($options{'payissue'})
2698 ? $options{'payissue'}
2700 $content{issue_number} = $payissue if $payissue;
2702 $content{recurring_billing} = 'YES'
2703 if qsearch('cust_pay', { 'custnum' => $self->custnum,
2705 'payinfo' => $payinfo,
2707 || qsearch('cust_pay', { 'custnum' => $self->custnum,
2709 'paymask' => $self->mask_payinfo('CARD', $payinfo),
2713 } elsif ( $method eq 'ECHECK' ) {
2714 ( $content{account_number}, $content{routing_code} ) =
2715 split('@', $payinfo);
2716 $content{bank_name} = $o_payname;
2717 $content{bank_state} = exists($options{'paystate'})
2718 ? $options{'paystate'}
2719 : $self->getfield('paystate');
2720 $content{account_type} = exists($options{'paytype'})
2721 ? uc($options{'paytype'}) || 'CHECKING'
2722 : uc($self->getfield('paytype')) || 'CHECKING';
2723 $content{account_name} = $payname;
2724 $content{customer_org} = $self->company ? 'B' : 'I';
2725 $content{state_id} = exists($options{'stateid'})
2726 ? $options{'stateid'}
2727 : $self->getfield('stateid');
2728 $content{state_id_state} = exists($options{'stateid_state'})
2729 ? $options{'stateid_state'}
2730 : $self->getfield('stateid_state');
2731 $content{customer_ssn} = exists($options{'ss'})
2734 } elsif ( $method eq 'LEC' ) {
2735 $content{phone} = $payinfo;
2739 # run transaction(s)
2742 my $balance = exists( $options{'balance'} )
2743 ? $options{'balance'}
2746 $self->select_for_update; #mutex ... just until we get our pending record in
2748 #the checks here are intended to catch concurrent payments
2749 #double-form-submission prevention is taken care of in cust_pay_pending::check
2752 return "The customer's balance has changed; $method transaction aborted."
2753 if $self->balance < $balance;
2754 #&& $self->balance < $amount; #might as well anyway?
2756 #also check and make sure there aren't *other* pending payments for this cust
2758 my @pending = qsearch('cust_pay_pending', {
2759 'custnum' => $self->custnum,
2760 'status' => { op=>'!=', value=>'done' }
2762 return "A payment is already being processed for this customer (".
2763 join(', ', map 'paypendingnum '. $_->paypendingnum, @pending ).
2764 "); $method transaction aborted."
2765 if scalar(@pending);
2767 #okay, good to go, if we're a duplicate, cust_pay_pending will kick us out
2769 my $cust_pay_pending = new FS::cust_pay_pending {
2770 'custnum' => $self->custnum,
2771 #'invnum' => $options{'invnum'},
2774 'payby' => $method2payby{$method},
2775 'payinfo' => $payinfo,
2776 'paydate' => $paydate,
2778 'gatewaynum' => ( $payment_gateway ? $payment_gateway->gatewaynum : '' ),
2780 $cust_pay_pending->payunique( $options{payunique} )
2781 if defined($options{payunique}) && length($options{payunique});
2782 my $cpp_new_err = $cust_pay_pending->insert; #mutex lost when this is inserted
2783 return $cpp_new_err if $cpp_new_err;
2785 my( $action1, $action2 ) = split(/\s*\,\s*/, $action );
2787 my $transaction = new Business::OnlinePayment( $processor, @bop_options );
2788 $transaction->content(
2791 'password' => $password,
2792 'action' => $action1,
2793 'description' => $options{'description'},
2794 'amount' => $amount,
2795 #'invoice_number' => $options{'invnum'},
2796 'customer_id' => $self->custnum,
2797 'last_name' => $paylast,
2798 'first_name' => $payfirst,
2800 'address' => $address,
2801 'city' => ( exists($options{'city'})
2804 'state' => ( exists($options{'state'})
2807 'zip' => ( exists($options{'zip'})
2810 'country' => ( exists($options{'country'})
2811 ? $options{'country'}
2813 'referer' => 'http://cleanwhisker.420.am/',
2815 'phone' => $self->daytime || $self->night,
2819 $cust_pay_pending->status('pending');
2820 my $cpp_pending_err = $cust_pay_pending->replace;
2821 return $cpp_pending_err if $cpp_pending_err;
2823 $transaction->submit();
2825 if ( $transaction->is_success() && $action2 ) {
2827 $cust_pay_pending->status('authorized');
2828 my $cpp_authorized_err = $cust_pay_pending->replace;
2829 return $cpp_authorized_err if $cpp_authorized_err;
2831 my $auth = $transaction->authorization;
2832 my $ordernum = $transaction->can('order_number')
2833 ? $transaction->order_number
2837 new Business::OnlinePayment( $processor, @bop_options );
2844 password => $password,
2845 order_number => $ordernum,
2847 authorization => $auth,
2848 description => $options{'description'},
2851 foreach my $field (qw( authorization_source_code returned_ACI
2852 transaction_identifier validation_code
2853 transaction_sequence_num local_transaction_date
2854 local_transaction_time AVS_result_code )) {
2855 $capture{$field} = $transaction->$field() if $transaction->can($field);
2858 $capture->content( %capture );
2862 unless ( $capture->is_success ) {
2863 my $e = "Authorization successful but capture failed, custnum #".
2864 $self->custnum. ': '. $capture->result_code.
2865 ": ". $capture->error_message;
2872 $cust_pay_pending->status($transaction->is_success() ? 'captured' : 'declined');
2873 my $cpp_captured_err = $cust_pay_pending->replace;
2874 return $cpp_captured_err if $cpp_captured_err;
2877 # remove paycvv after initial transaction
2880 #false laziness w/misc/process/payment.cgi - check both to make sure working
2882 if ( defined $self->dbdef_table->column('paycvv')
2883 && length($self->paycvv)
2884 && ! grep { $_ eq cardtype($payinfo) } $conf->config('cvv-save')
2886 my $error = $self->remove_cvv;
2888 warn "WARNING: error removing cvv: $error\n";
2896 if ( $transaction->is_success() ) {
2899 if ( $payment_gateway ) { # agent override
2900 $paybatch = $payment_gateway->gatewaynum. '-';
2903 $paybatch .= "$processor:". $transaction->authorization;
2905 $paybatch .= ':'. $transaction->order_number
2906 if $transaction->can('order_number')
2907 && length($transaction->order_number);
2909 my $cust_pay = new FS::cust_pay ( {
2910 'custnum' => $self->custnum,
2911 'invnum' => $options{'invnum'},
2914 'payby' => $method2payby{$method},
2915 'payinfo' => $payinfo,
2916 'paybatch' => $paybatch,
2917 'paydate' => $paydate,
2919 #doesn't hurt to know, even though the dup check is in cust_pay_pending now
2920 $cust_pay->payunique( $options{payunique} )
2921 if defined($options{payunique}) && length($options{payunique});
2923 my $oldAutoCommit = $FS::UID::AutoCommit;
2924 local $FS::UID::AutoCommit = 0;
2927 #start a transaction, insert the cust_pay and set cust_pay_pending.status to done in a single transction
2929 my $error = $cust_pay->insert($options{'manual'} ? ( 'manual' => 1 ) : () );
2932 $cust_pay->invnum(''); #try again with no specific invnum
2933 my $error2 = $cust_pay->insert( $options{'manual'} ?
2934 ( 'manual' => 1 ) : ()
2937 # gah. but at least we have a record of the state we had to abort in
2938 # from cust_pay_pending now.
2939 my $e = "WARNING: $method captured but payment not recorded - ".
2940 "error inserting payment ($processor): $error2".
2941 " (previously tried insert with invnum #$options{'invnum'}" .
2942 ": $error ) - pending payment saved as paypendingnum ".
2943 $cust_pay_pending->paypendingnum. "\n";
2949 if ( $options{'paynum_ref'} ) {
2950 ${ $options{'paynum_ref'} } = $cust_pay->paynum;
2953 $cust_pay_pending->status('done');
2954 $cust_pay_pending->statustext('captured');
2955 my $cpp_done_err = $cust_pay_pending->replace;
2957 if ( $cpp_done_err ) {
2959 $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
2960 my $e = "WARNING: $method captured but payment not recorded - ".
2961 "error updating status for paypendingnum ".
2962 $cust_pay_pending->paypendingnum. ": $cpp_done_err \n";
2968 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2969 return ''; #no error
2975 my $perror = "$processor error: ". $transaction->error_message;
2977 unless ( $transaction->error_message ) {
2980 #this should be normalized :/
2982 # bad, ad-hoc B:OP:PayflowPro "transaction_response" BS
2983 if ( $transaction->can('param')
2984 && $transaction->param('transaction_response') ) {
2985 $t_response = $transaction->param('transaction_response')
2987 # slightly better, ad-hoc B:OP:TransactionCentral without "param"
2988 } elsif ( $transaction->can('response_page') ) {
2990 'page' => ( $transaction->can('response_page')
2991 ? $transaction->response_page
2994 'code' => ( $transaction->can('response_code')
2995 ? $transaction->response_code
2998 'headers' => ( $transaction->can('response_headers')
2999 ? $transaction->response_headers
3005 "No additional debugging information available for $processor";
3008 $perror .= "No error_message returned from $processor -- ".
3009 ( ref($t_response) ? Dumper($t_response) : $t_response );
3013 if ( !$options{'quiet'} && !$realtime_bop_decline_quiet
3014 && $conf->exists('emaildecline')
3015 && grep { $_ ne 'POST' } $self->invoicing_list
3016 && ! grep { $transaction->error_message =~ /$_/ }
3017 $conf->config('emaildecline-exclude')
3019 my @templ = $conf->config('declinetemplate');
3020 my $template = new Text::Template (
3022 SOURCE => [ map "$_\n", @templ ],
3023 ) or return "($perror) can't create template: $Text::Template::ERROR";
3024 $template->compile()
3025 or return "($perror) can't compile template: $Text::Template::ERROR";
3027 my $templ_hash = { error => $transaction->error_message };
3029 my $error = send_email(
3030 'from' => $conf->config('invoice_from'),
3031 'to' => [ grep { $_ ne 'POST' } $self->invoicing_list ],
3032 'subject' => 'Your payment could not be processed',
3033 'body' => [ $template->fill_in(HASH => $templ_hash) ],
3036 $perror .= " (also received error sending decline notification: $error)"
3041 $cust_pay_pending->status('done');
3042 $cust_pay_pending->statustext("declined: $perror");
3043 my $cpp_done_err = $cust_pay_pending->replace;
3044 if ( $cpp_done_err ) {
3045 my $e = "WARNING: $method declined but pending payment not resolved - ".
3046 "error updating status for paypendingnum ".
3047 $cust_pay_pending->paypendingnum. ": $cpp_done_err \n";
3049 $perror = "$e ($perror)";
3057 =item default_payment_gateway
3061 sub default_payment_gateway {
3062 my( $self, $method ) = @_;
3064 die "Real-time processing not enabled\n"
3065 unless $conf->exists('business-onlinepayment');
3068 my $bop_config = 'business-onlinepayment';
3069 $bop_config .= '-ach'
3070 if $method =~ /^(ECHECK|CHEK)$/ && $conf->exists($bop_config. '-ach');
3071 my ( $processor, $login, $password, $action, @bop_options ) =
3072 $conf->config($bop_config);
3073 $action ||= 'normal authorization';
3074 pop @bop_options if scalar(@bop_options) % 2 && $bop_options[-1] =~ /^\s*$/;
3075 die "No real-time processor is enabled - ".
3076 "did you set the business-onlinepayment configuration value?\n"
3079 ( $processor, $login, $password, $action, @bop_options )
3084 Removes the I<paycvv> field from the database directly.
3086 If there is an error, returns the error, otherwise returns false.
3092 my $sth = dbh->prepare("UPDATE cust_main SET paycvv = '' WHERE custnum = ?")
3093 or return dbh->errstr;
3094 $sth->execute($self->custnum)
3095 or return $sth->errstr;
3100 =item realtime_refund_bop METHOD [ OPTION => VALUE ... ]
3102 Refunds a realtime credit card, ACH (electronic check) or phone bill transaction
3103 via a Business::OnlinePayment realtime gateway. See
3104 L<http://420.am/business-onlinepayment> for supported gateways.
3106 Available methods are: I<CC>, I<ECHECK> and I<LEC>
3108 Available options are: I<amount>, I<reason>, I<paynum>, I<paydate>
3110 Most gateways require a reference to an original payment transaction to refund,
3111 so you probably need to specify a I<paynum>.
3113 I<amount> defaults to the original amount of the payment if not specified.
3115 I<reason> specifies a reason for the refund.
3117 I<paydate> specifies the expiration date for a credit card overriding the
3118 value from the customer record or the payment record. Specified as yyyy-mm-dd
3120 Implementation note: If I<amount> is unspecified or equal to the amount of the
3121 orignal payment, first an attempt is made to "void" the transaction via
3122 the gateway (to cancel a not-yet settled transaction) and then if that fails,
3123 the normal attempt is made to "refund" ("credit") the transaction via the
3124 gateway is attempted.
3126 #The additional options I<payname>, I<address1>, I<address2>, I<city>, I<state>,
3127 #I<zip>, I<payinfo> and I<paydate> are also available. Any of these options,
3128 #if set, will override the value from the customer record.
3130 #If an I<invnum> is specified, this payment (if successful) is applied to the
3131 #specified invoice. If you don't specify an I<invnum> you might want to
3132 #call the B<apply_payments> method.
3136 #some false laziness w/realtime_bop, not enough to make it worth merging
3137 #but some useful small subs should be pulled out
3138 sub realtime_refund_bop {
3139 my( $self, $method, %options ) = @_;
3141 warn "$me realtime_refund_bop: $method refund\n";
3142 warn " $_ => $options{$_}\n" foreach keys %options;
3145 eval "use Business::OnlinePayment";
3149 # look up the original payment and optionally a gateway for that payment
3153 my $amount = $options{'amount'};
3155 my( $processor, $login, $password, @bop_options ) ;
3156 my( $auth, $order_number ) = ( '', '', '' );
3158 if ( $options{'paynum'} ) {
3160 warn " paynum: $options{paynum}\n" if $DEBUG > 1;
3161 $cust_pay = qsearchs('cust_pay', { paynum=>$options{'paynum'} } )
3162 or return "Unknown paynum $options{'paynum'}";
3163 $amount ||= $cust_pay->paid;
3165 $cust_pay->paybatch =~ /^((\d+)\-)?(\w+):\s*([\w\-\/ ]*)(:([\w\-]+))?$/
3166 or return "Can't parse paybatch for paynum $options{'paynum'}: ".
3167 $cust_pay->paybatch;
3168 my $gatewaynum = '';
3169 ( $gatewaynum, $processor, $auth, $order_number ) = ( $2, $3, $4, $6 );
3171 if ( $gatewaynum ) { #gateway for the payment to be refunded
3173 my $payment_gateway =
3174 qsearchs('payment_gateway', { 'gatewaynum' => $gatewaynum } );
3175 die "payment gateway $gatewaynum not found"
3176 unless $payment_gateway;
3178 $processor = $payment_gateway->gateway_module;
3179 $login = $payment_gateway->gateway_username;
3180 $password = $payment_gateway->gateway_password;
3181 @bop_options = $payment_gateway->options;
3183 } else { #try the default gateway
3185 my( $conf_processor, $unused_action );
3186 ( $conf_processor, $login, $password, $unused_action, @bop_options ) =
3187 $self->default_payment_gateway($method);
3189 return "processor of payment $options{'paynum'} $processor does not".
3190 " match default processor $conf_processor"
3191 unless $processor eq $conf_processor;
3196 } else { # didn't specify a paynum, so look for agent gateway overrides
3197 # like a normal transaction
3200 if ( $method eq 'CC' ) {
3201 $cardtype = cardtype($self->payinfo);
3202 } elsif ( $method eq 'ECHECK' ) {
3205 $cardtype = $method;
3208 qsearchs('agent_payment_gateway', { agentnum => $self->agentnum,
3209 cardtype => $cardtype,
3211 || qsearchs('agent_payment_gateway', { agentnum => $self->agentnum,
3213 taxclass => '', } );
3215 if ( $override ) { #use a payment gateway override
3217 my $payment_gateway = $override->payment_gateway;
3219 $processor = $payment_gateway->gateway_module;
3220 $login = $payment_gateway->gateway_username;
3221 $password = $payment_gateway->gateway_password;
3222 #$action = $payment_gateway->gateway_action;
3223 @bop_options = $payment_gateway->options;
3225 } else { #use the standard settings from the config
3228 ( $processor, $login, $password, $unused_action, @bop_options ) =
3229 $self->default_payment_gateway($method);
3234 return "neither amount nor paynum specified" unless $amount;
3239 'password' => $password,
3240 'order_number' => $order_number,
3241 'amount' => $amount,
3242 'referer' => 'http://cleanwhisker.420.am/',
3244 $content{authorization} = $auth
3245 if length($auth); #echeck/ACH transactions have an order # but no auth
3246 #(at least with authorize.net)
3248 my $disable_void_after;
3249 if ($conf->exists('disable_void_after')
3250 && $conf->config('disable_void_after') =~ /^(\d+)$/) {
3251 $disable_void_after = $1;
3254 #first try void if applicable
3255 if ( $cust_pay && $cust_pay->paid == $amount
3257 ( not defined($disable_void_after) )
3258 || ( time < ($cust_pay->_date + $disable_void_after ) )
3261 warn " attempting void\n" if $DEBUG > 1;
3262 my $void = new Business::OnlinePayment( $processor, @bop_options );
3263 $void->content( 'action' => 'void', %content );
3265 if ( $void->is_success ) {
3266 my $error = $cust_pay->void($options{'reason'});
3268 # gah, even with transactions.
3269 my $e = 'WARNING: Card/ACH voided but database not updated - '.
3270 "error voiding payment: $error";
3274 warn " void successful\n" if $DEBUG > 1;
3279 warn " void unsuccessful, trying refund\n"
3283 my $address = $self->address1;
3284 $address .= ", ". $self->address2 if $self->address2;
3286 my($payname, $payfirst, $paylast);
3287 if ( $self->payname && $method ne 'ECHECK' ) {
3288 $payname = $self->payname;
3289 $payname =~ /^\s*([\w \,\.\-\']*)?\s+([\w\,\.\-\']+)\s*$/
3290 or return "Illegal payname $payname";
3291 ($payfirst, $paylast) = ($1, $2);
3293 $payfirst = $self->getfield('first');
3294 $paylast = $self->getfield('last');
3295 $payname = "$payfirst $paylast";
3298 my @invoicing_list = $self->invoicing_list_emailonly;
3299 if ( $conf->exists('emailinvoiceautoalways')
3300 || $conf->exists('emailinvoiceauto') && ! @invoicing_list
3301 || ( $conf->exists('emailinvoiceonly') && ! @invoicing_list ) ) {
3302 push @invoicing_list, $self->all_emails;
3305 my $email = ($conf->exists('business-onlinepayment-email-override'))
3306 ? $conf->config('business-onlinepayment-email-override')
3307 : $invoicing_list[0];
3309 my $payip = exists($options{'payip'})
3312 $content{customer_ip} = $payip
3316 if ( $method eq 'CC' ) {
3319 $content{card_number} = $payinfo = $cust_pay->payinfo;
3320 (exists($options{'paydate'}) ? $options{'paydate'} : $cust_pay->paydate)
3321 =~ /^\d{2}(\d{2})[\/\-](\d+)[\/\-]\d+$/ &&
3322 ($content{expiration} = "$2/$1"); # where available
3324 $content{card_number} = $payinfo = $self->payinfo;
3325 (exists($options{'paydate'}) ? $options{'paydate'} : $self->paydate)
3326 =~ /^\d{2}(\d{2})[\/\-](\d+)[\/\-]\d+$/;
3327 $content{expiration} = "$2/$1";
3330 } elsif ( $method eq 'ECHECK' ) {
3333 $payinfo = $cust_pay->payinfo;
3335 $payinfo = $self->payinfo;
3337 ( $content{account_number}, $content{routing_code} )= split('@', $payinfo );
3338 $content{bank_name} = $self->payname;
3339 $content{account_type} = 'CHECKING';
3340 $content{account_name} = $payname;
3341 $content{customer_org} = $self->company ? 'B' : 'I';
3342 $content{customer_ssn} = $self->ss;
3343 } elsif ( $method eq 'LEC' ) {
3344 $content{phone} = $payinfo = $self->payinfo;
3348 my $refund = new Business::OnlinePayment( $processor, @bop_options );
3349 my %sub_content = $refund->content(
3350 'action' => 'credit',
3351 'customer_id' => $self->custnum,
3352 'last_name' => $paylast,
3353 'first_name' => $payfirst,
3355 'address' => $address,
3356 'city' => $self->city,
3357 'state' => $self->state,
3358 'zip' => $self->zip,
3359 'country' => $self->country,
3361 'phone' => $self->daytime || $self->night,
3364 warn join('', map { " $_ => $sub_content{$_}\n" } keys %sub_content )
3368 return "$processor error: ". $refund->error_message
3369 unless $refund->is_success();
3371 my %method2payby = (
3377 my $paybatch = "$processor:". $refund->authorization;
3378 $paybatch .= ':'. $refund->order_number
3379 if $refund->can('order_number') && $refund->order_number;
3381 while ( $cust_pay && $cust_pay->unapplied < $amount ) {
3382 my @cust_bill_pay = $cust_pay->cust_bill_pay;
3383 last unless @cust_bill_pay;
3384 my $cust_bill_pay = pop @cust_bill_pay;
3385 my $error = $cust_bill_pay->delete;
3389 my $cust_refund = new FS::cust_refund ( {
3390 'custnum' => $self->custnum,
3391 'paynum' => $options{'paynum'},
3392 'refund' => $amount,
3394 'payby' => $method2payby{$method},
3395 'payinfo' => $payinfo,
3396 'paybatch' => $paybatch,
3397 'reason' => $options{'reason'} || 'card or ACH refund',
3399 my $error = $cust_refund->insert;
3401 $cust_refund->paynum(''); #try again with no specific paynum
3402 my $error2 = $cust_refund->insert;
3404 # gah, even with transactions.
3405 my $e = 'WARNING: Card/ACH refunded but database not updated - '.
3406 "error inserting refund ($processor): $error2".
3407 " (previously tried insert with paynum #$options{'paynum'}" .
3418 =item batch_card OPTION => VALUE...
3420 Adds a payment for this invoice to the pending credit card batch (see
3421 L<FS::cust_pay_batch>), or, if the B<realtime> option is set to a true value,
3422 runs the payment using a realtime gateway.
3427 my ($self, %options) = @_;
3430 if (exists($options{amount})) {
3431 $amount = $options{amount};
3433 $amount = sprintf("%.2f", $self->balance - $self->in_transit_payments);
3435 return '' unless $amount > 0;
3437 my $invnum = delete $options{invnum};
3438 my $payby = $options{invnum} || $self->payby; #dubious
3440 if ($options{'realtime'}) {
3441 return $self->realtime_bop( FS::payby->payby2bop($self->payby),
3447 my $oldAutoCommit = $FS::UID::AutoCommit;
3448 local $FS::UID::AutoCommit = 0;
3451 #this needs to handle mysql as well as Pg, like svc_acct.pm
3452 #(make it into a common function if folks need to do batching with mysql)
3453 $dbh->do("LOCK TABLE pay_batch IN SHARE ROW EXCLUSIVE MODE")
3454 or return "Cannot lock pay_batch: " . $dbh->errstr;
3458 'payby' => FS::payby->payby2payment($payby),
3461 my $pay_batch = qsearchs( 'pay_batch', \%pay_batch );
3463 unless ( $pay_batch ) {
3464 $pay_batch = new FS::pay_batch \%pay_batch;
3465 my $error = $pay_batch->insert;
3467 $dbh->rollback if $oldAutoCommit;
3468 die "error creating new batch: $error\n";
3472 my $old_cust_pay_batch = qsearchs('cust_pay_batch', {
3473 'batchnum' => $pay_batch->batchnum,
3474 'custnum' => $self->custnum,
3477 foreach (qw( address1 address2 city state zip country payby payinfo paydate
3479 $options{$_} = '' unless exists($options{$_});
3482 my $cust_pay_batch = new FS::cust_pay_batch ( {
3483 'batchnum' => $pay_batch->batchnum,
3484 'invnum' => $invnum || 0, # is there a better value?
3485 # this field should be
3487 # cust_bill_pay_batch now
3488 'custnum' => $self->custnum,
3489 'last' => $self->getfield('last'),
3490 'first' => $self->getfield('first'),
3491 'address1' => $options{address1} || $self->address1,
3492 'address2' => $options{address2} || $self->address2,
3493 'city' => $options{city} || $self->city,
3494 'state' => $options{state} || $self->state,
3495 'zip' => $options{zip} || $self->zip,
3496 'country' => $options{country} || $self->country,
3497 'payby' => $options{payby} || $self->payby,
3498 'payinfo' => $options{payinfo} || $self->payinfo,
3499 'exp' => $options{paydate} || $self->paydate,
3500 'payname' => $options{payname} || $self->payname,
3501 'amount' => $amount, # consolidating
3504 $cust_pay_batch->paybatchnum($old_cust_pay_batch->paybatchnum)
3505 if $old_cust_pay_batch;
3508 if ($old_cust_pay_batch) {
3509 $error = $cust_pay_batch->replace($old_cust_pay_batch)
3511 $error = $cust_pay_batch->insert;
3515 $dbh->rollback if $oldAutoCommit;
3519 my $unapplied = $self->total_credited + $self->total_unapplied_payments + $self->in_transit_payments;
3520 foreach my $cust_bill ($self->open_cust_bill) {
3521 #$dbh->commit or die $dbh->errstr if $oldAutoCommit;
3522 my $cust_bill_pay_batch = new FS::cust_bill_pay_batch {
3523 'invnum' => $cust_bill->invnum,
3524 'paybatchnum' => $cust_pay_batch->paybatchnum,
3525 'amount' => $cust_bill->owed,
3528 if ($unapplied >= $cust_bill_pay_batch->amount){
3529 $unapplied -= $cust_bill_pay_batch->amount;
3532 $cust_bill_pay_batch->amount(sprintf ( "%.2f",
3533 $cust_bill_pay_batch->amount - $unapplied )); $unapplied = 0;
3535 $error = $cust_bill_pay_batch->insert;
3537 $dbh->rollback if $oldAutoCommit;
3542 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
3548 Returns the total owed for this customer on all invoices
3549 (see L<FS::cust_bill/owed>).
3555 $self->total_owed_date(2145859200); #12/31/2037
3558 =item total_owed_date TIME
3560 Returns the total owed for this customer on all invoices with date earlier than
3561 TIME. TIME is specified as a UNIX timestamp; see L<perlfunc/"time">). Also
3562 see L<Time::Local> and L<Date::Parse> for conversion functions.
3566 sub total_owed_date {
3570 foreach my $cust_bill (
3571 grep { $_->_date <= $time }
3572 qsearch('cust_bill', { 'custnum' => $self->custnum, } )
3574 $total_bill += $cust_bill->owed;
3576 sprintf( "%.2f", $total_bill );
3579 =item apply_payments_and_credits
3581 Applies unapplied payments and credits.
3583 In most cases, this new method should be used in place of sequential
3584 apply_payments and apply_credits methods.
3586 If there is an error, returns the error, otherwise returns false.
3590 sub apply_payments_and_credits {
3593 local $SIG{HUP} = 'IGNORE';
3594 local $SIG{INT} = 'IGNORE';
3595 local $SIG{QUIT} = 'IGNORE';
3596 local $SIG{TERM} = 'IGNORE';
3597 local $SIG{TSTP} = 'IGNORE';
3598 local $SIG{PIPE} = 'IGNORE';
3600 my $oldAutoCommit = $FS::UID::AutoCommit;
3601 local $FS::UID::AutoCommit = 0;
3604 $self->select_for_update; #mutex
3606 foreach my $cust_bill ( $self->open_cust_bill ) {
3607 my $error = $cust_bill->apply_payments_and_credits;
3609 $dbh->rollback if $oldAutoCommit;
3610 return "Error applying: $error";
3614 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
3619 =item apply_credits OPTION => VALUE ...
3621 Applies (see L<FS::cust_credit_bill>) unapplied credits (see L<FS::cust_credit>)
3622 to outstanding invoice balances in chronological order (or reverse
3623 chronological order if the I<order> option is set to B<newest>) and returns the
3624 value of any remaining unapplied credits available for refund (see
3625 L<FS::cust_refund>).
3627 Dies if there is an error.
3635 local $SIG{HUP} = 'IGNORE';
3636 local $SIG{INT} = 'IGNORE';
3637 local $SIG{QUIT} = 'IGNORE';
3638 local $SIG{TERM} = 'IGNORE';
3639 local $SIG{TSTP} = 'IGNORE';
3640 local $SIG{PIPE} = 'IGNORE';
3642 my $oldAutoCommit = $FS::UID::AutoCommit;
3643 local $FS::UID::AutoCommit = 0;
3646 $self->select_for_update; #mutex
3648 unless ( $self->total_credited ) {
3649 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
3653 my @credits = sort { $b->_date <=> $a->_date} (grep { $_->credited > 0 }
3654 qsearch('cust_credit', { 'custnum' => $self->custnum } ) );
3656 my @invoices = $self->open_cust_bill;
3657 @invoices = sort { $b->_date <=> $a->_date } @invoices
3658 if defined($opt{'order'}) && $opt{'order'} eq 'newest';
3661 foreach my $cust_bill ( @invoices ) {
3664 if ( !defined($credit) || $credit->credited == 0) {
3665 $credit = pop @credits or last;
3668 if ($cust_bill->owed >= $credit->credited) {
3669 $amount=$credit->credited;
3671 $amount=$cust_bill->owed;
3674 my $cust_credit_bill = new FS::cust_credit_bill ( {
3675 'crednum' => $credit->crednum,
3676 'invnum' => $cust_bill->invnum,
3677 'amount' => $amount,
3679 my $error = $cust_credit_bill->insert;
3681 $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
3685 redo if ($cust_bill->owed > 0);
3689 my $total_credited = $self->total_credited;
3691 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
3693 return $total_credited;
3696 =item apply_payments
3698 Applies (see L<FS::cust_bill_pay>) unapplied payments (see L<FS::cust_pay>)
3699 to outstanding invoice balances in chronological order.
3701 #and returns the value of any remaining unapplied payments.
3703 Dies if there is an error.
3707 sub apply_payments {
3710 local $SIG{HUP} = 'IGNORE';
3711 local $SIG{INT} = 'IGNORE';
3712 local $SIG{QUIT} = 'IGNORE';
3713 local $SIG{TERM} = 'IGNORE';
3714 local $SIG{TSTP} = 'IGNORE';
3715 local $SIG{PIPE} = 'IGNORE';
3717 my $oldAutoCommit = $FS::UID::AutoCommit;
3718 local $FS::UID::AutoCommit = 0;
3721 $self->select_for_update; #mutex
3725 my @payments = sort { $b->_date <=> $a->_date } ( grep { $_->unapplied > 0 }
3726 qsearch('cust_pay', { 'custnum' => $self->custnum } ) );
3728 my @invoices = sort { $a->_date <=> $b->_date} (grep { $_->owed > 0 }
3729 qsearch('cust_bill', { 'custnum' => $self->custnum } ) );
3733 foreach my $cust_bill ( @invoices ) {
3736 if ( !defined($payment) || $payment->unapplied == 0 ) {
3737 $payment = pop @payments or last;
3740 if ( $cust_bill->owed >= $payment->unapplied ) {
3741 $amount = $payment->unapplied;
3743 $amount = $cust_bill->owed;
3746 my $cust_bill_pay = new FS::cust_bill_pay ( {
3747 'paynum' => $payment->paynum,
3748 'invnum' => $cust_bill->invnum,
3749 'amount' => $amount,
3751 my $error = $cust_bill_pay->insert;
3753 $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
3757 redo if ( $cust_bill->owed > 0);
3761 my $total_unapplied_payments = $self->total_unapplied_payments;
3763 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
3765 return $total_unapplied_payments;
3768 =item total_credited
3770 Returns the total outstanding credit (see L<FS::cust_credit>) for this
3771 customer. See L<FS::cust_credit/credited>.
3775 sub total_credited {
3777 my $total_credit = 0;
3778 foreach my $cust_credit ( qsearch('cust_credit', {
3779 'custnum' => $self->custnum,
3781 $total_credit += $cust_credit->credited;
3783 sprintf( "%.2f", $total_credit );
3786 =item total_unapplied_payments
3788 Returns the total unapplied payments (see L<FS::cust_pay>) for this customer.
3789 See L<FS::cust_pay/unapplied>.
3793 sub total_unapplied_payments {
3795 my $total_unapplied = 0;
3796 foreach my $cust_pay ( qsearch('cust_pay', {
3797 'custnum' => $self->custnum,
3799 $total_unapplied += $cust_pay->unapplied;
3801 sprintf( "%.2f", $total_unapplied );
3804 =item total_unapplied_refunds
3806 Returns the total unrefunded refunds (see L<FS::cust_refund>) for this
3807 customer. See L<FS::cust_refund/unapplied>.
3811 sub total_unapplied_refunds {
3813 my $total_unapplied = 0;
3814 foreach my $cust_refund ( qsearch('cust_refund', {
3815 'custnum' => $self->custnum,
3817 $total_unapplied += $cust_refund->unapplied;
3819 sprintf( "%.2f", $total_unapplied );
3824 Returns the balance for this customer (total_owed plus total_unrefunded, minus
3825 total_credited minus total_unapplied_payments).
3833 + $self->total_unapplied_refunds
3834 - $self->total_credited
3835 - $self->total_unapplied_payments
3839 =item balance_date TIME
3841 Returns the balance for this customer, only considering invoices with date
3842 earlier than TIME (total_owed_date minus total_credited minus
3843 total_unapplied_payments). TIME is specified as a UNIX timestamp; see
3844 L<perlfunc/"time">). Also see L<Time::Local> and L<Date::Parse> for conversion
3853 $self->total_owed_date($time)
3854 + $self->total_unapplied_refunds
3855 - $self->total_credited
3856 - $self->total_unapplied_payments
3860 =item in_transit_payments
3862 Returns the total of requests for payments for this customer pending in
3863 batches in transit to the bank. See L<FS::pay_batch> and L<FS::cust_pay_batch>
3867 sub in_transit_payments {
3869 my $in_transit_payments = 0;
3870 foreach my $pay_batch ( qsearch('pay_batch', {
3873 foreach my $cust_pay_batch ( qsearch('cust_pay_batch', {
3874 'batchnum' => $pay_batch->batchnum,
3875 'custnum' => $self->custnum,
3877 $in_transit_payments += $cust_pay_batch->amount;
3880 sprintf( "%.2f", $in_transit_payments );
3883 =item paydate_monthyear
3885 Returns a two-element list consisting of the month and year of this customer's
3886 paydate (credit card expiration date for CARD customers)
3890 sub paydate_monthyear {
3892 if ( $self->paydate =~ /^(\d{4})-(\d{1,2})-\d{1,2}$/ ) { #Pg date format
3894 } elsif ( $self->paydate =~ /^(\d{1,2})-(\d{1,2}-)?(\d{4}$)/ ) {
3901 =item invoicing_list [ ARRAYREF ]
3903 If an arguement is given, sets these email addresses as invoice recipients
3904 (see L<FS::cust_main_invoice>). Errors are not fatal and are not reported
3905 (except as warnings), so use check_invoicing_list first.
3907 Returns a list of email addresses (with svcnum entries expanded).
3909 Note: You can clear the invoicing list by passing an empty ARRAYREF. You can
3910 check it without disturbing anything by passing nothing.
3912 This interface may change in the future.
3916 sub invoicing_list {
3917 my( $self, $arrayref ) = @_;
3920 my @cust_main_invoice;
3921 if ( $self->custnum ) {
3922 @cust_main_invoice =
3923 qsearch( 'cust_main_invoice', { 'custnum' => $self->custnum } );
3925 @cust_main_invoice = ();
3927 foreach my $cust_main_invoice ( @cust_main_invoice ) {
3928 #warn $cust_main_invoice->destnum;
3929 unless ( grep { $cust_main_invoice->address eq $_ } @{$arrayref} ) {
3930 #warn $cust_main_invoice->destnum;
3931 my $error = $cust_main_invoice->delete;
3932 warn $error if $error;
3935 if ( $self->custnum ) {
3936 @cust_main_invoice =
3937 qsearch( 'cust_main_invoice', { 'custnum' => $self->custnum } );
3939 @cust_main_invoice = ();
3941 my %seen = map { $_->address => 1 } @cust_main_invoice;
3942 foreach my $address ( @{$arrayref} ) {
3943 next if exists $seen{$address} && $seen{$address};
3944 $seen{$address} = 1;
3945 my $cust_main_invoice = new FS::cust_main_invoice ( {
3946 'custnum' => $self->custnum,
3949 my $error = $cust_main_invoice->insert;
3950 warn $error if $error;
3954 if ( $self->custnum ) {
3956 qsearch( 'cust_main_invoice', { 'custnum' => $self->custnum } );
3963 =item check_invoicing_list ARRAYREF
3965 Checks these arguements as valid input for the invoicing_list method. If there
3966 is an error, returns the error, otherwise returns false.
3970 sub check_invoicing_list {
3971 my( $self, $arrayref ) = @_;
3973 foreach my $address ( @$arrayref ) {
3975 if ($address eq 'FAX' and $self->getfield('fax') eq '') {
3976 return 'Can\'t add FAX invoice destination with a blank FAX number.';
3979 my $cust_main_invoice = new FS::cust_main_invoice ( {
3980 'custnum' => $self->custnum,
3983 my $error = $self->custnum
3984 ? $cust_main_invoice->check
3985 : $cust_main_invoice->checkdest
3987 return $error if $error;
3991 return "Email address required"
3992 if $conf->exists('cust_main-require_invoicing_list_email')
3993 && ! grep { $_ !~ /^([A-Z]+)$/ } @$arrayref;
3998 =item set_default_invoicing_list
4000 Sets the invoicing list to all accounts associated with this customer,
4001 overwriting any previous invoicing list.
4005 sub set_default_invoicing_list {
4007 $self->invoicing_list($self->all_emails);
4012 Returns the email addresses of all accounts provisioned for this customer.
4019 foreach my $cust_pkg ( $self->all_pkgs ) {
4020 my @cust_svc = qsearch('cust_svc', { 'pkgnum' => $cust_pkg->pkgnum } );
4022 map { qsearchs('svc_acct', { 'svcnum' => $_->svcnum } ) }
4023 grep { qsearchs('svc_acct', { 'svcnum' => $_->svcnum } ) }
4025 $list{$_}=1 foreach map { $_->email } @svc_acct;
4030 =item invoicing_list_addpost
4032 Adds postal invoicing to this customer. If this customer is already configured
4033 to receive postal invoices, does nothing.
4037 sub invoicing_list_addpost {
4039 return if grep { $_ eq 'POST' } $self->invoicing_list;
4040 my @invoicing_list = $self->invoicing_list;
4041 push @invoicing_list, 'POST';
4042 $self->invoicing_list(\@invoicing_list);
4045 =item invoicing_list_emailonly
4047 Returns the list of email invoice recipients (invoicing_list without non-email
4048 destinations such as POST and FAX).
4052 sub invoicing_list_emailonly {
4054 warn "$me invoicing_list_emailonly called"
4056 grep { $_ !~ /^([A-Z]+)$/ } $self->invoicing_list;
4059 =item invoicing_list_emailonly_scalar
4061 Returns the list of email invoice recipients (invoicing_list without non-email
4062 destinations such as POST and FAX) as a comma-separated scalar.
4066 sub invoicing_list_emailonly_scalar {
4068 warn "$me invoicing_list_emailonly_scalar called"
4070 join(', ', $self->invoicing_list_emailonly);
4073 =item referral_cust_main [ DEPTH [ EXCLUDE_HASHREF ] ]
4075 Returns an array of customers referred by this customer (referral_custnum set
4076 to this custnum). If DEPTH is given, recurses up to the given depth, returning
4077 customers referred by customers referred by this customer and so on, inclusive.
4078 The default behavior is DEPTH 1 (no recursion).
4082 sub referral_cust_main {
4084 my $depth = @_ ? shift : 1;
4085 my $exclude = @_ ? shift : {};
4088 map { $exclude->{$_->custnum}++; $_; }
4089 grep { ! $exclude->{ $_->custnum } }
4090 qsearch( 'cust_main', { 'referral_custnum' => $self->custnum } );
4094 map { $_->referral_cust_main($depth-1, $exclude) }
4101 =item referral_cust_main_ncancelled
4103 Same as referral_cust_main, except only returns customers with uncancelled
4108 sub referral_cust_main_ncancelled {
4110 grep { scalar($_->ncancelled_pkgs) } $self->referral_cust_main;
4113 =item referral_cust_pkg [ DEPTH ]
4115 Like referral_cust_main, except returns a flat list of all unsuspended (and
4116 uncancelled) packages for each customer. The number of items in this list may
4117 be useful for comission calculations (perhaps after a C<grep { my $pkgpart = $_->pkgpart; grep { $_ == $pkgpart } @commission_worthy_pkgparts> } $cust_main-> ).
4121 sub referral_cust_pkg {
4123 my $depth = @_ ? shift : 1;
4125 map { $_->unsuspended_pkgs }
4126 grep { $_->unsuspended_pkgs }
4127 $self->referral_cust_main($depth);
4130 =item referring_cust_main
4132 Returns the single cust_main record for the customer who referred this customer
4133 (referral_custnum), or false.
4137 sub referring_cust_main {
4139 return '' unless $self->referral_custnum;
4140 qsearchs('cust_main', { 'custnum' => $self->referral_custnum } );
4143 =item credit AMOUNT, REASON
4145 Applies a credit to this customer. If there is an error, returns the error,
4146 otherwise returns false.
4151 my( $self, $amount, $reason, %options ) = @_;
4152 my $cust_credit = new FS::cust_credit {
4153 'custnum' => $self->custnum,
4154 'amount' => $amount,
4155 'reason' => $reason,
4157 $cust_credit->insert(%options);
4160 =item charge AMOUNT [ PKG [ COMMENT [ TAXCLASS ] ] ]
4162 Creates a one-time charge for this customer. If there is an error, returns
4163 the error, otherwise returns false.
4169 my ( $amount, $quantity, $pkg, $comment, $taxclass, $additional, $classnum );
4170 if ( ref( $_[0] ) ) {
4171 $amount = $_[0]->{amount};
4172 $quantity = exists($_[0]->{quantity}) ? $_[0]->{quantity} : 1;
4173 $pkg = exists($_[0]->{pkg}) ? $_[0]->{pkg} : 'One-time charge';
4174 $comment = exists($_[0]->{comment}) ? $_[0]->{comment}
4175 : '$'. sprintf("%.2f",$amount);
4176 $taxclass = exists($_[0]->{taxclass}) ? $_[0]->{taxclass} : '';
4177 $classnum = exists($_[0]->{classnum}) ? $_[0]->{classnum} : '';
4178 $additional = $_[0]->{additional};
4182 $pkg = @_ ? shift : 'One-time charge';
4183 $comment = @_ ? shift : '$'. sprintf("%.2f",$amount);
4184 $taxclass = @_ ? shift : '';
4188 local $SIG{HUP} = 'IGNORE';
4189 local $SIG{INT} = 'IGNORE';
4190 local $SIG{QUIT} = 'IGNORE';
4191 local $SIG{TERM} = 'IGNORE';
4192 local $SIG{TSTP} = 'IGNORE';
4193 local $SIG{PIPE} = 'IGNORE';
4195 my $oldAutoCommit = $FS::UID::AutoCommit;
4196 local $FS::UID::AutoCommit = 0;
4199 my $part_pkg = new FS::part_pkg ( {
4201 'comment' => $comment,
4205 'classnum' => $classnum ? $classnum : '',
4206 'taxclass' => $taxclass,
4209 my %options = ( ( map { ("additional_info$_" => $additional->[$_] ) }
4210 ( 0 .. @$additional - 1 )
4212 'additional_count' => scalar(@$additional),
4213 'setup_fee' => $amount,
4216 my $error = $part_pkg->insert( options => \%options );
4218 $dbh->rollback if $oldAutoCommit;
4222 my $pkgpart = $part_pkg->pkgpart;
4223 my %type_pkgs = ( 'typenum' => $self->agent->typenum, 'pkgpart' => $pkgpart );
4224 unless ( qsearchs('type_pkgs', \%type_pkgs ) ) {
4225 my $type_pkgs = new FS::type_pkgs \%type_pkgs;
4226 $error = $type_pkgs->insert;
4228 $dbh->rollback if $oldAutoCommit;
4233 my $cust_pkg = new FS::cust_pkg ( {
4234 'custnum' => $self->custnum,
4235 'pkgpart' => $pkgpart,
4236 'quantity' => $quantity,
4239 $error = $cust_pkg->insert;
4241 $dbh->rollback if $oldAutoCommit;
4245 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
4250 #=item charge_postal_fee
4252 #Applies a one time charge this customer. If there is an error,
4253 #returns the error, returns the cust_pkg charge object or false
4254 #if there was no charge.
4258 # This should be a customer event. For that to work requires that bill
4259 # also be a customer event.
4261 sub charge_postal_fee {
4264 my $pkgpart = $conf->config('postal_invoice-fee_pkgpart');
4265 return '' unless ($pkgpart && grep { $_ eq 'POST' } $self->invoicing_list);
4267 my $cust_pkg = new FS::cust_pkg ( {
4268 'custnum' => $self->custnum,
4269 'pkgpart' => $pkgpart,
4273 my $error = $cust_pkg->insert;
4274 $error ? $error : $cust_pkg;
4279 Returns all the invoices (see L<FS::cust_bill>) for this customer.
4285 sort { $a->_date <=> $b->_date }
4286 qsearch('cust_bill', { 'custnum' => $self->custnum, } )
4289 =item open_cust_bill
4291 Returns all the open (owed > 0) invoices (see L<FS::cust_bill>) for this
4296 sub open_cust_bill {
4298 grep { $_->owed > 0 } $self->cust_bill;
4303 Returns all the credits (see L<FS::cust_credit>) for this customer.
4309 sort { $a->_date <=> $b->_date }
4310 qsearch( 'cust_credit', { 'custnum' => $self->custnum } )
4315 Returns all the payments (see L<FS::cust_pay>) for this customer.
4321 sort { $a->_date <=> $b->_date }
4322 qsearch( 'cust_pay', { 'custnum' => $self->custnum } )
4327 Returns all voided payments (see L<FS::cust_pay_void>) for this customer.
4333 sort { $a->_date <=> $b->_date }
4334 qsearch( 'cust_pay_void', { 'custnum' => $self->custnum } )
4340 Returns all the refunds (see L<FS::cust_refund>) for this customer.
4346 sort { $a->_date <=> $b->_date }
4347 qsearch( 'cust_refund', { 'custnum' => $self->custnum } )
4352 Returns a name string for this customer, either "Company (Last, First)" or
4359 my $name = $self->contact;
4360 $name = $self->company. " ($name)" if $self->company;
4366 Returns a name string for this (service/shipping) contact, either
4367 "Company (Last, First)" or "Last, First".
4373 if ( $self->get('ship_last') ) {
4374 my $name = $self->ship_contact;
4375 $name = $self->ship_company. " ($name)" if $self->ship_company;
4384 Returns a name string for this customer, either "Company" or "First Last".
4390 $self->company !~ /^\s*$/ ? $self->company : $self->contact_firstlast;
4393 =item ship_name_short
4395 Returns a name string for this (service/shipping) contact, either "Company"
4400 sub ship_name_short {
4402 if ( $self->get('ship_last') ) {
4403 $self->ship_company !~ /^\s*$/
4404 ? $self->ship_company
4405 : $self->ship_contact_firstlast;
4407 $self->name_company_or_firstlast;
4413 Returns this customer's full (billing) contact name only, "Last, First"
4419 $self->get('last'). ', '. $self->first;
4424 Returns this customer's full (shipping) contact name only, "Last, First"
4430 $self->get('ship_last')
4431 ? $self->get('ship_last'). ', '. $self->ship_first
4435 =item contact_firstlast
4437 Returns this customers full (billing) contact name only, "First Last".
4441 sub contact_firstlast {
4443 $self->first. ' '. $self->get('last');
4446 =item ship_contact_firstlast
4448 Returns this customer's full (shipping) contact name only, "First Last".
4452 sub ship_contact_firstlast {
4454 $self->get('ship_last')
4455 ? $self->first. ' '. $self->get('ship_last')
4456 : $self->contact_firstlast;
4461 Returns this customer's full country name
4467 code2country($self->country);
4474 Returns a status string for this customer, currently:
4478 =item prospect - No packages have ever been ordered
4480 =item active - One or more recurring packages is active
4482 =item inactive - No active recurring packages, but otherwise unsuspended/uncancelled (the inactive status is new - previously inactive customers were mis-identified as cancelled)
4484 =item suspended - All non-cancelled recurring packages are suspended
4486 =item cancelled - All recurring packages are cancelled
4492 sub status { shift->cust_status(@_); }
4496 for my $status (qw( prospect active inactive suspended cancelled )) {
4497 my $method = $status.'_sql';
4498 my $numnum = ( my $sql = $self->$method() ) =~ s/cust_main\.custnum/?/g;
4499 my $sth = dbh->prepare("SELECT $sql") or die dbh->errstr;
4500 $sth->execute( ($self->custnum) x $numnum )
4501 or die "Error executing 'SELECT $sql': ". $sth->errstr;
4502 return $status if $sth->fetchrow_arrayref->[0];
4506 =item ucfirst_cust_status
4508 =item ucfirst_status
4510 Returns the status with the first character capitalized.
4514 sub ucfirst_status { shift->ucfirst_cust_status(@_); }
4516 sub ucfirst_cust_status {
4518 ucfirst($self->cust_status);
4523 Returns a hex triplet color string for this customer's status.
4527 use vars qw(%statuscolor);
4528 tie %statuscolor, 'Tie::IxHash',
4529 'prospect' => '7e0079', #'000000', #black? naw, purple
4530 'active' => '00CC00', #green
4531 'inactive' => '0000CC', #blue
4532 'suspended' => 'FF9900', #yellow
4533 'cancelled' => 'FF0000', #red
4536 sub statuscolor { shift->cust_statuscolor(@_); }
4538 sub cust_statuscolor {
4540 $statuscolor{$self->cust_status};
4545 =head1 CLASS METHODS
4551 Class method that returns the list of possible status strings for customers
4552 (see L<the status method|/status>). For example:
4554 @statuses = FS::cust_main->statuses();
4559 #my $self = shift; #could be class...
4565 Returns an SQL expression identifying prospective cust_main records (customers
4566 with no packages ever ordered)
4570 use vars qw($select_count_pkgs);
4571 $select_count_pkgs =
4572 "SELECT COUNT(*) FROM cust_pkg
4573 WHERE cust_pkg.custnum = cust_main.custnum";
4575 sub select_count_pkgs_sql {
4579 sub prospect_sql { "
4580 0 = ( $select_count_pkgs )
4585 Returns an SQL expression identifying active cust_main records (customers with
4586 active recurring packages).
4591 0 < ( $select_count_pkgs AND ". FS::cust_pkg->active_sql. "
4597 Returns an SQL expression identifying inactive cust_main records (customers with
4598 no active recurring packages, but otherwise unsuspended/uncancelled).
4602 sub inactive_sql { "
4603 0 = ( $select_count_pkgs AND ". FS::cust_pkg->active_sql. " )
4605 0 < ( $select_count_pkgs AND ". FS::cust_pkg->inactive_sql. " )
4611 Returns an SQL expression identifying suspended cust_main records.
4616 sub suspended_sql { susp_sql(@_); }
4618 0 < ( $select_count_pkgs AND ". FS::cust_pkg->suspended_sql. " )
4620 0 = ( $select_count_pkgs AND ". FS::cust_pkg->active_sql. " )
4626 Returns an SQL expression identifying cancelled cust_main records.
4630 sub cancelled_sql { cancel_sql(@_); }
4633 my $recurring_sql = FS::cust_pkg->recurring_sql;
4634 my $cancelled_sql = FS::cust_pkg->cancelled_sql;
4637 0 < ( $select_count_pkgs )
4638 AND 0 < ( $select_count_pkgs AND $recurring_sql AND $cancelled_sql )
4639 AND 0 = ( $select_count_pkgs AND $recurring_sql
4640 AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
4642 AND 0 = ( $select_count_pkgs AND ". FS::cust_pkg->inactive_sql. " )
4648 =item uncancelled_sql
4650 Returns an SQL expression identifying un-cancelled cust_main records.
4654 sub uncancelled_sql { uncancel_sql(@_); }
4655 sub uncancel_sql { "
4656 ( 0 < ( $select_count_pkgs
4657 AND ( cust_pkg.cancel IS NULL
4658 OR cust_pkg.cancel = 0
4661 OR 0 = ( $select_count_pkgs )
4667 Returns an SQL fragment to retreive the balance.
4672 ( SELECT COALESCE( SUM(charged), 0 ) FROM cust_bill
4673 WHERE cust_bill.custnum = cust_main.custnum )
4674 - ( SELECT COALESCE( SUM(paid), 0 ) FROM cust_pay
4675 WHERE cust_pay.custnum = cust_main.custnum )
4676 - ( SELECT COALESCE( SUM(amount), 0 ) FROM cust_credit
4677 WHERE cust_credit.custnum = cust_main.custnum )
4678 + ( SELECT COALESCE( SUM(refund), 0 ) FROM cust_refund
4679 WHERE cust_refund.custnum = cust_main.custnum )
4682 =item balance_date_sql START_TIME [ END_TIME [ OPTION => VALUE ... ] ]
4684 Returns an SQL fragment to retreive the balance for this customer, only
4685 considering invoices with date earlier than START_TIME, and optionally not
4686 later than END_TIME (total_owed_date minus total_credited minus
4687 total_unapplied_payments).
4689 Times are specified as SQL fragments or numeric
4690 UNIX timestamps; see L<perlfunc/"time">). Also see L<Time::Local> and
4691 L<Date::Parse> for conversion functions. The empty string can be passed
4692 to disable that time constraint completely.
4694 Available options are:
4698 =item unapplied_date - 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)
4700 =item total - set to true to remove all customer comparison clauses, for totals
4702 =item where - WHERE clause hashref (elements "AND"ed together) (typically used with the total option)
4704 =item join - JOIN clause (typically used with the total option)
4712 sub balance_date_sql {
4713 my( $class, $start, $end, %opt ) = @_;
4715 my $owed = FS::cust_bill->owed_sql;
4716 my $unapp_refund = FS::cust_refund->unapplied_sql;
4717 my $unapp_credit = FS::cust_credit->unapplied_sql;
4718 my $unapp_pay = FS::cust_pay->unapplied_sql;
4720 my $j = $opt{'join'} || '';
4722 my $owed_wh = $class->_money_table_where( 'cust_bill', $start,$end,%opt );
4723 my $refund_wh = $class->_money_table_where( 'cust_refund', $start,$end,%opt );
4724 my $credit_wh = $class->_money_table_where( 'cust_credit', $start,$end,%opt );
4725 my $pay_wh = $class->_money_table_where( 'cust_pay', $start,$end,%opt );
4727 " ( SELECT COALESCE(SUM($owed), 0) FROM cust_bill $j $owed_wh )
4728 + ( SELECT COALESCE(SUM($unapp_refund), 0) FROM cust_refund $j $refund_wh )
4729 - ( SELECT COALESCE(SUM($unapp_credit), 0) FROM cust_credit $j $credit_wh )
4730 - ( SELECT COALESCE(SUM($unapp_pay), 0) FROM cust_pay $j $pay_wh )
4735 =item _money_table_where TABLE START_TIME [ END_TIME [ OPTION => VALUE ... ] ]
4737 Helper method for balance_date_sql; name (and usage) subject to change
4738 (suggestions welcome).
4740 Returns a WHERE clause for the specified monetary TABLE (cust_bill,
4741 cust_refund, cust_credit or cust_pay).
4743 If TABLE is "cust_bill" or the unapplied_date option is true, only
4744 considers records with date earlier than START_TIME, and optionally not
4745 later than END_TIME .
4749 sub _money_table_where {
4750 my( $class, $table, $start, $end, %opt ) = @_;
4753 push @where, "cust_main.custnum = $table.custnum" unless $opt{'total'};
4754 if ( $table eq 'cust_bill' || $opt{'unapplied_date'} ) {
4755 push @where, "$table._date <= $start" if defined($start) && length($start);
4756 push @where, "$table._date > $end" if defined($end) && length($end);
4758 push @where, @{$opt{'where'}} if $opt{'where'};
4759 my $where = scalar(@where) ? 'WHERE '. join(' AND ', @where ) : '';
4765 =item search_sql HASHREF
4769 Returns a qsearch hash expression to search for parameters specified in HREF.
4770 Valid parameters are
4778 =item cancelled_pkgs
4784 listref of start date, end date
4790 =item current_balance
4792 listref (list returned by FS::UI::Web::parse_lt_gt($cgi, 'current_balance'))
4796 =item flattened_pkgs
4805 my ($class, $params) = @_;
4816 if ( $params->{'agentnum'} =~ /^(\d+)$/ and $1 ) {
4818 "cust_main.agentnum = $1";
4825 #prospect active inactive suspended cancelled
4826 if ( grep { $params->{'status'} eq $_ } FS::cust_main->statuses() ) {
4827 my $method = $params->{'status'}. '_sql';
4828 #push @where, $class->$method();
4829 push @where, FS::cust_main->$method();
4833 # parse cancelled package checkbox
4838 $pkgwhere .= "AND (cancel = 0 or cancel is null)"
4839 unless $params->{'cancelled_pkgs'};
4845 foreach my $field (qw( signupdate )) {
4847 next unless exists($params->{$field});
4849 my($beginning, $ending) = @{$params->{$field}};
4852 "cust_main.$field IS NOT NULL",
4853 "cust_main.$field >= $beginning",
4854 "cust_main.$field <= $ending";
4856 $orderby ||= "ORDER BY cust_main.$field";
4864 my @payby = grep /^([A-Z]{4})$/, @{ $params->{'payby'} };
4866 push @where, '( '. join(' OR ', map "cust_main.payby = '$_'", @payby). ' )';
4873 #my $balance_sql = $class->balance_sql();
4874 my $balance_sql = FS::cust_main->balance_sql();
4876 push @where, map { s/current_balance/$balance_sql/; $_ }
4877 @{ $params->{'current_balance'} };
4883 if ( $params->{'custbatch'} =~ /^([\w\/\-\:\.]+)$/ and $1 ) {
4885 "cust_main.custbatch = '$1'";
4889 # setup queries, subs, etc. for the search
4892 $orderby ||= 'ORDER BY custnum';
4894 # here is the agent virtualization
4895 push @where, $FS::CurrentUser::CurrentUser->agentnums_sql;
4897 my $extra_sql = scalar(@where) ? ' WHERE '. join(' AND ', @where) : '';
4899 my $addl_from = 'LEFT JOIN cust_pkg USING ( custnum ) ';
4901 my $count_query = "SELECT COUNT(*) FROM cust_main $extra_sql";
4903 my $select = join(', ',
4904 'cust_main.custnum',
4905 FS::UI::Web::cust_sql_fields($params->{'cust_fields'}),
4908 my(@extra_headers) = ();
4909 my(@extra_fields) = ();
4911 if ($params->{'flattened_pkgs'}) {
4913 if ($dbh->{Driver}->{Name} eq 'Pg') {
4915 $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";
4917 }elsif ($dbh->{Driver}->{Name} =~ /^mysql/i) {
4918 $select .= ", GROUP_CONCAT(pkg SEPARATOR '|') as magic";
4919 $addl_from .= " LEFT JOIN part_pkg using ( pkgpart )";
4921 warn "warning: unknown database type ". $dbh->{Driver}->{Name}.
4922 "omitting packing information from report.";
4925 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";
4927 my $sth = dbh->prepare($header_query) or die dbh->errstr;
4928 $sth->execute() or die $sth->errstr;
4929 my $headerrow = $sth->fetchrow_arrayref;
4930 my $headercount = $headerrow ? $headerrow->[0] : 0;
4931 while($headercount) {
4932 unshift @extra_headers, "Package ". $headercount;
4933 unshift @extra_fields, eval q!sub {my $c = shift;
4934 my @a = split '\|', $c->magic;
4935 my $p = $a[!.--$headercount. q!];
4943 'table' => 'cust_main',
4944 'select' => $select,
4946 'extra_sql' => $extra_sql,
4947 'order_by' => $orderby,
4948 'count_query' => $count_query,
4949 'extra_headers' => \@extra_headers,
4950 'extra_fields' => \@extra_fields,
4955 =item email_search_sql HASHREF
4959 Emails a notice to the specified customers.
4961 Valid parameters are those of the L<search_sql> method, plus the following:
4983 Optional job queue job for status updates.
4987 Returns an error message, or false for success.
4989 If an error occurs during any email, stops the enture send and returns that
4990 error. Presumably if you're getting SMTP errors aborting is better than
4991 retrying everything.
4995 sub email_search_sql {
4996 my($class, $params) = @_;
4998 my $from = delete $params->{from};
4999 my $subject = delete $params->{subject};
5000 my $html_body = delete $params->{html_body};
5001 my $text_body = delete $params->{text_body};
5003 my $job = delete $params->{'job'};
5005 my $sql_query = $class->search_sql($params);
5007 my $count_query = delete($sql_query->{'count_query'});
5008 my $count_sth = dbh->prepare($count_query)
5009 or die "Error preparing $count_query: ". dbh->errstr;
5011 or die "Error executing $count_query: ". $count_sth->errstr;
5012 my $count_arrayref = $count_sth->fetchrow_arrayref;
5013 my $num_cust = $count_arrayref->[0];
5015 #my @extra_headers = @{ delete($sql_query->{'extra_headers'}) };
5016 #my @extra_fields = @{ delete($sql_query->{'extra_fields'}) };
5019 my( $num, $last, $min_sec ) = (0, time, 5); #progresbar foo
5021 #eventually order+limit magic to reduce memory use?
5022 foreach my $cust_main ( qsearch($sql_query) ) {
5024 my $to = $cust_main->invoicing_list_emailonly_scalar;
5027 my $error = send_email(
5031 'subject' => $subject,
5032 'html_body' => $html_body,
5033 'text_body' => $text_body,
5036 return $error if $error;
5038 if ( $job ) { #progressbar foo
5040 if ( time - $min_sec > $last ) {
5041 my $error = $job->update_statustext(
5042 int( 100 * $num / $num_cust )
5044 die $error if $error;
5054 use Storable qw(thaw);
5057 sub process_email_search_sql {
5059 #warn "$me process_re_X $method for job $job\n" if $DEBUG;
5061 my $param = thaw(decode_base64(shift));
5062 warn Dumper($param) if $DEBUG;
5064 $param->{'job'} = $job;
5066 my $error = FS::cust_main->email_search_sql( $param );
5067 die $error if $error;
5071 =item fuzzy_search FUZZY_HASHREF [ HASHREF, SELECT, EXTRA_SQL, CACHE_OBJ ]
5073 Performs a fuzzy (approximate) search and returns the matching FS::cust_main
5074 records. Currently, I<first>, I<last> and/or I<company> may be specified (the
5075 appropriate ship_ field is also searched).
5077 Additional options are the same as FS::Record::qsearch
5082 my( $self, $fuzzy, $hash, @opt) = @_;
5087 check_and_rebuild_fuzzyfiles();
5088 foreach my $field ( keys %$fuzzy ) {
5090 my $all = $self->all_X($field);
5091 next unless scalar(@$all);
5094 $match{$_}=1 foreach ( amatch( $fuzzy->{$field}, ['i'], @$all ) );
5097 foreach ( keys %match ) {
5098 push @fcust, qsearch('cust_main', { %$hash, $field=>$_}, @opt);
5099 push @fcust, qsearch('cust_main', { %$hash, "ship_$field"=>$_}, @opt);
5102 push @cust_main, grep { ! $fsaw{$_->custnum}++ } @fcust;
5105 # we want the components of $fuzzy ANDed, not ORed, but still don't want dupes
5107 @cust_main = grep { ++$saw{$_->custnum} == scalar(keys %$fuzzy) } @cust_main;
5115 Returns a masked version of the named field
5120 my ($self, $field) = @_;
5124 'x'x(length($self->getfield($field))-4).
5125 substr($self->getfield($field), (length($self->getfield($field))-4));
5135 =item smart_search OPTION => VALUE ...
5137 Accepts the following options: I<search>, the string to search for. The string
5138 will be searched for as a customer number, phone number, name or company name,
5139 as an exact, or, in some cases, a substring or fuzzy match (see the source code
5140 for the exact heuristics used); I<no_fuzzy_on_exact>, causes smart_search to
5141 skip fuzzy matching when an exact match is found.
5143 Any additional options are treated as an additional qualifier on the search
5146 Returns a (possibly empty) array of FS::cust_main objects.
5153 #here is the agent virtualization
5154 my $agentnums_sql = $FS::CurrentUser::CurrentUser->agentnums_sql;
5158 my $skip_fuzzy = delete $options{'no_fuzzy_on_exact'};
5159 my $search = delete $options{'search'};
5160 ( my $alphanum_search = $search ) =~ s/\W//g;
5162 if ( $alphanum_search =~ /^1?(\d{3})(\d{3})(\d{4})(\d*)$/ ) { #phone# search
5164 #false laziness w/Record::ut_phone
5165 my $phonen = "$1-$2-$3";
5166 $phonen .= " x$4" if $4;
5168 push @cust_main, qsearch( {
5169 'table' => 'cust_main',
5170 'hashref' => { %options },
5171 'extra_sql' => ( scalar(keys %options) ? ' AND ' : ' WHERE ' ).
5173 join(' OR ', map "$_ = '$phonen'",
5174 qw( daytime night fax
5175 ship_daytime ship_night ship_fax )
5178 " AND $agentnums_sql", #agent virtualization
5181 unless ( @cust_main || $phonen =~ /x\d+$/ ) { #no exact match
5182 #try looking for matches with extensions unless one was specified
5184 push @cust_main, qsearch( {
5185 'table' => 'cust_main',
5186 'hashref' => { %options },
5187 'extra_sql' => ( scalar(keys %options) ? ' AND ' : ' WHERE ' ).
5189 join(' OR ', map "$_ LIKE '$phonen\%'",
5191 ship_daytime ship_night )
5194 " AND $agentnums_sql", #agent virtualization
5199 # custnum search (also try agent_custid), with some tweaking options if your
5200 # legacy cust "numbers" have letters
5201 } elsif ( $search =~ /^\s*(\d+)\s*$/
5202 || ( $conf->config('cust_main-agent_custid-format') eq 'ww?d+'
5203 && $search =~ /^\s*(\w\w?\d+)\s*$/
5208 push @cust_main, qsearch( {
5209 'table' => 'cust_main',
5210 'hashref' => { 'custnum' => $1, %options },
5211 'extra_sql' => " AND $agentnums_sql", #agent virtualization
5214 push @cust_main, qsearch( {
5215 'table' => 'cust_main',
5216 'hashref' => { 'agent_custid' => $1, %options },
5217 'extra_sql' => " AND $agentnums_sql", #agent virtualization
5220 } elsif ( $search =~ /^\s*(\S.*\S)\s+\((.+), ([^,]+)\)\s*$/ ) {
5222 my($company, $last, $first) = ( $1, $2, $3 );
5224 # "Company (Last, First)"
5225 #this is probably something a browser remembered,
5226 #so just do an exact search
5228 foreach my $prefix ( '', 'ship_' ) {
5229 push @cust_main, qsearch( {
5230 'table' => 'cust_main',
5231 'hashref' => { $prefix.'first' => $first,
5232 $prefix.'last' => $last,
5233 $prefix.'company' => $company,
5236 'extra_sql' => " AND $agentnums_sql",
5240 } elsif ( $search =~ /^\s*(\S.*\S)\s*$/ ) { # value search
5241 # try (ship_){last,company}
5245 # # remove "(Last, First)" in "Company (Last, First)", otherwise the
5246 # # full strings the browser remembers won't work
5247 # $value =~ s/\([\w \,\.\-\']*\)$//; #false laziness w/Record::ut_name
5249 use Lingua::EN::NameParse;
5250 my $NameParse = new Lingua::EN::NameParse(
5252 allow_reversed => 1,
5255 my($last, $first) = ( '', '' );
5256 #maybe disable this too and just rely on NameParse?
5257 if ( $value =~ /^(.+),\s*([^,]+)$/ ) { # Last, First
5259 ($last, $first) = ( $1, $2 );
5261 #} elsif ( $value =~ /^(.+)\s+(.+)$/ ) {
5262 } elsif ( ! $NameParse->parse($value) ) {
5264 my %name = $NameParse->components;
5265 $first = $name{'given_name_1'};
5266 $last = $name{'surname_1'};
5270 if ( $first && $last ) {
5272 my($q_last, $q_first) = ( dbh->quote($last), dbh->quote($first) );
5275 my $sql = scalar(keys %options) ? ' AND ' : ' WHERE ';
5277 ( ( LOWER(last) = $q_last AND LOWER(first) = $q_first )
5278 OR ( LOWER(ship_last) = $q_last AND LOWER(ship_first) = $q_first )
5281 push @cust_main, qsearch( {
5282 'table' => 'cust_main',
5283 'hashref' => \%options,
5284 'extra_sql' => "$sql AND $agentnums_sql", #agent virtualization
5287 # or it just be something that was typed in... (try that in a sec)
5291 my $q_value = dbh->quote($value);
5294 my $sql = scalar(keys %options) ? ' AND ' : ' WHERE ';
5295 $sql .= " ( LOWER(last) = $q_value
5296 OR LOWER(company) = $q_value
5297 OR LOWER(ship_last) = $q_value
5298 OR LOWER(ship_company) = $q_value
5301 push @cust_main, qsearch( {
5302 'table' => 'cust_main',
5303 'hashref' => \%options,
5304 'extra_sql' => "$sql AND $agentnums_sql", #agent virtualization
5307 #always do substring & fuzzy,
5308 #getting complains searches are not returning enough
5309 unless ( @cust_main && $skip_fuzzy ) { #no exact match, trying substring/fuzzy
5311 #still some false laziness w/search_sql (was search/cust_main.cgi)
5316 { 'company' => { op=>'ILIKE', value=>"%$value%" }, },
5317 { 'ship_company' => { op=>'ILIKE', value=>"%$value%" }, },
5320 if ( $first && $last ) {
5323 { 'first' => { op=>'ILIKE', value=>"%$first%" },
5324 'last' => { op=>'ILIKE', value=>"%$last%" },
5326 { 'ship_first' => { op=>'ILIKE', value=>"%$first%" },
5327 'ship_last' => { op=>'ILIKE', value=>"%$last%" },
5334 { 'last' => { op=>'ILIKE', value=>"%$value%" }, },
5335 { 'ship_last' => { op=>'ILIKE', value=>"%$value%" }, },
5339 foreach my $hashref ( @hashrefs ) {
5341 push @cust_main, qsearch( {
5342 'table' => 'cust_main',
5343 'hashref' => { %$hashref,
5346 'extra_sql' => " AND $agentnums_sql", #agent virtualizaiton
5355 " AND $agentnums_sql", #extra_sql #agent virtualization
5358 if ( $first && $last ) {
5359 push @cust_main, FS::cust_main->fuzzy_search(
5360 { 'last' => $last, #fuzzy hashref
5361 'first' => $first }, #
5365 foreach my $field ( 'last', 'company' ) {
5367 FS::cust_main->fuzzy_search( { $field => $value }, @fuzopts );
5372 #eliminate duplicates
5374 @cust_main = grep { !$saw{$_->custnum}++ } @cust_main;
5384 Accepts the following options: I<email>, the email address to search for. The
5385 email address will be searched for as an email invoice destination and as an
5388 #Any additional options are treated as an additional qualifier on the search
5389 #(i.e. I<agentnum>).
5391 Returns a (possibly empty) array of FS::cust_main objects (but usually just
5401 my $email = delete $options{'email'};
5403 #we're only being used by RT at the moment... no agent virtualization yet
5404 #my $agentnums_sql = $FS::CurrentUser::CurrentUser->agentnums_sql;
5408 if ( $email =~ /([^@]+)\@([^@]+)/ ) {
5410 my ( $user, $domain ) = ( $1, $2 );
5412 warn "$me smart_search: searching for $user in domain $domain"
5418 'table' => 'cust_main_invoice',
5419 'hashref' => { 'dest' => $email },
5426 map $_->cust_svc->cust_pkg,
5428 'table' => 'svc_acct',
5429 'hashref' => { 'username' => $user, },
5431 'AND ( SELECT domain FROM svc_domain
5432 WHERE svc_acct.domsvc = svc_domain.svcnum
5433 ) = '. dbh->quote($domain),
5439 @cust_main = grep { !$saw{$_->custnum}++ } @cust_main;
5441 warn "$me smart_search: found ". scalar(@cust_main). " unique customers"
5448 =item check_and_rebuild_fuzzyfiles
5452 use vars qw(@fuzzyfields);
5453 @fuzzyfields = ( 'last', 'first', 'company' );
5455 sub check_and_rebuild_fuzzyfiles {
5456 my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
5457 rebuild_fuzzyfiles() if grep { ! -e "$dir/cust_main.$_" } @fuzzyfields
5460 =item rebuild_fuzzyfiles
5464 sub rebuild_fuzzyfiles {
5466 use Fcntl qw(:flock);
5468 my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
5469 mkdir $dir, 0700 unless -d $dir;
5471 foreach my $fuzzy ( @fuzzyfields ) {
5473 open(LOCK,">>$dir/cust_main.$fuzzy")
5474 or die "can't open $dir/cust_main.$fuzzy: $!";
5476 or die "can't lock $dir/cust_main.$fuzzy: $!";
5478 open (CACHE,">$dir/cust_main.$fuzzy.tmp")
5479 or die "can't open $dir/cust_main.$fuzzy.tmp: $!";
5481 foreach my $field ( $fuzzy, "ship_$fuzzy" ) {
5482 my $sth = dbh->prepare("SELECT $field FROM cust_main".
5483 " WHERE $field != '' AND $field IS NOT NULL");
5484 $sth->execute or die $sth->errstr;
5486 while ( my $row = $sth->fetchrow_arrayref ) {
5487 print CACHE $row->[0]. "\n";
5492 close CACHE or die "can't close $dir/cust_main.$fuzzy.tmp: $!";
5494 rename "$dir/cust_main.$fuzzy.tmp", "$dir/cust_main.$fuzzy";
5505 my( $self, $field ) = @_;
5506 my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
5507 open(CACHE,"<$dir/cust_main.$field")
5508 or die "can't open $dir/cust_main.$field: $!";
5509 my @array = map { chomp; $_; } <CACHE>;
5514 =item append_fuzzyfiles LASTNAME COMPANY
5518 sub append_fuzzyfiles {
5519 #my( $first, $last, $company ) = @_;
5521 &check_and_rebuild_fuzzyfiles;
5523 use Fcntl qw(:flock);
5525 my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
5527 foreach my $field (qw( first last company )) {
5532 open(CACHE,">>$dir/cust_main.$field")
5533 or die "can't open $dir/cust_main.$field: $!";
5534 flock(CACHE,LOCK_EX)
5535 or die "can't lock $dir/cust_main.$field: $!";
5537 print CACHE "$value\n";
5539 flock(CACHE,LOCK_UN)
5540 or die "can't unlock $dir/cust_main.$field: $!";
5549 =item process_batch_import
5551 Load a batch import as a queued JSRPC job
5555 use Storable qw(thaw);
5558 sub process_batch_import {
5561 my $param = thaw(decode_base64(shift));
5562 warn Dumper($param) if $DEBUG;
5564 my $files = $param->{'uploaded_files'}
5565 or die "No files provided.\n";
5567 my (%files) = map { /^(\w+):([\.\w]+)$/ ? ($1,$2):() } split /,/, $files;
5569 my $dir = '%%%FREESIDE_CACHE%%%/cache.'. $FS::UID::datasrc. '/';
5570 my $file = $dir. $files{'file'};
5573 if ( $file =~ /\.(\w+)$/i ) {
5577 warn "can't parse file type from filename $file; defaulting to CSV";
5582 FS::cust_main::batch_import( {
5586 custbatch => $param->{custbatch},
5587 agentnum => $param->{'agentnum'},
5588 refnum => $param->{'refnum'},
5589 pkgpart => $param->{'pkgpart'},
5590 #'fields' => [qw( cust_pkg.setup dayphone first last address1 address2
5591 # city state zip comments )],
5592 'format' => $param->{'format'},
5597 die "$error\n" if $error;
5605 #some false laziness w/cdr.pm now
5609 my $job = $param->{job};
5611 my $filename = $param->{file};
5612 my $type = $param->{type} || 'csv';
5614 my $custbatch = $param->{custbatch};
5616 my $agentnum = $param->{agentnum};
5617 my $refnum = $param->{refnum};
5618 my $pkgpart = $param->{pkgpart};
5620 my $format = $param->{'format'};
5624 if ( $format eq 'simple' ) {
5625 @fields = qw( cust_pkg.setup dayphone first last
5626 address1 address2 city state zip comments );
5628 } elsif ( $format eq 'extended' ) {
5629 @fields = qw( agent_custid refnum
5630 last first address1 address2 city state zip country
5632 ship_last ship_first ship_address1 ship_address2
5633 ship_city ship_state ship_zip ship_country
5634 payinfo paycvv paydate
5637 svc_acct.username svc_acct._password
5640 } elsif ( $format eq 'extended-plus_company' ) {
5641 @fields = qw( agent_custid refnum
5642 last first company address1 address2 city state zip country
5644 ship_last ship_first ship_company ship_address1 ship_address2
5645 ship_city ship_state ship_zip ship_country
5646 payinfo paycvv paydate
5649 svc_acct.username svc_acct._password
5653 die "unknown format $format";
5659 if ( $type eq 'csv' ) {
5661 eval "use Text::CSV_XS;";
5664 $parser = new Text::CSV_XS;
5666 @buffer = split(/\r?\n/, slurp($filename) );
5667 $count = scalar(@buffer);
5669 } elsif ( $type eq 'xls' ) {
5671 eval "use Spreadsheet::ParseExcel;";
5674 my $excel = new Spreadsheet::ParseExcel::Workbook->Parse($filename);
5675 $parser = $excel->{Worksheet}[0]; #first sheet
5677 $count = $parser->{MaxRow} || $parser->{MinRow};
5681 die "Unknown file type $type\n";
5686 local $SIG{HUP} = 'IGNORE';
5687 local $SIG{INT} = 'IGNORE';
5688 local $SIG{QUIT} = 'IGNORE';
5689 local $SIG{TERM} = 'IGNORE';
5690 local $SIG{TSTP} = 'IGNORE';
5691 local $SIG{PIPE} = 'IGNORE';
5693 my $oldAutoCommit = $FS::UID::AutoCommit;
5694 local $FS::UID::AutoCommit = 0;
5699 my( $last, $min_sec ) = ( time, 5 ); #progressbar foo
5703 if ( $type eq 'csv' ) {
5705 last unless scalar(@buffer);
5706 $line = shift(@buffer);
5708 $parser->parse($line) or do {
5709 $dbh->rollback if $oldAutoCommit;
5710 return "can't parse: ". $parser->error_input();
5712 @columns = $parser->fields();
5714 } elsif ( $type eq 'xls' ) {
5716 last if $row > ($parser->{MaxRow} || $parser->{MinRow});
5718 my @row = @{ $parser->{Cells}[$row] };
5719 @columns = map $_->{Val}, @row;
5722 #warn $z++. ": $_\n" for @columns;
5725 die "Unknown file type $type\n";
5728 #warn join('-',@columns);
5731 custbatch => $custbatch,
5732 agentnum => $agentnum,
5734 country => $conf->config('countrydefault') || 'US',
5735 payby => $payby, #default
5736 paydate => '12/2037', #default
5738 my $billtime = time;
5739 my %cust_pkg = ( pkgpart => $pkgpart );
5741 foreach my $field ( @fields ) {
5743 if ( $field =~ /^cust_pkg\.(pkgpart|setup|bill|susp|adjourn|expire|cancel)$/ ) {
5745 #$cust_pkg{$1} = str2time( shift @$columns );
5746 if ( $1 eq 'pkgpart' ) {
5747 $cust_pkg{$1} = shift @columns;
5748 } elsif ( $1 eq 'setup' ) {
5749 $billtime = str2time(shift @columns);
5751 $cust_pkg{$1} = str2time( shift @columns );
5754 } elsif ( $field =~ /^svc_acct\.(username|_password)$/ ) {
5756 $svc_acct{$1} = shift @columns;
5760 #refnum interception
5761 if ( $field eq 'refnum' && $columns[0] !~ /^\s*(\d+)\s*$/ ) {
5763 my $referral = $columns[0];
5764 my %hash = ( 'referral' => $referral,
5765 'agentnum' => $agentnum,
5769 my $part_referral = qsearchs('part_referral', \%hash )
5770 || new FS::part_referral \%hash;
5772 unless ( $part_referral->refnum ) {
5773 my $error = $part_referral->insert;
5775 $dbh->rollback if $oldAutoCommit;
5776 return "can't auto-insert advertising source: $referral: $error";
5780 $columns[0] = $part_referral->refnum;
5783 my $value = shift @columns;
5784 $cust_main{$field} = $value if length($value);
5788 $cust_main{'payby'} = 'CARD'
5789 if defined $cust_main{'payinfo'}
5790 && length $cust_main{'payinfo'};
5792 my $invoicing_list = $cust_main{'invoicing_list'}
5793 ? [ delete $cust_main{'invoicing_list'} ]
5796 my $cust_main = new FS::cust_main ( \%cust_main );
5799 tie my %hash, 'Tie::RefHash'; #this part is important
5801 if ( $cust_pkg{'pkgpart'} ) {
5802 my $cust_pkg = new FS::cust_pkg ( \%cust_pkg );
5805 if ( $svc_acct{'username'} ) {
5806 my $part_pkg = $cust_pkg->part_pkg;
5807 unless ( $part_pkg ) {
5808 $dbh->rollback if $oldAutoCommit;
5809 return "unknown pkgpart: ". $cust_pkg{'pkgpart'};
5811 $svc_acct{svcpart} = $part_pkg->svcpart( 'svc_acct' );
5812 push @svc_acct, new FS::svc_acct ( \%svc_acct )
5815 $hash{$cust_pkg} = \@svc_acct;
5818 my $error = $cust_main->insert( \%hash, $invoicing_list );
5821 $dbh->rollback if $oldAutoCommit;
5822 return "can't insert customer". ( $line ? " for $line" : '' ). ": $error";
5825 if ( $format eq 'simple' ) {
5827 #false laziness w/bill.cgi
5828 $error = $cust_main->bill( 'time' => $billtime );
5830 $dbh->rollback if $oldAutoCommit;
5831 return "can't bill customer for $line: $error";
5834 $error = $cust_main->apply_payments_and_credits;
5836 $dbh->rollback if $oldAutoCommit;
5837 return "can't bill customer for $line: $error";
5840 $error = $cust_main->collect();
5842 $dbh->rollback if $oldAutoCommit;
5843 return "can't collect customer for $line: $error";
5850 if ( $job && time - $min_sec > $last ) { #progress bar
5851 $job->update_statustext( int(100 * $row / $count) );
5857 $dbh->commit or die $dbh->errstr if $oldAutoCommit;;
5859 return "Empty file!" unless $row;
5871 #warn join('-',keys %$param);
5872 my $fh = $param->{filehandle};
5873 my @fields = @{$param->{fields}};
5875 eval "use Text::CSV_XS;";
5878 my $csv = new Text::CSV_XS;
5885 local $SIG{HUP} = 'IGNORE';
5886 local $SIG{INT} = 'IGNORE';
5887 local $SIG{QUIT} = 'IGNORE';
5888 local $SIG{TERM} = 'IGNORE';
5889 local $SIG{TSTP} = 'IGNORE';
5890 local $SIG{PIPE} = 'IGNORE';
5892 my $oldAutoCommit = $FS::UID::AutoCommit;
5893 local $FS::UID::AutoCommit = 0;
5896 #while ( $columns = $csv->getline($fh) ) {
5898 while ( defined($line=<$fh>) ) {
5900 $csv->parse($line) or do {
5901 $dbh->rollback if $oldAutoCommit;
5902 return "can't parse: ". $csv->error_input();
5905 my @columns = $csv->fields();
5906 #warn join('-',@columns);
5909 foreach my $field ( @fields ) {
5910 $row{$field} = shift @columns;
5913 my $cust_main = qsearchs('cust_main', { 'custnum' => $row{'custnum'} } );
5914 unless ( $cust_main ) {
5915 $dbh->rollback if $oldAutoCommit;
5916 return "unknown custnum $row{'custnum'}";
5919 if ( $row{'amount'} > 0 ) {
5920 my $error = $cust_main->charge($row{'amount'}, $row{'pkg'});
5922 $dbh->rollback if $oldAutoCommit;
5926 } elsif ( $row{'amount'} < 0 ) {
5927 my $error = $cust_main->credit( sprintf( "%.2f", 0-$row{'amount'} ),
5930 $dbh->rollback if $oldAutoCommit;
5940 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
5942 return "Empty file!" unless $imported;
5948 =item notify CUSTOMER_OBJECT TEMPLATE_NAME OPTIONS
5950 Sends a templated email notification to the customer (see L<Text::Template>).
5952 OPTIONS is a hash and may include
5954 I<from> - the email sender (default is invoice_from)
5956 I<to> - comma-separated scalar or arrayref of recipients
5957 (default is invoicing_list)
5959 I<subject> - The subject line of the sent email notification
5960 (default is "Notice from company_name")
5962 I<extra_fields> - a hashref of name/value pairs which will be substituted
5965 The following variables are vavailable in the template.
5967 I<$first> - the customer first name
5968 I<$last> - the customer last name
5969 I<$company> - the customer company
5970 I<$payby> - a description of the method of payment for the customer
5971 # would be nice to use FS::payby::shortname
5972 I<$payinfo> - the account information used to collect for this customer
5973 I<$expdate> - the expiration of the customer payment in seconds from epoch
5978 my ($customer, $template, %options) = @_;
5980 return unless $conf->exists($template);
5982 my $from = $conf->config('invoice_from') if $conf->exists('invoice_from');
5983 $from = $options{from} if exists($options{from});
5985 my $to = join(',', $customer->invoicing_list_emailonly);
5986 $to = $options{to} if exists($options{to});
5988 my $subject = "Notice from " . $conf->config('company_name')
5989 if $conf->exists('company_name');
5990 $subject = $options{subject} if exists($options{subject});
5992 my $notify_template = new Text::Template (TYPE => 'ARRAY',
5993 SOURCE => [ map "$_\n",
5994 $conf->config($template)]
5996 or die "can't create new Text::Template object: Text::Template::ERROR";
5997 $notify_template->compile()
5998 or die "can't compile template: Text::Template::ERROR";
6000 my $paydate = $customer->paydate || '2037-12-31';
6001 $FS::notify_template::_template::first = $customer->first;
6002 $FS::notify_template::_template::last = $customer->last;
6003 $FS::notify_template::_template::company = $customer->company;
6004 $FS::notify_template::_template::payinfo = $customer->mask_payinfo;
6005 my $payby = $customer->payby;
6006 my ($payyear,$paymonth,$payday) = split (/-/,$paydate);
6007 my $expire_time = timelocal(0,0,0,$payday,--$paymonth,$payyear);
6009 #credit cards expire at the end of the month/year of their exp date
6010 if ($payby eq 'CARD' || $payby eq 'DCRD') {
6011 $FS::notify_template::_template::payby = 'credit card';
6012 ($paymonth < 11) ? $paymonth++ : ($paymonth=0, $payyear++);
6013 $expire_time = timelocal(0,0,0,$payday,$paymonth,$payyear);
6015 }elsif ($payby eq 'COMP') {
6016 $FS::notify_template::_template::payby = 'complimentary account';
6018 $FS::notify_template::_template::payby = 'current method';
6020 $FS::notify_template::_template::expdate = $expire_time;
6022 for (keys %{$options{extra_fields}}){
6024 ${"FS::notify_template::_template::$_"} = $options{extra_fields}->{$_};
6027 send_email(from => $from,
6029 subject => $subject,
6030 body => $notify_template->fill_in( PACKAGE =>
6031 'FS::notify_template::_template' ),
6036 =item generate_letter CUSTOMER_OBJECT TEMPLATE_NAME OPTIONS
6038 Generates a templated notification to the customer (see L<Text::Template>).
6040 OPTIONS is a hash and may include
6042 I<extra_fields> - a hashref of name/value pairs which will be substituted
6043 into the template. These values may override values mentioned below
6044 and those from the customer record.
6046 The following variables are available in the template instead of or in addition
6047 to the fields of the customer record.
6049 I<$payby> - a description of the method of payment for the customer
6050 # would be nice to use FS::payby::shortname
6051 I<$payinfo> - the masked account information used to collect for this customer
6052 I<$expdate> - the expiration of the customer payment method in seconds from epoch
6053 I<$returnaddress> - the return address defaults to invoice_latexreturnaddress
6057 sub generate_letter {
6058 my ($self, $template, %options) = @_;
6060 return unless $conf->exists($template);
6062 my $letter_template = new Text::Template
6064 SOURCE => [ map "$_\n", $conf->config($template)],
6065 DELIMITERS => [ '[@--', '--@]' ],
6067 or die "can't create new Text::Template object: Text::Template::ERROR";
6069 $letter_template->compile()
6070 or die "can't compile template: Text::Template::ERROR";
6072 my %letter_data = map { $_ => $self->$_ } $self->fields;
6073 $letter_data{payinfo} = $self->mask_payinfo;
6075 my $paydate = $self->paydate || '2037-12-31';
6076 my $payby = $self->payby;
6077 my ($payyear,$paymonth,$payday) = split (/-/,$paydate);
6078 my $expire_time = timelocal(0,0,0,$payday,--$paymonth,$payyear);
6080 #credit cards expire at the end of the month/year of their exp date
6081 if ($payby eq 'CARD' || $payby eq 'DCRD') {
6082 $letter_data{payby} = 'credit card';
6083 ($paymonth < 11) ? $paymonth++ : ($paymonth=0, $payyear++);
6084 $expire_time = timelocal(0,0,0,$payday,$paymonth,$payyear);
6086 }elsif ($payby eq 'COMP') {
6087 $letter_data{payby} = 'complimentary account';
6089 $letter_data{payby} = 'current method';
6091 $letter_data{expdate} = $expire_time;
6093 for (keys %{$options{extra_fields}}){
6094 $letter_data{$_} = $options{extra_fields}->{$_};
6097 unless(exists($letter_data{returnaddress})){
6098 my $retadd = join("\n", $conf->config_orbase( 'invoice_latexreturnaddress',
6099 $self->_agent_template)
6102 $letter_data{returnaddress} = length($retadd) ? $retadd : '~';
6105 $letter_data{conf_dir} = "$FS::UID::conf_dir/conf.$FS::UID::datasrc";
6107 my $dir = $FS::UID::conf_dir."cache.". $FS::UID::datasrc;
6108 my $fh = new File::Temp( TEMPLATE => 'letter.'. $self->custnum. '.XXXXXXXX',
6112 ) or die "can't open temp file: $!\n";
6114 $letter_template->fill_in( OUTPUT => $fh, HASH => \%letter_data );
6116 $fh->filename =~ /^(.*).tex$/ or die "unparsable filename: ". $fh->filename;
6120 =item print_ps TEMPLATE
6122 Returns an postscript letter filled in from TEMPLATE, as a scalar.
6128 my $file = $self->generate_letter(@_);
6129 FS::Misc::generate_ps($file);
6132 =item print TEMPLATE
6134 Prints the filled in template.
6136 TEMPLATE is the name of a L<Text::Template> to fill in and print.
6140 sub queueable_print {
6143 my $self = qsearchs('cust_main', { 'custnum' => $opt{custnum} } )
6144 or die "invalid customer number: " . $opt{custvnum};
6146 my $error = $self->print( $opt{template} );
6147 die $error if $error;
6151 my ($self, $template) = (shift, shift);
6152 do_print [ $self->print_ps($template) ];
6155 sub agent_template {
6157 $self->_agent_plandata('agent_templatename');
6160 sub agent_invoice_from {
6162 $self->_agent_plandata('agent_invoice_from');
6165 sub _agent_plandata {
6166 my( $self, $option ) = @_;
6169 if ( driver_name =~ /^Pg/i ) {
6171 } elsif ( driver_name =~ /^mysql/i ) {
6174 die "don't know how to use regular expressions in ". driver_name. " databases";
6177 my $part_bill_event = qsearchs( 'part_bill_event',
6179 'payby' => $self->payby,
6180 'plan' => 'send_agent',
6181 'plandata' => { 'op' => $regexp,
6182 'value' => "(^|\n)agentnum ".
6190 'ORDER BY seconds LIMIT 1'
6193 return '' unless $part_bill_event;
6195 if ( $part_bill_event->plandata =~ /^$option (.*)$/m ) {
6198 warn "can't parse part_bill_event eventpart#". $part_bill_event->eventpart.
6199 " plandata for $option";
6211 The delete method should possibly take an FS::cust_main object reference
6212 instead of a scalar customer number.
6214 Bill and collect options should probably be passed as references instead of a
6217 There should probably be a configuration file with a list of allowed credit
6220 No multiple currency support (probably a larger project than just this module).
6222 payinfo_masked false laziness with cust_pay.pm and cust_refund.pm
6224 Birthdates rely on negative epoch values.
6226 The payby for card/check batches is broken. With mixed batching, bad
6231 L<FS::Record>, L<FS::cust_pkg>, L<FS::cust_bill>, L<FS::cust_credit>
6232 L<FS::agent>, L<FS::part_referral>, L<FS::cust_main_county>,
6233 L<FS::cust_main_invoice>, L<FS::UID>, schema.html from the base documentation.