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