implement line item bundling
[freeside.git] / FS / FS / cust_main.pm
1 package FS::cust_main;
2
3 require 5.006;
4 use strict;
5 use vars qw( @ISA @EXPORT_OK $DEBUG $me $conf @encrypted_fields
6              $import $skip_fuzzyfiles $ignore_expired_card @paytypes);
7 use vars qw( $realtime_bop_decline_quiet ); #ugh
8 use Safe;
9 use Carp;
10 use Exporter;
11 use Scalar::Util qw( blessed );
12 use Time::Local qw(timelocal_nocheck);
13 use Data::Dumper;
14 use Tie::IxHash;
15 use Digest::MD5 qw(md5_base64);
16 use Date::Format;
17 use Date::Parse;
18 #use Date::Manip;
19 use String::Approx qw(amatch);
20 use Business::CreditCard 0.28;
21 use Locale::Country;
22 use Data::Dumper;
23 use FS::UID qw( getotaker dbh driver_name );
24 use FS::Record qw( qsearchs qsearch dbdef );
25 use FS::Misc qw( send_email generate_ps do_print );
26 use FS::Msgcat qw(gettext);
27 use FS::cust_pkg;
28 use FS::cust_svc;
29 use FS::cust_bill;
30 use FS::cust_bill_pkg;
31 use FS::cust_pay;
32 use FS::cust_pay_pending;
33 use FS::cust_pay_void;
34 use FS::cust_pay_batch;
35 use FS::cust_credit;
36 use FS::cust_refund;
37 use FS::part_referral;
38 use FS::cust_main_county;
39 use FS::agent;
40 use FS::cust_main_invoice;
41 use FS::cust_credit_bill;
42 use FS::cust_bill_pay;
43 use FS::prepay_credit;
44 use FS::queue;
45 use FS::part_pkg;
46 use FS::part_event;
47 use FS::part_event_condition;
48 #use FS::cust_event;
49 use FS::type_pkgs;
50 use FS::payment_gateway;
51 use FS::agent_payment_gateway;
52 use FS::banned_pay;
53 use FS::payinfo_Mixin;
54 use FS::TicketSystem;
55
56 @ISA = qw( FS::Record FS::payinfo_Mixin );
57
58 @EXPORT_OK = qw( smart_search );
59
60 $realtime_bop_decline_quiet = 0;
61
62 # 1 is mostly method/subroutine entry and options
63 # 2 traces progress of some operations
64 # 3 is even more information including possibly sensitive data
65 $DEBUG = 0;
66 $me = '[FS::cust_main]';
67
68 $import = 0;
69 $skip_fuzzyfiles = 0;
70 $ignore_expired_card = 0;
71
72 @encrypted_fields = ('payinfo', 'paycvv');
73 @paytypes = ('', 'Personal checking', 'Personal savings', 'Business checking', 'Business savings');
74
75 #ask FS::UID to run this stuff for us later
76 #$FS::UID::callback{'FS::cust_main'} = sub { 
77 install_callback FS::UID sub { 
78   $conf = new FS::Conf;
79   #yes, need it for stuff below (prolly should be cached)
80 };
81
82 sub _cache {
83   my $self = shift;
84   my ( $hashref, $cache ) = @_;
85   if ( exists $hashref->{'pkgnum'} ) {
86     #@{ $self->{'_pkgnum'} } = ();
87     my $subcache = $cache->subcache( 'pkgnum', 'cust_pkg', $hashref->{custnum});
88     $self->{'_pkgnum'} = $subcache;
89     #push @{ $self->{'_pkgnum'} },
90     FS::cust_pkg->new_or_cached($hashref, $subcache) if $hashref->{pkgnum};
91   }
92 }
93
94 =head1 NAME
95
96 FS::cust_main - Object methods for cust_main records
97
98 =head1 SYNOPSIS
99
100   use FS::cust_main;
101
102   $record = new FS::cust_main \%hash;
103   $record = new FS::cust_main { 'column' => 'value' };
104
105   $error = $record->insert;
106
107   $error = $new_record->replace($old_record);
108
109   $error = $record->delete;
110
111   $error = $record->check;
112
113   @cust_pkg = $record->all_pkgs;
114
115   @cust_pkg = $record->ncancelled_pkgs;
116
117   @cust_pkg = $record->suspended_pkgs;
118
119   $error = $record->bill;
120   $error = $record->bill %options;
121   $error = $record->bill 'time' => $time;
122
123   $error = $record->collect;
124   $error = $record->collect %options;
125   $error = $record->collect 'invoice_time'   => $time,
126                           ;
127
128 =head1 DESCRIPTION
129
130 An FS::cust_main object represents a customer.  FS::cust_main inherits from 
131 FS::Record.  The following fields are currently supported:
132
133 =over 4
134
135 =item custnum - primary key (assigned automatically for new customers)
136
137 =item agentnum - agent (see L<FS::agent>)
138
139 =item refnum - Advertising source (see L<FS::part_referral>)
140
141 =item first - name
142
143 =item last - name
144
145 =item ss - social security number (optional)
146
147 =item company - (optional)
148
149 =item address1
150
151 =item address2 - (optional)
152
153 =item city
154
155 =item county - (optional, see L<FS::cust_main_county>)
156
157 =item state - (see L<FS::cust_main_county>)
158
159 =item zip
160
161 =item country - (see L<FS::cust_main_county>)
162
163 =item daytime - phone (optional)
164
165 =item night - phone (optional)
166
167 =item fax - phone (optional)
168
169 =item ship_first - name
170
171 =item ship_last - name
172
173 =item ship_company - (optional)
174
175 =item ship_address1
176
177 =item ship_address2 - (optional)
178
179 =item ship_city
180
181 =item ship_county - (optional, see L<FS::cust_main_county>)
182
183 =item ship_state - (see L<FS::cust_main_county>)
184
185 =item ship_zip
186
187 =item ship_country - (see L<FS::cust_main_county>)
188
189 =item ship_daytime - phone (optional)
190
191 =item ship_night - phone (optional)
192
193 =item ship_fax - phone (optional)
194
195 =item payby - Payment Type (See L<FS::payinfo_Mixin> for valid payby values)
196
197 =item payinfo - Payment Information (See L<FS::payinfo_Mixin> for data format)
198
199 =item paymask - Masked payinfo (See L<FS::payinfo_Mixin> for how this works)
200
201 =item paycvv
202
203 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
204
205 =item paydate - expiration date, mm/yyyy, m/yyyy, mm/yy or m/yy
206
207 =item paystart_month - start date month (maestro/solo cards only)
208
209 =item paystart_year - start date year (maestro/solo cards only)
210
211 =item payissue - issue number (maestro/solo cards only)
212
213 =item payname - name on card or billing name
214
215 =item payip - IP address from which payment information was received
216
217 =item tax - tax exempt, empty or `Y'
218
219 =item otaker - order taker (assigned automatically, see L<FS::UID>)
220
221 =item comments - comments (optional)
222
223 =item referral_custnum - referring customer number
224
225 =item spool_cdr - Enable individual CDR spooling, empty or `Y'
226
227 =back
228
229 =head1 METHODS
230
231 =over 4
232
233 =item new HASHREF
234
235 Creates a new customer.  To add the customer to the database, see L<"insert">.
236
237 Note that this stores the hash reference, not a distinct copy of the hash it
238 points to.  You can ask the object for a copy with the I<hash> method.
239
240 =cut
241
242 sub table { 'cust_main'; }
243
244 =item insert [ CUST_PKG_HASHREF [ , INVOICING_LIST_ARYREF ] [ , OPTION => VALUE ... ] ]
245
246 Adds this customer to the database.  If there is an error, returns the error,
247 otherwise returns false.
248
249 CUST_PKG_HASHREF: If you pass a Tie::RefHash data structure to the insert
250 method containing FS::cust_pkg and FS::svc_I<tablename> objects, all records
251 are inserted atomicly, or the transaction is rolled back.  Passing an empty
252 hash reference is equivalent to not supplying this parameter.  There should be
253 a better explanation of this, but until then, here's an example:
254
255   use Tie::RefHash;
256   tie %hash, 'Tie::RefHash'; #this part is important
257   %hash = (
258     $cust_pkg => [ $svc_acct ],
259     ...
260   );
261   $cust_main->insert( \%hash );
262
263 INVOICING_LIST_ARYREF: If you pass an arrarref to the insert method, it will
264 be set as the invoicing list (see L<"invoicing_list">).  Errors return as
265 expected and rollback the entire transaction; it is not necessary to call 
266 check_invoicing_list first.  The invoicing_list is set after the records in the
267 CUST_PKG_HASHREF above are inserted, so it is now possible to set an
268 invoicing_list destination to the newly-created svc_acct.  Here's an example:
269
270   $cust_main->insert( {}, [ $email, 'POST' ] );
271
272 Currently available options are: I<depend_jobnum> and I<noexport>.
273
274 If I<depend_jobnum> is set, all provisioning jobs will have a dependancy
275 on the supplied jobnum (they will not run until the specific job completes).
276 This can be used to defer provisioning until some action completes (such
277 as running the customer's credit card successfully).
278
279 The I<noexport> option is deprecated.  If I<noexport> is set true, no
280 provisioning jobs (exports) are scheduled.  (You can schedule them later with
281 the B<reexport> method.)
282
283 =cut
284
285 sub insert {
286   my $self = shift;
287   my $cust_pkgs = @_ ? shift : {};
288   my $invoicing_list = @_ ? shift : '';
289   my %options = @_;
290   warn "$me insert called with options ".
291        join(', ', map { "$_: $options{$_}" } keys %options ). "\n"
292     if $DEBUG;
293
294   local $SIG{HUP} = 'IGNORE';
295   local $SIG{INT} = 'IGNORE';
296   local $SIG{QUIT} = 'IGNORE';
297   local $SIG{TERM} = 'IGNORE';
298   local $SIG{TSTP} = 'IGNORE';
299   local $SIG{PIPE} = 'IGNORE';
300
301   my $oldAutoCommit = $FS::UID::AutoCommit;
302   local $FS::UID::AutoCommit = 0;
303   my $dbh = dbh;
304
305   my $prepay_identifier = '';
306   my( $amount, $seconds ) = ( 0, 0 );
307   my $payby = '';
308   if ( $self->payby eq 'PREPAY' ) {
309
310     $self->payby('BILL');
311     $prepay_identifier = $self->payinfo;
312     $self->payinfo('');
313
314     warn "  looking up prepaid card $prepay_identifier\n"
315       if $DEBUG > 1;
316
317     my $error = $self->get_prepay($prepay_identifier, \$amount, \$seconds);
318     if ( $error ) {
319       $dbh->rollback if $oldAutoCommit;
320       #return "error applying prepaid card (transaction rolled back): $error";
321       return $error;
322     }
323
324     $payby = 'PREP' if $amount;
325
326   } elsif ( $self->payby =~ /^(CASH|WEST|MCRD)$/ ) {
327
328     $payby = $1;
329     $self->payby('BILL');
330     $amount = $self->paid;
331
332   }
333
334   warn "  inserting $self\n"
335     if $DEBUG > 1;
336
337   $self->signupdate(time) unless $self->signupdate;
338
339   my $error = $self->SUPER::insert;
340   if ( $error ) {
341     $dbh->rollback if $oldAutoCommit;
342     #return "inserting cust_main record (transaction rolled back): $error";
343     return $error;
344   }
345
346   warn "  setting invoicing list\n"
347     if $DEBUG > 1;
348
349   if ( $invoicing_list ) {
350     $error = $self->check_invoicing_list( $invoicing_list );
351     if ( $error ) {
352       $dbh->rollback if $oldAutoCommit;
353       #return "checking invoicing_list (transaction rolled back): $error";
354       return $error;
355     }
356     $self->invoicing_list( $invoicing_list );
357   }
358
359   if (    $conf->config('cust_main-skeleton_tables')
360        && $conf->config('cust_main-skeleton_custnum') ) {
361
362     warn "  inserting skeleton records\n"
363       if $DEBUG > 1;
364
365     my $error = $self->start_copy_skel;
366     if ( $error ) {
367       $dbh->rollback if $oldAutoCommit;
368       return $error;
369     }
370
371   }
372
373   warn "  ordering packages\n"
374     if $DEBUG > 1;
375
376   $error = $self->order_pkgs($cust_pkgs, \$seconds, %options);
377   if ( $error ) {
378     $dbh->rollback if $oldAutoCommit;
379     return $error;
380   }
381
382   if ( $seconds ) {
383     $dbh->rollback if $oldAutoCommit;
384     return "No svc_acct record to apply pre-paid time";
385   }
386
387   if ( $amount ) {
388     warn "  inserting initial $payby payment of $amount\n"
389       if $DEBUG > 1;
390     $error = $self->insert_cust_pay($payby, $amount, $prepay_identifier);
391     if ( $error ) {
392       $dbh->rollback if $oldAutoCommit;
393       return "inserting payment (transaction rolled back): $error";
394     }
395   }
396
397   unless ( $import || $skip_fuzzyfiles ) {
398     warn "  queueing fuzzyfiles update\n"
399       if $DEBUG > 1;
400     $error = $self->queue_fuzzyfiles_update;
401     if ( $error ) {
402       $dbh->rollback if $oldAutoCommit;
403       return "updating fuzzy search cache: $error";
404     }
405   }
406
407   warn "  insert complete; committing transaction\n"
408     if $DEBUG > 1;
409
410   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
411   '';
412
413 }
414
415 sub start_copy_skel {
416   my $self = shift;
417
418   #'mg_user_preference' => {},
419   #'mg_user_indicator_profile.user_indicator_profile_id' => { 'mg_profile_indicator.profile_indicator_id' => { 'mg_profile_details.profile_detail_id' }, },
420   #'mg_watchlist_header.watchlist_header_id' => { 'mg_watchlist_details.watchlist_details_id' },
421   #'mg_user_grid_header.grid_header_id' => { 'mg_user_grid_details.user_grid_details_id' },
422   #'mg_portfolio_header.portfolio_header_id' => { 'mg_portfolio_trades.portfolio_trades_id' => { 'mg_portfolio_trades_positions.portfolio_trades_positions_id' } },
423   my @tables = eval(join('\n',$conf->config('cust_main-skeleton_tables')));
424   die $@ if $@;
425
426   _copy_skel( 'cust_main',                                 #tablename
427               $conf->config('cust_main-skeleton_custnum'), #sourceid
428               $self->custnum,                              #destid
429               @tables,                                     #child tables
430             );
431 }
432
433 #recursive subroutine, not a method
434 sub _copy_skel {
435   my( $table, $sourceid, $destid, %child_tables ) = @_;
436
437   my $primary_key;
438   if ( $table =~ /^(\w+)\.(\w+)$/ ) {
439     ( $table, $primary_key ) = ( $1, $2 );
440   } else {
441     my $dbdef_table = dbdef->table($table);
442     $primary_key = $dbdef_table->primary_key
443       or return "$table has no primary key".
444                 " (or do you need to run dbdef-create?)";
445   }
446
447   warn "  _copy_skel: $table.$primary_key $sourceid to $destid for ".
448        join (', ', keys %child_tables). "\n"
449     if $DEBUG > 2;
450
451   foreach my $child_table_def ( keys %child_tables ) {
452
453     my $child_table;
454     my $child_pkey = '';
455     if ( $child_table_def =~ /^(\w+)\.(\w+)$/ ) {
456       ( $child_table, $child_pkey ) = ( $1, $2 );
457     } else {
458       $child_table = $child_table_def;
459
460       $child_pkey = dbdef->table($child_table)->primary_key;
461       #  or return "$table has no primary key".
462       #            " (or do you need to run dbdef-create?)\n";
463     }
464
465     my $sequence = '';
466     if ( keys %{ $child_tables{$child_table_def} } ) {
467
468       return "$child_table has no primary key".
469              " (run dbdef-create or try specifying it?)\n"
470         unless $child_pkey;
471
472       #false laziness w/Record::insert and only works on Pg
473       #refactor the proper last-inserted-id stuff out of Record::insert if this
474       # ever gets use for anything besides a quick kludge for one customer
475       my $default = dbdef->table($child_table)->column($child_pkey)->default;
476       $default =~ /^nextval\(\(?'"?([\w\.]+)"?'/i
477         or return "can't parse $child_table.$child_pkey default value ".
478                   " for sequence name: $default";
479       $sequence = $1;
480
481     }
482   
483     my @sel_columns = grep { $_ ne $primary_key }
484                            dbdef->table($child_table)->columns;
485     my $sel_columns = join(', ', @sel_columns );
486
487     my @ins_columns = grep { $_ ne $child_pkey } @sel_columns;
488     my $ins_columns = ' ( '. join(', ', $primary_key, @ins_columns ). ' ) ';
489     my $placeholders = ' ( ?, '. join(', ', map '?', @ins_columns ). ' ) ';
490
491     my $sel_st = "SELECT $sel_columns FROM $child_table".
492                  " WHERE $primary_key = $sourceid";
493     warn "    $sel_st\n"
494       if $DEBUG > 2;
495     my $sel_sth = dbh->prepare( $sel_st )
496       or return dbh->errstr;
497   
498     $sel_sth->execute or return $sel_sth->errstr;
499
500     while ( my $row = $sel_sth->fetchrow_hashref ) {
501
502       warn "    selected row: ".
503            join(', ', map { "$_=".$row->{$_} } keys %$row ). "\n"
504         if $DEBUG > 2;
505
506       my $statement =
507         "INSERT INTO $child_table $ins_columns VALUES $placeholders";
508       my $ins_sth =dbh->prepare($statement)
509           or return dbh->errstr;
510       my @param = ( $destid, map $row->{$_}, @ins_columns );
511       warn "    $statement: [ ". join(', ', @param). " ]\n"
512         if $DEBUG > 2;
513       $ins_sth->execute( @param )
514         or return $ins_sth->errstr;
515
516       #next unless keys %{ $child_tables{$child_table} };
517       next unless $sequence;
518       
519       #another section of that laziness
520       my $seq_sql = "SELECT currval('$sequence')";
521       my $seq_sth = dbh->prepare($seq_sql) or return dbh->errstr;
522       $seq_sth->execute or return $seq_sth->errstr;
523       my $insertid = $seq_sth->fetchrow_arrayref->[0];
524   
525       # don't drink soap!  recurse!  recurse!  okay!
526       my $error =
527         _copy_skel( $child_table_def,
528                     $row->{$child_pkey}, #sourceid
529                     $insertid, #destid
530                     %{ $child_tables{$child_table_def} },
531                   );
532       return $error if $error;
533
534     }
535
536   }
537
538   return '';
539
540 }
541
542 =item order_pkgs HASHREF, [ SECONDSREF, [ , OPTION => VALUE ... ] ]
543
544 Like the insert method on an existing record, this method orders a package
545 and included services atomicaly.  Pass a Tie::RefHash data structure to this
546 method containing FS::cust_pkg and FS::svc_I<tablename> objects.  There should
547 be a better explanation of this, but until then, here's an example:
548
549   use Tie::RefHash;
550   tie %hash, 'Tie::RefHash'; #this part is important
551   %hash = (
552     $cust_pkg => [ $svc_acct ],
553     ...
554   );
555   $cust_main->order_pkgs( \%hash, \'0', 'noexport'=>1 );
556
557 Services can be new, in which case they are inserted, or existing unaudited
558 services, in which case they are linked to the newly-created package.
559
560 Currently available options are: I<depend_jobnum> and I<noexport>.
561
562 If I<depend_jobnum> is set, all provisioning jobs will have a dependancy
563 on the supplied jobnum (they will not run until the specific job completes).
564 This can be used to defer provisioning until some action completes (such
565 as running the customer's credit card successfully).
566
567 The I<noexport> option is deprecated.  If I<noexport> is set true, no
568 provisioning jobs (exports) are scheduled.  (You can schedule them later with
569 the B<reexport> method for each cust_pkg object.  Using the B<reexport> method
570 on the cust_main object is not recommended, as existing services will also be
571 reexported.)
572
573 =cut
574
575 sub order_pkgs {
576   my $self = shift;
577   my $cust_pkgs = shift;
578   my $seconds = shift;
579   my %options = @_;
580   my %svc_options = ();
581   $svc_options{'depend_jobnum'} = $options{'depend_jobnum'}
582     if exists $options{'depend_jobnum'};
583   warn "$me order_pkgs called with options ".
584        join(', ', map { "$_: $options{$_}" } keys %options ). "\n"
585     if $DEBUG;
586
587   local $SIG{HUP} = 'IGNORE';
588   local $SIG{INT} = 'IGNORE';
589   local $SIG{QUIT} = 'IGNORE';
590   local $SIG{TERM} = 'IGNORE';
591   local $SIG{TSTP} = 'IGNORE';
592   local $SIG{PIPE} = 'IGNORE';
593
594   my $oldAutoCommit = $FS::UID::AutoCommit;
595   local $FS::UID::AutoCommit = 0;
596   my $dbh = dbh;
597
598   local $FS::svc_Common::noexport_hack = 1 if $options{'noexport'};
599
600   foreach my $cust_pkg ( keys %$cust_pkgs ) {
601     $cust_pkg->custnum( $self->custnum );
602     my $error = $cust_pkg->insert;
603     if ( $error ) {
604       $dbh->rollback if $oldAutoCommit;
605       return "inserting cust_pkg (transaction rolled back): $error";
606     }
607     foreach my $svc_something ( @{$cust_pkgs->{$cust_pkg}} ) {
608       if ( $svc_something->svcnum ) {
609         my $old_cust_svc = $svc_something->cust_svc;
610         my $new_cust_svc = new FS::cust_svc { $old_cust_svc->hash };
611         $new_cust_svc->pkgnum( $cust_pkg->pkgnum);
612         $error = $new_cust_svc->replace($old_cust_svc);
613       } else {
614         $svc_something->pkgnum( $cust_pkg->pkgnum );
615         if ( $seconds && $$seconds && $svc_something->isa('FS::svc_acct') ) {
616           $svc_something->seconds( $svc_something->seconds + $$seconds );
617           $$seconds = 0;
618         }
619         $error = $svc_something->insert(%svc_options);
620       }
621       if ( $error ) {
622         $dbh->rollback if $oldAutoCommit;
623         #return "inserting svc_ (transaction rolled back): $error";
624         return $error;
625       }
626     }
627   }
628
629   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
630   ''; #no error
631 }
632
633 =item recharge_prepay IDENTIFIER | PREPAY_CREDIT_OBJ [ , AMOUNTREF, SECONDSREF, UPBYTEREF, DOWNBYTEREF ]
634
635 Recharges this (existing) customer with the specified prepaid card (see
636 L<FS::prepay_credit>), specified either by I<identifier> or as an
637 FS::prepay_credit object.  If there is an error, returns the error, otherwise
638 returns false.
639
640 Optionally, four scalar references can be passed as well.  They will have their
641 values filled in with the amount, number of seconds, and number of upload and
642 download bytes applied by this prepaid
643 card.
644
645 =cut
646
647 sub recharge_prepay { 
648   my( $self, $prepay_credit, $amountref, $secondsref, 
649       $upbytesref, $downbytesref, $totalbytesref ) = @_;
650
651   local $SIG{HUP} = 'IGNORE';
652   local $SIG{INT} = 'IGNORE';
653   local $SIG{QUIT} = 'IGNORE';
654   local $SIG{TERM} = 'IGNORE';
655   local $SIG{TSTP} = 'IGNORE';
656   local $SIG{PIPE} = 'IGNORE';
657
658   my $oldAutoCommit = $FS::UID::AutoCommit;
659   local $FS::UID::AutoCommit = 0;
660   my $dbh = dbh;
661
662   my( $amount, $seconds, $upbytes, $downbytes, $totalbytes) = ( 0, 0, 0, 0, 0 );
663
664   my $error = $self->get_prepay($prepay_credit, \$amount,
665                                 \$seconds, \$upbytes, \$downbytes, \$totalbytes)
666            || $self->increment_seconds($seconds)
667            || $self->increment_upbytes($upbytes)
668            || $self->increment_downbytes($downbytes)
669            || $self->increment_totalbytes($totalbytes)
670            || $self->insert_cust_pay_prepay( $amount,
671                                              ref($prepay_credit)
672                                                ? $prepay_credit->identifier
673                                                : $prepay_credit
674                                            );
675
676   if ( $error ) {
677     $dbh->rollback if $oldAutoCommit;
678     return $error;
679   }
680
681   if ( defined($amountref)  ) { $$amountref  = $amount;  }
682   if ( defined($secondsref) ) { $$secondsref = $seconds; }
683   if ( defined($upbytesref) ) { $$upbytesref = $upbytes; }
684   if ( defined($downbytesref) ) { $$downbytesref = $downbytes; }
685   if ( defined($totalbytesref) ) { $$totalbytesref = $totalbytes; }
686
687   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
688   '';
689
690 }
691
692 =item get_prepay IDENTIFIER | PREPAY_CREDIT_OBJ , AMOUNTREF, SECONDSREF
693
694 Looks up and deletes a prepaid card (see L<FS::prepay_credit>),
695 specified either by I<identifier> or as an FS::prepay_credit object.
696
697 References to I<amount> and I<seconds> scalars should be passed as arguments
698 and will be incremented by the values of the prepaid card.
699
700 If the prepaid card specifies an I<agentnum> (see L<FS::agent>), it is used to
701 check or set this customer's I<agentnum>.
702
703 If there is an error, returns the error, otherwise returns false.
704
705 =cut
706
707
708 sub get_prepay {
709   my( $self, $prepay_credit, $amountref, $secondsref,
710       $upref, $downref, $totalref) = @_;
711
712   local $SIG{HUP} = 'IGNORE';
713   local $SIG{INT} = 'IGNORE';
714   local $SIG{QUIT} = 'IGNORE';
715   local $SIG{TERM} = 'IGNORE';
716   local $SIG{TSTP} = 'IGNORE';
717   local $SIG{PIPE} = 'IGNORE';
718
719   my $oldAutoCommit = $FS::UID::AutoCommit;
720   local $FS::UID::AutoCommit = 0;
721   my $dbh = dbh;
722
723   unless ( ref($prepay_credit) ) {
724
725     my $identifier = $prepay_credit;
726
727     $prepay_credit = qsearchs(
728       'prepay_credit',
729       { 'identifier' => $prepay_credit },
730       '',
731       'FOR UPDATE'
732     );
733
734     unless ( $prepay_credit ) {
735       $dbh->rollback if $oldAutoCommit;
736       return "Invalid prepaid card: ". $identifier;
737     }
738
739   }
740
741   if ( $prepay_credit->agentnum ) {
742     if ( $self->agentnum && $self->agentnum != $prepay_credit->agentnum ) {
743       $dbh->rollback if $oldAutoCommit;
744       return "prepaid card not valid for agent ". $self->agentnum;
745     }
746     $self->agentnum($prepay_credit->agentnum);
747   }
748
749   my $error = $prepay_credit->delete;
750   if ( $error ) {
751     $dbh->rollback if $oldAutoCommit;
752     return "removing prepay_credit (transaction rolled back): $error";
753   }
754
755   $$amountref  += $prepay_credit->amount;
756   $$secondsref += $prepay_credit->seconds;
757   $$upref      += $prepay_credit->upbytes;
758   $$downref    += $prepay_credit->downbytes;
759   $$totalref   += $prepay_credit->totalbytes;
760
761   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
762   '';
763
764 }
765
766 =item increment_upbytes SECONDS
767
768 Updates this customer's single or primary account (see L<FS::svc_acct>) by
769 the specified number of upbytes.  If there is an error, returns the error,
770 otherwise returns false.
771
772 =cut
773
774 sub increment_upbytes {
775   _increment_column( shift, 'upbytes', @_);
776 }
777
778 =item increment_downbytes SECONDS
779
780 Updates this customer's single or primary account (see L<FS::svc_acct>) by
781 the specified number of downbytes.  If there is an error, returns the error,
782 otherwise returns false.
783
784 =cut
785
786 sub increment_downbytes {
787   _increment_column( shift, 'downbytes', @_);
788 }
789
790 =item increment_totalbytes SECONDS
791
792 Updates this customer's single or primary account (see L<FS::svc_acct>) by
793 the specified number of totalbytes.  If there is an error, returns the error,
794 otherwise returns false.
795
796 =cut
797
798 sub increment_totalbytes {
799   _increment_column( shift, 'totalbytes', @_);
800 }
801
802 =item increment_seconds SECONDS
803
804 Updates this customer's single or primary account (see L<FS::svc_acct>) by
805 the specified number of seconds.  If there is an error, returns the error,
806 otherwise returns false.
807
808 =cut
809
810 sub increment_seconds {
811   _increment_column( shift, 'seconds', @_);
812 }
813
814 =item _increment_column AMOUNT
815
816 Updates this customer's single or primary account (see L<FS::svc_acct>) by
817 the specified number of seconds or bytes.  If there is an error, returns
818 the error, otherwise returns false.
819
820 =cut
821
822 sub _increment_column {
823   my( $self, $column, $amount ) = @_;
824   warn "$me increment_column called: $column, $amount\n"
825     if $DEBUG;
826
827   return '' unless $amount;
828
829   my @cust_pkg = grep { $_->part_pkg->svcpart('svc_acct') }
830                       $self->ncancelled_pkgs;
831
832   if ( ! @cust_pkg ) {
833     return 'No packages with primary or single services found'.
834            ' to apply pre-paid time';
835   } elsif ( scalar(@cust_pkg) > 1 ) {
836     #maybe have a way to specify the package/account?
837     return 'Multiple packages found to apply pre-paid time';
838   }
839
840   my $cust_pkg = $cust_pkg[0];
841   warn "  found package pkgnum ". $cust_pkg->pkgnum. "\n"
842     if $DEBUG > 1;
843
844   my @cust_svc =
845     $cust_pkg->cust_svc( $cust_pkg->part_pkg->svcpart('svc_acct') );
846
847   if ( ! @cust_svc ) {
848     return 'No account found to apply pre-paid time';
849   } elsif ( scalar(@cust_svc) > 1 ) {
850     return 'Multiple accounts found to apply pre-paid time';
851   }
852   
853   my $svc_acct = $cust_svc[0]->svc_x;
854   warn "  found service svcnum ". $svc_acct->pkgnum.
855        ' ('. $svc_acct->email. ")\n"
856     if $DEBUG > 1;
857
858   $column = "increment_$column";
859   $svc_acct->$column($amount);
860
861 }
862
863 =item insert_cust_pay_prepay AMOUNT [ PAYINFO ]
864
865 Inserts a prepayment in the specified amount for this customer.  An optional
866 second argument can specify the prepayment identifier for tracking purposes.
867 If there is an error, returns the error, otherwise returns false.
868
869 =cut
870
871 sub insert_cust_pay_prepay {
872   shift->insert_cust_pay('PREP', @_);
873 }
874
875 =item insert_cust_pay_cash AMOUNT [ PAYINFO ]
876
877 Inserts a cash payment in the specified amount for this customer.  An optional
878 second argument can specify the payment identifier for tracking purposes.
879 If there is an error, returns the error, otherwise returns false.
880
881 =cut
882
883 sub insert_cust_pay_cash {
884   shift->insert_cust_pay('CASH', @_);
885 }
886
887 =item insert_cust_pay_west AMOUNT [ PAYINFO ]
888
889 Inserts a Western Union payment in the specified amount for this customer.  An
890 optional second argument can specify the prepayment identifier for tracking
891 purposes.  If there is an error, returns the error, otherwise returns false.
892
893 =cut
894
895 sub insert_cust_pay_west {
896   shift->insert_cust_pay('WEST', @_);
897 }
898
899 sub insert_cust_pay {
900   my( $self, $payby, $amount ) = splice(@_, 0, 3);
901   my $payinfo = scalar(@_) ? shift : '';
902
903   my $cust_pay = new FS::cust_pay {
904     'custnum' => $self->custnum,
905     'paid'    => sprintf('%.2f', $amount),
906     #'_date'   => #date the prepaid card was purchased???
907     'payby'   => $payby,
908     'payinfo' => $payinfo,
909   };
910   $cust_pay->insert;
911
912 }
913
914 =item reexport
915
916 This method is deprecated.  See the I<depend_jobnum> option to the insert and
917 order_pkgs methods for a better way to defer provisioning.
918
919 Re-schedules all exports by calling the B<reexport> method of all associated
920 packages (see L<FS::cust_pkg>).  If there is an error, returns the error;
921 otherwise returns false.
922
923 =cut
924
925 sub reexport {
926   my $self = shift;
927
928   carp "WARNING: FS::cust_main::reexport is deprectated; ".
929        "use the depend_jobnum option to insert or order_pkgs to delay export";
930
931   local $SIG{HUP} = 'IGNORE';
932   local $SIG{INT} = 'IGNORE';
933   local $SIG{QUIT} = 'IGNORE';
934   local $SIG{TERM} = 'IGNORE';
935   local $SIG{TSTP} = 'IGNORE';
936   local $SIG{PIPE} = 'IGNORE';
937
938   my $oldAutoCommit = $FS::UID::AutoCommit;
939   local $FS::UID::AutoCommit = 0;
940   my $dbh = dbh;
941
942   foreach my $cust_pkg ( $self->ncancelled_pkgs ) {
943     my $error = $cust_pkg->reexport;
944     if ( $error ) {
945       $dbh->rollback if $oldAutoCommit;
946       return $error;
947     }
948   }
949
950   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
951   '';
952
953 }
954
955 =item delete NEW_CUSTNUM
956
957 This deletes the customer.  If there is an error, returns the error, otherwise
958 returns false.
959
960 This will completely remove all traces of the customer record.  This is not
961 what you want when a customer cancels service; for that, cancel all of the
962 customer's packages (see L</cancel>).
963
964 If the customer has any uncancelled packages, you need to pass a new (valid)
965 customer number for those packages to be transferred to.  Cancelled packages
966 will be deleted.  Did I mention that this is NOT what you want when a customer
967 cancels service and that you really should be looking see L<FS::cust_pkg/cancel>?
968
969 You can't delete a customer with invoices (see L<FS::cust_bill>),
970 or credits (see L<FS::cust_credit>), payments (see L<FS::cust_pay>) or
971 refunds (see L<FS::cust_refund>).
972
973 =cut
974
975 sub delete {
976   my $self = shift;
977
978   local $SIG{HUP} = 'IGNORE';
979   local $SIG{INT} = 'IGNORE';
980   local $SIG{QUIT} = 'IGNORE';
981   local $SIG{TERM} = 'IGNORE';
982   local $SIG{TSTP} = 'IGNORE';
983   local $SIG{PIPE} = 'IGNORE';
984
985   my $oldAutoCommit = $FS::UID::AutoCommit;
986   local $FS::UID::AutoCommit = 0;
987   my $dbh = dbh;
988
989   if ( $self->cust_bill ) {
990     $dbh->rollback if $oldAutoCommit;
991     return "Can't delete a customer with invoices";
992   }
993   if ( $self->cust_credit ) {
994     $dbh->rollback if $oldAutoCommit;
995     return "Can't delete a customer with credits";
996   }
997   if ( $self->cust_pay ) {
998     $dbh->rollback if $oldAutoCommit;
999     return "Can't delete a customer with payments";
1000   }
1001   if ( $self->cust_refund ) {
1002     $dbh->rollback if $oldAutoCommit;
1003     return "Can't delete a customer with refunds";
1004   }
1005
1006   my @cust_pkg = $self->ncancelled_pkgs;
1007   if ( @cust_pkg ) {
1008     my $new_custnum = shift;
1009     unless ( qsearchs( 'cust_main', { 'custnum' => $new_custnum } ) ) {
1010       $dbh->rollback if $oldAutoCommit;
1011       return "Invalid new customer number: $new_custnum";
1012     }
1013     foreach my $cust_pkg ( @cust_pkg ) {
1014       my %hash = $cust_pkg->hash;
1015       $hash{'custnum'} = $new_custnum;
1016       my $new_cust_pkg = new FS::cust_pkg ( \%hash );
1017       my $error = $new_cust_pkg->replace($cust_pkg,
1018                                          options => { $cust_pkg->options },
1019                                         );
1020       if ( $error ) {
1021         $dbh->rollback if $oldAutoCommit;
1022         return $error;
1023       }
1024     }
1025   }
1026   my @cancelled_cust_pkg = $self->all_pkgs;
1027   foreach my $cust_pkg ( @cancelled_cust_pkg ) {
1028     my $error = $cust_pkg->delete;
1029     if ( $error ) {
1030       $dbh->rollback if $oldAutoCommit;
1031       return $error;
1032     }
1033   }
1034
1035   foreach my $cust_main_invoice ( #(email invoice destinations, not invoices)
1036     qsearch( 'cust_main_invoice', { 'custnum' => $self->custnum } )
1037   ) {
1038     my $error = $cust_main_invoice->delete;
1039     if ( $error ) {
1040       $dbh->rollback if $oldAutoCommit;
1041       return $error;
1042     }
1043   }
1044
1045   my $error = $self->SUPER::delete;
1046   if ( $error ) {
1047     $dbh->rollback if $oldAutoCommit;
1048     return $error;
1049   }
1050
1051   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1052   '';
1053
1054 }
1055
1056 =item replace [ OLD_RECORD ] [ INVOICING_LIST_ARYREF ]
1057
1058 Replaces the OLD_RECORD with this one in the database.  If there is an error,
1059 returns the error, otherwise returns false.
1060
1061 INVOICING_LIST_ARYREF: If you pass an arrarref to the insert method, it will
1062 be set as the invoicing list (see L<"invoicing_list">).  Errors return as
1063 expected and rollback the entire transaction; it is not necessary to call 
1064 check_invoicing_list first.  Here's an example:
1065
1066   $new_cust_main->replace( $old_cust_main, [ $email, 'POST' ] );
1067
1068 =cut
1069
1070 sub replace {
1071   my $self = shift;
1072
1073   my $old = ( blessed($_[0]) && $_[0]->isa('FS::Record') )
1074               ? shift
1075               : $self->replace_old;
1076
1077   my @param = @_;
1078
1079   warn "$me replace called\n"
1080     if $DEBUG;
1081
1082   my $curuser = $FS::CurrentUser::CurrentUser;
1083   if (    $self->payby eq 'COMP'
1084        && $self->payby ne $old->payby
1085        && ! $curuser->access_right('Complimentary customer')
1086      )
1087   {
1088     return "You are not permitted to create complimentary accounts.";
1089   }
1090
1091   local($ignore_expired_card) = 1
1092     if $old->payby  =~ /^(CARD|DCRD)$/
1093     && $self->payby =~ /^(CARD|DCRD)$/
1094     && ( $old->payinfo eq $self->payinfo || $old->paymask eq $self->paymask );
1095
1096   local $SIG{HUP} = 'IGNORE';
1097   local $SIG{INT} = 'IGNORE';
1098   local $SIG{QUIT} = 'IGNORE';
1099   local $SIG{TERM} = 'IGNORE';
1100   local $SIG{TSTP} = 'IGNORE';
1101   local $SIG{PIPE} = 'IGNORE';
1102
1103   my $oldAutoCommit = $FS::UID::AutoCommit;
1104   local $FS::UID::AutoCommit = 0;
1105   my $dbh = dbh;
1106
1107   my $error = $self->SUPER::replace($old);
1108
1109   if ( $error ) {
1110     $dbh->rollback if $oldAutoCommit;
1111     return $error;
1112   }
1113
1114   if ( @param ) { # INVOICING_LIST_ARYREF
1115     my $invoicing_list = shift @param;
1116     $error = $self->check_invoicing_list( $invoicing_list );
1117     if ( $error ) {
1118       $dbh->rollback if $oldAutoCommit;
1119       return $error;
1120     }
1121     $self->invoicing_list( $invoicing_list );
1122   }
1123
1124   if ( $self->payby =~ /^(CARD|CHEK|LECB)$/ &&
1125        grep { $self->get($_) ne $old->get($_) } qw(payinfo paydate payname) ) {
1126     # card/check/lec info has changed, want to retry realtime_ invoice events
1127     my $error = $self->retry_realtime;
1128     if ( $error ) {
1129       $dbh->rollback if $oldAutoCommit;
1130       return $error;
1131     }
1132   }
1133
1134   unless ( $import || $skip_fuzzyfiles ) {
1135     $error = $self->queue_fuzzyfiles_update;
1136     if ( $error ) {
1137       $dbh->rollback if $oldAutoCommit;
1138       return "updating fuzzy search cache: $error";
1139     }
1140   }
1141
1142   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1143   '';
1144
1145 }
1146
1147 =item queue_fuzzyfiles_update
1148
1149 Used by insert & replace to update the fuzzy search cache
1150
1151 =cut
1152
1153 sub queue_fuzzyfiles_update {
1154   my $self = shift;
1155
1156   local $SIG{HUP} = 'IGNORE';
1157   local $SIG{INT} = 'IGNORE';
1158   local $SIG{QUIT} = 'IGNORE';
1159   local $SIG{TERM} = 'IGNORE';
1160   local $SIG{TSTP} = 'IGNORE';
1161   local $SIG{PIPE} = 'IGNORE';
1162
1163   my $oldAutoCommit = $FS::UID::AutoCommit;
1164   local $FS::UID::AutoCommit = 0;
1165   my $dbh = dbh;
1166
1167   my $queue = new FS::queue { 'job' => 'FS::cust_main::append_fuzzyfiles' };
1168   my $error = $queue->insert( map $self->getfield($_),
1169                                   qw(first last company)
1170                             );
1171   if ( $error ) {
1172     $dbh->rollback if $oldAutoCommit;
1173     return "queueing job (transaction rolled back): $error";
1174   }
1175
1176   if ( $self->ship_last ) {
1177     $queue = new FS::queue { 'job' => 'FS::cust_main::append_fuzzyfiles' };
1178     $error = $queue->insert( map $self->getfield("ship_$_"),
1179                                  qw(first last company)
1180                            );
1181     if ( $error ) {
1182       $dbh->rollback if $oldAutoCommit;
1183       return "queueing job (transaction rolled back): $error";
1184     }
1185   }
1186
1187   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1188   '';
1189
1190 }
1191
1192 =item check
1193
1194 Checks all fields to make sure this is a valid customer record.  If there is
1195 an error, returns the error, otherwise returns false.  Called by the insert
1196 and replace methods.
1197
1198 =cut
1199
1200 sub check {
1201   my $self = shift;
1202
1203   warn "$me check BEFORE: \n". $self->_dump
1204     if $DEBUG > 2;
1205
1206   my $error =
1207     $self->ut_numbern('custnum')
1208     || $self->ut_number('agentnum')
1209     || $self->ut_textn('agent_custid')
1210     || $self->ut_number('refnum')
1211     || $self->ut_name('last')
1212     || $self->ut_name('first')
1213     || $self->ut_snumbern('birthdate')
1214     || $self->ut_snumbern('signupdate')
1215     || $self->ut_textn('company')
1216     || $self->ut_text('address1')
1217     || $self->ut_textn('address2')
1218     || $self->ut_text('city')
1219     || $self->ut_textn('county')
1220     || $self->ut_textn('state')
1221     || $self->ut_country('country')
1222     || $self->ut_anything('comments')
1223     || $self->ut_numbern('referral_custnum')
1224     || $self->ut_textn('stateid')
1225     || $self->ut_textn('stateid_state')
1226     || $self->ut_textn('invoice_terms')
1227   ;
1228   #barf.  need message catalogs.  i18n.  etc.
1229   $error .= "Please select an advertising source."
1230     if $error =~ /^Illegal or empty \(numeric\) refnum: /;
1231   return $error if $error;
1232
1233   return "Unknown agent"
1234     unless qsearchs( 'agent', { 'agentnum' => $self->agentnum } );
1235
1236   return "Unknown refnum"
1237     unless qsearchs( 'part_referral', { 'refnum' => $self->refnum } );
1238
1239   return "Unknown referring custnum: ". $self->referral_custnum
1240     unless ! $self->referral_custnum 
1241            || qsearchs( 'cust_main', { 'custnum' => $self->referral_custnum } );
1242
1243   if ( $self->ss eq '' ) {
1244     $self->ss('');
1245   } else {
1246     my $ss = $self->ss;
1247     $ss =~ s/\D//g;
1248     $ss =~ /^(\d{3})(\d{2})(\d{4})$/
1249       or return "Illegal social security number: ". $self->ss;
1250     $self->ss("$1-$2-$3");
1251   }
1252
1253
1254 # bad idea to disable, causes billing to fail because of no tax rates later
1255 #  unless ( $import ) {
1256     unless ( qsearch('cust_main_county', {
1257       'country' => $self->country,
1258       'state'   => '',
1259      } ) ) {
1260       return "Unknown state/county/country: ".
1261         $self->state. "/". $self->county. "/". $self->country
1262         unless qsearch('cust_main_county',{
1263           'state'   => $self->state,
1264           'county'  => $self->county,
1265           'country' => $self->country,
1266         } );
1267     }
1268 #  }
1269
1270   $error =
1271     $self->ut_phonen('daytime', $self->country)
1272     || $self->ut_phonen('night', $self->country)
1273     || $self->ut_phonen('fax', $self->country)
1274     || $self->ut_zip('zip', $self->country)
1275   ;
1276   return $error if $error;
1277
1278   if ( $conf->exists('cust_main-require_phone')
1279        && ! length($self->daytime) && ! length($self->night)
1280      ) {
1281
1282     my $daytime_label = FS::Msgcat::_gettext('daytime') =~ /^(daytime)?$/
1283                           ? 'Day Phone'
1284                           : FS::Msgcat::_gettext('daytime');
1285     my $night_label = FS::Msgcat::_gettext('night') =~ /^(night)?$/
1286                         ? 'Night Phone'
1287                         : FS::Msgcat::_gettext('night');
1288   
1289     return "$daytime_label or $night_label is required"
1290   
1291   }
1292
1293   if ( $self->has_ship_address
1294        && scalar ( grep { $self->getfield($_) ne $self->getfield("ship_$_") }
1295                         $self->addr_fields )
1296      )
1297   {
1298     my $error =
1299       $self->ut_name('ship_last')
1300       || $self->ut_name('ship_first')
1301       || $self->ut_textn('ship_company')
1302       || $self->ut_text('ship_address1')
1303       || $self->ut_textn('ship_address2')
1304       || $self->ut_text('ship_city')
1305       || $self->ut_textn('ship_county')
1306       || $self->ut_textn('ship_state')
1307       || $self->ut_country('ship_country')
1308     ;
1309     return $error if $error;
1310
1311     #false laziness with above
1312     unless ( qsearchs('cust_main_county', {
1313       'country' => $self->ship_country,
1314       'state'   => '',
1315      } ) ) {
1316       return "Unknown ship_state/ship_county/ship_country: ".
1317         $self->ship_state. "/". $self->ship_county. "/". $self->ship_country
1318         unless qsearch('cust_main_county',{
1319           'state'   => $self->ship_state,
1320           'county'  => $self->ship_county,
1321           'country' => $self->ship_country,
1322         } );
1323     }
1324     #eofalse
1325
1326     $error =
1327       $self->ut_phonen('ship_daytime', $self->ship_country)
1328       || $self->ut_phonen('ship_night', $self->ship_country)
1329       || $self->ut_phonen('ship_fax', $self->ship_country)
1330       || $self->ut_zip('ship_zip', $self->ship_country)
1331     ;
1332     return $error if $error;
1333
1334     return "Unit # is required."
1335       if $self->ship_address2 =~ /^\s*$/
1336       && $conf->exists('cust_main-require_address2');
1337
1338   } else { # ship_ info eq billing info, so don't store dup info in database
1339
1340     $self->setfield("ship_$_", '')
1341       foreach $self->addr_fields;
1342
1343     return "Unit # is required."
1344       if $self->address2 =~ /^\s*$/
1345       && $conf->exists('cust_main-require_address2');
1346
1347   }
1348
1349   #$self->payby =~ /^(CARD|DCRD|CHEK|DCHK|LECB|BILL|COMP|PREPAY|CASH|WEST|MCRD)$/
1350   #  or return "Illegal payby: ". $self->payby;
1351   #$self->payby($1);
1352   FS::payby->can_payby($self->table, $self->payby)
1353     or return "Illegal payby: ". $self->payby;
1354
1355   $error =    $self->ut_numbern('paystart_month')
1356            || $self->ut_numbern('paystart_year')
1357            || $self->ut_numbern('payissue')
1358            || $self->ut_textn('paytype')
1359   ;
1360   return $error if $error;
1361
1362   if ( $self->payip eq '' ) {
1363     $self->payip('');
1364   } else {
1365     $error = $self->ut_ip('payip');
1366     return $error if $error;
1367   }
1368
1369   # If it is encrypted and the private key is not availaible then we can't
1370   # check the credit card.
1371
1372   my $check_payinfo = 1;
1373
1374   if ($self->is_encrypted($self->payinfo)) {
1375     $check_payinfo = 0;
1376   }
1377
1378   if ( $check_payinfo && $self->payby =~ /^(CARD|DCRD)$/ ) {
1379
1380     my $payinfo = $self->payinfo;
1381     $payinfo =~ s/\D//g;
1382     $payinfo =~ /^(\d{13,16})$/
1383       or return gettext('invalid_card'); # . ": ". $self->payinfo;
1384     $payinfo = $1;
1385     $self->payinfo($payinfo);
1386     validate($payinfo)
1387       or return gettext('invalid_card'); # . ": ". $self->payinfo;
1388
1389     return gettext('unknown_card_type')
1390       if cardtype($self->payinfo) eq "Unknown";
1391
1392     my $ban = qsearchs('banned_pay', $self->_banned_pay_hashref);
1393     if ( $ban ) {
1394       return 'Banned credit card: banned on '.
1395              time2str('%a %h %o at %r', $ban->_date).
1396              ' by '. $ban->otaker.
1397              ' (ban# '. $ban->bannum. ')';
1398     }
1399
1400     if (length($self->paycvv) && !$self->is_encrypted($self->paycvv)) {
1401       if ( cardtype($self->payinfo) eq 'American Express card' ) {
1402         $self->paycvv =~ /^(\d{4})$/
1403           or return "CVV2 (CID) for American Express cards is four digits.";
1404         $self->paycvv($1);
1405       } else {
1406         $self->paycvv =~ /^(\d{3})$/
1407           or return "CVV2 (CVC2/CID) is three digits.";
1408         $self->paycvv($1);
1409       }
1410     } else {
1411       $self->paycvv('');
1412     }
1413
1414     my $cardtype = cardtype($payinfo);
1415     if ( $cardtype =~ /^(Switch|Solo)$/i ) {
1416
1417       return "Start date or issue number is required for $cardtype cards"
1418         unless $self->paystart_month && $self->paystart_year or $self->payissue;
1419
1420       return "Start month must be between 1 and 12"
1421         if $self->paystart_month
1422            and $self->paystart_month < 1 || $self->paystart_month > 12;
1423
1424       return "Start year must be 1990 or later"
1425         if $self->paystart_year
1426            and $self->paystart_year < 1990;
1427
1428       return "Issue number must be beween 1 and 99"
1429         if $self->payissue
1430           and $self->payissue < 1 || $self->payissue > 99;
1431
1432     } else {
1433       $self->paystart_month('');
1434       $self->paystart_year('');
1435       $self->payissue('');
1436     }
1437
1438   } elsif ( $check_payinfo && $self->payby =~ /^(CHEK|DCHK)$/ ) {
1439
1440     my $payinfo = $self->payinfo;
1441     $payinfo =~ s/[^\d\@]//g;
1442     if ( $conf->exists('echeck-nonus') ) {
1443       $payinfo =~ /^(\d+)\@(\d+)$/ or return 'invalid echeck account@aba';
1444       $payinfo = "$1\@$2";
1445     } else {
1446       $payinfo =~ /^(\d+)\@(\d{9})$/ or return 'invalid echeck account@aba';
1447       $payinfo = "$1\@$2";
1448     }
1449     $self->payinfo($payinfo);
1450     $self->paycvv('');
1451
1452     my $ban = qsearchs('banned_pay', $self->_banned_pay_hashref);
1453     if ( $ban ) {
1454       return 'Banned ACH account: banned on '.
1455              time2str('%a %h %o at %r', $ban->_date).
1456              ' by '. $ban->otaker.
1457              ' (ban# '. $ban->bannum. ')';
1458     }
1459
1460   } elsif ( $self->payby eq 'LECB' ) {
1461
1462     my $payinfo = $self->payinfo;
1463     $payinfo =~ s/\D//g;
1464     $payinfo =~ /^1?(\d{10})$/ or return 'invalid btn billing telephone number';
1465     $payinfo = $1;
1466     $self->payinfo($payinfo);
1467     $self->paycvv('');
1468
1469   } elsif ( $self->payby eq 'BILL' ) {
1470
1471     $error = $self->ut_textn('payinfo');
1472     return "Illegal P.O. number: ". $self->payinfo if $error;
1473     $self->paycvv('');
1474
1475   } elsif ( $self->payby eq 'COMP' ) {
1476
1477     my $curuser = $FS::CurrentUser::CurrentUser;
1478     if (    ! $self->custnum
1479          && ! $curuser->access_right('Complimentary customer')
1480        )
1481     {
1482       return "You are not permitted to create complimentary accounts."
1483     }
1484
1485     $error = $self->ut_textn('payinfo');
1486     return "Illegal comp account issuer: ". $self->payinfo if $error;
1487     $self->paycvv('');
1488
1489   } elsif ( $self->payby eq 'PREPAY' ) {
1490
1491     my $payinfo = $self->payinfo;
1492     $payinfo =~ s/\W//g; #anything else would just confuse things
1493     $self->payinfo($payinfo);
1494     $error = $self->ut_alpha('payinfo');
1495     return "Illegal prepayment identifier: ". $self->payinfo if $error;
1496     return "Unknown prepayment identifier"
1497       unless qsearchs('prepay_credit', { 'identifier' => $self->payinfo } );
1498     $self->paycvv('');
1499
1500   }
1501
1502   if ( $self->paydate eq '' || $self->paydate eq '-' ) {
1503     return "Expiration date required"
1504       unless $self->payby =~ /^(BILL|PREPAY|CHEK|DCHK|LECB|CASH|WEST|MCRD)$/;
1505     $self->paydate('');
1506   } else {
1507     my( $m, $y );
1508     if ( $self->paydate =~ /^(\d{1,2})[\/\-](\d{2}(\d{2})?)$/ ) {
1509       ( $m, $y ) = ( $1, length($2) == 4 ? $2 : "20$2" );
1510     } elsif ( $self->paydate =~ /^(20)?(\d{2})[\/\-](\d{1,2})[\/\-]\d+$/ ) {
1511       ( $m, $y ) = ( $3, "20$2" );
1512     } else {
1513       return "Illegal expiration date: ". $self->paydate;
1514     }
1515     $self->paydate("$y-$m-01");
1516     my($nowm,$nowy)=(localtime(time))[4,5]; $nowm++; $nowy+=1900;
1517     return gettext('expired_card')
1518       if !$import
1519       && !$ignore_expired_card 
1520       && ( $y<$nowy || ( $y==$nowy && $1<$nowm ) );
1521   }
1522
1523   if ( $self->payname eq '' && $self->payby !~ /^(CHEK|DCHK)$/ &&
1524        ( ! $conf->exists('require_cardname')
1525          || $self->payby !~ /^(CARD|DCRD)$/  ) 
1526   ) {
1527     $self->payname( $self->first. " ". $self->getfield('last') );
1528   } else {
1529     $self->payname =~ /^([\w \,\.\-\'\&]+)$/
1530       or return gettext('illegal_name'). " payname: ". $self->payname;
1531     $self->payname($1);
1532   }
1533
1534   foreach my $flag (qw( tax spool_cdr )) {
1535     $self->$flag() =~ /^(Y?)$/ or return "Illegal $flag: ". $self->$flag();
1536     $self->$flag($1);
1537   }
1538
1539   $self->otaker(getotaker) unless $self->otaker;
1540
1541   warn "$me check AFTER: \n". $self->_dump
1542     if $DEBUG > 2;
1543
1544   $self->SUPER::check;
1545 }
1546
1547 =item addr_fields 
1548
1549 Returns a list of fields which have ship_ duplicates.
1550
1551 =cut
1552
1553 sub addr_fields {
1554   qw( last first company
1555       address1 address2 city county state zip country
1556       daytime night fax
1557     );
1558 }
1559
1560 =item has_ship_address
1561
1562 Returns true if this customer record has a separate shipping address.
1563
1564 =cut
1565
1566 sub has_ship_address {
1567   my $self = shift;
1568   scalar( grep { $self->getfield("ship_$_") ne '' } $self->addr_fields );
1569 }
1570
1571 =item all_pkgs
1572
1573 Returns all packages (see L<FS::cust_pkg>) for this customer.
1574
1575 =cut
1576
1577 sub all_pkgs {
1578   my $self = shift;
1579
1580   return $self->num_pkgs unless wantarray;
1581
1582   my @cust_pkg = ();
1583   if ( $self->{'_pkgnum'} ) {
1584     @cust_pkg = values %{ $self->{'_pkgnum'}->cache };
1585   } else {
1586     @cust_pkg = qsearch( 'cust_pkg', { 'custnum' => $self->custnum });
1587   }
1588
1589   sort sort_packages @cust_pkg;
1590 }
1591
1592 =item cust_pkg
1593
1594 Synonym for B<all_pkgs>.
1595
1596 =cut
1597
1598 sub cust_pkg {
1599   shift->all_pkgs(@_);
1600 }
1601
1602 =item ncancelled_pkgs
1603
1604 Returns all non-cancelled packages (see L<FS::cust_pkg>) for this customer.
1605
1606 =cut
1607
1608 sub ncancelled_pkgs {
1609   my $self = shift;
1610
1611   return $self->num_ncancelled_pkgs unless wantarray;
1612
1613   my @cust_pkg = ();
1614   if ( $self->{'_pkgnum'} ) {
1615
1616     warn "$me ncancelled_pkgs: returning cached objects"
1617       if $DEBUG > 1;
1618
1619     @cust_pkg = grep { ! $_->getfield('cancel') }
1620                 values %{ $self->{'_pkgnum'}->cache };
1621
1622   } else {
1623
1624     warn "$me ncancelled_pkgs: searching for packages with custnum ".
1625          $self->custnum. "\n"
1626       if $DEBUG > 1;
1627
1628     @cust_pkg =
1629       qsearch( 'cust_pkg', {
1630                              'custnum' => $self->custnum,
1631                              'cancel'  => '',
1632                            });
1633     push @cust_pkg,
1634       qsearch( 'cust_pkg', {
1635                              'custnum' => $self->custnum,
1636                              'cancel'  => 0,
1637                            });
1638   }
1639
1640   sort sort_packages @cust_pkg;
1641
1642 }
1643
1644 # This should be generalized to use config options to determine order.
1645 sub sort_packages {
1646   if ( $a->get('cancel') and $b->get('cancel') ) {
1647     $a->pkgnum <=> $b->pkgnum;
1648   } elsif ( $a->get('cancel') or $b->get('cancel') ) {
1649     return -1 if $b->get('cancel');
1650     return  1 if $a->get('cancel');
1651     return 0;
1652   } else {
1653     $a->pkgnum <=> $b->pkgnum;
1654   }
1655 }
1656
1657 =item suspended_pkgs
1658
1659 Returns all suspended packages (see L<FS::cust_pkg>) for this customer.
1660
1661 =cut
1662
1663 sub suspended_pkgs {
1664   my $self = shift;
1665   grep { $_->susp } $self->ncancelled_pkgs;
1666 }
1667
1668 =item unflagged_suspended_pkgs
1669
1670 Returns all unflagged suspended packages (see L<FS::cust_pkg>) for this
1671 customer (thouse packages without the `manual_flag' set).
1672
1673 =cut
1674
1675 sub unflagged_suspended_pkgs {
1676   my $self = shift;
1677   return $self->suspended_pkgs
1678     unless dbdef->table('cust_pkg')->column('manual_flag');
1679   grep { ! $_->manual_flag } $self->suspended_pkgs;
1680 }
1681
1682 =item unsuspended_pkgs
1683
1684 Returns all unsuspended (and uncancelled) packages (see L<FS::cust_pkg>) for
1685 this customer.
1686
1687 =cut
1688
1689 sub unsuspended_pkgs {
1690   my $self = shift;
1691   grep { ! $_->susp } $self->ncancelled_pkgs;
1692 }
1693
1694 =item num_cancelled_pkgs
1695
1696 Returns the number of cancelled packages (see L<FS::cust_pkg>) for this
1697 customer.
1698
1699 =cut
1700
1701 sub num_cancelled_pkgs {
1702   shift->num_pkgs("cust_pkg.cancel IS NOT NULL AND cust_pkg.cancel != 0");
1703 }
1704
1705 sub num_ncancelled_pkgs {
1706   shift->num_pkgs("( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )");
1707 }
1708
1709 sub num_pkgs {
1710   my( $self ) = shift;
1711   my $sql = scalar(@_) ? shift : '';
1712   $sql = "AND $sql" if $sql && $sql !~ /^\s*$/ && $sql !~ /^\s*AND/i;
1713   my $sth = dbh->prepare(
1714     "SELECT COUNT(*) FROM cust_pkg WHERE custnum = ? $sql"
1715   ) or die dbh->errstr;
1716   $sth->execute($self->custnum) or die $sth->errstr;
1717   $sth->fetchrow_arrayref->[0];
1718 }
1719
1720 =item unsuspend
1721
1722 Unsuspends all unflagged suspended packages (see L</unflagged_suspended_pkgs>
1723 and L<FS::cust_pkg>) for this customer.  Always returns a list: an empty list
1724 on success or a list of errors.
1725
1726 =cut
1727
1728 sub unsuspend {
1729   my $self = shift;
1730   grep { $_->unsuspend } $self->suspended_pkgs;
1731 }
1732
1733 =item suspend
1734
1735 Suspends all unsuspended packages (see L<FS::cust_pkg>) for this customer.
1736
1737 Returns a list: an empty list on success or a list of errors.
1738
1739 =cut
1740
1741 sub suspend {
1742   my $self = shift;
1743   grep { $_->suspend(@_) } $self->unsuspended_pkgs;
1744 }
1745
1746 =item suspend_if_pkgpart HASHREF | PKGPART [ , PKGPART ... ]
1747
1748 Suspends all unsuspended packages (see L<FS::cust_pkg>) matching the listed
1749 PKGPARTs (see L<FS::part_pkg>).  Preferred usage is to pass a hashref instead
1750 of a list of pkgparts; the hashref has the following keys:
1751
1752 =over 4
1753
1754 =item pkgparts - listref of pkgparts
1755
1756 =item (other options are passed to the suspend method)
1757
1758 =back
1759
1760
1761 Returns a list: an empty list on success or a list of errors.
1762
1763 =cut
1764
1765 sub suspend_if_pkgpart {
1766   my $self = shift;
1767   my (@pkgparts, %opt);
1768   if (ref($_[0]) eq 'HASH'){
1769     @pkgparts = @{$_[0]{pkgparts}};
1770     %opt      = %{$_[0]};
1771   }else{
1772     @pkgparts = @_;
1773   }
1774   grep { $_->suspend(%opt) }
1775     grep { my $pkgpart = $_->pkgpart; grep { $pkgpart eq $_ } @pkgparts }
1776       $self->unsuspended_pkgs;
1777 }
1778
1779 =item suspend_unless_pkgpart HASHREF | PKGPART [ , PKGPART ... ]
1780
1781 Suspends all unsuspended packages (see L<FS::cust_pkg>) unless they match the
1782 given PKGPARTs (see L<FS::part_pkg>).  Preferred usage is to pass a hashref
1783 instead of a list of pkgparts; the hashref has the following keys:
1784
1785 =over 4
1786
1787 =item pkgparts - listref of pkgparts
1788
1789 =item (other options are passed to the suspend method)
1790
1791 =back
1792
1793 Returns a list: an empty list on success or a list of errors.
1794
1795 =cut
1796
1797 sub suspend_unless_pkgpart {
1798   my $self = shift;
1799   my (@pkgparts, %opt);
1800   if (ref($_[0]) eq 'HASH'){
1801     @pkgparts = @{$_[0]{pkgparts}};
1802     %opt      = %{$_[0]};
1803   }else{
1804     @pkgparts = @_;
1805   }
1806   grep { $_->suspend(%opt) }
1807     grep { my $pkgpart = $_->pkgpart; ! grep { $pkgpart eq $_ } @pkgparts }
1808       $self->unsuspended_pkgs;
1809 }
1810
1811 =item cancel [ OPTION => VALUE ... ]
1812
1813 Cancels all uncancelled packages (see L<FS::cust_pkg>) for this customer.
1814
1815 Available options are:
1816
1817 =over 4
1818
1819 =item quiet - can be set true to supress email cancellation notices.
1820
1821 =item reason - can be set to a cancellation reason (see L<FS:reason>), either a reasonnum of an existing reason, or passing a hashref will create a new reason.  The hashref should have the following keys: typenum - Reason type (see L<FS::reason_type>, reason - Text of the new reason.
1822
1823 =item ban - can be set true to ban this customer's credit card or ACH information, if present.
1824
1825 =back
1826
1827 Always returns a list: an empty list on success or a list of errors.
1828
1829 =cut
1830
1831 sub cancel {
1832   my( $self, %opt ) = @_;
1833
1834   warn "$me cancel called on customer ". $self->custnum. " with options ".
1835        join(', ', map { "$_: $opt{$_}" } keys %opt ). "\n"
1836     if $DEBUG;
1837
1838   return ( 'access denied' )
1839     unless $FS::CurrentUser::CurrentUser->access_right('Cancel customer');
1840
1841   if ( $opt{'ban'} && $self->payby =~ /^(CARD|DCRD|CHEK|DCHK)$/ ) {
1842
1843     #should try decryption (we might have the private key)
1844     # and if not maybe queue a job for the server that does?
1845     return ( "Can't (yet) ban encrypted credit cards" )
1846       if $self->is_encrypted($self->payinfo);
1847
1848     my $ban = new FS::banned_pay $self->_banned_pay_hashref;
1849     my $error = $ban->insert;
1850     return ( $error ) if $error;
1851
1852   }
1853
1854   my @pkgs = $self->ncancelled_pkgs;
1855
1856   warn "$me cancelling ". scalar($self->ncancelled_pkgs). "/".
1857        scalar(@pkgs). " packages for customer ". $self->custnum. "\n"
1858     if $DEBUG;
1859
1860   grep { $_ } map { $_->cancel(%opt) } $self->ncancelled_pkgs;
1861 }
1862
1863 sub _banned_pay_hashref {
1864   my $self = shift;
1865
1866   my %payby2ban = (
1867     'CARD' => 'CARD',
1868     'DCRD' => 'CARD',
1869     'CHEK' => 'CHEK',
1870     'DCHK' => 'CHEK'
1871   );
1872
1873   {
1874     'payby'   => $payby2ban{$self->payby},
1875     'payinfo' => md5_base64($self->payinfo),
1876     #don't ever *search* on reason! #'reason'  =>
1877   };
1878 }
1879
1880 =item notes
1881
1882 Returns all notes (see L<FS::cust_main_note>) for this customer.
1883
1884 =cut
1885
1886 sub notes {
1887   my $self = shift;
1888   #order by?
1889   qsearch( 'cust_main_note',
1890            { 'custnum' => $self->custnum },
1891            '',
1892            'ORDER BY _DATE DESC'
1893          );
1894 }
1895
1896 =item agent
1897
1898 Returns the agent (see L<FS::agent>) for this customer.
1899
1900 =cut
1901
1902 sub agent {
1903   my $self = shift;
1904   qsearchs( 'agent', { 'agentnum' => $self->agentnum } );
1905 }
1906
1907 =item bill_and_collect 
1908
1909 Cancels and suspends any packages due, generates bills, applies payments and
1910 cred
1911
1912 Warns on errors (Does not currently: If there is an error, returns the error, otherwise returns false.)
1913
1914 Options are passed as name-value pairs.  Currently available options are:
1915
1916 =over 4
1917
1918 =item time
1919
1920 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:
1921
1922  use Date::Parse;
1923  ...
1924  $cust_main->bill( 'time' => str2time('April 20th, 2001') );
1925
1926 =item invoice_time
1927
1928 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.
1929
1930 =item check_freq
1931
1932 "1d" for the traditional, daily events (the default), or "1m" for the new monthly events (part_event.check_freq)
1933
1934 =item resetup
1935
1936 If set true, re-charges setup fees.
1937
1938 =item debug
1939
1940 Debugging level.  Default is 0 (no debugging), or can be set to 1 (passed-in options), 2 (traces progress), 3 (more information), or 4 (include full search queries)
1941
1942 =back
1943
1944 =cut
1945
1946 sub bill_and_collect {
1947   my( $self, %options ) = @_;
1948
1949   ###
1950   # cancel packages
1951   ###
1952
1953   #$^T not $options{time} because freeside-daily -d is for pre-printing invoices
1954   foreach my $cust_pkg (
1955     grep { $_->expire && $_->expire <= $^T } $self->ncancelled_pkgs
1956   ) {
1957     my $error = $cust_pkg->cancel;
1958     warn "Error cancelling expired pkg ". $cust_pkg->pkgnum.
1959          " for custnum ". $self->custnum. ": $error"
1960       if $error;
1961   }
1962
1963   ###
1964   # suspend packages
1965   ###
1966
1967   #$^T not $options{time} because freeside-daily -d is for pre-printing invoices
1968   foreach my $cust_pkg (
1969     grep { (    $_->part_pkg->is_prepaid && $_->bill && $_->bill < $^T
1970              || $_->adjourn && $_->adjourn <= $^T
1971            )
1972            && ! $_->susp
1973          }
1974          $self->ncancelled_pkgs
1975   ) {
1976     my $error = $cust_pkg->suspend;
1977     warn "Error suspending package ". $cust_pkg->pkgnum.
1978          " for custnum ". $self->custnum. ": $error"
1979       if $error;
1980   }
1981
1982   ###
1983   # bill and collect
1984   ###
1985
1986   my $error = $self->bill( %options );
1987   warn "Error billing, custnum ". $self->custnum. ": $error" if $error;
1988
1989   $self->apply_payments_and_credits;
1990
1991   $error = $self->collect( %options );
1992   warn "Error collecting, custnum". $self->custnum. ": $error" if $error;
1993
1994 }
1995
1996 =item bill OPTIONS
1997
1998 Generates invoices (see L<FS::cust_bill>) for this customer.  Usually used in
1999 conjunction with the collect method by calling B<bill_and_collect>.
2000
2001 If there is an error, returns the error, otherwise returns false.
2002
2003 Options are passed as name-value pairs.  Currently available options are:
2004
2005 =over 4
2006
2007 =item resetup
2008
2009 If set true, re-charges setup fees.
2010
2011 =item time
2012
2013 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:
2014
2015  use Date::Parse;
2016  ...
2017  $cust_main->bill( 'time' => str2time('April 20th, 2001') );
2018
2019 =item pkg_list
2020
2021 An array ref of specific packages (objects) to attempt billing, instead trying all of them.
2022
2023  $cust_main->bill( pkg_list => [$pkg1, $pkg2] );
2024
2025 =item invoice_time
2026
2027 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.
2028
2029 =back
2030
2031 =cut
2032
2033 sub bill {
2034   my( $self, %options ) = @_;
2035   return '' if $self->payby eq 'COMP';
2036   warn "$me bill customer ". $self->custnum. "\n"
2037     if $DEBUG;
2038
2039   my $time = $options{'time'} || time;
2040
2041   #put below somehow?
2042   local $SIG{HUP} = 'IGNORE';
2043   local $SIG{INT} = 'IGNORE';
2044   local $SIG{QUIT} = 'IGNORE';
2045   local $SIG{TERM} = 'IGNORE';
2046   local $SIG{TSTP} = 'IGNORE';
2047   local $SIG{PIPE} = 'IGNORE';
2048
2049   my $oldAutoCommit = $FS::UID::AutoCommit;
2050   local $FS::UID::AutoCommit = 0;
2051   my $dbh = dbh;
2052
2053   $self->select_for_update; #mutex
2054
2055   my @cust_bill_pkg = ();
2056
2057   ###
2058   # find the packages which are due for billing, find out how much they are
2059   # & generate invoice database.
2060   ###
2061
2062   my( $total_setup, $total_recur ) = ( 0, 0 );
2063   my %tax;
2064   my %taxlisthash;
2065   my @precommit_hooks = ();
2066
2067   foreach my $cust_pkg (
2068     qsearch('cust_pkg', { 'custnum' => $self->custnum } )
2069   ) {
2070
2071     #NO!! next if $cust_pkg->cancel;  
2072     next if $cust_pkg->getfield('cancel');  
2073
2074     warn "  bill package ". $cust_pkg->pkgnum. "\n" if $DEBUG > 1;
2075
2076     #? to avoid use of uninitialized value errors... ?
2077     $cust_pkg->setfield('bill', '')
2078       unless defined($cust_pkg->bill);
2079  
2080     #my $part_pkg = $cust_pkg->part_pkg;
2081
2082     my $real_pkgpart = $cust_pkg->pkgpart;
2083     my %hash = $cust_pkg->hash;
2084     my $old_cust_pkg = new FS::cust_pkg \%hash;
2085
2086     foreach my $part_pkg ( $cust_pkg->part_pkg->self_and_bill_linked ) {
2087
2088       $cust_pkg->pkgpart($part_pkg->pkgpart); 
2089       $cust_pkg->set($_, $hash{$_}) foreach qw( setup last_bill bill );
2090   
2091       my @details = ();
2092
2093       my $lineitems = 0;
2094
2095       ###
2096       # bill setup
2097       ###
2098
2099       my $setup = 0;
2100       if ( ! $cust_pkg->setup &&
2101            (
2102              ( $conf->exists('disable_setup_suspended_pkgs') &&
2103               ! $cust_pkg->getfield('susp')
2104             ) || ! $conf->exists('disable_setup_suspended_pkgs')
2105            )
2106         || $options{'resetup'}
2107       ) {
2108     
2109         warn "    bill setup\n" if $DEBUG > 1;
2110         $lineitems++;
2111
2112         $setup = eval { $cust_pkg->calc_setup( $time, \@details ) };
2113         if ( $@ ) {
2114           $dbh->rollback if $oldAutoCommit;
2115           return "$@ running calc_setup for $cust_pkg\n";
2116         }
2117
2118         $cust_pkg->setfield('setup', $time)
2119           unless $cust_pkg->setup;
2120               #do need it, but it won't get written to the db
2121               #|| $cust_pkg->pkgpart != $real_pkgpart;
2122
2123       }
2124
2125       ###
2126       # bill recurring fee
2127       ### 
2128
2129       my $recur = 0;
2130       my $sdate;
2131       if ( $part_pkg->getfield('freq') ne '0' &&
2132            ! $cust_pkg->getfield('susp') &&
2133            ( $cust_pkg->getfield('bill') || 0 ) <= $time
2134       ) {
2135
2136         # XXX should this be a package event?  probably.  events are called
2137         # at collection time at the moment, though...
2138         $part_pkg->reset_usage($cust_pkg, 'debug'=>$DEBUG)
2139           if $part_pkg->can('reset_usage');
2140           #don't want to reset usage just cause we want a line item??
2141           #&& $part_pkg->pkgpart == $real_pkgpart;
2142   
2143         warn "    bill recur\n" if $DEBUG > 1;
2144         $lineitems++;
2145   
2146         # XXX shared with $recur_prog
2147         $sdate = $cust_pkg->bill || $cust_pkg->setup || $time;
2148   
2149         #over two params!  lets at least switch to a hashref for the rest...
2150         my %param = ( 'precommit_hooks' => \@precommit_hooks, );
2151   
2152         $recur = eval { $cust_pkg->calc_recur( \$sdate, \@details, \%param ) };
2153         if ( $@ ) {
2154           $dbh->rollback if $oldAutoCommit;
2155           return "$@ running calc_recur for $cust_pkg\n";
2156         }
2157
2158   
2159         #change this bit to use Date::Manip? CAREFUL with timezones (see
2160         # mailing list archive)
2161         my ($sec,$min,$hour,$mday,$mon,$year) =
2162           (localtime($sdate) )[0,1,2,3,4,5];
2163     
2164         #pro-rating magic - if $recur_prog fiddles $sdate, want to use that
2165         # only for figuring next bill date, nothing else, so, reset $sdate again
2166         # here
2167         $sdate = $cust_pkg->bill || $cust_pkg->setup || $time;
2168         $cust_pkg->last_bill($sdate);
2169     
2170         if ( $part_pkg->freq =~ /^\d+$/ ) {
2171           $mon += $part_pkg->freq;
2172           until ( $mon < 12 ) { $mon -= 12; $year++; }
2173         } elsif ( $part_pkg->freq =~ /^(\d+)w$/ ) {
2174           my $weeks = $1;
2175           $mday += $weeks * 7;
2176         } elsif ( $part_pkg->freq =~ /^(\d+)d$/ ) {
2177           my $days = $1;
2178           $mday += $days;
2179         } elsif ( $part_pkg->freq =~ /^(\d+)h$/ ) {
2180           my $hours = $1;
2181           $hour += $hours;
2182         } else {
2183           $dbh->rollback if $oldAutoCommit;
2184           return "unparsable frequency: ". $part_pkg->freq;
2185         }
2186         $cust_pkg->setfield('bill',
2187           timelocal_nocheck($sec,$min,$hour,$mday,$mon,$year));
2188   
2189       }
2190
2191       warn "\$setup is undefined" unless defined($setup);
2192       warn "\$recur is undefined" unless defined($recur);
2193       warn "\$cust_pkg->bill is undefined" unless defined($cust_pkg->bill);
2194   
2195       ###
2196       # If there's line items, create em cust_bill_pkg records
2197       # If $cust_pkg has been modified, update it (if we're a real pkgpart)
2198       ###
2199   
2200       if ( $lineitems ) {
2201
2202         if ( $cust_pkg->modified && $cust_pkg->pkgpart == $real_pkgpart ) {
2203           # hmm.. and if just the options are modified in some weird price plan?
2204   
2205           warn "  package ". $cust_pkg->pkgnum. " modified; updating\n"
2206             if $DEBUG >1;
2207   
2208           my $error = $cust_pkg->replace( $old_cust_pkg,
2209                                           'options' => { $cust_pkg->options },
2210                                         );
2211           if ( $error ) { #just in case
2212             $dbh->rollback if $oldAutoCommit;
2213             return "Error modifying pkgnum ". $cust_pkg->pkgnum. ": $error";
2214           }
2215         }
2216   
2217         $setup = sprintf( "%.2f", $setup );
2218         $recur = sprintf( "%.2f", $recur );
2219         if ( $setup < 0 && ! $conf->exists('allow_negative_charges') ) {
2220           $dbh->rollback if $oldAutoCommit;
2221           return "negative setup $setup for pkgnum ". $cust_pkg->pkgnum;
2222         }
2223         if ( $recur < 0 && ! $conf->exists('allow_negative_charges') ) {
2224           $dbh->rollback if $oldAutoCommit;
2225           return "negative recur $recur for pkgnum ". $cust_pkg->pkgnum;
2226         }
2227   
2228         if ( $setup != 0 || $recur != 0 ) {
2229   
2230           warn "    charges (setup=$setup, recur=$recur); adding line items\n"
2231             if $DEBUG > 1;
2232           my $cust_bill_pkg = new FS::cust_bill_pkg {
2233             'pkgnum'  => $cust_pkg->pkgnum,
2234             'setup'   => $setup,
2235             'recur'   => $recur,
2236             'sdate'   => $sdate,
2237             'edate'   => $cust_pkg->bill,
2238             'details' => \@details,
2239           };
2240           $cust_bill_pkg->pkgpart_override($part_pkg->pkgpart)
2241             unless $part_pkg->pkgpart == $real_pkgpart;
2242           push @cust_bill_pkg, $cust_bill_pkg;
2243
2244           $total_setup += $setup;
2245           $total_recur += $recur;
2246   
2247           ###
2248           # handle taxes
2249           ###
2250   
2251           unless ( $self->tax =~ /Y/i || $self->payby eq 'COMP' ) {
2252   
2253             my @taxes = ();
2254             my @taxoverrides = $part_pkg->part_pkg_taxoverride;
2255             
2256             my $prefix = 
2257               ( $conf->exists('tax-ship_address') && length($self->ship_last) )
2258               ? 'ship_'
2259               : '';
2260   
2261             if ( $conf->exists('enable_taxproducts')
2262                  && (scalar(@taxoverrides) || $part_pkg->taxproductnum )
2263                )
2264             { 
2265   
2266               my @taxclassnums = ();
2267               my $geocode = $self->geocode('cch');
2268   
2269               if ( scalar( @taxoverrides ) ) {
2270                 @taxclassnums = map { $_->taxclassnum } @taxoverrides;
2271               }elsif ( $part_pkg->taxproductnum ) {
2272                 @taxclassnums = map { $_->taxclassnum }
2273                                 $part_pkg->part_pkg_taxrate('cch', $geocode);
2274               }
2275   
2276               my $extra_sql =
2277                 "AND (".
2278                 join(' OR ', map { "taxclassnum = $_" } @taxclassnums ). ")";
2279   
2280               @taxes = qsearch({ 'table' => 'tax_rate',
2281                                  'hashref' => { 'geocode' => $geocode, },
2282                                  'extra_sql' => $extra_sql,
2283                               })
2284                 if scalar(@taxclassnums);
2285   
2286   
2287             }else{
2288   
2289               my %taxhash = map { $_ => $self->get("$prefix$_") }
2290                                 qw( state county country );
2291   
2292               $taxhash{'taxclass'} = $part_pkg->taxclass;
2293   
2294               @taxes = qsearch( 'cust_main_county', \%taxhash );
2295
2296             unless ( @taxes ) {
2297               $taxhash{'taxclass'} = '';
2298               @taxes =  qsearch( 'cust_main_county', \%taxhash );
2299             }
2300
2301             #one more try at a whole-country tax rate
2302             unless ( @taxes ) {
2303               $taxhash{$_} = '' foreach qw( state county );
2304               @taxes =  qsearch( 'cust_main_county', \%taxhash );
2305             }
2306
2307             } #if $conf->exists('enable_taxproducts') 
2308   
2309             # maybe eliminate this entirely, along with all the 0% records
2310             unless ( @taxes ) {
2311               $dbh->rollback if $oldAutoCommit;
2312               my $error;
2313               if ( $conf->exists('enable_taxproducts') ) { 
2314                 $error = 
2315                   "fatal: can't find tax rate for zip/taxproduct/pkgpart ".
2316                   join('/', ( map $self->get("$prefix$_"),
2317                                   qw(zip)
2318                             ),
2319                             $part_pkg->taxproduct_description,
2320                             $part_pkg->pkgpart ). "\n";
2321               } else {
2322                 $error = 
2323                   "fatal: can't find tax rate for state/county/country/taxclass ".
2324                   join('/', ( map $self->get("$prefix$_"),
2325                                   qw(state county country)
2326                             ),
2327                             $part_pkg->taxclass ). "\n";
2328               }
2329               return $error;
2330             }
2331     
2332             foreach my $tax ( @taxes ) {
2333               my $taxname = ref( $tax ). ' '. $tax->taxnum;
2334               if ( exists( $taxlisthash{ $taxname } ) ) {
2335                 push @{ $taxlisthash{ $taxname  } }, $cust_bill_pkg;
2336               }else{
2337                 $taxlisthash{ $taxname } = [ $tax, $cust_bill_pkg ];
2338               }
2339             }
2340
2341
2342           } #unless $self->tax =~ /Y/i || $self->payby eq 'COMP'
2343
2344         } #if $setup != 0 || $recur != 0
2345       
2346       } #if $cust_pkg->modified
2347
2348     } #foreach my $part_pkg
2349
2350   } #foreach my $cust_pkg
2351
2352   unless ( @cust_bill_pkg ) { #don't create an invoice w/o line items
2353     #but do commit any package date cycling that happened
2354     $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2355     return '';
2356   }
2357
2358   foreach my $tax ( keys %taxlisthash ) {
2359     my $tax_object = shift @{ $taxlisthash{$tax} };
2360     my $listref_or_error = $tax_object->taxline( @{ $taxlisthash{$tax} } );
2361     unless (ref($listref_or_error)) {
2362       $dbh->rollback if $oldAutoCommit;
2363       return $listref_or_error;
2364     }
2365
2366     $tax{ $listref_or_error->[0] } += $listref_or_error->[1];
2367   
2368   }
2369
2370   foreach my $taxname ( grep { $tax{$_} > 0 } keys %tax ) {
2371     my $tax = sprintf('%.2f', $tax{$taxname} );
2372     $total_setup = sprintf('%.2f', $total_setup+$tax );
2373   
2374     push @cust_bill_pkg, new FS::cust_bill_pkg {
2375       'pkgnum'   => 0,
2376       'setup'    => $tax,
2377       'recur'    => 0,
2378       'sdate'    => '',
2379       'edate'    => '',
2380       'itemdesc' => $taxname,
2381     };
2382
2383   }
2384
2385   my $charged = sprintf('%.2f', $total_setup + $total_recur );
2386
2387   #create the new invoice
2388   my $cust_bill = new FS::cust_bill ( {
2389     'custnum' => $self->custnum,
2390     '_date'   => ( $options{'invoice_time'} || $time ),
2391     'charged' => $charged,
2392   } );
2393   my $error = $cust_bill->insert;
2394   if ( $error ) {
2395     $dbh->rollback if $oldAutoCommit;
2396     return "can't create invoice for customer #". $self->custnum. ": $error";
2397   }
2398
2399   foreach my $cust_bill_pkg ( @cust_bill_pkg ) {
2400     $cust_bill_pkg->invnum($cust_bill->invnum); 
2401     my $error = $cust_bill_pkg->insert;
2402     if ( $error ) {
2403       $dbh->rollback if $oldAutoCommit;
2404       return "can't create invoice line item: $error";
2405     }
2406   }
2407     
2408
2409   foreach my $hook ( @precommit_hooks ) { 
2410     eval {
2411       &{$hook}; #($self) ?
2412     };
2413     if ( $@ ) {
2414       $dbh->rollback if $oldAutoCommit;
2415       return "$@ running precommit hook $hook\n";
2416     }
2417   }
2418   
2419   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2420   ''; #no error
2421 }
2422
2423 =item collect OPTIONS
2424
2425 (Attempt to) collect money for this customer's outstanding invoices (see
2426 L<FS::cust_bill>).  Usually used after the bill method.
2427
2428 Actions are now triggered by billing events; see L<FS::part_event> and the
2429 billing events web interface.  Old-style invoice events (see
2430 L<FS::part_bill_event>) have been deprecated.
2431
2432 If there is an error, returns the error, otherwise returns false.
2433
2434 Options are passed as name-value pairs.
2435
2436 Currently available options are:
2437
2438 =over 4
2439
2440 =item invoice_time
2441
2442 Use this time when deciding when to print invoices and late notices on those invoices.  The default is now.  It is specified as a UNIX timestamp; see L<perlfunc/"time">).  Also see L<Time::Local> and L<Date::Parse> for conversion functions.
2443
2444 =item retry
2445
2446 Retry card/echeck/LEC transactions even when not scheduled by invoice events.
2447
2448 =item quiet
2449
2450 set true to surpress email card/ACH decline notices.
2451
2452 =item check_freq
2453
2454 "1d" for the traditional, daily events (the default), or "1m" for the new monthly events (part_event.check_freq)
2455
2456 =item payby
2457
2458 allows for one time override of normal customer billing method
2459
2460 =item debug
2461
2462 Debugging level.  Default is 0 (no debugging), or can be set to 1 (passed-in options), 2 (traces progress), 3 (more information), or 4 (include full search queries)
2463
2464
2465 =back
2466
2467 =cut
2468
2469 sub collect {
2470   my( $self, %options ) = @_;
2471   my $invoice_time = $options{'invoice_time'} || time;
2472
2473   #put below somehow?
2474   local $SIG{HUP} = 'IGNORE';
2475   local $SIG{INT} = 'IGNORE';
2476   local $SIG{QUIT} = 'IGNORE';
2477   local $SIG{TERM} = 'IGNORE';
2478   local $SIG{TSTP} = 'IGNORE';
2479   local $SIG{PIPE} = 'IGNORE';
2480
2481   my $oldAutoCommit = $FS::UID::AutoCommit;
2482   local $FS::UID::AutoCommit = 0;
2483   my $dbh = dbh;
2484
2485   $self->select_for_update; #mutex
2486
2487   if ( $DEBUG ) {
2488     my $balance = $self->balance;
2489     warn "$me collect customer ". $self->custnum. ": balance $balance\n"
2490   }
2491
2492   if ( exists($options{'retry_card'}) ) {
2493     carp 'retry_card option passed to collect is deprecated; use retry';
2494     $options{'retry'} ||= $options{'retry_card'};
2495   }
2496   if ( exists($options{'retry'}) && $options{'retry'} ) {
2497     my $error = $self->retry_realtime;
2498     if ( $error ) {
2499       $dbh->rollback if $oldAutoCommit;
2500       return $error;
2501     }
2502   }
2503
2504   # false laziness w/pay_batch::import_results
2505
2506   my $due_cust_event = $self->due_cust_event(
2507     'debug'      => ( $options{'debug'} || 0 ),
2508     'time'       => $invoice_time,
2509     'check_freq' => $options{'check_freq'},
2510   );
2511   unless( ref($due_cust_event) ) {
2512     $dbh->rollback if $oldAutoCommit;
2513     return $due_cust_event;
2514   }
2515
2516   foreach my $cust_event ( @$due_cust_event ) {
2517
2518     #XXX lock event
2519     
2520     #re-eval event conditions (a previous event could have changed things)
2521     unless ( $cust_event->test_conditions( 'time' => $invoice_time ) ) {
2522       #don't leave stray "new/locked" records around
2523       my $error = $cust_event->delete;
2524       if ( $error ) {
2525         #gah, even with transactions
2526         $dbh->commit if $oldAutoCommit; #well.
2527         return $error;
2528       }
2529       next;
2530     }
2531
2532     {
2533       local $realtime_bop_decline_quiet = 1 if $options{'quiet'};
2534       warn "  running cust_event ". $cust_event->eventnum. "\n"
2535         if $DEBUG > 1;
2536
2537       
2538       #if ( my $error = $cust_event->do_event(%options) ) { #XXX %options?
2539       if ( my $error = $cust_event->do_event() ) {
2540         #XXX wtf is this?  figure out a proper dealio with return value
2541         #from do_event
2542           # gah, even with transactions.
2543           $dbh->commit if $oldAutoCommit; #well.
2544           return $error;
2545         }
2546     }
2547
2548   }
2549
2550   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2551   '';
2552
2553 }
2554
2555 =item due_cust_event [ HASHREF | OPTION => VALUE ... ]
2556
2557 Inserts database records for and returns an ordered listref of new events due
2558 for this customer, as FS::cust_event objects (see L<FS::cust_event>).  If no
2559 events are due, an empty listref is returned.  If there is an error, returns a
2560 scalar error message.
2561
2562 To actually run the events, call each event's test_condition method, and if
2563 still true, call the event's do_event method.
2564
2565 Options are passed as a hashref or as a list of name-value pairs.  Available
2566 options are:
2567
2568 =over 4
2569
2570 =item check_freq
2571
2572 Search only for events of this check frequency (how often events of this type are checked); currently "1d" (daily, the default) and "1m" (monthly) are recognized.
2573
2574 =item time
2575
2576 "Current time" for the events.
2577
2578 =item debug
2579
2580 Debugging level.  Default is 0 (no debugging), or can be set to 1 (passed-in options), 2 (traces progress), 3 (more information), or 4 (include full search queries)
2581
2582 =item eventtable
2583
2584 Only return events for the specified eventtable (by default, events of all eventtables are returned)
2585
2586 =item objects
2587
2588 Explicitly pass the objects to be tested (typically used with eventtable).
2589
2590 =back
2591
2592 =cut
2593
2594 sub due_cust_event {
2595   my $self = shift;
2596   my %opt = ref($_[0]) ? %{ $_[0] } : @_;
2597
2598   #???
2599   #my $DEBUG = $opt{'debug'}
2600   local($DEBUG) = $opt{'debug'}
2601     if defined($opt{'debug'}) && $opt{'debug'} > $DEBUG;
2602
2603   warn "$me due_cust_event called with options ".
2604        join(', ', map { "$_: $opt{$_}" } keys %opt). "\n"
2605     if $DEBUG;
2606
2607   $opt{'time'} ||= time;
2608
2609   local $SIG{HUP} = 'IGNORE';
2610   local $SIG{INT} = 'IGNORE';
2611   local $SIG{QUIT} = 'IGNORE';
2612   local $SIG{TERM} = 'IGNORE';
2613   local $SIG{TSTP} = 'IGNORE';
2614   local $SIG{PIPE} = 'IGNORE';
2615
2616   my $oldAutoCommit = $FS::UID::AutoCommit;
2617   local $FS::UID::AutoCommit = 0;
2618   my $dbh = dbh;
2619
2620   $self->select_for_update; #mutex
2621
2622   ###
2623   # 1: find possible events (initial search)
2624   ###
2625   
2626   my @cust_event = ();
2627
2628   my @eventtable = $opt{'eventtable'}
2629                      ? ( $opt{'eventtable'} )
2630                      : FS::part_event->eventtables_runorder;
2631
2632   foreach my $eventtable ( @eventtable ) {
2633
2634     my @objects;
2635     if ( $opt{'objects'} ) {
2636
2637       @objects = @{ $opt{'objects'} };
2638
2639     } else {
2640
2641       #my @objects = $self->eventtable(); # sub cust_main { @{ [ $self ] }; }
2642       @objects = ( $eventtable eq 'cust_main' )
2643                    ? ( $self )
2644                    : ( $self->$eventtable() );
2645
2646     }
2647
2648     my @e_cust_event = ();
2649
2650     my $cross = "CROSS JOIN $eventtable";
2651     $cross .= ' LEFT JOIN cust_main USING ( custnum )'
2652       unless $eventtable eq 'cust_main';
2653
2654     foreach my $object ( @objects ) {
2655
2656       #this first search uses the condition_sql magic for optimization.
2657       #the more possible events we can eliminate in this step the better
2658
2659       my $cross_where = '';
2660       my $pkey = $object->primary_key;
2661       $cross_where = "$eventtable.$pkey = ". $object->$pkey();
2662
2663       my $join = FS::part_event_condition->join_conditions_sql( $eventtable );
2664       my $extra_sql =
2665         FS::part_event_condition->where_conditions_sql( $eventtable,
2666                                                         'time'=>$opt{'time'}
2667                                                       );
2668       my $order = FS::part_event_condition->order_conditions_sql( $eventtable );
2669
2670       $extra_sql = "AND $extra_sql" if $extra_sql;
2671
2672       #here is the agent virtualization
2673       $extra_sql .= " AND (    part_event.agentnum IS NULL
2674                             OR part_event.agentnum = ". $self->agentnum. ' )';
2675
2676       $extra_sql .= " $order";
2677
2678       warn "searching for events for $eventtable ". $object->$pkey. "\n"
2679         if $opt{'debug'} > 2;
2680       my @part_event = qsearch( {
2681         'debug'     => ( $opt{'debug'} > 3 ? 1 : 0 ),
2682         'select'    => 'part_event.*',
2683         'table'     => 'part_event',
2684         'addl_from' => "$cross $join",
2685         'hashref'   => { 'check_freq' => ( $opt{'check_freq'} || '1d' ),
2686                          'eventtable' => $eventtable,
2687                          'disabled'   => '',
2688                        },
2689         'extra_sql' => "AND $cross_where $extra_sql",
2690       } );
2691
2692       if ( $DEBUG > 2 ) {
2693         my $pkey = $object->primary_key;
2694         warn "      ". scalar(@part_event).
2695              " possible events found for $eventtable ". $object->$pkey(). "\n";
2696       }
2697
2698       push @e_cust_event, map { $_->new_cust_event($object) } @part_event;
2699
2700     }
2701
2702     warn "    ". scalar(@e_cust_event).
2703          " subtotal possible cust events found for $eventtable\n"
2704       if $DEBUG > 1;
2705
2706     push @cust_event, @e_cust_event;
2707
2708   }
2709
2710   warn "  ". scalar(@cust_event).
2711        " total possible cust events found in initial search\n"
2712     if $DEBUG; # > 1;
2713
2714   ##
2715   # 2: test conditions
2716   ##
2717   
2718   my %unsat = ();
2719
2720   @cust_event = grep $_->test_conditions( 'time'          => $opt{'time'},
2721                                           'stats_hashref' => \%unsat ),
2722                      @cust_event;
2723
2724   warn "  ". scalar(@cust_event). " cust events left satisfying conditions\n"
2725     if $DEBUG; # > 1;
2726
2727   warn "    invalid conditions not eliminated with condition_sql:\n".
2728        join('', map "      $_: ".$unsat{$_}."\n", keys %unsat )
2729     if $DEBUG; # > 1;
2730
2731   ##
2732   # 3: insert
2733   ##
2734
2735   foreach my $cust_event ( @cust_event ) {
2736
2737     my $error = $cust_event->insert();
2738     if ( $error ) {
2739       $dbh->rollback if $oldAutoCommit;
2740       return $error;
2741     }
2742                                        
2743   }
2744
2745   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2746
2747   ##
2748   # 4: return
2749   ##
2750
2751   warn "  returning events: ". Dumper(@cust_event). "\n"
2752     if $DEBUG > 2;
2753
2754   \@cust_event;
2755
2756 }
2757
2758 =item retry_realtime
2759
2760 Schedules realtime / batch  credit card / electronic check / LEC billing
2761 events for for retry.  Useful if card information has changed or manual
2762 retry is desired.  The 'collect' method must be called to actually retry
2763 the transaction.
2764
2765 Implementation details: For either this customer, or for each of this
2766 customer's open invoices, changes the status of the first "done" (with
2767 statustext error) realtime processing event to "failed".
2768
2769 =cut
2770
2771 sub retry_realtime {
2772   my $self = shift;
2773
2774   local $SIG{HUP} = 'IGNORE';
2775   local $SIG{INT} = 'IGNORE';
2776   local $SIG{QUIT} = 'IGNORE';
2777   local $SIG{TERM} = 'IGNORE';
2778   local $SIG{TSTP} = 'IGNORE';
2779   local $SIG{PIPE} = 'IGNORE';
2780
2781   my $oldAutoCommit = $FS::UID::AutoCommit;
2782   local $FS::UID::AutoCommit = 0;
2783   my $dbh = dbh;
2784
2785   #a little false laziness w/due_cust_event (not too bad, really)
2786
2787   my $join = FS::part_event_condition->join_conditions_sql;
2788   my $order = FS::part_event_condition->order_conditions_sql;
2789   my $mine = 
2790   '( '
2791    . join ( ' OR ' , map { 
2792     "( part_event.eventtable = " . dbh->quote($_) 
2793     . " AND tablenum IN( SELECT " . dbdef->table($_)->primary_key . " from $_ where custnum = " . dbh->quote( $self->custnum ) . "))" ;
2794    } FS::part_event->eventtables)
2795    . ') ';
2796
2797   #here is the agent virtualization
2798   my $agent_virt = " (    part_event.agentnum IS NULL
2799                        OR part_event.agentnum = ". $self->agentnum. ' )';
2800
2801   #XXX this shouldn't be hardcoded, actions should declare it...
2802   my @realtime_events = qw(
2803     cust_bill_realtime_card
2804     cust_bill_realtime_check
2805     cust_bill_realtime_lec
2806     cust_bill_batch
2807   );
2808
2809   my $is_realtime_event = ' ( '. join(' OR ', map "part_event.action = '$_'",
2810                                                   @realtime_events
2811                                      ).
2812                           ' ) ';
2813
2814   my @cust_event = qsearchs({
2815     'table'     => 'cust_event',
2816     'select'    => 'cust_event.*',
2817     'addl_from' => "LEFT JOIN part_event USING ( eventpart ) $join",
2818     'hashref'   => { 'status' => 'done' },
2819     'extra_sql' => " AND statustext IS NOT NULL AND statustext != '' ".
2820                    " AND $mine AND $is_realtime_event AND $agent_virt $order" # LIMIT 1"
2821   });
2822
2823   my %seen_invnum = ();
2824   foreach my $cust_event (@cust_event) {
2825
2826     #max one for the customer, one for each open invoice
2827     my $cust_X = $cust_event->cust_X;
2828     next if $seen_invnum{ $cust_event->part_event->eventtable eq 'cust_bill'
2829                           ? $cust_X->invnum
2830                           : 0
2831                         }++
2832          or $cust_event->part_event->eventtable eq 'cust_bill'
2833             && ! $cust_X->owed;
2834
2835     my $error = $cust_event->retry;
2836     if ( $error ) {
2837       $dbh->rollback if $oldAutoCommit;
2838       return "error scheduling event for retry: $error";
2839     }
2840
2841   }
2842
2843   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2844   '';
2845
2846 }
2847
2848 =item realtime_bop METHOD AMOUNT [ OPTION => VALUE ... ]
2849
2850 Runs a realtime credit card, ACH (electronic check) or phone bill transaction
2851 via a Business::OnlinePayment realtime gateway.  See
2852 L<http://420.am/business-onlinepayment> for supported gateways.
2853
2854 Available methods are: I<CC>, I<ECHECK> and I<LEC>
2855
2856 Available options are: I<description>, I<invnum>, I<quiet>, I<paynum_ref>, I<payunique>
2857
2858 The additional options I<payname>, I<address1>, I<address2>, I<city>, I<state>,
2859 I<zip>, I<payinfo> and I<paydate> are also available.  Any of these options,
2860 if set, will override the value from the customer record.
2861
2862 I<description> is a free-text field passed to the gateway.  It defaults to
2863 "Internet services".
2864
2865 If an I<invnum> is specified, this payment (if successful) is applied to the
2866 specified invoice.  If you don't specify an I<invnum> you might want to
2867 call the B<apply_payments> method.
2868
2869 I<quiet> can be set true to surpress email decline notices.
2870
2871 I<paynum_ref> can be set to a scalar reference.  It will be filled in with the
2872 resulting paynum, if any.
2873
2874 I<payunique> is a unique identifier for this payment.
2875
2876 (moved from cust_bill) (probably should get realtime_{card,ach,lec} here too)
2877
2878 =cut
2879
2880 sub realtime_bop {
2881   my( $self, $method, $amount, %options ) = @_;
2882   if ( $DEBUG ) {
2883     warn "$me realtime_bop: $method $amount\n";
2884     warn "  $_ => $options{$_}\n" foreach keys %options;
2885   }
2886
2887   $options{'description'} ||= 'Internet services';
2888
2889   return $self->fake_bop($method, $amount, %options) if $options{'fake'};
2890
2891   eval "use Business::OnlinePayment";  
2892   die $@ if $@;
2893
2894   my $payinfo = exists($options{'payinfo'})
2895                   ? $options{'payinfo'}
2896                   : $self->payinfo;
2897
2898   my %method2payby = (
2899     'CC'     => 'CARD',
2900     'ECHECK' => 'CHEK',
2901     'LEC'    => 'LECB',
2902   );
2903
2904   ###
2905   # check for banned credit card/ACH
2906   ###
2907
2908   my $ban = qsearchs('banned_pay', {
2909     'payby'   => $method2payby{$method},
2910     'payinfo' => md5_base64($payinfo),
2911   } );
2912   return "Banned credit card" if $ban;
2913
2914   ###
2915   # select a gateway
2916   ###
2917
2918   my $taxclass = '';
2919   if ( $options{'invnum'} ) {
2920     my $cust_bill = qsearchs('cust_bill', { 'invnum' => $options{'invnum'} } );
2921     die "invnum ". $options{'invnum'}. " not found" unless $cust_bill;
2922     my @taxclasses =
2923       map  { $_->part_pkg->taxclass }
2924       grep { $_ }
2925       map  { $_->cust_pkg }
2926       $cust_bill->cust_bill_pkg;
2927     unless ( grep { $taxclasses[0] ne $_ } @taxclasses ) { #unless there are
2928                                                            #different taxclasses
2929       $taxclass = $taxclasses[0];
2930     }
2931   }
2932
2933   #look for an agent gateway override first
2934   my $cardtype;
2935   if ( $method eq 'CC' ) {
2936     $cardtype = cardtype($payinfo);
2937   } elsif ( $method eq 'ECHECK' ) {
2938     $cardtype = 'ACH';
2939   } else {
2940     $cardtype = $method;
2941   }
2942
2943   my $override =
2944        qsearchs('agent_payment_gateway', { agentnum => $self->agentnum,
2945                                            cardtype => $cardtype,
2946                                            taxclass => $taxclass,       } )
2947     || qsearchs('agent_payment_gateway', { agentnum => $self->agentnum,
2948                                            cardtype => '',
2949                                            taxclass => $taxclass,       } )
2950     || qsearchs('agent_payment_gateway', { agentnum => $self->agentnum,
2951                                            cardtype => $cardtype,
2952                                            taxclass => '',              } )
2953     || qsearchs('agent_payment_gateway', { agentnum => $self->agentnum,
2954                                            cardtype => '',
2955                                            taxclass => '',              } );
2956
2957   my $payment_gateway = '';
2958   my( $processor, $login, $password, $action, @bop_options );
2959   if ( $override ) { #use a payment gateway override
2960
2961     $payment_gateway = $override->payment_gateway;
2962
2963     $processor   = $payment_gateway->gateway_module;
2964     $login       = $payment_gateway->gateway_username;
2965     $password    = $payment_gateway->gateway_password;
2966     $action      = $payment_gateway->gateway_action;
2967     @bop_options = $payment_gateway->options;
2968
2969   } else { #use the standard settings from the config
2970
2971     ( $processor, $login, $password, $action, @bop_options ) =
2972       $self->default_payment_gateway($method);
2973
2974   }
2975
2976   ###
2977   # massage data
2978   ###
2979
2980   my $address = exists($options{'address1'})
2981                     ? $options{'address1'}
2982                     : $self->address1;
2983   my $address2 = exists($options{'address2'})
2984                     ? $options{'address2'}
2985                     : $self->address2;
2986   $address .= ", ". $address2 if length($address2);
2987
2988   my $o_payname = exists($options{'payname'})
2989                     ? $options{'payname'}
2990                     : $self->payname;
2991   my($payname, $payfirst, $paylast);
2992   if ( $o_payname && $method ne 'ECHECK' ) {
2993     ($payname = $o_payname) =~ /^\s*([\w \,\.\-\']*)?\s+([\w\,\.\-\']+)\s*$/
2994       or return "Illegal payname $payname";
2995     ($payfirst, $paylast) = ($1, $2);
2996   } else {
2997     $payfirst = $self->getfield('first');
2998     $paylast = $self->getfield('last');
2999     $payname =  "$payfirst $paylast";
3000   }
3001
3002   my @invoicing_list = $self->invoicing_list_emailonly;
3003   if ( $conf->exists('emailinvoiceautoalways')
3004        || $conf->exists('emailinvoiceauto') && ! @invoicing_list
3005        || ( $conf->exists('emailinvoiceonly') && ! @invoicing_list ) ) {
3006     push @invoicing_list, $self->all_emails;
3007   }
3008
3009   my $email = ($conf->exists('business-onlinepayment-email-override'))
3010               ? $conf->config('business-onlinepayment-email-override')
3011               : $invoicing_list[0];
3012
3013   my %content = ();
3014
3015   my $payip = exists($options{'payip'})
3016                 ? $options{'payip'}
3017                 : $self->payip;
3018   $content{customer_ip} = $payip
3019     if length($payip);
3020
3021   $content{invoice_number} = $options{'invnum'}
3022     if exists($options{'invnum'}) && length($options{'invnum'});
3023
3024   $content{email_customer} = 
3025     (    $conf->exists('business-onlinepayment-email_customer')
3026       || $conf->exists('business-onlinepayment-email-override') );
3027       
3028   my $paydate = '';
3029   if ( $method eq 'CC' ) { 
3030
3031     $content{card_number} = $payinfo;
3032     $paydate = exists($options{'paydate'})
3033                     ? $options{'paydate'}
3034                     : $self->paydate;
3035     $paydate =~ /^\d{2}(\d{2})[\/\-](\d+)[\/\-]\d+$/;
3036     $content{expiration} = "$2/$1";
3037
3038     my $paycvv = exists($options{'paycvv'})
3039                    ? $options{'paycvv'}
3040                    : $self->paycvv;
3041     $content{cvv2} = $paycvv
3042       if length($paycvv);
3043
3044     my $paystart_month = exists($options{'paystart_month'})
3045                            ? $options{'paystart_month'}
3046                            : $self->paystart_month;
3047
3048     my $paystart_year  = exists($options{'paystart_year'})
3049                            ? $options{'paystart_year'}
3050                            : $self->paystart_year;
3051
3052     $content{card_start} = "$paystart_month/$paystart_year"
3053       if $paystart_month && $paystart_year;
3054
3055     my $payissue       = exists($options{'payissue'})
3056                            ? $options{'payissue'}
3057                            : $self->payissue;
3058     $content{issue_number} = $payissue if $payissue;
3059
3060     $content{recurring_billing} = 'YES'
3061       if qsearch('cust_pay', { 'custnum' => $self->custnum,
3062                                'payby'   => 'CARD',
3063                                'payinfo' => $payinfo,
3064                              } )
3065       || qsearch('cust_pay', { 'custnum' => $self->custnum,
3066                                'payby'   => 'CARD',
3067                                'paymask' => $self->mask_payinfo('CARD', $payinfo),
3068                              } );
3069
3070
3071   } elsif ( $method eq 'ECHECK' ) {
3072     ( $content{account_number}, $content{routing_code} ) =
3073       split('@', $payinfo);
3074     $content{bank_name} = $o_payname;
3075     $content{bank_state} = exists($options{'paystate'})
3076                              ? $options{'paystate'}
3077                              : $self->getfield('paystate');
3078     $content{account_type} = exists($options{'paytype'})
3079                                ? uc($options{'paytype'}) || 'CHECKING'
3080                                : uc($self->getfield('paytype')) || 'CHECKING';
3081     $content{account_name} = $payname;
3082     $content{customer_org} = $self->company ? 'B' : 'I';
3083     $content{state_id}       = exists($options{'stateid'})
3084                                  ? $options{'stateid'}
3085                                  : $self->getfield('stateid');
3086     $content{state_id_state} = exists($options{'stateid_state'})
3087                                  ? $options{'stateid_state'}
3088                                  : $self->getfield('stateid_state');
3089     $content{customer_ssn} = exists($options{'ss'})
3090                                ? $options{'ss'}
3091                                : $self->ss;
3092   } elsif ( $method eq 'LEC' ) {
3093     $content{phone} = $payinfo;
3094   }
3095
3096   ###
3097   # run transaction(s)
3098   ###
3099
3100   my $balance = exists( $options{'balance'} )
3101                   ? $options{'balance'}
3102                   : $self->balance;
3103
3104   $self->select_for_update; #mutex ... just until we get our pending record in
3105
3106   #the checks here are intended to catch concurrent payments
3107   #double-form-submission prevention is taken care of in cust_pay_pending::check
3108
3109   #check the balance
3110   return "The customer's balance has changed; $method transaction aborted."
3111     if $self->balance < $balance;
3112     #&& $self->balance < $amount; #might as well anyway?
3113
3114   #also check and make sure there aren't *other* pending payments for this cust
3115
3116   my @pending = qsearch('cust_pay_pending', {
3117     'custnum' => $self->custnum,
3118     'status'  => { op=>'!=', value=>'done' } 
3119   });
3120   return "A payment is already being processed for this customer (".
3121          join(', ', map 'paypendingnum '. $_->paypendingnum, @pending ).
3122          "); $method transaction aborted."
3123     if scalar(@pending);
3124
3125   #okay, good to go, if we're a duplicate, cust_pay_pending will kick us out
3126
3127   my $cust_pay_pending = new FS::cust_pay_pending {
3128     'custnum'    => $self->custnum,
3129     #'invnum'     => $options{'invnum'},
3130     'paid'       => $amount,
3131     '_date'      => '',
3132     'payby'      => $method2payby{$method},
3133     'payinfo'    => $payinfo,
3134     'paydate'    => $paydate,
3135     'status'     => 'new',
3136     'gatewaynum' => ( $payment_gateway ? $payment_gateway->gatewaynum : '' ),
3137   };
3138   $cust_pay_pending->payunique( $options{payunique} )
3139     if defined($options{payunique}) && length($options{payunique});
3140   my $cpp_new_err = $cust_pay_pending->insert; #mutex lost when this is inserted
3141   return $cpp_new_err if $cpp_new_err;
3142
3143   my( $action1, $action2 ) = split(/\s*\,\s*/, $action );
3144
3145   my $transaction = new Business::OnlinePayment( $processor, @bop_options );
3146   $transaction->content(
3147     'type'           => $method,
3148     'login'          => $login,
3149     'password'       => $password,
3150     'action'         => $action1,
3151     'description'    => $options{'description'},
3152     'amount'         => $amount,
3153     #'invoice_number' => $options{'invnum'},
3154     'customer_id'    => $self->custnum,
3155     'last_name'      => $paylast,
3156     'first_name'     => $payfirst,
3157     'name'           => $payname,
3158     'address'        => $address,
3159     'city'           => ( exists($options{'city'})
3160                             ? $options{'city'}
3161                             : $self->city          ),
3162     'state'          => ( exists($options{'state'})
3163                             ? $options{'state'}
3164                             : $self->state          ),
3165     'zip'            => ( exists($options{'zip'})
3166                             ? $options{'zip'}
3167                             : $self->zip          ),
3168     'country'        => ( exists($options{'country'})
3169                             ? $options{'country'}
3170                             : $self->country          ),
3171     'referer'        => 'http://cleanwhisker.420.am/',
3172     'email'          => $email,
3173     'phone'          => $self->daytime || $self->night,
3174     %content, #after
3175   );
3176
3177   $cust_pay_pending->status('pending');
3178   my $cpp_pending_err = $cust_pay_pending->replace;
3179   return $cpp_pending_err if $cpp_pending_err;
3180
3181   #config?
3182   my $BOP_TESTING = 0;
3183   my $BOP_TESTING_SUCCESS = 1;
3184
3185   unless ( $BOP_TESTING ) {
3186     $transaction->submit();
3187   } else {
3188     if ( $BOP_TESTING_SUCCESS ) {
3189       $transaction->is_success(1);
3190       $transaction->authorization('fake auth');
3191     } else {
3192       $transaction->is_success(0);
3193       $transaction->error_message('fake failure');
3194     }
3195   }
3196
3197   if ( $transaction->is_success() && $action2 ) {
3198
3199     $cust_pay_pending->status('authorized');
3200     my $cpp_authorized_err = $cust_pay_pending->replace;
3201     return $cpp_authorized_err if $cpp_authorized_err;
3202
3203     my $auth = $transaction->authorization;
3204     my $ordernum = $transaction->can('order_number')
3205                    ? $transaction->order_number
3206                    : '';
3207
3208     my $capture =
3209       new Business::OnlinePayment( $processor, @bop_options );
3210
3211     my %capture = (
3212       %content,
3213       type           => $method,
3214       action         => $action2,
3215       login          => $login,
3216       password       => $password,
3217       order_number   => $ordernum,
3218       amount         => $amount,
3219       authorization  => $auth,
3220       description    => $options{'description'},
3221     );
3222
3223     foreach my $field (qw( authorization_source_code returned_ACI
3224                            transaction_identifier validation_code           
3225                            transaction_sequence_num local_transaction_date    
3226                            local_transaction_time AVS_result_code          )) {
3227       $capture{$field} = $transaction->$field() if $transaction->can($field);
3228     }
3229
3230     $capture->content( %capture );
3231
3232     $capture->submit();
3233
3234     unless ( $capture->is_success ) {
3235       my $e = "Authorization successful but capture failed, custnum #".
3236               $self->custnum. ': '.  $capture->result_code.
3237               ": ". $capture->error_message;
3238       warn $e;
3239       return $e;
3240     }
3241
3242   }
3243
3244   $cust_pay_pending->status($transaction->is_success() ? 'captured' : 'declined');
3245   my $cpp_captured_err = $cust_pay_pending->replace;
3246   return $cpp_captured_err if $cpp_captured_err;
3247
3248   ###
3249   # remove paycvv after initial transaction
3250   ###
3251
3252   #false laziness w/misc/process/payment.cgi - check both to make sure working
3253   # correctly
3254   if ( defined $self->dbdef_table->column('paycvv')
3255        && length($self->paycvv)
3256        && ! grep { $_ eq cardtype($payinfo) } $conf->config('cvv-save')
3257   ) {
3258     my $error = $self->remove_cvv;
3259     if ( $error ) {
3260       warn "WARNING: error removing cvv: $error\n";
3261     }
3262   }
3263
3264   ###
3265   # result handling
3266   ###
3267
3268   if ( $transaction->is_success() ) {
3269
3270     my $paybatch = '';
3271     if ( $payment_gateway ) { # agent override
3272       $paybatch = $payment_gateway->gatewaynum. '-';
3273     }
3274
3275     $paybatch .= "$processor:". $transaction->authorization;
3276
3277     $paybatch .= ':'. $transaction->order_number
3278       if $transaction->can('order_number')
3279       && length($transaction->order_number);
3280
3281     my $cust_pay = new FS::cust_pay ( {
3282        'custnum'  => $self->custnum,
3283        'invnum'   => $options{'invnum'},
3284        'paid'     => $amount,
3285        '_date'    => '',
3286        'payby'    => $method2payby{$method},
3287        'payinfo'  => $payinfo,
3288        'paybatch' => $paybatch,
3289        'paydate'  => $paydate,
3290     } );
3291     #doesn't hurt to know, even though the dup check is in cust_pay_pending now
3292     $cust_pay->payunique( $options{payunique} )
3293       if defined($options{payunique}) && length($options{payunique});
3294
3295     my $oldAutoCommit = $FS::UID::AutoCommit;
3296     local $FS::UID::AutoCommit = 0;
3297     my $dbh = dbh;
3298
3299     #start a transaction, insert the cust_pay and set cust_pay_pending.status to done in a single transction
3300
3301     my $error = $cust_pay->insert($options{'manual'} ? ( 'manual' => 1 ) : () );
3302
3303     if ( $error ) {
3304       $cust_pay->invnum(''); #try again with no specific invnum
3305       my $error2 = $cust_pay->insert( $options{'manual'} ?
3306                                       ( 'manual' => 1 ) : ()
3307                                     );
3308       if ( $error2 ) {
3309         # gah.  but at least we have a record of the state we had to abort in
3310         # from cust_pay_pending now.
3311         my $e = "WARNING: $method captured but payment not recorded - ".
3312                 "error inserting payment ($processor): $error2".
3313                 " (previously tried insert with invnum #$options{'invnum'}" .
3314                 ": $error ) - pending payment saved as paypendingnum ".
3315                 $cust_pay_pending->paypendingnum. "\n";
3316         warn $e;
3317         return $e;
3318       }
3319     }
3320
3321     if ( $options{'paynum_ref'} ) {
3322       ${ $options{'paynum_ref'} } = $cust_pay->paynum;
3323     }
3324
3325     $cust_pay_pending->status('done');
3326     $cust_pay_pending->statustext('captured');
3327     my $cpp_done_err = $cust_pay_pending->replace;
3328
3329     if ( $cpp_done_err ) {
3330
3331       $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
3332       my $e = "WARNING: $method captured but payment not recorded - ".
3333               "error updating status for paypendingnum ".
3334               $cust_pay_pending->paypendingnum. ": $cpp_done_err \n";
3335       warn $e;
3336       return $e;
3337
3338     } else {
3339
3340       $dbh->commit or die $dbh->errstr if $oldAutoCommit;
3341       return ''; #no error
3342
3343     }
3344
3345   } else {
3346
3347     my $perror = "$processor error: ". $transaction->error_message;
3348
3349     unless ( $transaction->error_message ) {
3350
3351       my $t_response;
3352       if ( $transaction->can('response_page') ) {
3353         $t_response = {
3354                         'page'    => ( $transaction->can('response_page')
3355                                          ? $transaction->response_page
3356                                          : ''
3357                                      ),
3358                         'code'    => ( $transaction->can('response_code')
3359                                          ? $transaction->response_code
3360                                          : ''
3361                                      ),
3362                         'headers' => ( $transaction->can('response_headers')
3363                                          ? $transaction->response_headers
3364                                          : ''
3365                                      ),
3366                       };
3367       } else {
3368         $t_response .=
3369           "No additional debugging information available for $processor";
3370       }
3371
3372       $perror .= "No error_message returned from $processor -- ".
3373                  ( ref($t_response) ? Dumper($t_response) : $t_response );
3374
3375     }
3376
3377     if ( !$options{'quiet'} && !$realtime_bop_decline_quiet
3378          && $conf->exists('emaildecline')
3379          && grep { $_ ne 'POST' } $self->invoicing_list
3380          && ! grep { $transaction->error_message =~ /$_/ }
3381                    $conf->config('emaildecline-exclude')
3382     ) {
3383       my @templ = $conf->config('declinetemplate');
3384       my $template = new Text::Template (
3385         TYPE   => 'ARRAY',
3386         SOURCE => [ map "$_\n", @templ ],
3387       ) or return "($perror) can't create template: $Text::Template::ERROR";
3388       $template->compile()
3389         or return "($perror) can't compile template: $Text::Template::ERROR";
3390
3391       my $templ_hash = { error => $transaction->error_message };
3392
3393       my $error = send_email(
3394         'from'    => $conf->config('invoice_from'),
3395         'to'      => [ grep { $_ ne 'POST' } $self->invoicing_list ],
3396         'subject' => 'Your payment could not be processed',
3397         'body'    => [ $template->fill_in(HASH => $templ_hash) ],
3398       );
3399
3400       $perror .= " (also received error sending decline notification: $error)"
3401         if $error;
3402
3403     }
3404
3405     $cust_pay_pending->status('done');
3406     $cust_pay_pending->statustext("declined: $perror");
3407     my $cpp_done_err = $cust_pay_pending->replace;
3408     if ( $cpp_done_err ) {
3409       my $e = "WARNING: $method declined but pending payment not resolved - ".
3410               "error updating status for paypendingnum ".
3411               $cust_pay_pending->paypendingnum. ": $cpp_done_err \n";
3412       warn $e;
3413       $perror = "$e ($perror)";
3414     }
3415
3416     return $perror;
3417   }
3418
3419 }
3420
3421 =item fake_bop
3422
3423 =cut
3424
3425 sub fake_bop {
3426   my( $self, $method, $amount, %options ) = @_;
3427
3428   if ( $options{'fake_failure'} ) {
3429      return "Error: No error; test failure requested with fake_failure";
3430   }
3431
3432   my %method2payby = (
3433     'CC'     => 'CARD',
3434     'ECHECK' => 'CHEK',
3435     'LEC'    => 'LECB',
3436   );
3437
3438   #my $paybatch = '';
3439   #if ( $payment_gateway ) { # agent override
3440   #  $paybatch = $payment_gateway->gatewaynum. '-';
3441   #}
3442   #
3443   #$paybatch .= "$processor:". $transaction->authorization;
3444   #
3445   #$paybatch .= ':'. $transaction->order_number
3446   #  if $transaction->can('order_number')
3447   #  && length($transaction->order_number);
3448
3449   my $paybatch = 'FakeProcessor:54:32';
3450
3451   my $cust_pay = new FS::cust_pay ( {
3452      'custnum'  => $self->custnum,
3453      'invnum'   => $options{'invnum'},
3454      'paid'     => $amount,
3455      '_date'    => '',
3456      'payby'    => $method2payby{$method},
3457      #'payinfo'  => $payinfo,
3458      'payinfo'  => '4111111111111111',
3459      'paybatch' => $paybatch,
3460      #'paydate'  => $paydate,
3461      'paydate'  => '2012-05-01',
3462   } );
3463   $cust_pay->payunique( $options{payunique} ) if length($options{payunique});
3464
3465   my $error = $cust_pay->insert($options{'manual'} ? ( 'manual' => 1 ) : () );
3466
3467   if ( $error ) {
3468     $cust_pay->invnum(''); #try again with no specific invnum
3469     my $error2 = $cust_pay->insert( $options{'manual'} ?
3470                                     ( 'manual' => 1 ) : ()
3471                                   );
3472     if ( $error2 ) {
3473       # gah, even with transactions.
3474       my $e = 'WARNING: Card/ACH debited but database not updated - '.
3475               "error inserting (fake!) payment: $error2".
3476               " (previously tried insert with invnum #$options{'invnum'}" .
3477               ": $error )";
3478       warn $e;
3479       return $e;
3480     }
3481   }
3482
3483   if ( $options{'paynum_ref'} ) {
3484     ${ $options{'paynum_ref'} } = $cust_pay->paynum;
3485   }
3486
3487   return ''; #no error
3488
3489 }
3490
3491 =item default_payment_gateway
3492
3493 =cut
3494
3495 sub default_payment_gateway {
3496   my( $self, $method ) = @_;
3497
3498   die "Real-time processing not enabled\n"
3499     unless $conf->exists('business-onlinepayment');
3500
3501   #load up config
3502   my $bop_config = 'business-onlinepayment';
3503   $bop_config .= '-ach'
3504     if $method =~ /^(ECHECK|CHEK)$/ && $conf->exists($bop_config. '-ach');
3505   my ( $processor, $login, $password, $action, @bop_options ) =
3506     $conf->config($bop_config);
3507   $action ||= 'normal authorization';
3508   pop @bop_options if scalar(@bop_options) % 2 && $bop_options[-1] =~ /^\s*$/;
3509   die "No real-time processor is enabled - ".
3510       "did you set the business-onlinepayment configuration value?\n"
3511     unless $processor;
3512
3513   ( $processor, $login, $password, $action, @bop_options )
3514 }
3515
3516 =item remove_cvv
3517
3518 Removes the I<paycvv> field from the database directly.
3519
3520 If there is an error, returns the error, otherwise returns false.
3521
3522 =cut
3523
3524 sub remove_cvv {
3525   my $self = shift;
3526   my $sth = dbh->prepare("UPDATE cust_main SET paycvv = '' WHERE custnum = ?")
3527     or return dbh->errstr;
3528   $sth->execute($self->custnum)
3529     or return $sth->errstr;
3530   $self->paycvv('');
3531   '';
3532 }
3533
3534 =item realtime_refund_bop METHOD [ OPTION => VALUE ... ]
3535
3536 Refunds a realtime credit card, ACH (electronic check) or phone bill transaction
3537 via a Business::OnlinePayment realtime gateway.  See
3538 L<http://420.am/business-onlinepayment> for supported gateways.
3539
3540 Available methods are: I<CC>, I<ECHECK> and I<LEC>
3541
3542 Available options are: I<amount>, I<reason>, I<paynum>, I<paydate>
3543
3544 Most gateways require a reference to an original payment transaction to refund,
3545 so you probably need to specify a I<paynum>.
3546
3547 I<amount> defaults to the original amount of the payment if not specified.
3548
3549 I<reason> specifies a reason for the refund.
3550
3551 I<paydate> specifies the expiration date for a credit card overriding the
3552 value from the customer record or the payment record. Specified as yyyy-mm-dd
3553
3554 Implementation note: If I<amount> is unspecified or equal to the amount of the
3555 orignal payment, first an attempt is made to "void" the transaction via
3556 the gateway (to cancel a not-yet settled transaction) and then if that fails,
3557 the normal attempt is made to "refund" ("credit") the transaction via the
3558 gateway is attempted.
3559
3560 #The additional options I<payname>, I<address1>, I<address2>, I<city>, I<state>,
3561 #I<zip>, I<payinfo> and I<paydate> are also available.  Any of these options,
3562 #if set, will override the value from the customer record.
3563
3564 #If an I<invnum> is specified, this payment (if successful) is applied to the
3565 #specified invoice.  If you don't specify an I<invnum> you might want to
3566 #call the B<apply_payments> method.
3567
3568 =cut
3569
3570 #some false laziness w/realtime_bop, not enough to make it worth merging
3571 #but some useful small subs should be pulled out
3572 sub realtime_refund_bop {
3573   my( $self, $method, %options ) = @_;
3574   if ( $DEBUG ) {
3575     warn "$me realtime_refund_bop: $method refund\n";
3576     warn "  $_ => $options{$_}\n" foreach keys %options;
3577   }
3578
3579   eval "use Business::OnlinePayment";  
3580   die $@ if $@;
3581
3582   ###
3583   # look up the original payment and optionally a gateway for that payment
3584   ###
3585
3586   my $cust_pay = '';
3587   my $amount = $options{'amount'};
3588
3589   my( $processor, $login, $password, @bop_options ) ;
3590   my( $auth, $order_number ) = ( '', '', '' );
3591
3592   if ( $options{'paynum'} ) {
3593
3594     warn "  paynum: $options{paynum}\n" if $DEBUG > 1;
3595     $cust_pay = qsearchs('cust_pay', { paynum=>$options{'paynum'} } )
3596       or return "Unknown paynum $options{'paynum'}";
3597     $amount ||= $cust_pay->paid;
3598
3599     $cust_pay->paybatch =~ /^((\d+)\-)?(\w+):\s*([\w\-\/ ]*)(:([\w\-]+))?$/
3600       or return "Can't parse paybatch for paynum $options{'paynum'}: ".
3601                 $cust_pay->paybatch;
3602     my $gatewaynum = '';
3603     ( $gatewaynum, $processor, $auth, $order_number ) = ( $2, $3, $4, $6 );
3604
3605     if ( $gatewaynum ) { #gateway for the payment to be refunded
3606
3607       my $payment_gateway =
3608         qsearchs('payment_gateway', { 'gatewaynum' => $gatewaynum } );
3609       die "payment gateway $gatewaynum not found"
3610         unless $payment_gateway;
3611
3612       $processor   = $payment_gateway->gateway_module;
3613       $login       = $payment_gateway->gateway_username;
3614       $password    = $payment_gateway->gateway_password;
3615       @bop_options = $payment_gateway->options;
3616
3617     } else { #try the default gateway
3618
3619       my( $conf_processor, $unused_action );
3620       ( $conf_processor, $login, $password, $unused_action, @bop_options ) =
3621         $self->default_payment_gateway($method);
3622
3623       return "processor of payment $options{'paynum'} $processor does not".
3624              " match default processor $conf_processor"
3625         unless $processor eq $conf_processor;
3626
3627     }
3628
3629
3630   } else { # didn't specify a paynum, so look for agent gateway overrides
3631            # like a normal transaction 
3632
3633     my $cardtype;
3634     if ( $method eq 'CC' ) {
3635       $cardtype = cardtype($self->payinfo);
3636     } elsif ( $method eq 'ECHECK' ) {
3637       $cardtype = 'ACH';
3638     } else {
3639       $cardtype = $method;
3640     }
3641     my $override =
3642            qsearchs('agent_payment_gateway', { agentnum => $self->agentnum,
3643                                                cardtype => $cardtype,
3644                                                taxclass => '',              } )
3645         || qsearchs('agent_payment_gateway', { agentnum => $self->agentnum,
3646                                                cardtype => '',
3647                                                taxclass => '',              } );
3648
3649     if ( $override ) { #use a payment gateway override
3650  
3651       my $payment_gateway = $override->payment_gateway;
3652
3653       $processor   = $payment_gateway->gateway_module;
3654       $login       = $payment_gateway->gateway_username;
3655       $password    = $payment_gateway->gateway_password;
3656       #$action      = $payment_gateway->gateway_action;
3657       @bop_options = $payment_gateway->options;
3658
3659     } else { #use the standard settings from the config
3660
3661       my $unused_action;
3662       ( $processor, $login, $password, $unused_action, @bop_options ) =
3663         $self->default_payment_gateway($method);
3664
3665     }
3666
3667   }
3668   return "neither amount nor paynum specified" unless $amount;
3669
3670   my %content = (
3671     'type'           => $method,
3672     'login'          => $login,
3673     'password'       => $password,
3674     'order_number'   => $order_number,
3675     'amount'         => $amount,
3676     'referer'        => 'http://cleanwhisker.420.am/',
3677   );
3678   $content{authorization} = $auth
3679     if length($auth); #echeck/ACH transactions have an order # but no auth
3680                       #(at least with authorize.net)
3681
3682   my $disable_void_after;
3683   if ($conf->exists('disable_void_after')
3684       && $conf->config('disable_void_after') =~ /^(\d+)$/) {
3685     $disable_void_after = $1;
3686   }
3687
3688   #first try void if applicable
3689   if ( $cust_pay && $cust_pay->paid == $amount
3690     && (
3691       ( not defined($disable_void_after) )
3692       || ( time < ($cust_pay->_date + $disable_void_after ) )
3693     )
3694   ) {
3695     warn "  attempting void\n" if $DEBUG > 1;
3696     my $void = new Business::OnlinePayment( $processor, @bop_options );
3697     $void->content( 'action' => 'void', %content );
3698     $void->submit();
3699     if ( $void->is_success ) {
3700       my $error = $cust_pay->void($options{'reason'});
3701       if ( $error ) {
3702         # gah, even with transactions.
3703         my $e = 'WARNING: Card/ACH voided but database not updated - '.
3704                 "error voiding payment: $error";
3705         warn $e;
3706         return $e;
3707       }
3708       warn "  void successful\n" if $DEBUG > 1;
3709       return '';
3710     }
3711   }
3712
3713   warn "  void unsuccessful, trying refund\n"
3714     if $DEBUG > 1;
3715
3716   #massage data
3717   my $address = $self->address1;
3718   $address .= ", ". $self->address2 if $self->address2;
3719
3720   my($payname, $payfirst, $paylast);
3721   if ( $self->payname && $method ne 'ECHECK' ) {
3722     $payname = $self->payname;
3723     $payname =~ /^\s*([\w \,\.\-\']*)?\s+([\w\,\.\-\']+)\s*$/
3724       or return "Illegal payname $payname";
3725     ($payfirst, $paylast) = ($1, $2);
3726   } else {
3727     $payfirst = $self->getfield('first');
3728     $paylast = $self->getfield('last');
3729     $payname =  "$payfirst $paylast";
3730   }
3731
3732   my @invoicing_list = $self->invoicing_list_emailonly;
3733   if ( $conf->exists('emailinvoiceautoalways')
3734        || $conf->exists('emailinvoiceauto') && ! @invoicing_list
3735        || ( $conf->exists('emailinvoiceonly') && ! @invoicing_list ) ) {
3736     push @invoicing_list, $self->all_emails;
3737   }
3738
3739   my $email = ($conf->exists('business-onlinepayment-email-override'))
3740               ? $conf->config('business-onlinepayment-email-override')
3741               : $invoicing_list[0];
3742
3743   my $payip = exists($options{'payip'})
3744                 ? $options{'payip'}
3745                 : $self->payip;
3746   $content{customer_ip} = $payip
3747     if length($payip);
3748
3749   my $payinfo = '';
3750   if ( $method eq 'CC' ) {
3751
3752     if ( $cust_pay ) {
3753       $content{card_number} = $payinfo = $cust_pay->payinfo;
3754       (exists($options{'paydate'}) ? $options{'paydate'} : $cust_pay->paydate)
3755         =~ /^\d{2}(\d{2})[\/\-](\d+)[\/\-]\d+$/ &&
3756         ($content{expiration} = "$2/$1");  # where available
3757     } else {
3758       $content{card_number} = $payinfo = $self->payinfo;
3759       (exists($options{'paydate'}) ? $options{'paydate'} : $self->paydate)
3760         =~ /^\d{2}(\d{2})[\/\-](\d+)[\/\-]\d+$/;
3761       $content{expiration} = "$2/$1";
3762     }
3763
3764   } elsif ( $method eq 'ECHECK' ) {
3765
3766     if ( $cust_pay ) {
3767       $payinfo = $cust_pay->payinfo;
3768     } else {
3769       $payinfo = $self->payinfo;
3770     } 
3771     ( $content{account_number}, $content{routing_code} )= split('@', $payinfo );
3772     $content{bank_name} = $self->payname;
3773     $content{account_type} = 'CHECKING';
3774     $content{account_name} = $payname;
3775     $content{customer_org} = $self->company ? 'B' : 'I';
3776     $content{customer_ssn} = $self->ss;
3777   } elsif ( $method eq 'LEC' ) {
3778     $content{phone} = $payinfo = $self->payinfo;
3779   }
3780
3781   #then try refund
3782   my $refund = new Business::OnlinePayment( $processor, @bop_options );
3783   my %sub_content = $refund->content(
3784     'action'         => 'credit',
3785     'customer_id'    => $self->custnum,
3786     'last_name'      => $paylast,
3787     'first_name'     => $payfirst,
3788     'name'           => $payname,
3789     'address'        => $address,
3790     'city'           => $self->city,
3791     'state'          => $self->state,
3792     'zip'            => $self->zip,
3793     'country'        => $self->country,
3794     'email'          => $email,
3795     'phone'          => $self->daytime || $self->night,
3796     %content, #after
3797   );
3798   warn join('', map { "  $_ => $sub_content{$_}\n" } keys %sub_content )
3799     if $DEBUG > 1;
3800   $refund->submit();
3801
3802   return "$processor error: ". $refund->error_message
3803     unless $refund->is_success();
3804
3805   my %method2payby = (
3806     'CC'     => 'CARD',
3807     'ECHECK' => 'CHEK',
3808     'LEC'    => 'LECB',
3809   );
3810
3811   my $paybatch = "$processor:". $refund->authorization;
3812   $paybatch .= ':'. $refund->order_number
3813     if $refund->can('order_number') && $refund->order_number;
3814
3815   while ( $cust_pay && $cust_pay->unapplied < $amount ) {
3816     my @cust_bill_pay = $cust_pay->cust_bill_pay;
3817     last unless @cust_bill_pay;
3818     my $cust_bill_pay = pop @cust_bill_pay;
3819     my $error = $cust_bill_pay->delete;
3820     last if $error;
3821   }
3822
3823   my $cust_refund = new FS::cust_refund ( {
3824     'custnum'  => $self->custnum,
3825     'paynum'   => $options{'paynum'},
3826     'refund'   => $amount,
3827     '_date'    => '',
3828     'payby'    => $method2payby{$method},
3829     'payinfo'  => $payinfo,
3830     'paybatch' => $paybatch,
3831     'reason'   => $options{'reason'} || 'card or ACH refund',
3832   } );
3833   my $error = $cust_refund->insert;
3834   if ( $error ) {
3835     $cust_refund->paynum(''); #try again with no specific paynum
3836     my $error2 = $cust_refund->insert;
3837     if ( $error2 ) {
3838       # gah, even with transactions.
3839       my $e = 'WARNING: Card/ACH refunded but database not updated - '.
3840               "error inserting refund ($processor): $error2".
3841               " (previously tried insert with paynum #$options{'paynum'}" .
3842               ": $error )";
3843       warn $e;
3844       return $e;
3845     }
3846   }
3847
3848   ''; #no error
3849
3850 }
3851
3852 =item batch_card OPTION => VALUE...
3853
3854 Adds a payment for this invoice to the pending credit card batch (see
3855 L<FS::cust_pay_batch>), or, if the B<realtime> option is set to a true value,
3856 runs the payment using a realtime gateway.
3857
3858 =cut
3859
3860 sub batch_card {
3861   my ($self, %options) = @_;
3862
3863   my $amount;
3864   if (exists($options{amount})) {
3865     $amount = $options{amount};
3866   }else{
3867     $amount = sprintf("%.2f", $self->balance - $self->in_transit_payments);
3868   }
3869   return '' unless $amount > 0;
3870   
3871   my $invnum = delete $options{invnum};
3872   my $payby = $options{invnum} || $self->payby;  #dubious
3873
3874   if ($options{'realtime'}) {
3875     return $self->realtime_bop( FS::payby->payby2bop($self->payby),
3876                                 $amount,
3877                                 %options,
3878                               );
3879   }
3880
3881   my $oldAutoCommit = $FS::UID::AutoCommit;
3882   local $FS::UID::AutoCommit = 0;
3883   my $dbh = dbh;
3884
3885   #this needs to handle mysql as well as Pg, like svc_acct.pm
3886   #(make it into a common function if folks need to do batching with mysql)
3887   $dbh->do("LOCK TABLE pay_batch IN SHARE ROW EXCLUSIVE MODE")
3888     or return "Cannot lock pay_batch: " . $dbh->errstr;
3889
3890   my %pay_batch = (
3891     'status' => 'O',
3892     'payby'  => FS::payby->payby2payment($payby),
3893   );
3894
3895   my $pay_batch = qsearchs( 'pay_batch', \%pay_batch );
3896
3897   unless ( $pay_batch ) {
3898     $pay_batch = new FS::pay_batch \%pay_batch;
3899     my $error = $pay_batch->insert;
3900     if ( $error ) {
3901       $dbh->rollback if $oldAutoCommit;
3902       die "error creating new batch: $error\n";
3903     }
3904   }
3905
3906   my $old_cust_pay_batch = qsearchs('cust_pay_batch', {
3907       'batchnum' => $pay_batch->batchnum,
3908       'custnum'  => $self->custnum,
3909   } );
3910
3911   foreach (qw( address1 address2 city state zip country payby payinfo paydate
3912                payname )) {
3913     $options{$_} = '' unless exists($options{$_});
3914   }
3915
3916   my $cust_pay_batch = new FS::cust_pay_batch ( {
3917     'batchnum' => $pay_batch->batchnum,
3918     'invnum'   => $invnum || 0,                    # is there a better value?
3919                                                    # this field should be
3920                                                    # removed...
3921                                                    # cust_bill_pay_batch now
3922     'custnum'  => $self->custnum,
3923     'last'     => $self->getfield('last'),
3924     'first'    => $self->getfield('first'),
3925     'address1' => $options{address1} || $self->address1,
3926     'address2' => $options{address2} || $self->address2,
3927     'city'     => $options{city}     || $self->city,
3928     'state'    => $options{state}    || $self->state,
3929     'zip'      => $options{zip}      || $self->zip,
3930     'country'  => $options{country}  || $self->country,
3931     'payby'    => $options{payby}    || $self->payby,
3932     'payinfo'  => $options{payinfo}  || $self->payinfo,
3933     'exp'      => $options{paydate}  || $self->paydate,
3934     'payname'  => $options{payname}  || $self->payname,
3935     'amount'   => $amount,                         # consolidating
3936   } );
3937   
3938   $cust_pay_batch->paybatchnum($old_cust_pay_batch->paybatchnum)
3939     if $old_cust_pay_batch;
3940
3941   my $error;
3942   if ($old_cust_pay_batch) {
3943     $error = $cust_pay_batch->replace($old_cust_pay_batch)
3944   } else {
3945     $error = $cust_pay_batch->insert;
3946   }
3947
3948   if ( $error ) {
3949     $dbh->rollback if $oldAutoCommit;
3950     die $error;
3951   }
3952
3953   my $unapplied = $self->total_credited + $self->total_unapplied_payments + $self->in_transit_payments;
3954   foreach my $cust_bill ($self->open_cust_bill) {
3955     #$dbh->commit or die $dbh->errstr if $oldAutoCommit;
3956     my $cust_bill_pay_batch = new FS::cust_bill_pay_batch {
3957       'invnum' => $cust_bill->invnum,
3958       'paybatchnum' => $cust_pay_batch->paybatchnum,
3959       'amount' => $cust_bill->owed,
3960       '_date' => time,
3961     };
3962     if ($unapplied >= $cust_bill_pay_batch->amount){
3963       $unapplied -= $cust_bill_pay_batch->amount;
3964       next;
3965     }else{
3966       $cust_bill_pay_batch->amount(sprintf ( "%.2f", 
3967                                    $cust_bill_pay_batch->amount - $unapplied ));      $unapplied = 0;
3968     }
3969     $error = $cust_bill_pay_batch->insert;
3970     if ( $error ) {
3971       $dbh->rollback if $oldAutoCommit;
3972       die $error;
3973     }
3974   }
3975
3976   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
3977   '';
3978 }
3979
3980 =item total_owed
3981
3982 Returns the total owed for this customer on all invoices
3983 (see L<FS::cust_bill/owed>).
3984
3985 =cut
3986
3987 sub total_owed {
3988   my $self = shift;
3989   $self->total_owed_date(2145859200); #12/31/2037
3990 }
3991
3992 =item total_owed_date TIME
3993
3994 Returns the total owed for this customer on all invoices with date earlier than
3995 TIME.  TIME is specified as a UNIX timestamp; see L<perlfunc/"time">).  Also
3996 see L<Time::Local> and L<Date::Parse> for conversion functions.
3997
3998 =cut
3999
4000 sub total_owed_date {
4001   my $self = shift;
4002   my $time = shift;
4003   my $total_bill = 0;
4004   foreach my $cust_bill (
4005     grep { $_->_date <= $time }
4006       qsearch('cust_bill', { 'custnum' => $self->custnum, } )
4007   ) {
4008     $total_bill += $cust_bill->owed;
4009   }
4010   sprintf( "%.2f", $total_bill );
4011 }
4012
4013 =item apply_payments_and_credits
4014
4015 Applies unapplied payments and credits.
4016
4017 In most cases, this new method should be used in place of sequential
4018 apply_payments and apply_credits methods.
4019
4020 If there is an error, returns the error, otherwise returns false.
4021
4022 =cut
4023
4024 sub apply_payments_and_credits {
4025   my $self = shift;
4026
4027   local $SIG{HUP} = 'IGNORE';
4028   local $SIG{INT} = 'IGNORE';
4029   local $SIG{QUIT} = 'IGNORE';
4030   local $SIG{TERM} = 'IGNORE';
4031   local $SIG{TSTP} = 'IGNORE';
4032   local $SIG{PIPE} = 'IGNORE';
4033
4034   my $oldAutoCommit = $FS::UID::AutoCommit;
4035   local $FS::UID::AutoCommit = 0;
4036   my $dbh = dbh;
4037
4038   $self->select_for_update; #mutex
4039
4040   foreach my $cust_bill ( $self->open_cust_bill ) {
4041     my $error = $cust_bill->apply_payments_and_credits;
4042     if ( $error ) {
4043       $dbh->rollback if $oldAutoCommit;
4044       return "Error applying: $error";
4045     }
4046   }
4047
4048   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
4049   ''; #no error
4050
4051 }
4052
4053 =item apply_credits OPTION => VALUE ...
4054
4055 Applies (see L<FS::cust_credit_bill>) unapplied credits (see L<FS::cust_credit>)
4056 to outstanding invoice balances in chronological order (or reverse
4057 chronological order if the I<order> option is set to B<newest>) and returns the
4058 value of any remaining unapplied credits available for refund (see
4059 L<FS::cust_refund>).
4060
4061 Dies if there is an error.
4062
4063 =cut
4064
4065 sub apply_credits {
4066   my $self = shift;
4067   my %opt = @_;
4068
4069   local $SIG{HUP} = 'IGNORE';
4070   local $SIG{INT} = 'IGNORE';
4071   local $SIG{QUIT} = 'IGNORE';
4072   local $SIG{TERM} = 'IGNORE';
4073   local $SIG{TSTP} = 'IGNORE';
4074   local $SIG{PIPE} = 'IGNORE';
4075
4076   my $oldAutoCommit = $FS::UID::AutoCommit;
4077   local $FS::UID::AutoCommit = 0;
4078   my $dbh = dbh;
4079
4080   $self->select_for_update; #mutex
4081
4082   unless ( $self->total_credited ) {
4083     $dbh->commit or die $dbh->errstr if $oldAutoCommit;
4084     return 0;
4085   }
4086
4087   my @credits = sort { $b->_date <=> $a->_date} (grep { $_->credited > 0 }
4088       qsearch('cust_credit', { 'custnum' => $self->custnum } ) );
4089
4090   my @invoices = $self->open_cust_bill;
4091   @invoices = sort { $b->_date <=> $a->_date } @invoices
4092     if defined($opt{'order'}) && $opt{'order'} eq 'newest';
4093
4094   my $credit;
4095   foreach my $cust_bill ( @invoices ) {
4096     my $amount;
4097
4098     if ( !defined($credit) || $credit->credited == 0) {
4099       $credit = pop @credits or last;
4100     }
4101
4102     if ($cust_bill->owed >= $credit->credited) {
4103       $amount=$credit->credited;
4104     }else{
4105       $amount=$cust_bill->owed;
4106     }
4107     
4108     my $cust_credit_bill = new FS::cust_credit_bill ( {
4109       'crednum' => $credit->crednum,
4110       'invnum'  => $cust_bill->invnum,
4111       'amount'  => $amount,
4112     } );
4113     my $error = $cust_credit_bill->insert;
4114     if ( $error ) {
4115       $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
4116       die $error;
4117     }
4118     
4119     redo if ($cust_bill->owed > 0);
4120
4121   }
4122
4123   my $total_credited = $self->total_credited;
4124
4125   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
4126
4127   return $total_credited;
4128 }
4129
4130 =item apply_payments
4131
4132 Applies (see L<FS::cust_bill_pay>) unapplied payments (see L<FS::cust_pay>)
4133 to outstanding invoice balances in chronological order.
4134
4135  #and returns the value of any remaining unapplied payments.
4136
4137 Dies if there is an error.
4138
4139 =cut
4140
4141 sub apply_payments {
4142   my $self = shift;
4143
4144   local $SIG{HUP} = 'IGNORE';
4145   local $SIG{INT} = 'IGNORE';
4146   local $SIG{QUIT} = 'IGNORE';
4147   local $SIG{TERM} = 'IGNORE';
4148   local $SIG{TSTP} = 'IGNORE';
4149   local $SIG{PIPE} = 'IGNORE';
4150
4151   my $oldAutoCommit = $FS::UID::AutoCommit;
4152   local $FS::UID::AutoCommit = 0;
4153   my $dbh = dbh;
4154
4155   $self->select_for_update; #mutex
4156
4157   #return 0 unless
4158
4159   my @payments = sort { $b->_date <=> $a->_date } ( grep { $_->unapplied > 0 }
4160       qsearch('cust_pay', { 'custnum' => $self->custnum } ) );
4161
4162   my @invoices = sort { $a->_date <=> $b->_date} (grep { $_->owed > 0 }
4163       qsearch('cust_bill', { 'custnum' => $self->custnum } ) );
4164
4165   my $payment;
4166
4167   foreach my $cust_bill ( @invoices ) {
4168     my $amount;
4169
4170     if ( !defined($payment) || $payment->unapplied == 0 ) {
4171       $payment = pop @payments or last;
4172     }
4173
4174     if ( $cust_bill->owed >= $payment->unapplied ) {
4175       $amount = $payment->unapplied;
4176     } else {
4177       $amount = $cust_bill->owed;
4178     }
4179
4180     my $cust_bill_pay = new FS::cust_bill_pay ( {
4181       'paynum' => $payment->paynum,
4182       'invnum' => $cust_bill->invnum,
4183       'amount' => $amount,
4184     } );
4185     my $error = $cust_bill_pay->insert;
4186     if ( $error ) {
4187       $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
4188       die $error;
4189     }
4190
4191     redo if ( $cust_bill->owed > 0);
4192
4193   }
4194
4195   my $total_unapplied_payments = $self->total_unapplied_payments;
4196
4197   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
4198
4199   return $total_unapplied_payments;
4200 }
4201
4202 =item total_credited
4203
4204 Returns the total outstanding credit (see L<FS::cust_credit>) for this
4205 customer.  See L<FS::cust_credit/credited>.
4206
4207 =cut
4208
4209 sub total_credited {
4210   my $self = shift;
4211   my $total_credit = 0;
4212   foreach my $cust_credit ( qsearch('cust_credit', {
4213     'custnum' => $self->custnum,
4214   } ) ) {
4215     $total_credit += $cust_credit->credited;
4216   }
4217   sprintf( "%.2f", $total_credit );
4218 }
4219
4220 =item total_unapplied_payments
4221
4222 Returns the total unapplied payments (see L<FS::cust_pay>) for this customer.
4223 See L<FS::cust_pay/unapplied>.
4224
4225 =cut
4226
4227 sub total_unapplied_payments {
4228   my $self = shift;
4229   my $total_unapplied = 0;
4230   foreach my $cust_pay ( qsearch('cust_pay', {
4231     'custnum' => $self->custnum,
4232   } ) ) {
4233     $total_unapplied += $cust_pay->unapplied;
4234   }
4235   sprintf( "%.2f", $total_unapplied );
4236 }
4237
4238 =item total_unapplied_refunds
4239
4240 Returns the total unrefunded refunds (see L<FS::cust_refund>) for this
4241 customer.  See L<FS::cust_refund/unapplied>.
4242
4243 =cut
4244
4245 sub total_unapplied_refunds {
4246   my $self = shift;
4247   my $total_unapplied = 0;
4248   foreach my $cust_refund ( qsearch('cust_refund', {
4249     'custnum' => $self->custnum,
4250   } ) ) {
4251     $total_unapplied += $cust_refund->unapplied;
4252   }
4253   sprintf( "%.2f", $total_unapplied );
4254 }
4255
4256 =item balance
4257
4258 Returns the balance for this customer (total_owed plus total_unrefunded, minus
4259 total_credited minus total_unapplied_payments).
4260
4261 =cut
4262
4263 sub balance {
4264   my $self = shift;
4265   sprintf( "%.2f",
4266       $self->total_owed
4267     + $self->total_unapplied_refunds
4268     - $self->total_credited
4269     - $self->total_unapplied_payments
4270   );
4271 }
4272
4273 =item balance_date TIME
4274
4275 Returns the balance for this customer, only considering invoices with date
4276 earlier than TIME (total_owed_date minus total_credited minus
4277 total_unapplied_payments).  TIME is specified as a UNIX timestamp; see
4278 L<perlfunc/"time">).  Also see L<Time::Local> and L<Date::Parse> for conversion
4279 functions.
4280
4281 =cut
4282
4283 sub balance_date {
4284   my $self = shift;
4285   my $time = shift;
4286   sprintf( "%.2f",
4287         $self->total_owed_date($time)
4288       + $self->total_unapplied_refunds
4289       - $self->total_credited
4290       - $self->total_unapplied_payments
4291   );
4292 }
4293
4294 =item in_transit_payments
4295
4296 Returns the total of requests for payments for this customer pending in 
4297 batches in transit to the bank.  See L<FS::pay_batch> and L<FS::cust_pay_batch>
4298
4299 =cut
4300
4301 sub in_transit_payments {
4302   my $self = shift;
4303   my $in_transit_payments = 0;
4304   foreach my $pay_batch ( qsearch('pay_batch', {
4305     'status' => 'I',
4306   } ) ) {
4307     foreach my $cust_pay_batch ( qsearch('cust_pay_batch', {
4308       'batchnum' => $pay_batch->batchnum,
4309       'custnum' => $self->custnum,
4310     } ) ) {
4311       $in_transit_payments += $cust_pay_batch->amount;
4312     }
4313   }
4314   sprintf( "%.2f", $in_transit_payments );
4315 }
4316
4317 =item paydate_monthyear
4318
4319 Returns a two-element list consisting of the month and year of this customer's
4320 paydate (credit card expiration date for CARD customers)
4321
4322 =cut
4323
4324 sub paydate_monthyear {
4325   my $self = shift;
4326   if ( $self->paydate  =~ /^(\d{4})-(\d{1,2})-\d{1,2}$/ ) { #Pg date format
4327     ( $2, $1 );
4328   } elsif ( $self->paydate =~ /^(\d{1,2})-(\d{1,2}-)?(\d{4}$)/ ) {
4329     ( $1, $3 );
4330   } else {
4331     ('', '');
4332   }
4333 }
4334
4335 =item invoicing_list [ ARRAYREF ]
4336
4337 If an arguement is given, sets these email addresses as invoice recipients
4338 (see L<FS::cust_main_invoice>).  Errors are not fatal and are not reported
4339 (except as warnings), so use check_invoicing_list first.
4340
4341 Returns a list of email addresses (with svcnum entries expanded).
4342
4343 Note: You can clear the invoicing list by passing an empty ARRAYREF.  You can
4344 check it without disturbing anything by passing nothing.
4345
4346 This interface may change in the future.
4347
4348 =cut
4349
4350 sub invoicing_list {
4351   my( $self, $arrayref ) = @_;
4352
4353   if ( $arrayref ) {
4354     my @cust_main_invoice;
4355     if ( $self->custnum ) {
4356       @cust_main_invoice = 
4357         qsearch( 'cust_main_invoice', { 'custnum' => $self->custnum } );
4358     } else {
4359       @cust_main_invoice = ();
4360     }
4361     foreach my $cust_main_invoice ( @cust_main_invoice ) {
4362       #warn $cust_main_invoice->destnum;
4363       unless ( grep { $cust_main_invoice->address eq $_ } @{$arrayref} ) {
4364         #warn $cust_main_invoice->destnum;
4365         my $error = $cust_main_invoice->delete;
4366         warn $error if $error;
4367       }
4368     }
4369     if ( $self->custnum ) {
4370       @cust_main_invoice = 
4371         qsearch( 'cust_main_invoice', { 'custnum' => $self->custnum } );
4372     } else {
4373       @cust_main_invoice = ();
4374     }
4375     my %seen = map { $_->address => 1 } @cust_main_invoice;
4376     foreach my $address ( @{$arrayref} ) {
4377       next if exists $seen{$address} && $seen{$address};
4378       $seen{$address} = 1;
4379       my $cust_main_invoice = new FS::cust_main_invoice ( {
4380         'custnum' => $self->custnum,
4381         'dest'    => $address,
4382       } );
4383       my $error = $cust_main_invoice->insert;
4384       warn $error if $error;
4385     }
4386   }
4387   
4388   if ( $self->custnum ) {
4389     map { $_->address }
4390       qsearch( 'cust_main_invoice', { 'custnum' => $self->custnum } );
4391   } else {
4392     ();
4393   }
4394
4395 }
4396
4397 =item check_invoicing_list ARRAYREF
4398
4399 Checks these arguements as valid input for the invoicing_list method.  If there
4400 is an error, returns the error, otherwise returns false.
4401
4402 =cut
4403
4404 sub check_invoicing_list {
4405   my( $self, $arrayref ) = @_;
4406
4407   foreach my $address ( @$arrayref ) {
4408
4409     if ($address eq 'FAX' and $self->getfield('fax') eq '') {
4410       return 'Can\'t add FAX invoice destination with a blank FAX number.';
4411     }
4412
4413     my $cust_main_invoice = new FS::cust_main_invoice ( {
4414       'custnum' => $self->custnum,
4415       'dest'    => $address,
4416     } );
4417     my $error = $self->custnum
4418                 ? $cust_main_invoice->check
4419                 : $cust_main_invoice->checkdest
4420     ;
4421     return $error if $error;
4422
4423   }
4424
4425   return "Email address required"
4426     if $conf->exists('cust_main-require_invoicing_list_email')
4427     && ! grep { $_ !~ /^([A-Z]+)$/ } @$arrayref;
4428
4429   '';
4430 }
4431
4432 =item set_default_invoicing_list
4433
4434 Sets the invoicing list to all accounts associated with this customer,
4435 overwriting any previous invoicing list.
4436
4437 =cut
4438
4439 sub set_default_invoicing_list {
4440   my $self = shift;
4441   $self->invoicing_list($self->all_emails);
4442 }
4443
4444 =item all_emails
4445
4446 Returns the email addresses of all accounts provisioned for this customer.
4447
4448 =cut
4449
4450 sub all_emails {
4451   my $self = shift;
4452   my %list;
4453   foreach my $cust_pkg ( $self->all_pkgs ) {
4454     my @cust_svc = qsearch('cust_svc', { 'pkgnum' => $cust_pkg->pkgnum } );
4455     my @svc_acct =
4456       map { qsearchs('svc_acct', { 'svcnum' => $_->svcnum } ) }
4457         grep { qsearchs('svc_acct', { 'svcnum' => $_->svcnum } ) }
4458           @cust_svc;
4459     $list{$_}=1 foreach map { $_->email } @svc_acct;
4460   }
4461   keys %list;
4462 }
4463
4464 =item invoicing_list_addpost
4465
4466 Adds postal invoicing to this customer.  If this customer is already configured
4467 to receive postal invoices, does nothing.
4468
4469 =cut
4470
4471 sub invoicing_list_addpost {
4472   my $self = shift;
4473   return if grep { $_ eq 'POST' } $self->invoicing_list;
4474   my @invoicing_list = $self->invoicing_list;
4475   push @invoicing_list, 'POST';
4476   $self->invoicing_list(\@invoicing_list);
4477 }
4478
4479 =item invoicing_list_emailonly
4480
4481 Returns the list of email invoice recipients (invoicing_list without non-email
4482 destinations such as POST and FAX).
4483
4484 =cut
4485
4486 sub invoicing_list_emailonly {
4487   my $self = shift;
4488   warn "$me invoicing_list_emailonly called"
4489     if $DEBUG;
4490   grep { $_ !~ /^([A-Z]+)$/ } $self->invoicing_list;
4491 }
4492
4493 =item invoicing_list_emailonly_scalar
4494
4495 Returns the list of email invoice recipients (invoicing_list without non-email
4496 destinations such as POST and FAX) as a comma-separated scalar.
4497
4498 =cut
4499
4500 sub invoicing_list_emailonly_scalar {
4501   my $self = shift;
4502   warn "$me invoicing_list_emailonly_scalar called"
4503     if $DEBUG;
4504   join(', ', $self->invoicing_list_emailonly);
4505 }
4506
4507 =item referral_cust_main [ DEPTH [ EXCLUDE_HASHREF ] ]
4508
4509 Returns an array of customers referred by this customer (referral_custnum set
4510 to this custnum).  If DEPTH is given, recurses up to the given depth, returning
4511 customers referred by customers referred by this customer and so on, inclusive.
4512 The default behavior is DEPTH 1 (no recursion).
4513
4514 =cut
4515
4516 sub referral_cust_main {
4517   my $self = shift;
4518   my $depth = @_ ? shift : 1;
4519   my $exclude = @_ ? shift : {};
4520
4521   my @cust_main =
4522     map { $exclude->{$_->custnum}++; $_; }
4523       grep { ! $exclude->{ $_->custnum } }
4524         qsearch( 'cust_main', { 'referral_custnum' => $self->custnum } );
4525
4526   if ( $depth > 1 ) {
4527     push @cust_main,
4528       map { $_->referral_cust_main($depth-1, $exclude) }
4529         @cust_main;
4530   }
4531
4532   @cust_main;
4533 }
4534
4535 =item referral_cust_main_ncancelled
4536
4537 Same as referral_cust_main, except only returns customers with uncancelled
4538 packages.
4539
4540 =cut
4541
4542 sub referral_cust_main_ncancelled {
4543   my $self = shift;
4544   grep { scalar($_->ncancelled_pkgs) } $self->referral_cust_main;
4545 }
4546
4547 =item referral_cust_pkg [ DEPTH ]
4548
4549 Like referral_cust_main, except returns a flat list of all unsuspended (and
4550 uncancelled) packages for each customer.  The number of items in this list may
4551 be useful for comission calculations (perhaps after a C<grep { my $pkgpart = $_->pkgpart; grep { $_ == $pkgpart } @commission_worthy_pkgparts> } $cust_main-> ).
4552
4553 =cut
4554
4555 sub referral_cust_pkg {
4556   my $self = shift;
4557   my $depth = @_ ? shift : 1;
4558
4559   map { $_->unsuspended_pkgs }
4560     grep { $_->unsuspended_pkgs }
4561       $self->referral_cust_main($depth);
4562 }
4563
4564 =item referring_cust_main
4565
4566 Returns the single cust_main record for the customer who referred this customer
4567 (referral_custnum), or false.
4568
4569 =cut
4570
4571 sub referring_cust_main {
4572   my $self = shift;
4573   return '' unless $self->referral_custnum;
4574   qsearchs('cust_main', { 'custnum' => $self->referral_custnum } );
4575 }
4576
4577 =item credit AMOUNT, REASON
4578
4579 Applies a credit to this customer.  If there is an error, returns the error,
4580 otherwise returns false.
4581
4582 =cut
4583
4584 sub credit {
4585   my( $self, $amount, $reason, %options ) = @_;
4586   my $cust_credit = new FS::cust_credit {
4587     'custnum' => $self->custnum,
4588     'amount'  => $amount,
4589     'reason'  => $reason,
4590   };
4591   $cust_credit->insert(%options);
4592 }
4593
4594 =item charge AMOUNT [ PKG [ COMMENT [ TAXCLASS ] ] ]
4595
4596 Creates a one-time charge for this customer.  If there is an error, returns
4597 the error, otherwise returns false.
4598
4599 =cut
4600
4601 sub charge {
4602   my $self = shift;
4603   my ( $amount, $pkg, $comment, $taxclass, $additional, $classnum );
4604   if ( ref( $_[0] ) ) {
4605     $amount     = $_[0]->{amount};
4606     $pkg        = exists($_[0]->{pkg}) ? $_[0]->{pkg} : 'One-time charge';
4607     $comment    = exists($_[0]->{comment}) ? $_[0]->{comment}
4608                                            : '$'. sprintf("%.2f",$amount);
4609     $taxclass   = exists($_[0]->{taxclass}) ? $_[0]->{taxclass} : '';
4610     $classnum   = exists($_[0]->{classnum}) ? $_[0]->{classnum} : '';
4611     $additional = $_[0]->{additional};
4612   }else{
4613     $amount     = shift;
4614     $pkg        = @_ ? shift : 'One-time charge';
4615     $comment    = @_ ? shift : '$'. sprintf("%.2f",$amount);
4616     $taxclass   = @_ ? shift : '';
4617     $additional = [];
4618   }
4619
4620   local $SIG{HUP} = 'IGNORE';
4621   local $SIG{INT} = 'IGNORE';
4622   local $SIG{QUIT} = 'IGNORE';
4623   local $SIG{TERM} = 'IGNORE';
4624   local $SIG{TSTP} = 'IGNORE';
4625   local $SIG{PIPE} = 'IGNORE';
4626
4627   my $oldAutoCommit = $FS::UID::AutoCommit;
4628   local $FS::UID::AutoCommit = 0;
4629   my $dbh = dbh;
4630
4631   my $part_pkg = new FS::part_pkg ( {
4632     'pkg'      => $pkg,
4633     'comment'  => $comment,
4634     'plan'     => 'flat',
4635     'freq'     => 0,
4636     'disabled' => 'Y',
4637     'classnum' => $classnum ? $classnum : '',
4638     'taxclass' => $taxclass,
4639   } );
4640
4641   my %options = ( ( map { ("additional_info$_" => $additional->[$_] ) }
4642                         ( 0 .. @$additional - 1 )
4643                   ),
4644                   'additional_count' => scalar(@$additional),
4645                   'setup_fee' => $amount,
4646                 );
4647
4648   my $error = $part_pkg->insert( options => \%options );
4649   if ( $error ) {
4650     $dbh->rollback if $oldAutoCommit;
4651     return $error;
4652   }
4653
4654   my $pkgpart = $part_pkg->pkgpart;
4655   my %type_pkgs = ( 'typenum' => $self->agent->typenum, 'pkgpart' => $pkgpart );
4656   unless ( qsearchs('type_pkgs', \%type_pkgs ) ) {
4657     my $type_pkgs = new FS::type_pkgs \%type_pkgs;
4658     $error = $type_pkgs->insert;
4659     if ( $error ) {
4660       $dbh->rollback if $oldAutoCommit;
4661       return $error;
4662     }
4663   }
4664
4665   my $cust_pkg = new FS::cust_pkg ( {
4666     'custnum' => $self->custnum,
4667     'pkgpart' => $pkgpart,
4668   } );
4669
4670   $error = $cust_pkg->insert;
4671   if ( $error ) {
4672     $dbh->rollback if $oldAutoCommit;
4673     return $error;
4674   }
4675
4676   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
4677   '';
4678
4679 }
4680
4681 =item cust_bill
4682
4683 Returns all the invoices (see L<FS::cust_bill>) for this customer.
4684
4685 =cut
4686
4687 sub cust_bill {
4688   my $self = shift;
4689   sort { $a->_date <=> $b->_date }
4690     qsearch('cust_bill', { 'custnum' => $self->custnum, } )
4691 }
4692
4693 =item open_cust_bill
4694
4695 Returns all the open (owed > 0) invoices (see L<FS::cust_bill>) for this
4696 customer.
4697
4698 =cut
4699
4700 sub open_cust_bill {
4701   my $self = shift;
4702   grep { $_->owed > 0 } $self->cust_bill;
4703 }
4704
4705 =item cust_credit
4706
4707 Returns all the credits (see L<FS::cust_credit>) for this customer.
4708
4709 =cut
4710
4711 sub cust_credit {
4712   my $self = shift;
4713   sort { $a->_date <=> $b->_date }
4714     qsearch( 'cust_credit', { 'custnum' => $self->custnum } )
4715 }
4716
4717 =item cust_pay
4718
4719 Returns all the payments (see L<FS::cust_pay>) for this customer.
4720
4721 =cut
4722
4723 sub cust_pay {
4724   my $self = shift;
4725   sort { $a->_date <=> $b->_date }
4726     qsearch( 'cust_pay', { 'custnum' => $self->custnum } )
4727 }
4728
4729 =item cust_pay_void
4730
4731 Returns all voided payments (see L<FS::cust_pay_void>) for this customer.
4732
4733 =cut
4734
4735 sub cust_pay_void {
4736   my $self = shift;
4737   sort { $a->_date <=> $b->_date }
4738     qsearch( 'cust_pay_void', { 'custnum' => $self->custnum } )
4739 }
4740
4741 =item cust_pay_batch
4742
4743 Returns all batched payments (see L<FS::cust_pay_void>) for this customer.
4744
4745 =cut
4746
4747 sub cust_pay_batch {
4748   my $self = shift;
4749   sort { $a->_date <=> $b->_date }
4750     qsearch( 'cust_pay_batch', { 'custnum' => $self->custnum } )
4751 }
4752
4753 =item cust_refund
4754
4755 Returns all the refunds (see L<FS::cust_refund>) for this customer.
4756
4757 =cut
4758
4759 sub cust_refund {
4760   my $self = shift;
4761   sort { $a->_date <=> $b->_date }
4762     qsearch( 'cust_refund', { 'custnum' => $self->custnum } )
4763 }
4764
4765 =item name
4766
4767 Returns a name string for this customer, either "Company (Last, First)" or
4768 "Last, First".
4769
4770 =cut
4771
4772 sub name {
4773   my $self = shift;
4774   my $name = $self->contact;
4775   $name = $self->company. " ($name)" if $self->company;
4776   $name;
4777 }
4778
4779 =item ship_name
4780
4781 Returns a name string for this (service/shipping) contact, either
4782 "Company (Last, First)" or "Last, First".
4783
4784 =cut
4785
4786 sub ship_name {
4787   my $self = shift;
4788   if ( $self->get('ship_last') ) { 
4789     my $name = $self->ship_contact;
4790     $name = $self->ship_company. " ($name)" if $self->ship_company;
4791     $name;
4792   } else {
4793     $self->name;
4794   }
4795 }
4796
4797 =item contact
4798
4799 Returns this customer's full (billing) contact name only, "Last, First"
4800
4801 =cut
4802
4803 sub contact {
4804   my $self = shift;
4805   $self->get('last'). ', '. $self->first;
4806 }
4807
4808 =item ship_contact
4809
4810 Returns this customer's full (shipping) contact name only, "Last, First"
4811
4812 =cut
4813
4814 sub ship_contact {
4815   my $self = shift;
4816   $self->get('ship_last')
4817     ? $self->get('ship_last'). ', '. $self->ship_first
4818     : $self->contact;
4819 }
4820
4821 =item country_full
4822
4823 Returns this customer's full country name
4824
4825 =cut
4826
4827 sub country_full {
4828   my $self = shift;
4829   code2country($self->country);
4830 }
4831
4832 =item geocode DATA_VENDOR
4833
4834 Returns a value for the customer location as encoded by DATA_VENDOR.
4835 Currently this only makes sense for "CCH" as DATA_VENDOR.
4836
4837 =cut
4838
4839 sub geocode {
4840   my ($self, $data_vendor) = (shift, shift);  #always cch for now
4841
4842   my $prefix = ( $conf->exists('tax-ship_address') && length($self->ship_last) )
4843                ? 'ship_'
4844                : '';
4845
4846   my ($zip,$plus4) = split /-/, $self->get("${prefix}zip")
4847     if $self->country eq 'US';
4848
4849   #CCH specific location stuff
4850   my $extra_sql = "AND plus4lo <= '$plus4' AND plus4hi >= '$plus4'";
4851
4852   my $geocode = '';
4853   my $cust_tax_location =
4854     qsearchs( {
4855                 'table'     => 'cust_tax_location', 
4856                 'hashref'   => { 'zip' => $zip, 'data_vendor' => $data_vendor },
4857                 'extra_sql' => $extra_sql,
4858               }
4859             );
4860   $geocode = $cust_tax_location->geocode
4861     if $cust_tax_location;
4862
4863   $geocode;
4864 }
4865
4866 =item cust_status
4867
4868 =item status
4869
4870 Returns a status string for this customer, currently:
4871
4872 =over 4
4873
4874 =item prospect - No packages have ever been ordered
4875
4876 =item active - One or more recurring packages is active
4877
4878 =item inactive - No active recurring packages, but otherwise unsuspended/uncancelled (the inactive status is new - previously inactive customers were mis-identified as cancelled)
4879
4880 =item suspended - All non-cancelled recurring packages are suspended
4881
4882 =item cancelled - All recurring packages are cancelled
4883
4884 =back
4885
4886 =cut
4887
4888 sub status { shift->cust_status(@_); }
4889
4890 sub cust_status {
4891   my $self = shift;
4892   for my $status (qw( prospect active inactive suspended cancelled )) {
4893     my $method = $status.'_sql';
4894     my $numnum = ( my $sql = $self->$method() ) =~ s/cust_main\.custnum/?/g;
4895     my $sth = dbh->prepare("SELECT $sql") or die dbh->errstr;
4896     $sth->execute( ($self->custnum) x $numnum )
4897       or die "Error executing 'SELECT $sql': ". $sth->errstr;
4898     return $status if $sth->fetchrow_arrayref->[0];
4899   }
4900 }
4901
4902 =item ucfirst_cust_status
4903
4904 =item ucfirst_status
4905
4906 Returns the status with the first character capitalized.
4907
4908 =cut
4909
4910 sub ucfirst_status { shift->ucfirst_cust_status(@_); }
4911
4912 sub ucfirst_cust_status {
4913   my $self = shift;
4914   ucfirst($self->cust_status);
4915 }
4916
4917 =item statuscolor
4918
4919 Returns a hex triplet color string for this customer's status.
4920
4921 =cut
4922
4923 use vars qw(%statuscolor);
4924 tie my %statuscolor, 'Tie::IxHash',
4925   'prospect'  => '7e0079', #'000000', #black?  naw, purple
4926   'active'    => '00CC00', #green
4927   'inactive'  => '0000CC', #blue
4928   'suspended' => 'FF9900', #yellow
4929   'cancelled' => 'FF0000', #red
4930 ;
4931
4932 sub statuscolor { shift->cust_statuscolor(@_); }
4933
4934 sub cust_statuscolor {
4935   my $self = shift;
4936   $statuscolor{$self->cust_status};
4937 }
4938
4939 =item tickets
4940
4941 Returns an array of hashes representing the customer's RT tickets.
4942
4943 =cut
4944
4945 sub tickets {
4946   my $self = shift;
4947
4948   my $num = $conf->config('cust_main-max_tickets') || 10;
4949   my @tickets = ();
4950
4951   unless ( $conf->config('ticket_system-custom_priority_field') ) {
4952
4953     @tickets = @{ FS::TicketSystem->customer_tickets($self->custnum, $num) };
4954
4955   } else {
4956
4957     foreach my $priority (
4958       $conf->config('ticket_system-custom_priority_field-values'), ''
4959     ) {
4960       last if scalar(@tickets) >= $num;
4961       push @tickets, 
4962         @{ FS::TicketSystem->customer_tickets( $self->custnum,
4963                                                $num - scalar(@tickets),
4964                                                $priority,
4965                                              )
4966          };
4967     }
4968   }
4969   (@tickets);
4970 }
4971
4972 # Return services representing svc_accts in customer support packages
4973 sub support_services {
4974   my $self = shift;
4975   my %packages = map { $_ => 1 } $conf->config('support_packages');
4976
4977   grep { $_->pkg_svc && $_->pkg_svc->primary_svc eq 'Y' }
4978     grep { $_->part_svc->svcdb eq 'svc_acct' }
4979     map { $_->cust_svc }
4980     grep { exists $packages{ $_->pkgpart } }
4981     $self->ncancelled_pkgs;
4982
4983 }
4984
4985 =back
4986
4987 =head1 CLASS METHODS
4988
4989 =over 4
4990
4991 =item statuses
4992
4993 Class method that returns the list of possible status strings for customers
4994 (see L<the status method|/status>).  For example:
4995
4996   @statuses = FS::cust_main->statuses();
4997
4998 =cut
4999
5000 sub statuses {
5001   #my $self = shift; #could be class...
5002   keys %statuscolor;
5003 }
5004
5005 =item prospect_sql
5006
5007 Returns an SQL expression identifying prospective cust_main records (customers
5008 with no packages ever ordered)
5009
5010 =cut
5011
5012 use vars qw($select_count_pkgs);
5013 $select_count_pkgs =
5014   "SELECT COUNT(*) FROM cust_pkg
5015     WHERE cust_pkg.custnum = cust_main.custnum";
5016
5017 sub select_count_pkgs_sql {
5018   $select_count_pkgs;
5019 }
5020
5021 sub prospect_sql { "
5022   0 = ( $select_count_pkgs )
5023 "; }
5024
5025 =item active_sql
5026
5027 Returns an SQL expression identifying active cust_main records (customers with
5028 no active recurring packages, but otherwise unsuspended/uncancelled).
5029
5030 =cut
5031
5032 sub active_sql { "
5033   0 < ( $select_count_pkgs AND ". FS::cust_pkg->active_sql. "
5034       )
5035 "; }
5036
5037 =item inactive_sql
5038
5039 Returns an SQL expression identifying inactive cust_main records (customers with
5040 active recurring packages).
5041
5042 =cut
5043
5044 sub inactive_sql { "
5045   0 = ( $select_count_pkgs AND ". FS::cust_pkg->active_sql. " )
5046   AND
5047   0 < ( $select_count_pkgs AND ". FS::cust_pkg->inactive_sql. " )
5048 "; }
5049
5050 =item susp_sql
5051 =item suspended_sql
5052
5053 Returns an SQL expression identifying suspended cust_main records.
5054
5055 =cut
5056
5057
5058 sub suspended_sql { susp_sql(@_); }
5059 sub susp_sql { "
5060     0 < ( $select_count_pkgs AND ". FS::cust_pkg->suspended_sql. " )
5061     AND
5062     0 = ( $select_count_pkgs AND ". FS::cust_pkg->active_sql. " )
5063 "; }
5064
5065 =item cancel_sql
5066 =item cancelled_sql
5067
5068 Returns an SQL expression identifying cancelled cust_main records.
5069
5070 =cut
5071
5072 sub cancelled_sql { cancel_sql(@_); }
5073 sub cancel_sql {
5074
5075   my $recurring_sql = FS::cust_pkg->recurring_sql;
5076   #my $recurring_sql = "
5077   #  '0' != ( select freq from part_pkg
5078   #             where cust_pkg.pkgpart = part_pkg.pkgpart )
5079   #";
5080
5081   "
5082     0 < ( $select_count_pkgs )
5083     AND 0 = ( $select_count_pkgs AND $recurring_sql
5084                   AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
5085             )
5086   ";
5087 }
5088
5089 =item uncancel_sql
5090 =item uncancelled_sql
5091
5092 Returns an SQL expression identifying un-cancelled cust_main records.
5093
5094 =cut
5095
5096 sub uncancelled_sql { uncancel_sql(@_); }
5097 sub uncancel_sql { "
5098   ( 0 < ( $select_count_pkgs
5099                    AND ( cust_pkg.cancel IS NULL
5100                          OR cust_pkg.cancel = 0
5101                        )
5102         )
5103     OR 0 = ( $select_count_pkgs )
5104   )
5105 "; }
5106
5107 =item balance_sql
5108
5109 Returns an SQL fragment to retreive the balance.
5110
5111 =cut
5112
5113 sub balance_sql { "
5114     ( SELECT COALESCE( SUM(charged), 0 ) FROM cust_bill
5115         WHERE cust_bill.custnum   = cust_main.custnum     )
5116   - ( SELECT COALESCE( SUM(paid),    0 ) FROM cust_pay
5117         WHERE cust_pay.custnum    = cust_main.custnum     )
5118   - ( SELECT COALESCE( SUM(amount),  0 ) FROM cust_credit
5119         WHERE cust_credit.custnum = cust_main.custnum     )
5120   + ( SELECT COALESCE( SUM(refund),  0 ) FROM cust_refund
5121         WHERE cust_refund.custnum = cust_main.custnum     )
5122 "; }
5123
5124 =item balance_date_sql START_TIME [ END_TIME [ OPTION => VALUE ... ] ]
5125
5126 Returns an SQL fragment to retreive the balance for this customer, only
5127 considering invoices with date earlier than START_TIME, and optionally not
5128 later than END_TIME (total_owed_date minus total_credited minus
5129 total_unapplied_payments).
5130
5131 Times are specified as SQL fragments or numeric
5132 UNIX timestamps; see L<perlfunc/"time">).  Also see L<Time::Local> and
5133 L<Date::Parse> for conversion functions.  The empty string can be passed
5134 to disable that time constraint completely.
5135
5136 Available options are:
5137
5138 =over 4
5139
5140 =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)
5141
5142 =item total - set to true to remove all customer comparison clauses, for totals
5143
5144 =item where - WHERE clause hashref (elements "AND"ed together) (typically used with the total option)
5145
5146 =item join - JOIN clause (typically used with the total option)
5147
5148 =item 
5149
5150 =back
5151
5152 =cut
5153
5154 sub balance_date_sql {
5155   my( $class, $start, $end, %opt ) = @_;
5156
5157   my $owed         = FS::cust_bill->owed_sql;
5158   my $unapp_refund = FS::cust_refund->unapplied_sql;
5159   my $unapp_credit = FS::cust_credit->unapplied_sql;
5160   my $unapp_pay    = FS::cust_pay->unapplied_sql;
5161
5162   my $j = $opt{'join'} || '';
5163
5164   my $owed_wh   = $class->_money_table_where( 'cust_bill',   $start,$end,%opt );
5165   my $refund_wh = $class->_money_table_where( 'cust_refund', $start,$end,%opt );
5166   my $credit_wh = $class->_money_table_where( 'cust_credit', $start,$end,%opt );
5167   my $pay_wh    = $class->_money_table_where( 'cust_pay',    $start,$end,%opt );
5168
5169   "   ( SELECT COALESCE(SUM($owed),         0) FROM cust_bill   $j $owed_wh   )
5170     + ( SELECT COALESCE(SUM($unapp_refund), 0) FROM cust_refund $j $refund_wh )
5171     - ( SELECT COALESCE(SUM($unapp_credit), 0) FROM cust_credit $j $credit_wh )
5172     - ( SELECT COALESCE(SUM($unapp_pay),    0) FROM cust_pay    $j $pay_wh    )
5173   ";
5174
5175 }
5176
5177 =item _money_table_where TABLE START_TIME [ END_TIME [ OPTION => VALUE ... ] ]
5178
5179 Helper method for balance_date_sql; name (and usage) subject to change
5180 (suggestions welcome).
5181
5182 Returns a WHERE clause for the specified monetary TABLE (cust_bill,
5183 cust_refund, cust_credit or cust_pay).
5184
5185 If TABLE is "cust_bill" or the unapplied_date option is true, only
5186 considers records with date earlier than START_TIME, and optionally not
5187 later than END_TIME .
5188
5189 =cut
5190
5191 sub _money_table_where {
5192   my( $class, $table, $start, $end, %opt ) = @_;
5193
5194   my @where = ();
5195   push @where, "cust_main.custnum = $table.custnum" unless $opt{'total'};
5196   if ( $table eq 'cust_bill' || $opt{'unapplied_date'} ) {
5197     push @where, "$table._date <= $start" if defined($start) && length($start);
5198     push @where, "$table._date >  $end"   if defined($end)   && length($end);
5199   }
5200   push @where, @{$opt{'where'}} if $opt{'where'};
5201   my $where = scalar(@where) ? 'WHERE '. join(' AND ', @where ) : '';
5202
5203   $where;
5204
5205 }
5206
5207 =item fuzzy_search FUZZY_HASHREF [ HASHREF, SELECT, EXTRA_SQL, CACHE_OBJ ]
5208
5209 Performs a fuzzy (approximate) search and returns the matching FS::cust_main
5210 records.  Currently, I<first>, I<last> and/or I<company> may be specified (the
5211 appropriate ship_ field is also searched).
5212
5213 Additional options are the same as FS::Record::qsearch
5214
5215 =cut
5216
5217 sub fuzzy_search {
5218   my( $self, $fuzzy, $hash, @opt) = @_;
5219   #$self
5220   $hash ||= {};
5221   my @cust_main = ();
5222
5223   check_and_rebuild_fuzzyfiles();
5224   foreach my $field ( keys %$fuzzy ) {
5225
5226     my $all = $self->all_X($field);
5227     next unless scalar(@$all);
5228
5229     my %match = ();
5230     $match{$_}=1 foreach ( amatch( $fuzzy->{$field}, ['i'], @$all ) );
5231
5232     my @fcust = ();
5233     foreach ( keys %match ) {
5234       push @fcust, qsearch('cust_main', { %$hash, $field=>$_}, @opt);
5235       push @fcust, qsearch('cust_main', { %$hash, "ship_$field"=>$_}, @opt);
5236     }
5237     my %fsaw = ();
5238     push @cust_main, grep { ! $fsaw{$_->custnum}++ } @fcust;
5239   }
5240
5241   # we want the components of $fuzzy ANDed, not ORed, but still don't want dupes
5242   my %saw = ();
5243   @cust_main = grep { ++$saw{$_->custnum} == scalar(keys %$fuzzy) } @cust_main;
5244
5245   @cust_main;
5246
5247 }
5248
5249 =item masked FIELD
5250
5251 Returns a masked version of the named field
5252
5253 =cut
5254
5255 sub masked {
5256 my ($self,$field) = @_;
5257
5258 # Show last four
5259
5260 'x'x(length($self->getfield($field))-4).
5261   substr($self->getfield($field), (length($self->getfield($field))-4));
5262
5263 }
5264
5265 =back
5266
5267 =head1 SUBROUTINES
5268
5269 =over 4
5270
5271 =item smart_search OPTION => VALUE ...
5272
5273 Accepts the following options: I<search>, the string to search for.  The string
5274 will be searched for as a customer number, phone number, name or company name,
5275 as an exact, or, in some cases, a substring or fuzzy match (see the source code
5276 for the exact heuristics used); I<no_fuzzy_on_exact>, causes smart_search to
5277 skip fuzzy matching when an exact match is found.
5278
5279 Any additional options are treated as an additional qualifier on the search
5280 (i.e. I<agentnum>).
5281
5282 Returns a (possibly empty) array of FS::cust_main objects.
5283
5284 =cut
5285
5286 sub smart_search {
5287   my %options = @_;
5288
5289   #here is the agent virtualization
5290   my $agentnums_sql = $FS::CurrentUser::CurrentUser->agentnums_sql;
5291
5292   my @cust_main = ();
5293
5294   my $skip_fuzzy = delete $options{'no_fuzzy_on_exact'};
5295   my $search = delete $options{'search'};
5296   ( my $alphanum_search = $search ) =~ s/\W//g;
5297   
5298   if ( $alphanum_search =~ /^1?(\d{3})(\d{3})(\d{4})(\d*)$/ ) { #phone# search
5299
5300     #false laziness w/Record::ut_phone
5301     my $phonen = "$1-$2-$3";
5302     $phonen .= " x$4" if $4;
5303
5304     push @cust_main, qsearch( {
5305       'table'   => 'cust_main',
5306       'hashref' => { %options },
5307       'extra_sql' => ( scalar(keys %options) ? ' AND ' : ' WHERE ' ).
5308                      ' ( '.
5309                          join(' OR ', map "$_ = '$phonen'",
5310                                           qw( daytime night fax
5311                                               ship_daytime ship_night ship_fax )
5312                              ).
5313                      ' ) '.
5314                      " AND $agentnums_sql", #agent virtualization
5315     } );
5316
5317     unless ( @cust_main || $phonen =~ /x\d+$/ ) { #no exact match
5318       #try looking for matches with extensions unless one was specified
5319
5320       push @cust_main, qsearch( {
5321         'table'   => 'cust_main',
5322         'hashref' => { %options },
5323         'extra_sql' => ( scalar(keys %options) ? ' AND ' : ' WHERE ' ).
5324                        ' ( '.
5325                            join(' OR ', map "$_ LIKE '$phonen\%'",
5326                                             qw( daytime night
5327                                                 ship_daytime ship_night )
5328                                ).
5329                        ' ) '.
5330                        " AND $agentnums_sql", #agent virtualization
5331       } );
5332
5333     }
5334
5335   # custnum search (also try agent_custid), with some tweaking options if your
5336   # legacy cust "numbers" have letters
5337   } elsif ( $search =~ /^\s*(\d+)\s*$/
5338             || ( $conf->config('cust_main-agent_custid-format') eq 'ww?d+'
5339                  && $search =~ /^\s*(\w\w?\d+)\s*$/
5340                )
5341           )
5342   {
5343
5344     push @cust_main, qsearch( {
5345       'table'     => 'cust_main',
5346       'hashref'   => { 'custnum' => $1, %options },
5347       'extra_sql' => " AND $agentnums_sql", #agent virtualization
5348     } );
5349
5350     push @cust_main, qsearch( {
5351       'table'     => 'cust_main',
5352       'hashref'   => { 'agent_custid' => $1, %options },
5353       'extra_sql' => " AND $agentnums_sql", #agent virtualization
5354     } );
5355
5356   } elsif ( $search =~ /^\s*(\S.*\S)\s+\((.+), ([^,]+)\)\s*$/ ) {
5357
5358     my($company, $last, $first) = ( $1, $2, $3 );
5359
5360     # "Company (Last, First)"
5361     #this is probably something a browser remembered,
5362     #so just do an exact search
5363
5364     foreach my $prefix ( '', 'ship_' ) {
5365       push @cust_main, qsearch( {
5366         'table'     => 'cust_main',
5367         'hashref'   => { $prefix.'first'   => $first,
5368                          $prefix.'last'    => $last,
5369                          $prefix.'company' => $company,
5370                          %options,
5371                        },
5372         'extra_sql' => " AND $agentnums_sql",
5373       } );
5374     }
5375
5376   } elsif ( $search =~ /^\s*(\S.*\S)\s*$/ ) { # value search
5377                                               # try (ship_){last,company}
5378
5379     my $value = lc($1);
5380
5381     # # remove "(Last, First)" in "Company (Last, First)", otherwise the
5382     # # full strings the browser remembers won't work
5383     # $value =~ s/\([\w \,\.\-\']*\)$//; #false laziness w/Record::ut_name
5384
5385     use Lingua::EN::NameParse;
5386     my $NameParse = new Lingua::EN::NameParse(
5387              auto_clean     => 1,
5388              allow_reversed => 1,
5389     );
5390
5391     my($last, $first) = ( '', '' );
5392     #maybe disable this too and just rely on NameParse?
5393     if ( $value =~ /^(.+),\s*([^,]+)$/ ) { # Last, First
5394     
5395       ($last, $first) = ( $1, $2 );
5396     
5397     #} elsif  ( $value =~ /^(.+)\s+(.+)$/ ) {
5398     } elsif ( ! $NameParse->parse($value) ) {
5399
5400       my %name = $NameParse->components;
5401       $first = $name{'given_name_1'};
5402       $last  = $name{'surname_1'};
5403
5404     }
5405
5406     if ( $first && $last ) {
5407
5408       my($q_last, $q_first) = ( dbh->quote($last), dbh->quote($first) );
5409
5410       #exact
5411       my $sql = scalar(keys %options) ? ' AND ' : ' WHERE ';
5412       $sql .= "
5413         (     ( LOWER(last) = $q_last AND LOWER(first) = $q_first )
5414            OR ( LOWER(ship_last) = $q_last AND LOWER(ship_first) = $q_first )
5415         )";
5416
5417       push @cust_main, qsearch( {
5418         'table'     => 'cust_main',
5419         'hashref'   => \%options,
5420         'extra_sql' => "$sql AND $agentnums_sql", #agent virtualization
5421       } );
5422
5423       # or it just be something that was typed in... (try that in a sec)
5424
5425     }
5426
5427     my $q_value = dbh->quote($value);
5428
5429     #exact
5430     my $sql = scalar(keys %options) ? ' AND ' : ' WHERE ';
5431     $sql .= " (    LOWER(last)         = $q_value
5432                 OR LOWER(company)      = $q_value
5433                 OR LOWER(ship_last)    = $q_value
5434                 OR LOWER(ship_company) = $q_value
5435               )";
5436
5437     push @cust_main, qsearch( {
5438       'table'     => 'cust_main',
5439       'hashref'   => \%options,
5440       'extra_sql' => "$sql AND $agentnums_sql", #agent virtualization
5441     } );
5442
5443     #no exact match, trying substring/fuzzy
5444     #always do substring & fuzzy (unless they're explicity config'ed off)
5445     #getting complaints searches are not returning enough
5446     unless ( @cust_main  && $skip_fuzzy || $conf->exists('disable-fuzzy') ) {
5447
5448       #still some false laziness w/ search/cust_main.cgi
5449
5450       #substring
5451
5452       my @hashrefs = (
5453         { 'company'      => { op=>'ILIKE', value=>"%$value%" }, },
5454         { 'ship_company' => { op=>'ILIKE', value=>"%$value%" }, },
5455       );
5456
5457       if ( $first && $last ) {
5458
5459         push @hashrefs,
5460           { 'first'        => { op=>'ILIKE', value=>"%$first%" },
5461             'last'         => { op=>'ILIKE', value=>"%$last%" },
5462           },
5463           { 'ship_first'   => { op=>'ILIKE', value=>"%$first%" },
5464             'ship_last'    => { op=>'ILIKE', value=>"%$last%" },
5465           },
5466         ;
5467
5468       } else {
5469
5470         push @hashrefs,
5471           { 'last'         => { op=>'ILIKE', value=>"%$value%" }, },
5472           { 'ship_last'    => { op=>'ILIKE', value=>"%$value%" }, },
5473         ;
5474       }
5475
5476       foreach my $hashref ( @hashrefs ) {
5477
5478         push @cust_main, qsearch( {
5479           'table'     => 'cust_main',
5480           'hashref'   => { %$hashref,
5481                            %options,
5482                          },
5483           'extra_sql' => " AND $agentnums_sql", #agent virtualizaiton
5484         } );
5485
5486       }
5487
5488       #fuzzy
5489       my @fuzopts = (
5490         \%options,                #hashref
5491         '',                       #select
5492         " AND $agentnums_sql",    #extra_sql  #agent virtualization
5493       );
5494
5495       if ( $first && $last ) {
5496         push @cust_main, FS::cust_main->fuzzy_search(
5497           { 'last'   => $last,    #fuzzy hashref
5498             'first'  => $first }, #
5499           @fuzopts
5500         );
5501       }
5502       foreach my $field ( 'last', 'company' ) {
5503         push @cust_main,
5504           FS::cust_main->fuzzy_search( { $field => $value }, @fuzopts );
5505       }
5506
5507     }
5508
5509     #eliminate duplicates
5510     my %saw = ();
5511     @cust_main = grep { !$saw{$_->custnum}++ } @cust_main;
5512
5513   }
5514
5515   @cust_main;
5516
5517 }
5518
5519 =item email_search
5520
5521 Accepts the following options: I<email>, the email address to search for.  The
5522 email address will be searched for as an email invoice destination and as an
5523 svc_acct account.
5524
5525 #Any additional options are treated as an additional qualifier on the search
5526 #(i.e. I<agentnum>).
5527
5528 Returns a (possibly empty) array of FS::cust_main objects (but usually just
5529 none or one).
5530
5531 =cut
5532
5533 sub email_search {
5534   my %options = @_;
5535
5536   local($DEBUG) = 1;
5537
5538   my $email = delete $options{'email'};
5539
5540   #we're only being used by RT at the moment... no agent virtualization yet
5541   #my $agentnums_sql = $FS::CurrentUser::CurrentUser->agentnums_sql;
5542
5543   my @cust_main = ();
5544
5545   if ( $email =~ /([^@]+)\@([^@]+)/ ) {
5546
5547     my ( $user, $domain ) = ( $1, $2 );
5548
5549     warn "$me smart_search: searching for $user in domain $domain"
5550       if $DEBUG;
5551
5552     push @cust_main,
5553       map $_->cust_main,
5554           qsearch( {
5555                      'table'     => 'cust_main_invoice',
5556                      'hashref'   => { 'dest' => $email },
5557                    }
5558                  );
5559
5560     push @cust_main,
5561       map  $_->cust_main,
5562       grep $_,
5563       map  $_->cust_svc->cust_pkg,
5564           qsearch( {
5565                      'table'     => 'svc_acct',
5566                      'hashref'   => { 'username' => $user, },
5567                      'extra_sql' =>
5568                        'AND ( SELECT domain FROM svc_domain
5569                                 WHERE svc_acct.domsvc = svc_domain.svcnum
5570                             ) = '. dbh->quote($domain),
5571                    }
5572                  );
5573   }
5574
5575   my %saw = ();
5576   @cust_main = grep { !$saw{$_->custnum}++ } @cust_main;
5577
5578   warn "$me smart_search: found ". scalar(@cust_main). " unique customers"
5579     if $DEBUG;
5580
5581   @cust_main;
5582
5583 }
5584
5585 =item check_and_rebuild_fuzzyfiles
5586
5587 =cut
5588
5589 use vars qw(@fuzzyfields);
5590 @fuzzyfields = ( 'last', 'first', 'company' );
5591
5592 sub check_and_rebuild_fuzzyfiles {
5593   my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
5594   rebuild_fuzzyfiles() if grep { ! -e "$dir/cust_main.$_" } @fuzzyfields
5595 }
5596
5597 =item rebuild_fuzzyfiles
5598
5599 =cut
5600
5601 sub rebuild_fuzzyfiles {
5602
5603   use Fcntl qw(:flock);
5604
5605   my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
5606   mkdir $dir, 0700 unless -d $dir;
5607
5608   foreach my $fuzzy ( @fuzzyfields ) {
5609
5610     open(LOCK,">>$dir/cust_main.$fuzzy")
5611       or die "can't open $dir/cust_main.$fuzzy: $!";
5612     flock(LOCK,LOCK_EX)
5613       or die "can't lock $dir/cust_main.$fuzzy: $!";
5614
5615     open (CACHE,">$dir/cust_main.$fuzzy.tmp")
5616       or die "can't open $dir/cust_main.$fuzzy.tmp: $!";
5617
5618     foreach my $field ( $fuzzy, "ship_$fuzzy" ) {
5619       my $sth = dbh->prepare("SELECT $field FROM cust_main".
5620                              " WHERE $field != '' AND $field IS NOT NULL");
5621       $sth->execute or die $sth->errstr;
5622
5623       while ( my $row = $sth->fetchrow_arrayref ) {
5624         print CACHE $row->[0]. "\n";
5625       }
5626
5627     } 
5628
5629     close CACHE or die "can't close $dir/cust_main.$fuzzy.tmp: $!";
5630   
5631     rename "$dir/cust_main.$fuzzy.tmp", "$dir/cust_main.$fuzzy";
5632     close LOCK;
5633   }
5634
5635 }
5636
5637 =item all_X
5638
5639 =cut
5640
5641 sub all_X {
5642   my( $self, $field ) = @_;
5643   my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
5644   open(CACHE,"<$dir/cust_main.$field")
5645     or die "can't open $dir/cust_main.$field: $!";
5646   my @array = map { chomp; $_; } <CACHE>;
5647   close CACHE;
5648   \@array;
5649 }
5650
5651 =item append_fuzzyfiles LASTNAME COMPANY
5652
5653 =cut
5654
5655 sub append_fuzzyfiles {
5656   #my( $first, $last, $company ) = @_;
5657
5658   &check_and_rebuild_fuzzyfiles;
5659
5660   use Fcntl qw(:flock);
5661
5662   my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
5663
5664   foreach my $field (qw( first last company )) {
5665     my $value = shift;
5666
5667     if ( $value ) {
5668
5669       open(CACHE,">>$dir/cust_main.$field")
5670         or die "can't open $dir/cust_main.$field: $!";
5671       flock(CACHE,LOCK_EX)
5672         or die "can't lock $dir/cust_main.$field: $!";
5673
5674       print CACHE "$value\n";
5675
5676       flock(CACHE,LOCK_UN)
5677         or die "can't unlock $dir/cust_main.$field: $!";
5678       close CACHE;
5679     }
5680
5681   }
5682
5683   1;
5684 }
5685
5686 =item batch_import
5687
5688 =cut
5689
5690 sub batch_import {
5691   my $param = shift;
5692   #warn join('-',keys %$param);
5693   my $fh = $param->{filehandle};
5694   my $agentnum = $param->{agentnum};
5695
5696   my $refnum = $param->{refnum};
5697   my $pkgpart = $param->{pkgpart};
5698
5699   #my @fields = @{$param->{fields}};
5700   my $format = $param->{'format'};
5701   my @fields;
5702   my $payby;
5703   if ( $format eq 'simple' ) {
5704     @fields = qw( cust_pkg.setup dayphone first last
5705                   address1 address2 city state zip comments );
5706     $payby = 'BILL';
5707   } elsif ( $format eq 'extended' ) {
5708     @fields = qw( agent_custid refnum
5709                   last first address1 address2 city state zip country
5710                   daytime night
5711                   ship_last ship_first ship_address1 ship_address2
5712                   ship_city ship_state ship_zip ship_country
5713                   payinfo paycvv paydate
5714                   invoicing_list
5715                   cust_pkg.pkgpart
5716                   svc_acct.username svc_acct._password 
5717                 );
5718     $payby = 'BILL';
5719  } elsif ( $format eq 'extended-plus_company' ) {
5720     @fields = qw( agent_custid refnum
5721                   last first company address1 address2 city state zip country
5722                   daytime night
5723                   ship_last ship_first ship_company ship_address1 ship_address2
5724                   ship_city ship_state ship_zip ship_country
5725                   payinfo paycvv paydate
5726                   invoicing_list
5727                   cust_pkg.pkgpart
5728                   svc_acct.username svc_acct._password 
5729                 );
5730     $payby = 'BILL';
5731   } else {
5732     die "unknown format $format";
5733   }
5734
5735   eval "use Text::CSV_XS;";
5736   die $@ if $@;
5737
5738   my $csv = new Text::CSV_XS;
5739   #warn $csv;
5740   #warn $fh;
5741
5742   my $imported = 0;
5743   #my $columns;
5744
5745   local $SIG{HUP} = 'IGNORE';
5746   local $SIG{INT} = 'IGNORE';
5747   local $SIG{QUIT} = 'IGNORE';
5748   local $SIG{TERM} = 'IGNORE';
5749   local $SIG{TSTP} = 'IGNORE';
5750   local $SIG{PIPE} = 'IGNORE';
5751
5752   my $oldAutoCommit = $FS::UID::AutoCommit;
5753   local $FS::UID::AutoCommit = 0;
5754   my $dbh = dbh;
5755   
5756   #while ( $columns = $csv->getline($fh) ) {
5757   my $line;
5758   while ( defined($line=<$fh>) ) {
5759
5760     $csv->parse($line) or do {
5761       $dbh->rollback if $oldAutoCommit;
5762       return "can't parse: ". $csv->error_input();
5763     };
5764
5765     my @columns = $csv->fields();
5766     #warn join('-',@columns);
5767
5768     my %cust_main = (
5769       agentnum => $agentnum,
5770       refnum   => $refnum,
5771       country  => $conf->config('countrydefault') || 'US',
5772       payby    => $payby, #default
5773       paydate  => '12/2037', #default
5774     );
5775     my $billtime = time;
5776     my %cust_pkg = ( pkgpart => $pkgpart );
5777     my %svc_acct = ();
5778     foreach my $field ( @fields ) {
5779
5780       if ( $field =~ /^cust_pkg\.(pkgpart|setup|bill|susp|adjourn|expire|cancel)$/ ) {
5781
5782         #$cust_pkg{$1} = str2time( shift @$columns );
5783         if ( $1 eq 'pkgpart' ) {
5784           $cust_pkg{$1} = shift @columns;
5785         } elsif ( $1 eq 'setup' ) {
5786           $billtime = str2time(shift @columns);
5787         } else {
5788           $cust_pkg{$1} = str2time( shift @columns );
5789         } 
5790
5791       } elsif ( $field =~ /^svc_acct\.(username|_password)$/ ) {
5792
5793         $svc_acct{$1} = shift @columns;
5794         
5795       } else {
5796
5797         #refnum interception
5798         if ( $field eq 'refnum' && $columns[0] !~ /^\s*(\d+)\s*$/ ) {
5799
5800           my $referral = $columns[0];
5801           my %hash = ( 'referral' => $referral,
5802                        'agentnum' => $agentnum,
5803                        'disabled' => '',
5804                      );
5805
5806           my $part_referral = qsearchs('part_referral', \%hash )
5807                               || new FS::part_referral \%hash;
5808
5809           unless ( $part_referral->refnum ) {
5810             my $error = $part_referral->insert;
5811             if ( $error ) {
5812               $dbh->rollback if $oldAutoCommit;
5813               return "can't auto-insert advertising source: $referral: $error";
5814             }
5815           }
5816
5817           $columns[0] = $part_referral->refnum;
5818         }
5819
5820         #$cust_main{$field} = shift @$columns; 
5821         $cust_main{$field} = shift @columns; 
5822       }
5823     }
5824
5825     $cust_main{'payby'} = 'CARD' if length($cust_main{'payinfo'});
5826
5827     my $invoicing_list = $cust_main{'invoicing_list'}
5828                            ? [ delete $cust_main{'invoicing_list'} ]
5829                            : [];
5830
5831     my $cust_main = new FS::cust_main ( \%cust_main );
5832
5833     use Tie::RefHash;
5834     tie my %hash, 'Tie::RefHash'; #this part is important
5835
5836     if ( $cust_pkg{'pkgpart'} ) {
5837       my $cust_pkg = new FS::cust_pkg ( \%cust_pkg );
5838
5839       my @svc_acct = ();
5840       if ( $svc_acct{'username'} ) {
5841         my $part_pkg = $cust_pkg->part_pkg;
5842         unless ( $part_pkg ) {
5843           $dbh->rollback if $oldAutoCommit;
5844           return "unknown pkgpart: ". $cust_pkg{'pkgpart'};
5845         } 
5846         $svc_acct{svcpart} = $part_pkg->svcpart( 'svc_acct' );
5847         push @svc_acct, new FS::svc_acct ( \%svc_acct )
5848       }
5849
5850       $hash{$cust_pkg} = \@svc_acct;
5851     }
5852
5853     my $error = $cust_main->insert( \%hash, $invoicing_list );
5854
5855     if ( $error ) {
5856       $dbh->rollback if $oldAutoCommit;
5857       return "can't insert customer for $line: $error";
5858     }
5859
5860     if ( $format eq 'simple' ) {
5861
5862       #false laziness w/bill.cgi
5863       $error = $cust_main->bill( 'time' => $billtime );
5864       if ( $error ) {
5865         $dbh->rollback if $oldAutoCommit;
5866         return "can't bill customer for $line: $error";
5867       }
5868   
5869       $error = $cust_main->apply_payments_and_credits;
5870       if ( $error ) {
5871         $dbh->rollback if $oldAutoCommit;
5872         return "can't bill customer for $line: $error";
5873       }
5874
5875       $error = $cust_main->collect();
5876       if ( $error ) {
5877         $dbh->rollback if $oldAutoCommit;
5878         return "can't collect customer for $line: $error";
5879       }
5880
5881     }
5882
5883     $imported++;
5884   }
5885
5886   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
5887
5888   return "Empty file!" unless $imported;
5889
5890   ''; #no error
5891
5892 }
5893
5894 =item batch_charge
5895
5896 =cut
5897
5898 sub batch_charge {
5899   my $param = shift;
5900   #warn join('-',keys %$param);
5901   my $fh = $param->{filehandle};
5902   my @fields = @{$param->{fields}};
5903
5904   eval "use Text::CSV_XS;";
5905   die $@ if $@;
5906
5907   my $csv = new Text::CSV_XS;
5908   #warn $csv;
5909   #warn $fh;
5910
5911   my $imported = 0;
5912   #my $columns;
5913
5914   local $SIG{HUP} = 'IGNORE';
5915   local $SIG{INT} = 'IGNORE';
5916   local $SIG{QUIT} = 'IGNORE';
5917   local $SIG{TERM} = 'IGNORE';
5918   local $SIG{TSTP} = 'IGNORE';
5919   local $SIG{PIPE} = 'IGNORE';
5920
5921   my $oldAutoCommit = $FS::UID::AutoCommit;
5922   local $FS::UID::AutoCommit = 0;
5923   my $dbh = dbh;
5924   
5925   #while ( $columns = $csv->getline($fh) ) {
5926   my $line;
5927   while ( defined($line=<$fh>) ) {
5928
5929     $csv->parse($line) or do {
5930       $dbh->rollback if $oldAutoCommit;
5931       return "can't parse: ". $csv->error_input();
5932     };
5933
5934     my @columns = $csv->fields();
5935     #warn join('-',@columns);
5936
5937     my %row = ();
5938     foreach my $field ( @fields ) {
5939       $row{$field} = shift @columns;
5940     }
5941
5942     my $cust_main = qsearchs('cust_main', { 'custnum' => $row{'custnum'} } );
5943     unless ( $cust_main ) {
5944       $dbh->rollback if $oldAutoCommit;
5945       return "unknown custnum $row{'custnum'}";
5946     }
5947
5948     if ( $row{'amount'} > 0 ) {
5949       my $error = $cust_main->charge($row{'amount'}, $row{'pkg'});
5950       if ( $error ) {
5951         $dbh->rollback if $oldAutoCommit;
5952         return $error;
5953       }
5954       $imported++;
5955     } elsif ( $row{'amount'} < 0 ) {
5956       my $error = $cust_main->credit( sprintf( "%.2f", 0-$row{'amount'} ),
5957                                       $row{'pkg'}                         );
5958       if ( $error ) {
5959         $dbh->rollback if $oldAutoCommit;
5960         return $error;
5961       }
5962       $imported++;
5963     } else {
5964       #hmm?
5965     }
5966
5967   }
5968
5969   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
5970
5971   return "Empty file!" unless $imported;
5972
5973   ''; #no error
5974
5975 }
5976
5977 =item notify CUSTOMER_OBJECT TEMPLATE_NAME OPTIONS
5978
5979 Sends a templated email notification to the customer (see L<Text::Template>).
5980
5981 OPTIONS is a hash and may include
5982
5983 I<from> - the email sender (default is invoice_from)
5984
5985 I<to> - comma-separated scalar or arrayref of recipients 
5986    (default is invoicing_list)
5987
5988 I<subject> - The subject line of the sent email notification
5989    (default is "Notice from company_name")
5990
5991 I<extra_fields> - a hashref of name/value pairs which will be substituted
5992    into the template
5993
5994 The following variables are vavailable in the template.
5995
5996 I<$first> - the customer first name
5997 I<$last> - the customer last name
5998 I<$company> - the customer company
5999 I<$payby> - a description of the method of payment for the customer
6000             # would be nice to use FS::payby::shortname
6001 I<$payinfo> - the account information used to collect for this customer
6002 I<$expdate> - the expiration of the customer payment in seconds from epoch
6003
6004 =cut
6005
6006 sub notify {
6007   my ($customer, $template, %options) = @_;
6008
6009   return unless $conf->exists($template);
6010
6011   my $from = $conf->config('invoice_from') if $conf->exists('invoice_from');
6012   $from = $options{from} if exists($options{from});
6013
6014   my $to = join(',', $customer->invoicing_list_emailonly);
6015   $to = $options{to} if exists($options{to});
6016   
6017   my $subject = "Notice from " . $conf->config('company_name')
6018     if $conf->exists('company_name');
6019   $subject = $options{subject} if exists($options{subject});
6020
6021   my $notify_template = new Text::Template (TYPE => 'ARRAY',
6022                                             SOURCE => [ map "$_\n",
6023                                               $conf->config($template)]
6024                                            )
6025     or die "can't create new Text::Template object: Text::Template::ERROR";
6026   $notify_template->compile()
6027     or die "can't compile template: Text::Template::ERROR";
6028
6029   $FS::notify_template::_template::company_name = $conf->config('company_name');
6030   $FS::notify_template::_template::company_address =
6031     join("\n", $conf->config('company_address') ). "\n";
6032
6033   my $paydate = $customer->paydate || '2037-12-31';
6034   $FS::notify_template::_template::first = $customer->first;
6035   $FS::notify_template::_template::last = $customer->last;
6036   $FS::notify_template::_template::company = $customer->company;
6037   $FS::notify_template::_template::payinfo = $customer->mask_payinfo;
6038   my $payby = $customer->payby;
6039   my ($payyear,$paymonth,$payday) = split (/-/,$paydate);
6040   my $expire_time = timelocal(0,0,0,$payday,--$paymonth,$payyear);
6041
6042   #credit cards expire at the end of the month/year of their exp date
6043   if ($payby eq 'CARD' || $payby eq 'DCRD') {
6044     $FS::notify_template::_template::payby = 'credit card';
6045     ($paymonth < 11) ? $paymonth++ : ($paymonth=0, $payyear++);
6046     $expire_time = timelocal(0,0,0,$payday,$paymonth,$payyear);
6047     $expire_time--;
6048   }elsif ($payby eq 'COMP') {
6049     $FS::notify_template::_template::payby = 'complimentary account';
6050   }else{
6051     $FS::notify_template::_template::payby = 'current method';
6052   }
6053   $FS::notify_template::_template::expdate = $expire_time;
6054
6055   for (keys %{$options{extra_fields}}){
6056     no strict "refs";
6057     ${"FS::notify_template::_template::$_"} = $options{extra_fields}->{$_};
6058   }
6059
6060   send_email(from => $from,
6061              to => $to,
6062              subject => $subject,
6063              body => $notify_template->fill_in( PACKAGE =>
6064                                                 'FS::notify_template::_template'                                              ),
6065             );
6066
6067 }
6068
6069 =item generate_letter CUSTOMER_OBJECT TEMPLATE_NAME OPTIONS
6070
6071 Generates a templated notification to the customer (see L<Text::Template>).
6072
6073 OPTIONS is a hash and may include
6074
6075 I<extra_fields> - a hashref of name/value pairs which will be substituted
6076    into the template.  These values may override values mentioned below
6077    and those from the customer record.
6078
6079 The following variables are available in the template instead of or in addition
6080 to the fields of the customer record.
6081
6082 I<$payby> - a description of the method of payment for the customer
6083             # would be nice to use FS::payby::shortname
6084 I<$payinfo> - the masked account information used to collect for this customer
6085 I<$expdate> - the expiration of the customer payment method in seconds from epoch
6086 I<$returnaddress> - the return address defaults to invoice_latexreturnaddress or company_address
6087
6088 =cut
6089
6090 sub generate_letter {
6091   my ($self, $template, %options) = @_;
6092
6093   return unless $conf->exists($template);
6094
6095   my $letter_template = new Text::Template
6096                         ( TYPE       => 'ARRAY',
6097                           SOURCE     => [ map "$_\n", $conf->config($template)],
6098                           DELIMITERS => [ '[@--', '--@]' ],
6099                         )
6100     or die "can't create new Text::Template object: Text::Template::ERROR";
6101
6102   $letter_template->compile()
6103     or die "can't compile template: Text::Template::ERROR";
6104
6105   my %letter_data = map { $_ => $self->$_ } $self->fields;
6106   $letter_data{payinfo} = $self->mask_payinfo;
6107
6108   #my $paydate = $self->paydate || '2037-12-31';
6109   my $paydate = $self->paydate =~ /^\S+$/ ? $self->paydate : '2037-12-31';
6110
6111   my $payby = $self->payby;
6112   my ($payyear,$paymonth,$payday) = split (/-/,$paydate);
6113   my $expire_time = timelocal(0,0,0,$payday,--$paymonth,$payyear);
6114
6115   #credit cards expire at the end of the month/year of their exp date
6116   if ($payby eq 'CARD' || $payby eq 'DCRD') {
6117     $letter_data{payby} = 'credit card';
6118     ($paymonth < 11) ? $paymonth++ : ($paymonth=0, $payyear++);
6119     $expire_time = timelocal(0,0,0,$payday,$paymonth,$payyear);
6120     $expire_time--;
6121   }elsif ($payby eq 'COMP') {
6122     $letter_data{payby} = 'complimentary account';
6123   }else{
6124     $letter_data{payby} = 'current method';
6125   }
6126   $letter_data{expdate} = $expire_time;
6127
6128   for (keys %{$options{extra_fields}}){
6129     $letter_data{$_} = $options{extra_fields}->{$_};
6130   }
6131
6132   unless(exists($letter_data{returnaddress})){
6133     my $retadd = join("\n", $conf->config_orbase( 'invoice_latexreturnaddress',
6134                                                   $self->agent_template)
6135                      );
6136     if ( length($retadd) ) {
6137       $letter_data{returnaddress} = $retadd;
6138     } elsif ( grep /\S/, $conf->config('company_address') ) {
6139       $letter_data{returnaddress} =
6140         join( '\\*'."\n", map s/( {2,})/'~' x length($1)/eg,
6141                           $conf->config('company_address')
6142         );
6143     } else {
6144       $letter_data{returnaddress} = '~';
6145     }
6146   }
6147
6148   $letter_data{conf_dir} = "$FS::UID::conf_dir/conf.$FS::UID::datasrc";
6149
6150   $letter_data{company_name} = $conf->config('company_name');
6151
6152   my $dir = $FS::UID::conf_dir."cache.". $FS::UID::datasrc;
6153   my $fh = new File::Temp( TEMPLATE => 'letter.'. $self->custnum. '.XXXXXXXX',
6154                            DIR      => $dir,
6155                            SUFFIX   => '.tex',
6156                            UNLINK   => 0,
6157                          ) or die "can't open temp file: $!\n";
6158
6159   $letter_template->fill_in( OUTPUT => $fh, HASH => \%letter_data );
6160   close $fh;
6161   $fh->filename =~ /^(.*).tex$/ or die "unparsable filename: ". $fh->filename;
6162   return $1;
6163 }
6164
6165 =item print_ps TEMPLATE 
6166
6167 Returns an postscript letter filled in from TEMPLATE, as a scalar.
6168
6169 =cut
6170
6171 sub print_ps {
6172   my $self = shift;
6173   my $file = $self->generate_letter(@_);
6174   FS::Misc::generate_ps($file);
6175 }
6176
6177 =item print TEMPLATE
6178
6179 Prints the filled in template.
6180
6181 TEMPLATE is the name of a L<Text::Template> to fill in and print.
6182
6183 =cut
6184
6185 sub queueable_print {
6186   my %opt = @_;
6187
6188   my $self = qsearchs('cust_main', { 'custnum' => $opt{custnum} } )
6189     or die "invalid customer number: " . $opt{custvnum};
6190
6191   my $error = $self->print( $opt{template} );
6192   die $error if $error;
6193 }
6194
6195 sub print {
6196   my ($self, $template) = (shift, shift);
6197   do_print [ $self->print_ps($template) ];
6198 }
6199
6200 sub agent_template {
6201   my $self = shift;
6202   $self->_agent_plandata('agent_templatename');
6203 }
6204
6205 sub agent_invoice_from {
6206   my $self = shift;
6207   $self->_agent_plandata('agent_invoice_from');
6208 }
6209
6210 sub _agent_plandata {
6211   my( $self, $option ) = @_;
6212
6213   #yuck.  this whole thing needs to be reconciled better with 1.9's idea of
6214   #agent-specific Conf
6215
6216   use FS::part_event::Condition;
6217   
6218   my $agentnum = $self->agentnum;
6219
6220   my $regexp = '';
6221   if ( driver_name =~ /^Pg/i ) {
6222     $regexp = '~';
6223   } elsif ( driver_name =~ /^mysql/i ) {
6224     $regexp = 'REGEXP';
6225   } else {
6226     die "don't know how to use regular expressions in ". driver_name. " databases";
6227   }
6228
6229   my $part_event_option =
6230     qsearchs({
6231       'select'    => 'part_event_option.*',
6232       'table'     => 'part_event_option',
6233       'addl_from' => q{
6234         LEFT JOIN part_event USING ( eventpart )
6235         LEFT JOIN part_event_option AS peo_agentnum
6236           ON ( part_event.eventpart = peo_agentnum.eventpart
6237                AND peo_agentnum.optionname = 'agentnum'
6238                AND peo_agentnum.optionvalue }. $regexp. q{ '(^|,)}. $agentnum. q{(,|$)'
6239              )
6240         LEFT JOIN part_event_option AS peo_cust_bill_age
6241           ON ( part_event.eventpart = peo_cust_bill_age.eventpart
6242                AND peo_cust_bill_age.optionname = 'cust_bill_age'
6243              )
6244       },
6245       #'hashref'   => { 'optionname' => $option },
6246       #'hashref'   => { 'part_event_option.optionname' => $option },
6247       'extra_sql' =>
6248         " WHERE part_event_option.optionname = ". dbh->quote($option).
6249         " AND action = 'cust_bill_send_agent' ".
6250         " AND ( disabled IS NULL OR disabled != 'Y' ) ".
6251         " AND peo_agentnum.optionname = 'agentnum' ".
6252         " AND agentnum IS NULL OR agentnum = $agentnum ".
6253         " ORDER BY
6254            CASE WHEN peo_cust_bill_age.optionname != 'cust_bill_age'
6255            THEN -1
6256            ELSE ". FS::part_event::Condition->age2seconds_sql('peo_cust_bill_age.optionvalue').
6257         " END
6258           , part_event.weight".
6259         " LIMIT 1"
6260     });
6261     
6262   unless ( $part_event_option ) {
6263     return $self->agent->invoice_template || ''
6264       if $option eq 'agent_templatename';
6265     return '';
6266   }
6267
6268   $part_event_option->optionvalue;
6269
6270 }
6271
6272 sub queued_bill {
6273   ## actual sub, not a method, designed to be called from the queue.
6274   ## sets up the customer, and calls the bill_and_collect
6275   my (%args) = @_; #, ($time, $invoice_time, $check_freq, $resetup) = @_;
6276   my $cust_main = qsearchs( 'cust_main', { custnum => $args{'custnum'} } );
6277       $cust_main->bill_and_collect(
6278         %args,
6279       );
6280 }
6281
6282 =back
6283
6284 =head1 BUGS
6285
6286 The delete method.
6287
6288 The delete method should possibly take an FS::cust_main object reference
6289 instead of a scalar customer number.
6290
6291 Bill and collect options should probably be passed as references instead of a
6292 list.
6293
6294 There should probably be a configuration file with a list of allowed credit
6295 card types.
6296
6297 No multiple currency support (probably a larger project than just this module).
6298
6299 payinfo_masked false laziness with cust_pay.pm and cust_refund.pm
6300
6301 Birthdates rely on negative epoch values.
6302
6303 The payby for card/check batches is broken.  With mixed batching, bad
6304 things will happen.
6305
6306 B<collect> I<invoice_time> should be renamed I<time>, like B<bill>.
6307
6308 =head1 SEE ALSO
6309
6310 L<FS::Record>, L<FS::cust_pkg>, L<FS::cust_bill>, L<FS::cust_credit>
6311 L<FS::agent>, L<FS::part_referral>, L<FS::cust_main_county>,
6312 L<FS::cust_main_invoice>, L<FS::UID>, schema.html from the base documentation.
6313
6314 =cut
6315
6316 1;
6317