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