add business-onlinepayment-email_customer flag
[freeside.git] / FS / FS / cust_main.pm
1 package FS::cust_main;
2
3 require 5.006;
4 use strict;
5 use vars qw( @ISA @EXPORT_OK $DEBUG $me $conf @encrypted_fields
6              $import $skip_fuzzyfiles $ignore_expired_card @paytypes);
7 use vars qw( $realtime_bop_decline_quiet ); #ugh
8 use Safe;
9 use Carp;
10 use Exporter;
11 use Time::Local qw(timelocal_nocheck);
12 use Data::Dumper;
13 use Tie::IxHash;
14 use Digest::MD5 qw(md5_base64);
15 use Date::Format;
16 use Date::Parse;
17 #use Date::Manip;
18 use String::Approx qw(amatch);
19 use Business::CreditCard 0.28;
20 use Locale::Country;
21 use Data::Dumper;
22 use FS::UID qw( getotaker dbh driver_name );
23 use FS::Record qw( qsearchs qsearch dbdef );
24 use FS::Misc qw( send_email generate_ps do_print );
25 use FS::Msgcat qw(gettext);
26 use FS::cust_pkg;
27 use FS::cust_svc;
28 use FS::cust_bill;
29 use FS::cust_bill_pkg;
30 use FS::cust_pay;
31 use FS::cust_pay_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 ) = shift;
1685   my $sql = scalar(@_) ? shift : '';
1686   $sql = "AND $sql" if $sql && $sql !~ /^\s*$/ && $sql !~ /^\s*AND/i;
1687   my $sth = dbh->prepare(
1688     "SELECT COUNT(*) FROM cust_pkg WHERE custnum = ? $sql"
1689   ) or die dbh->errstr;
1690   $sth->execute($self->custnum) or die $sth->errstr;
1691   $sth->fetchrow_arrayref->[0];
1692 }
1693
1694 =item unsuspend
1695
1696 Unsuspends all unflagged suspended packages (see L</unflagged_suspended_pkgs>
1697 and L<FS::cust_pkg>) for this customer.  Always returns a list: an empty list
1698 on success or a list of errors.
1699
1700 =cut
1701
1702 sub unsuspend {
1703   my $self = shift;
1704   grep { $_->unsuspend } $self->suspended_pkgs;
1705 }
1706
1707 =item suspend
1708
1709 Suspends all unsuspended packages (see L<FS::cust_pkg>) for this customer.
1710
1711 Returns a list: an empty list on success or a list of errors.
1712
1713 =cut
1714
1715 sub suspend {
1716   my $self = shift;
1717   grep { $_->suspend(@_) } $self->unsuspended_pkgs;
1718 }
1719
1720 =item suspend_if_pkgpart HASHREF | PKGPART [ , PKGPART ... ]
1721
1722 Suspends all unsuspended packages (see L<FS::cust_pkg>) matching the listed
1723 PKGPARTs (see L<FS::part_pkg>).  Preferred usage is to pass a hashref instead
1724 of a list of pkgparts; the hashref has the following keys:
1725
1726 =over 4
1727
1728 =item pkgparts - listref of pkgparts
1729
1730 =item (other options are passed to the suspend method)
1731
1732 =back
1733
1734
1735 Returns a list: an empty list on success or a list of errors.
1736
1737 =cut
1738
1739 sub suspend_if_pkgpart {
1740   my $self = shift;
1741   my (@pkgparts, %opt);
1742   if (ref($_[0]) eq 'HASH'){
1743     @pkgparts = @{$_[0]{pkgparts}};
1744     %opt      = %{$_[0]};
1745   }else{
1746     @pkgparts = @_;
1747   }
1748   grep { $_->suspend(%opt) }
1749     grep { my $pkgpart = $_->pkgpart; grep { $pkgpart eq $_ } @pkgparts }
1750       $self->unsuspended_pkgs;
1751 }
1752
1753 =item suspend_unless_pkgpart HASHREF | PKGPART [ , PKGPART ... ]
1754
1755 Suspends all unsuspended packages (see L<FS::cust_pkg>) unless they match the
1756 given PKGPARTs (see L<FS::part_pkg>).  Preferred usage is to pass a hashref
1757 instead of a list of pkgparts; the hashref has the following keys:
1758
1759 =over 4
1760
1761 =item pkgparts - listref of pkgparts
1762
1763 =item (other options are passed to the suspend method)
1764
1765 =back
1766
1767 Returns a list: an empty list on success or a list of errors.
1768
1769 =cut
1770
1771 sub suspend_unless_pkgpart {
1772   my $self = shift;
1773   my (@pkgparts, %opt);
1774   if (ref($_[0]) eq 'HASH'){
1775     @pkgparts = @{$_[0]{pkgparts}};
1776     %opt      = %{$_[0]};
1777   }else{
1778     @pkgparts = @_;
1779   }
1780   grep { $_->suspend(%opt) }
1781     grep { my $pkgpart = $_->pkgpart; ! grep { $pkgpart eq $_ } @pkgparts }
1782       $self->unsuspended_pkgs;
1783 }
1784
1785 =item cancel [ OPTION => VALUE ... ]
1786
1787 Cancels all uncancelled packages (see L<FS::cust_pkg>) for this customer.
1788
1789 Available options are:
1790
1791 =over 4
1792
1793 =item quiet - can be set true to supress email cancellation notices.
1794
1795 =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.
1796
1797 =item ban - can be set true to ban this customer's credit card or ACH information, if present.
1798
1799 =back
1800
1801 Always returns a list: an empty list on success or a list of errors.
1802
1803 =cut
1804
1805 sub cancel {
1806   my( $self, %opt ) = @_;
1807
1808   warn "$me cancel called on customer ". $self->custnum. " with options ".
1809        join(', ', map { "$_: $opt{$_}" } keys %opt ). "\n"
1810     if $DEBUG;
1811
1812   return ( 'access denied' )
1813     unless $FS::CurrentUser::CurrentUser->access_right('Cancel customer');
1814
1815   if ( $opt{'ban'} && $self->payby =~ /^(CARD|DCRD|CHEK|DCHK)$/ ) {
1816
1817     #should try decryption (we might have the private key)
1818     # and if not maybe queue a job for the server that does?
1819     return ( "Can't (yet) ban encrypted credit cards" )
1820       if $self->is_encrypted($self->payinfo);
1821
1822     my $ban = new FS::banned_pay $self->_banned_pay_hashref;
1823     my $error = $ban->insert;
1824     return ( $error ) if $error;
1825
1826   }
1827
1828   my @pkgs = $self->ncancelled_pkgs;
1829
1830   warn "$me cancelling ". scalar($self->ncancelled_pkgs). "/".
1831        scalar(@pkgs). " packages for customer ". $self->custnum. "\n"
1832     if $DEBUG;
1833
1834   grep { $_ } map { $_->cancel(%opt) } $self->ncancelled_pkgs;
1835 }
1836
1837 sub _banned_pay_hashref {
1838   my $self = shift;
1839
1840   my %payby2ban = (
1841     'CARD' => 'CARD',
1842     'DCRD' => 'CARD',
1843     'CHEK' => 'CHEK',
1844     'DCHK' => 'CHEK'
1845   );
1846
1847   {
1848     'payby'   => $payby2ban{$self->payby},
1849     'payinfo' => md5_base64($self->payinfo),
1850     #don't ever *search* on reason! #'reason'  =>
1851   };
1852 }
1853
1854 =item notes
1855
1856 Returns all notes (see L<FS::cust_main_note>) for this customer.
1857
1858 =cut
1859
1860 sub notes {
1861   my $self = shift;
1862   #order by?
1863   qsearch( 'cust_main_note',
1864            { 'custnum' => $self->custnum },
1865            '',
1866            'ORDER BY _DATE DESC'
1867          );
1868 }
1869
1870 =item agent
1871
1872 Returns the agent (see L<FS::agent>) for this customer.
1873
1874 =cut
1875
1876 sub agent {
1877   my $self = shift;
1878   qsearchs( 'agent', { 'agentnum' => $self->agentnum } );
1879 }
1880
1881 =item bill_and_collect 
1882
1883 Cancels and suspends any packages due, generates bills, applies payments and
1884 cred
1885
1886 Warns on errors (Does not currently: If there is an error, returns the error, otherwise returns false.)
1887
1888 Options are passed as name-value pairs.  Currently available options are:
1889
1890 =over 4
1891
1892 =item time
1893
1894 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:
1895
1896  use Date::Parse;
1897  ...
1898  $cust_main->bill( 'time' => str2time('April 20th, 2001') );
1899
1900 =item invoice_time
1901
1902 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.
1903
1904 =item check_freq
1905
1906 "1d" for the traditional, daily events (the default), or "1m" for the new monthly events (part_event.check_freq)
1907
1908 =item resetup
1909
1910 If set true, re-charges setup fees.
1911
1912 =item debug
1913
1914 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)
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     unless ( $cust_event->test_conditions( 'time' => $invoice_time ) ) {
2529       #don't leave stray "new/locked" records around
2530       my $error = $cust_event->delete;
2531       if ( $error ) {
2532         #gah, even with transactions
2533         $dbh->commit if $oldAutoCommit; #well.
2534         return $error;
2535       }
2536       next;
2537     }
2538
2539     {
2540       local $realtime_bop_decline_quiet = 1 if $options{'quiet'};
2541       warn "  running cust_event ". $cust_event->eventnum. "\n"
2542         if $DEBUG > 1;
2543
2544       
2545       #if ( my $error = $cust_event->do_event(%options) ) { #XXX %options?
2546       if ( my $error = $cust_event->do_event() ) {
2547         #XXX wtf is this?  figure out a proper dealio with return value
2548         #from do_event
2549           # gah, even with transactions.
2550           $dbh->commit if $oldAutoCommit; #well.
2551           return $error;
2552         }
2553     }
2554
2555   }
2556
2557   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2558   '';
2559
2560 }
2561
2562 =item due_cust_event [ HASHREF | OPTION => VALUE ... ]
2563
2564 Inserts database records for and returns an ordered listref of new events due
2565 for this customer, as FS::cust_event objects (see L<FS::cust_event>).  If no
2566 events are due, an empty listref is returned.  If there is an error, returns a
2567 scalar error message.
2568
2569 To actually run the events, call each event's test_condition method, and if
2570 still true, call the event's do_event method.
2571
2572 Options are passed as a hashref or as a list of name-value pairs.  Available
2573 options are:
2574
2575 =over 4
2576
2577 =item check_freq
2578
2579 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.
2580
2581 =item time
2582
2583 "Current time" for the events.
2584
2585 =item debug
2586
2587 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)
2588
2589 =item eventtable
2590
2591 Only return events for the specified eventtable (by default, events of all eventtables are returned)
2592
2593 =item objects
2594
2595 Explicitly pass the objects to be tested (typically used with eventtable).
2596
2597 =back
2598
2599 =cut
2600
2601 sub due_cust_event {
2602   my $self = shift;
2603   my %opt = ref($_[0]) ? %{ $_[0] } : @_;
2604
2605   #???
2606   #my $DEBUG = $opt{'debug'}
2607   local($DEBUG) = $opt{'debug'}
2608     if defined($opt{'debug'}) && $opt{'debug'} > $DEBUG;
2609
2610   warn "$me due_cust_event called with options ".
2611        join(', ', map { "$_: $opt{$_}" } keys %opt). "\n"
2612     if $DEBUG;
2613
2614   $opt{'time'} ||= time;
2615
2616   local $SIG{HUP} = 'IGNORE';
2617   local $SIG{INT} = 'IGNORE';
2618   local $SIG{QUIT} = 'IGNORE';
2619   local $SIG{TERM} = 'IGNORE';
2620   local $SIG{TSTP} = 'IGNORE';
2621   local $SIG{PIPE} = 'IGNORE';
2622
2623   my $oldAutoCommit = $FS::UID::AutoCommit;
2624   local $FS::UID::AutoCommit = 0;
2625   my $dbh = dbh;
2626
2627   $self->select_for_update; #mutex
2628
2629   ###
2630   # 1: find possible events (initial search)
2631   ###
2632   
2633   my @cust_event = ();
2634
2635   my @eventtable = $opt{'eventtable'}
2636                      ? ( $opt{'eventtable'} )
2637                      : FS::part_event->eventtables_runorder;
2638
2639   foreach my $eventtable ( @eventtable ) {
2640
2641     my @objects;
2642     if ( $opt{'objects'} ) {
2643
2644       @objects = @{ $opt{'objects'} };
2645
2646     } else {
2647
2648       #my @objects = $self->eventtable(); # sub cust_main { @{ [ $self ] }; }
2649       @objects = ( $eventtable eq 'cust_main' )
2650                    ? ( $self )
2651                    : ( $self->$eventtable() );
2652
2653     }
2654
2655     my @e_cust_event = ();
2656
2657     my $cross = "CROSS JOIN $eventtable";
2658     $cross .= ' LEFT JOIN cust_main USING ( custnum )'
2659       unless $eventtable eq 'cust_main';
2660
2661     foreach my $object ( @objects ) {
2662
2663       #this first search uses the condition_sql magic for optimization.
2664       #the more possible events we can eliminate in this step the better
2665
2666       my $cross_where = '';
2667       my $pkey = $object->primary_key;
2668       $cross_where = "$eventtable.$pkey = ". $object->$pkey();
2669
2670       my $join = FS::part_event_condition->join_conditions_sql( $eventtable );
2671       my $extra_sql =
2672         FS::part_event_condition->where_conditions_sql( $eventtable,
2673                                                         'time'=>$opt{'time'}
2674                                                       );
2675       my $order = FS::part_event_condition->order_conditions_sql( $eventtable );
2676
2677       $extra_sql = "AND $extra_sql" if $extra_sql;
2678
2679       #here is the agent virtualization
2680       $extra_sql .= " AND (    part_event.agentnum IS NULL
2681                             OR part_event.agentnum = ". $self->agentnum. ' )';
2682
2683       $extra_sql .= " $order";
2684
2685       warn "searching for events for $eventtable ". $object->$pkey. "\n"
2686         if $opt{'debug'} > 2;
2687       my @part_event = qsearch( {
2688         'debug'     => ( $opt{'debug'} > 3 ? 1 : 0 ),
2689         'select'    => 'part_event.*',
2690         'table'     => 'part_event',
2691         'addl_from' => "$cross $join",
2692         'hashref'   => { 'check_freq' => ( $opt{'check_freq'} || '1d' ),
2693                          'eventtable' => $eventtable,
2694                          'disabled'   => '',
2695                        },
2696         'extra_sql' => "AND $cross_where $extra_sql",
2697       } );
2698
2699       if ( $DEBUG > 2 ) {
2700         my $pkey = $object->primary_key;
2701         warn "      ". scalar(@part_event).
2702              " possible events found for $eventtable ". $object->$pkey(). "\n";
2703       }
2704
2705       push @e_cust_event, map { $_->new_cust_event($object) } @part_event;
2706
2707     }
2708
2709     warn "    ". scalar(@e_cust_event).
2710          " subtotal possible cust events found for $eventtable\n"
2711       if $DEBUG > 1;
2712
2713     push @cust_event, @e_cust_event;
2714
2715   }
2716
2717   warn "  ". scalar(@cust_event).
2718        " total possible cust events found in initial search\n"
2719     if $DEBUG; # > 1;
2720
2721   ##
2722   # 2: test conditions
2723   ##
2724   
2725   my %unsat = ();
2726
2727   @cust_event = grep $_->test_conditions( 'time'          => $opt{'time'},
2728                                           'stats_hashref' => \%unsat ),
2729                      @cust_event;
2730
2731   warn "  ". scalar(@cust_event). " cust events left satisfying conditions\n"
2732     if $DEBUG; # > 1;
2733
2734   warn "    invalid conditions not eliminated with condition_sql:\n".
2735        join('', map "      $_: ".$unsat{$_}."\n", keys %unsat )
2736     if $DEBUG; # > 1;
2737
2738   ##
2739   # 3: insert
2740   ##
2741
2742   foreach my $cust_event ( @cust_event ) {
2743
2744     my $error = $cust_event->insert();
2745     if ( $error ) {
2746       $dbh->rollback if $oldAutoCommit;
2747       return $error;
2748     }
2749                                        
2750   }
2751
2752   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2753
2754   ##
2755   # 4: return
2756   ##
2757
2758   warn "  returning events: ". Dumper(@cust_event). "\n"
2759     if $DEBUG > 2;
2760
2761   \@cust_event;
2762
2763 }
2764
2765 =item retry_realtime
2766
2767 Schedules realtime / batch  credit card / electronic check / LEC billing
2768 events for for retry.  Useful if card information has changed or manual
2769 retry is desired.  The 'collect' method must be called to actually retry
2770 the transaction.
2771
2772 Implementation details: For either this customer, or for each of this
2773 customer's open invoices, changes the status of the first "done" (with
2774 statustext error) realtime processing event to "failed".
2775
2776 =cut
2777
2778 sub retry_realtime {
2779   my $self = shift;
2780
2781   local $SIG{HUP} = 'IGNORE';
2782   local $SIG{INT} = 'IGNORE';
2783   local $SIG{QUIT} = 'IGNORE';
2784   local $SIG{TERM} = 'IGNORE';
2785   local $SIG{TSTP} = 'IGNORE';
2786   local $SIG{PIPE} = 'IGNORE';
2787
2788   my $oldAutoCommit = $FS::UID::AutoCommit;
2789   local $FS::UID::AutoCommit = 0;
2790   my $dbh = dbh;
2791
2792   #a little false laziness w/due_cust_event (not too bad, really)
2793
2794   my $join = FS::part_event_condition->join_conditions_sql;
2795   my $order = FS::part_event_condition->order_conditions_sql;
2796   my $mine = 
2797   '( '
2798    . join ( ' OR ' , map { 
2799     "( part_event.eventtable = " . dbh->quote($_) 
2800     . " AND tablenum IN( SELECT " . dbdef->table($_)->primary_key . " from $_ where custnum = " . dbh->quote( $self->custnum ) . "))" ;
2801    } FS::part_event->eventtables)
2802    . ') ';
2803
2804   #here is the agent virtualization
2805   my $agent_virt = " (    part_event.agentnum IS NULL
2806                        OR part_event.agentnum = ". $self->agentnum. ' )';
2807
2808   #XXX this shouldn't be hardcoded, actions should declare it...
2809   my @realtime_events = qw(
2810     cust_bill_realtime_card
2811     cust_bill_realtime_check
2812     cust_bill_realtime_lec
2813     cust_bill_batch
2814   );
2815
2816   my $is_realtime_event = ' ( '. join(' OR ', map "part_event.action = '$_'",
2817                                                   @realtime_events
2818                                      ).
2819                           ' ) ';
2820
2821   my @cust_event = qsearchs({
2822     'table'     => 'cust_event',
2823     'select'    => 'cust_event.*',
2824     'addl_from' => "LEFT JOIN part_event USING ( eventpart ) $join",
2825     'hashref'   => { 'status' => 'done' },
2826     'extra_sql' => " AND statustext IS NOT NULL AND statustext != '' ".
2827                    " AND $mine AND $is_realtime_event AND $agent_virt $order" # LIMIT 1"
2828   });
2829
2830   my %seen_invnum = ();
2831   foreach my $cust_event (@cust_event) {
2832
2833     #max one for the customer, one for each open invoice
2834     my $cust_X = $cust_event->cust_X;
2835     next if $seen_invnum{ $cust_event->part_event->eventtable eq 'cust_bill'
2836                           ? $cust_X->invnum
2837                           : 0
2838                         }++
2839          or $cust_event->part_event->eventtable eq 'cust_bill'
2840             && ! $cust_X->owed;
2841
2842     my $error = $cust_event->retry;
2843     if ( $error ) {
2844       $dbh->rollback if $oldAutoCommit;
2845       return "error scheduling event for retry: $error";
2846     }
2847
2848   }
2849
2850   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2851   '';
2852
2853 }
2854
2855 =item realtime_bop METHOD AMOUNT [ OPTION => VALUE ... ]
2856
2857 Runs a realtime credit card, ACH (electronic check) or phone bill transaction
2858 via a Business::OnlinePayment realtime gateway.  See
2859 L<http://420.am/business-onlinepayment> for supported gateways.
2860
2861 Available methods are: I<CC>, I<ECHECK> and I<LEC>
2862
2863 Available options are: I<description>, I<invnum>, I<quiet>, I<paynum_ref>
2864
2865 The additional options I<payname>, I<address1>, I<address2>, I<city>, I<state>,
2866 I<zip>, I<payinfo> and I<paydate> are also available.  Any of these options,
2867 if set, will override the value from the customer record.
2868
2869 I<description> is a free-text field passed to the gateway.  It defaults to
2870 "Internet services".
2871
2872 If an I<invnum> is specified, this payment (if successful) is applied to the
2873 specified invoice.  If you don't specify an I<invnum> you might want to
2874 call the B<apply_payments> method.
2875
2876 I<quiet> can be set true to surpress email decline notices.
2877
2878 I<paynum_ref> can be set to a scalar reference.  It will be filled in with the
2879 resulting paynum, if any.
2880
2881 (moved from cust_bill) (probably should get realtime_{card,ach,lec} here too)
2882
2883 =cut
2884
2885 sub realtime_bop {
2886   my( $self, $method, $amount, %options ) = @_;
2887   if ( $DEBUG ) {
2888     warn "$me realtime_bop: $method $amount\n";
2889     warn "  $_ => $options{$_}\n" foreach keys %options;
2890   }
2891
2892   $options{'description'} ||= 'Internet services';
2893
2894   return $self->fake_bop($method, $amount, %options) if $options{'fake'};
2895
2896   eval "use Business::OnlinePayment";  
2897   die $@ if $@;
2898
2899   my $payinfo = exists($options{'payinfo'})
2900                   ? $options{'payinfo'}
2901                   : $self->payinfo;
2902
2903   my %method2payby = (
2904     'CC'     => 'CARD',
2905     'ECHECK' => 'CHEK',
2906     'LEC'    => 'LECB',
2907   );
2908
2909   ###
2910   # check for banned credit card/ACH
2911   ###
2912
2913   my $ban = qsearchs('banned_pay', {
2914     'payby'   => $method2payby{$method},
2915     'payinfo' => md5_base64($payinfo),
2916   } );
2917   return "Banned credit card" if $ban;
2918
2919   ###
2920   # select a gateway
2921   ###
2922
2923   my $taxclass = '';
2924   if ( $options{'invnum'} ) {
2925     my $cust_bill = qsearchs('cust_bill', { 'invnum' => $options{'invnum'} } );
2926     die "invnum ". $options{'invnum'}. " not found" unless $cust_bill;
2927     my @taxclasses =
2928       map  { $_->part_pkg->taxclass }
2929       grep { $_ }
2930       map  { $_->cust_pkg }
2931       $cust_bill->cust_bill_pkg;
2932     unless ( grep { $taxclasses[0] ne $_ } @taxclasses ) { #unless there are
2933                                                            #different taxclasses
2934       $taxclass = $taxclasses[0];
2935     }
2936   }
2937
2938   #look for an agent gateway override first
2939   my $cardtype;
2940   if ( $method eq 'CC' ) {
2941     $cardtype = cardtype($payinfo);
2942   } elsif ( $method eq 'ECHECK' ) {
2943     $cardtype = 'ACH';
2944   } else {
2945     $cardtype = $method;
2946   }
2947
2948   my $override =
2949        qsearchs('agent_payment_gateway', { agentnum => $self->agentnum,
2950                                            cardtype => $cardtype,
2951                                            taxclass => $taxclass,       } )
2952     || qsearchs('agent_payment_gateway', { agentnum => $self->agentnum,
2953                                            cardtype => '',
2954                                            taxclass => $taxclass,       } )
2955     || qsearchs('agent_payment_gateway', { agentnum => $self->agentnum,
2956                                            cardtype => $cardtype,
2957                                            taxclass => '',              } )
2958     || qsearchs('agent_payment_gateway', { agentnum => $self->agentnum,
2959                                            cardtype => '',
2960                                            taxclass => '',              } );
2961
2962   my $payment_gateway = '';
2963   my( $processor, $login, $password, $action, @bop_options );
2964   if ( $override ) { #use a payment gateway override
2965
2966     $payment_gateway = $override->payment_gateway;
2967
2968     $processor   = $payment_gateway->gateway_module;
2969     $login       = $payment_gateway->gateway_username;
2970     $password    = $payment_gateway->gateway_password;
2971     $action      = $payment_gateway->gateway_action;
2972     @bop_options = $payment_gateway->options;
2973
2974   } else { #use the standard settings from the config
2975
2976     ( $processor, $login, $password, $action, @bop_options ) =
2977       $self->default_payment_gateway($method);
2978
2979   }
2980
2981   ###
2982   # massage data
2983   ###
2984
2985   my $address = exists($options{'address1'})
2986                     ? $options{'address1'}
2987                     : $self->address1;
2988   my $address2 = exists($options{'address2'})
2989                     ? $options{'address2'}
2990                     : $self->address2;
2991   $address .= ", ". $address2 if length($address2);
2992
2993   my $o_payname = exists($options{'payname'})
2994                     ? $options{'payname'}
2995                     : $self->payname;
2996   my($payname, $payfirst, $paylast);
2997   if ( $o_payname && $method ne 'ECHECK' ) {
2998     ($payname = $o_payname) =~ /^\s*([\w \,\.\-\']*)?\s+([\w\,\.\-\']+)\s*$/
2999       or return "Illegal payname $payname";
3000     ($payfirst, $paylast) = ($1, $2);
3001   } else {
3002     $payfirst = $self->getfield('first');
3003     $paylast = $self->getfield('last');
3004     $payname =  "$payfirst $paylast";
3005   }
3006
3007   my @invoicing_list = $self->invoicing_list_emailonly;
3008   if ( $conf->exists('emailinvoiceautoalways')
3009        || $conf->exists('emailinvoiceauto') && ! @invoicing_list
3010        || ( $conf->exists('emailinvoiceonly') && ! @invoicing_list ) ) {
3011     push @invoicing_list, $self->all_emails;
3012   }
3013
3014   my $email = ($conf->exists('business-onlinepayment-email-override'))
3015               ? $conf->config('business-onlinepayment-email-override')
3016               : $invoicing_list[0];
3017
3018   my %content = ();
3019
3020   my $payip = exists($options{'payip'})
3021                 ? $options{'payip'}
3022                 : $self->payip;
3023   $content{customer_ip} = $payip
3024     if length($payip);
3025
3026   $content{invoice_number} = $options{'invnum'}
3027     if exists($options{'invnum'}) && length($options{'invnum'});
3028
3029   $content{email_customer} = 
3030     (    $conf->exists('business-onlinepayment-email_customer')
3031       || $conf->exists('business-onlinepayment-email-override') );
3032       
3033   my $paydate = '';
3034   if ( $method eq 'CC' ) { 
3035
3036     $content{card_number} = $payinfo;
3037     $paydate = exists($options{'paydate'})
3038                     ? $options{'paydate'}
3039                     : $self->paydate;
3040     $paydate =~ /^\d{2}(\d{2})[\/\-](\d+)[\/\-]\d+$/;
3041     $content{expiration} = "$2/$1";
3042
3043     my $paycvv = exists($options{'paycvv'})
3044                    ? $options{'paycvv'}
3045                    : $self->paycvv;
3046     $content{cvv2} = $paycvv
3047       if length($paycvv);
3048
3049     my $paystart_month = exists($options{'paystart_month'})
3050                            ? $options{'paystart_month'}
3051                            : $self->paystart_month;
3052
3053     my $paystart_year  = exists($options{'paystart_year'})
3054                            ? $options{'paystart_year'}
3055                            : $self->paystart_year;
3056
3057     $content{card_start} = "$paystart_month/$paystart_year"
3058       if $paystart_month && $paystart_year;
3059
3060     my $payissue       = exists($options{'payissue'})
3061                            ? $options{'payissue'}
3062                            : $self->payissue;
3063     $content{issue_number} = $payissue if $payissue;
3064
3065     $content{recurring_billing} = 'YES'
3066       if qsearch('cust_pay', { 'custnum' => $self->custnum,
3067                                'payby'   => 'CARD',
3068                                'payinfo' => $payinfo,
3069                              } )
3070       || qsearch('cust_pay', { 'custnum' => $self->custnum,
3071                                'payby'   => 'CARD',
3072                                'paymask' => $self->mask_payinfo('CARD', $payinfo),
3073                              } );
3074
3075
3076   } elsif ( $method eq 'ECHECK' ) {
3077     ( $content{account_number}, $content{routing_code} ) =
3078       split('@', $payinfo);
3079     $content{bank_name} = $o_payname;
3080     $content{bank_state} = exists($options{'paystate'})
3081                              ? $options{'paystate'}
3082                              : $self->getfield('paystate');
3083     $content{account_type} = exists($options{'paytype'})
3084                                ? uc($options{'paytype'}) || 'CHECKING'
3085                                : uc($self->getfield('paytype')) || 'CHECKING';
3086     $content{account_name} = $payname;
3087     $content{customer_org} = $self->company ? 'B' : 'I';
3088     $content{state_id}       = exists($options{'stateid'})
3089                                  ? $options{'stateid'}
3090                                  : $self->getfield('stateid');
3091     $content{state_id_state} = exists($options{'stateid_state'})
3092                                  ? $options{'stateid_state'}
3093                                  : $self->getfield('stateid_state');
3094     $content{customer_ssn} = exists($options{'ss'})
3095                                ? $options{'ss'}
3096                                : $self->ss;
3097   } elsif ( $method eq 'LEC' ) {
3098     $content{phone} = $payinfo;
3099   }
3100
3101   ###
3102   # run transaction(s)
3103   ###
3104
3105   my( $action1, $action2 ) = split(/\s*\,\s*/, $action );
3106
3107   my $transaction = new Business::OnlinePayment( $processor, @bop_options );
3108   $transaction->content(
3109     'type'           => $method,
3110     'login'          => $login,
3111     'password'       => $password,
3112     'action'         => $action1,
3113     'description'    => $options{'description'},
3114     'amount'         => $amount,
3115     #'invoice_number' => $options{'invnum'},
3116     'customer_id'    => $self->custnum,
3117     'last_name'      => $paylast,
3118     'first_name'     => $payfirst,
3119     'name'           => $payname,
3120     'address'        => $address,
3121     'city'           => ( exists($options{'city'})
3122                             ? $options{'city'}
3123                             : $self->city          ),
3124     'state'          => ( exists($options{'state'})
3125                             ? $options{'state'}
3126                             : $self->state          ),
3127     'zip'            => ( exists($options{'zip'})
3128                             ? $options{'zip'}
3129                             : $self->zip          ),
3130     'country'        => ( exists($options{'country'})
3131                             ? $options{'country'}
3132                             : $self->country          ),
3133     'referer'        => 'http://cleanwhisker.420.am/',
3134     'email'          => $email,
3135     'phone'          => $self->daytime || $self->night,
3136     %content, #after
3137   );
3138   $transaction->submit();
3139
3140   if ( $transaction->is_success() && $action2 ) {
3141     my $auth = $transaction->authorization;
3142     my $ordernum = $transaction->can('order_number')
3143                    ? $transaction->order_number
3144                    : '';
3145
3146     my $capture =
3147       new Business::OnlinePayment( $processor, @bop_options );
3148
3149     my %capture = (
3150       %content,
3151       type           => $method,
3152       action         => $action2,
3153       login          => $login,
3154       password       => $password,
3155       order_number   => $ordernum,
3156       amount         => $amount,
3157       authorization  => $auth,
3158       description    => $options{'description'},
3159     );
3160
3161     foreach my $field (qw( authorization_source_code returned_ACI
3162                            transaction_identifier validation_code           
3163                            transaction_sequence_num local_transaction_date    
3164                            local_transaction_time AVS_result_code          )) {
3165       $capture{$field} = $transaction->$field() if $transaction->can($field);
3166     }
3167
3168     $capture->content( %capture );
3169
3170     $capture->submit();
3171
3172     unless ( $capture->is_success ) {
3173       my $e = "Authorization successful but capture failed, custnum #".
3174               $self->custnum. ': '.  $capture->result_code.
3175               ": ". $capture->error_message;
3176       warn $e;
3177       return $e;
3178     }
3179
3180   }
3181
3182   ###
3183   # remove paycvv after initial transaction
3184   ###
3185
3186   #false laziness w/misc/process/payment.cgi - check both to make sure working
3187   # correctly
3188   if ( defined $self->dbdef_table->column('paycvv')
3189        && length($self->paycvv)
3190        && ! grep { $_ eq cardtype($payinfo) } $conf->config('cvv-save')
3191   ) {
3192     my $error = $self->remove_cvv;
3193     if ( $error ) {
3194       warn "WARNING: error removing cvv: $error\n";
3195     }
3196   }
3197
3198   ###
3199   # result handling
3200   ###
3201
3202   if ( $transaction->is_success() ) {
3203
3204     my %method2payby = (
3205       'CC'     => 'CARD',
3206       'ECHECK' => 'CHEK',
3207       'LEC'    => 'LECB',
3208     );
3209
3210     my $paybatch = '';
3211     if ( $payment_gateway ) { # agent override
3212       $paybatch = $payment_gateway->gatewaynum. '-';
3213     }
3214
3215     $paybatch .= "$processor:". $transaction->authorization;
3216
3217     $paybatch .= ':'. $transaction->order_number
3218       if $transaction->can('order_number')
3219       && length($transaction->order_number);
3220
3221     my $cust_pay = new FS::cust_pay ( {
3222        'custnum'  => $self->custnum,
3223        'invnum'   => $options{'invnum'},
3224        'paid'     => $amount,
3225        '_date'    => '',
3226        'payby'    => $method2payby{$method},
3227        'payinfo'  => $payinfo,
3228        'paybatch' => $paybatch,
3229        'paydate'  => $paydate,
3230     } );
3231     $cust_pay->payunique( $options{payunique} ) if length($options{payunique});
3232
3233     my $error = $cust_pay->insert($options{'manual'} ? ( 'manual' => 1 ) : () );
3234
3235     if ( $error ) {
3236       $cust_pay->invnum(''); #try again with no specific invnum
3237       my $error2 = $cust_pay->insert( $options{'manual'} ?
3238                                       ( 'manual' => 1 ) : ()
3239                                     );
3240       if ( $error2 ) {
3241         # gah, even with transactions.
3242         my $e = 'WARNING: Card/ACH debited but database not updated - '.
3243                 "error inserting payment ($processor): $error2".
3244                 " (previously tried insert with invnum #$options{'invnum'}" .
3245                 ": $error )";
3246         warn $e;
3247         return $e;
3248       }
3249     }
3250
3251     if ( $options{'paynum_ref'} ) {
3252       ${ $options{'paynum_ref'} } = $cust_pay->paynum;
3253     }
3254
3255     return ''; #no error
3256
3257   } else {
3258
3259     my $perror = "$processor error: ". $transaction->error_message;
3260
3261     unless ( $transaction->error_message ) {
3262
3263       my $t_response;
3264       if ( $transaction->can('response_page') ) {
3265         $t_response = {
3266                         'page'    => ( $transaction->can('response_page')
3267                                          ? $transaction->response_page
3268                                          : ''
3269                                      ),
3270                         'code'    => ( $transaction->can('response_code')
3271                                          ? $transaction->response_code
3272                                          : ''
3273                                      ),
3274                         'headers' => ( $transaction->can('response_headers')
3275                                          ? $transaction->response_headers
3276                                          : ''
3277                                      ),
3278                       };
3279       } else {
3280         $t_response .=
3281           "No additional debugging information available for $processor";
3282       }
3283
3284       $perror .= "No error_message returned from $processor -- ".
3285                  ( ref($t_response) ? Dumper($t_response) : $t_response );
3286
3287     }
3288
3289     if ( !$options{'quiet'} && !$realtime_bop_decline_quiet
3290          && $conf->exists('emaildecline')
3291          && grep { $_ ne 'POST' } $self->invoicing_list
3292          && ! grep { $transaction->error_message =~ /$_/ }
3293                    $conf->config('emaildecline-exclude')
3294     ) {
3295       my @templ = $conf->config('declinetemplate');
3296       my $template = new Text::Template (
3297         TYPE   => 'ARRAY',
3298         SOURCE => [ map "$_\n", @templ ],
3299       ) or return "($perror) can't create template: $Text::Template::ERROR";
3300       $template->compile()
3301         or return "($perror) can't compile template: $Text::Template::ERROR";
3302
3303       my $templ_hash = { error => $transaction->error_message };
3304
3305       my $error = send_email(
3306         'from'    => $conf->config('invoice_from'),
3307         'to'      => [ grep { $_ ne 'POST' } $self->invoicing_list ],
3308         'subject' => 'Your payment could not be processed',
3309         'body'    => [ $template->fill_in(HASH => $templ_hash) ],
3310       );
3311
3312       $perror .= " (also received error sending decline notification: $error)"
3313         if $error;
3314
3315     }
3316   
3317     return $perror;
3318   }
3319
3320 }
3321
3322 =item fake_bop
3323
3324 =cut
3325
3326 sub fake_bop {
3327   my( $self, $method, $amount, %options ) = @_;
3328
3329   if ( $options{'fake_failure'} ) {
3330      return "Error: No error; test failure requested with fake_failure";
3331   }
3332
3333   my %method2payby = (
3334     'CC'     => 'CARD',
3335     'ECHECK' => 'CHEK',
3336     'LEC'    => 'LECB',
3337   );
3338
3339   #my $paybatch = '';
3340   #if ( $payment_gateway ) { # agent override
3341   #  $paybatch = $payment_gateway->gatewaynum. '-';
3342   #}
3343   #
3344   #$paybatch .= "$processor:". $transaction->authorization;
3345   #
3346   #$paybatch .= ':'. $transaction->order_number
3347   #  if $transaction->can('order_number')
3348   #  && length($transaction->order_number);
3349
3350   my $paybatch = 'FakeProcessor:54:32';
3351
3352   my $cust_pay = new FS::cust_pay ( {
3353      'custnum'  => $self->custnum,
3354      'invnum'   => $options{'invnum'},
3355      'paid'     => $amount,
3356      '_date'    => '',
3357      'payby'    => $method2payby{$method},
3358      #'payinfo'  => $payinfo,
3359      'payinfo'  => '4111111111111111',
3360      'paybatch' => $paybatch,
3361      #'paydate'  => $paydate,
3362      'paydate'  => '2012-05-01',
3363   } );
3364   $cust_pay->payunique( $options{payunique} ) if length($options{payunique});
3365
3366   my $error = $cust_pay->insert($options{'manual'} ? ( 'manual' => 1 ) : () );
3367
3368   if ( $error ) {
3369     $cust_pay->invnum(''); #try again with no specific invnum
3370     my $error2 = $cust_pay->insert( $options{'manual'} ?
3371                                     ( 'manual' => 1 ) : ()
3372                                   );
3373     if ( $error2 ) {
3374       # gah, even with transactions.
3375       my $e = 'WARNING: Card/ACH debited but database not updated - '.
3376               "error inserting (fake!) payment: $error2".
3377               " (previously tried insert with invnum #$options{'invnum'}" .
3378               ": $error )";
3379       warn $e;
3380       return $e;
3381     }
3382   }
3383
3384   if ( $options{'paynum_ref'} ) {
3385     ${ $options{'paynum_ref'} } = $cust_pay->paynum;
3386   }
3387
3388   return ''; #no error
3389
3390 }
3391
3392 =item default_payment_gateway
3393
3394 =cut
3395
3396 sub default_payment_gateway {
3397   my( $self, $method ) = @_;
3398
3399   die "Real-time processing not enabled\n"
3400     unless $conf->exists('business-onlinepayment');
3401
3402   #load up config
3403   my $bop_config = 'business-onlinepayment';
3404   $bop_config .= '-ach'
3405     if $method =~ /^(ECHECK|CHEK)$/ && $conf->exists($bop_config. '-ach');
3406   my ( $processor, $login, $password, $action, @bop_options ) =
3407     $conf->config($bop_config);
3408   $action ||= 'normal authorization';
3409   pop @bop_options if scalar(@bop_options) % 2 && $bop_options[-1] =~ /^\s*$/;
3410   die "No real-time processor is enabled - ".
3411       "did you set the business-onlinepayment configuration value?\n"
3412     unless $processor;
3413
3414   ( $processor, $login, $password, $action, @bop_options )
3415 }
3416
3417 =item remove_cvv
3418
3419 Removes the I<paycvv> field from the database directly.
3420
3421 If there is an error, returns the error, otherwise returns false.
3422
3423 =cut
3424
3425 sub remove_cvv {
3426   my $self = shift;
3427   my $sth = dbh->prepare("UPDATE cust_main SET paycvv = '' WHERE custnum = ?")
3428     or return dbh->errstr;
3429   $sth->execute($self->custnum)
3430     or return $sth->errstr;
3431   $self->paycvv('');
3432   '';
3433 }
3434
3435 =item realtime_refund_bop METHOD [ OPTION => VALUE ... ]
3436
3437 Refunds a realtime credit card, ACH (electronic check) or phone bill transaction
3438 via a Business::OnlinePayment realtime gateway.  See
3439 L<http://420.am/business-onlinepayment> for supported gateways.
3440
3441 Available methods are: I<CC>, I<ECHECK> and I<LEC>
3442
3443 Available options are: I<amount>, I<reason>, I<paynum>, I<paydate>
3444
3445 Most gateways require a reference to an original payment transaction to refund,
3446 so you probably need to specify a I<paynum>.
3447
3448 I<amount> defaults to the original amount of the payment if not specified.
3449
3450 I<reason> specifies a reason for the refund.
3451
3452 I<paydate> specifies the expiration date for a credit card overriding the
3453 value from the customer record or the payment record. Specified as yyyy-mm-dd
3454
3455 Implementation note: If I<amount> is unspecified or equal to the amount of the
3456 orignal payment, first an attempt is made to "void" the transaction via
3457 the gateway (to cancel a not-yet settled transaction) and then if that fails,
3458 the normal attempt is made to "refund" ("credit") the transaction via the
3459 gateway is attempted.
3460
3461 #The additional options I<payname>, I<address1>, I<address2>, I<city>, I<state>,
3462 #I<zip>, I<payinfo> and I<paydate> are also available.  Any of these options,
3463 #if set, will override the value from the customer record.
3464
3465 #If an I<invnum> is specified, this payment (if successful) is applied to the
3466 #specified invoice.  If you don't specify an I<invnum> you might want to
3467 #call the B<apply_payments> method.
3468
3469 =cut
3470
3471 #some false laziness w/realtime_bop, not enough to make it worth merging
3472 #but some useful small subs should be pulled out
3473 sub realtime_refund_bop {
3474   my( $self, $method, %options ) = @_;
3475   if ( $DEBUG ) {
3476     warn "$me realtime_refund_bop: $method refund\n";
3477     warn "  $_ => $options{$_}\n" foreach keys %options;
3478   }
3479
3480   eval "use Business::OnlinePayment";  
3481   die $@ if $@;
3482
3483   ###
3484   # look up the original payment and optionally a gateway for that payment
3485   ###
3486
3487   my $cust_pay = '';
3488   my $amount = $options{'amount'};
3489
3490   my( $processor, $login, $password, @bop_options ) ;
3491   my( $auth, $order_number ) = ( '', '', '' );
3492
3493   if ( $options{'paynum'} ) {
3494
3495     warn "  paynum: $options{paynum}\n" if $DEBUG > 1;
3496     $cust_pay = qsearchs('cust_pay', { paynum=>$options{'paynum'} } )
3497       or return "Unknown paynum $options{'paynum'}";
3498     $amount ||= $cust_pay->paid;
3499
3500     $cust_pay->paybatch =~ /^((\d+)\-)?(\w+):\s*([\w\-\/ ]*)(:([\w\-]+))?$/
3501       or return "Can't parse paybatch for paynum $options{'paynum'}: ".
3502                 $cust_pay->paybatch;
3503     my $gatewaynum = '';
3504     ( $gatewaynum, $processor, $auth, $order_number ) = ( $2, $3, $4, $6 );
3505
3506     if ( $gatewaynum ) { #gateway for the payment to be refunded
3507
3508       my $payment_gateway =
3509         qsearchs('payment_gateway', { 'gatewaynum' => $gatewaynum } );
3510       die "payment gateway $gatewaynum not found"
3511         unless $payment_gateway;
3512
3513       $processor   = $payment_gateway->gateway_module;
3514       $login       = $payment_gateway->gateway_username;
3515       $password    = $payment_gateway->gateway_password;
3516       @bop_options = $payment_gateway->options;
3517
3518     } else { #try the default gateway
3519
3520       my( $conf_processor, $unused_action );
3521       ( $conf_processor, $login, $password, $unused_action, @bop_options ) =
3522         $self->default_payment_gateway($method);
3523
3524       return "processor of payment $options{'paynum'} $processor does not".
3525              " match default processor $conf_processor"
3526         unless $processor eq $conf_processor;
3527
3528     }
3529
3530
3531   } else { # didn't specify a paynum, so look for agent gateway overrides
3532            # like a normal transaction 
3533
3534     my $cardtype;
3535     if ( $method eq 'CC' ) {
3536       $cardtype = cardtype($self->payinfo);
3537     } elsif ( $method eq 'ECHECK' ) {
3538       $cardtype = 'ACH';
3539     } else {
3540       $cardtype = $method;
3541     }
3542     my $override =
3543            qsearchs('agent_payment_gateway', { agentnum => $self->agentnum,
3544                                                cardtype => $cardtype,
3545                                                taxclass => '',              } )
3546         || qsearchs('agent_payment_gateway', { agentnum => $self->agentnum,
3547                                                cardtype => '',
3548                                                taxclass => '',              } );
3549
3550     if ( $override ) { #use a payment gateway override
3551  
3552       my $payment_gateway = $override->payment_gateway;
3553
3554       $processor   = $payment_gateway->gateway_module;
3555       $login       = $payment_gateway->gateway_username;
3556       $password    = $payment_gateway->gateway_password;
3557       #$action      = $payment_gateway->gateway_action;
3558       @bop_options = $payment_gateway->options;
3559
3560     } else { #use the standard settings from the config
3561
3562       my $unused_action;
3563       ( $processor, $login, $password, $unused_action, @bop_options ) =
3564         $self->default_payment_gateway($method);
3565
3566     }
3567
3568   }
3569   return "neither amount nor paynum specified" unless $amount;
3570
3571   my %content = (
3572     'type'           => $method,
3573     'login'          => $login,
3574     'password'       => $password,
3575     'order_number'   => $order_number,
3576     'amount'         => $amount,
3577     'referer'        => 'http://cleanwhisker.420.am/',
3578   );
3579   $content{authorization} = $auth
3580     if length($auth); #echeck/ACH transactions have an order # but no auth
3581                       #(at least with authorize.net)
3582
3583   my $disable_void_after;
3584   if ($conf->exists('disable_void_after')
3585       && $conf->config('disable_void_after') =~ /^(\d+)$/) {
3586     $disable_void_after = $1;
3587   }
3588
3589   #first try void if applicable
3590   if ( $cust_pay && $cust_pay->paid == $amount
3591     && (
3592       ( not defined($disable_void_after) )
3593       || ( time < ($cust_pay->_date + $disable_void_after ) )
3594     )
3595   ) {
3596     warn "  attempting void\n" if $DEBUG > 1;
3597     my $void = new Business::OnlinePayment( $processor, @bop_options );
3598     $void->content( 'action' => 'void', %content );
3599     $void->submit();
3600     if ( $void->is_success ) {
3601       my $error = $cust_pay->void($options{'reason'});
3602       if ( $error ) {
3603         # gah, even with transactions.
3604         my $e = 'WARNING: Card/ACH voided but database not updated - '.
3605                 "error voiding payment: $error";
3606         warn $e;
3607         return $e;
3608       }
3609       warn "  void successful\n" if $DEBUG > 1;
3610       return '';
3611     }
3612   }
3613
3614   warn "  void unsuccessful, trying refund\n"
3615     if $DEBUG > 1;
3616
3617   #massage data
3618   my $address = $self->address1;
3619   $address .= ", ". $self->address2 if $self->address2;
3620
3621   my($payname, $payfirst, $paylast);
3622   if ( $self->payname && $method ne 'ECHECK' ) {
3623     $payname = $self->payname;
3624     $payname =~ /^\s*([\w \,\.\-\']*)?\s+([\w\,\.\-\']+)\s*$/
3625       or return "Illegal payname $payname";
3626     ($payfirst, $paylast) = ($1, $2);
3627   } else {
3628     $payfirst = $self->getfield('first');
3629     $paylast = $self->getfield('last');
3630     $payname =  "$payfirst $paylast";
3631   }
3632
3633   my @invoicing_list = $self->invoicing_list_emailonly;
3634   if ( $conf->exists('emailinvoiceautoalways')
3635        || $conf->exists('emailinvoiceauto') && ! @invoicing_list
3636        || ( $conf->exists('emailinvoiceonly') && ! @invoicing_list ) ) {
3637     push @invoicing_list, $self->all_emails;
3638   }
3639
3640   my $email = ($conf->exists('business-onlinepayment-email-override'))
3641               ? $conf->config('business-onlinepayment-email-override')
3642               : $invoicing_list[0];
3643
3644   my $payip = exists($options{'payip'})
3645                 ? $options{'payip'}
3646                 : $self->payip;
3647   $content{customer_ip} = $payip
3648     if length($payip);
3649
3650   my $payinfo = '';
3651   if ( $method eq 'CC' ) {
3652
3653     if ( $cust_pay ) {
3654       $content{card_number} = $payinfo = $cust_pay->payinfo;
3655       (exists($options{'paydate'}) ? $options{'paydate'} : $cust_pay->paydate)
3656         =~ /^\d{2}(\d{2})[\/\-](\d+)[\/\-]\d+$/ &&
3657         ($content{expiration} = "$2/$1");  # where available
3658     } else {
3659       $content{card_number} = $payinfo = $self->payinfo;
3660       (exists($options{'paydate'}) ? $options{'paydate'} : $self->paydate)
3661         =~ /^\d{2}(\d{2})[\/\-](\d+)[\/\-]\d+$/;
3662       $content{expiration} = "$2/$1";
3663     }
3664
3665   } elsif ( $method eq 'ECHECK' ) {
3666
3667     if ( $cust_pay ) {
3668       $payinfo = $cust_pay->payinfo;
3669     } else {
3670       $payinfo = $self->payinfo;
3671     } 
3672     ( $content{account_number}, $content{routing_code} )= split('@', $payinfo );
3673     $content{bank_name} = $self->payname;
3674     $content{account_type} = 'CHECKING';
3675     $content{account_name} = $payname;
3676     $content{customer_org} = $self->company ? 'B' : 'I';
3677     $content{customer_ssn} = $self->ss;
3678   } elsif ( $method eq 'LEC' ) {
3679     $content{phone} = $payinfo = $self->payinfo;
3680   }
3681
3682   #then try refund
3683   my $refund = new Business::OnlinePayment( $processor, @bop_options );
3684   my %sub_content = $refund->content(
3685     'action'         => 'credit',
3686     'customer_id'    => $self->custnum,
3687     'last_name'      => $paylast,
3688     'first_name'     => $payfirst,
3689     'name'           => $payname,
3690     'address'        => $address,
3691     'city'           => $self->city,
3692     'state'          => $self->state,
3693     'zip'            => $self->zip,
3694     'country'        => $self->country,
3695     'email'          => $email,
3696     'phone'          => $self->daytime || $self->night,
3697     %content, #after
3698   );
3699   warn join('', map { "  $_ => $sub_content{$_}\n" } keys %sub_content )
3700     if $DEBUG > 1;
3701   $refund->submit();
3702
3703   return "$processor error: ". $refund->error_message
3704     unless $refund->is_success();
3705
3706   my %method2payby = (
3707     'CC'     => 'CARD',
3708     'ECHECK' => 'CHEK',
3709     'LEC'    => 'LECB',
3710   );
3711
3712   my $paybatch = "$processor:". $refund->authorization;
3713   $paybatch .= ':'. $refund->order_number
3714     if $refund->can('order_number') && $refund->order_number;
3715
3716   while ( $cust_pay && $cust_pay->unapplied < $amount ) {
3717     my @cust_bill_pay = $cust_pay->cust_bill_pay;
3718     last unless @cust_bill_pay;
3719     my $cust_bill_pay = pop @cust_bill_pay;
3720     my $error = $cust_bill_pay->delete;
3721     last if $error;
3722   }
3723
3724   my $cust_refund = new FS::cust_refund ( {
3725     'custnum'  => $self->custnum,
3726     'paynum'   => $options{'paynum'},
3727     'refund'   => $amount,
3728     '_date'    => '',
3729     'payby'    => $method2payby{$method},
3730     'payinfo'  => $payinfo,
3731     'paybatch' => $paybatch,
3732     'reason'   => $options{'reason'} || 'card or ACH refund',
3733   } );
3734   my $error = $cust_refund->insert;
3735   if ( $error ) {
3736     $cust_refund->paynum(''); #try again with no specific paynum
3737     my $error2 = $cust_refund->insert;
3738     if ( $error2 ) {
3739       # gah, even with transactions.
3740       my $e = 'WARNING: Card/ACH refunded but database not updated - '.
3741               "error inserting refund ($processor): $error2".
3742               " (previously tried insert with paynum #$options{'paynum'}" .
3743               ": $error )";
3744       warn $e;
3745       return $e;
3746     }
3747   }
3748
3749   ''; #no error
3750
3751 }
3752
3753 =item batch_card OPTION => VALUE...
3754
3755 Adds a payment for this invoice to the pending credit card batch (see
3756 L<FS::cust_pay_batch>), or, if the B<realtime> option is set to a true value,
3757 runs the payment using a realtime gateway.
3758
3759 =cut
3760
3761 sub batch_card {
3762   my ($self, %options) = @_;
3763
3764   my $amount;
3765   if (exists($options{amount})) {
3766     $amount = $options{amount};
3767   }else{
3768     $amount = sprintf("%.2f", $self->balance - $self->in_transit_payments);
3769   }
3770   return '' unless $amount > 0;
3771   
3772   my $invnum = delete $options{invnum};
3773   my $payby = $options{invnum} || $self->payby;  #dubious
3774
3775   if ($options{'realtime'}) {
3776     return $self->realtime_bop( FS::payby->payby2bop($self->payby),
3777                                 $amount,
3778                                 %options,
3779                               );
3780   }
3781
3782   my $oldAutoCommit = $FS::UID::AutoCommit;
3783   local $FS::UID::AutoCommit = 0;
3784   my $dbh = dbh;
3785
3786   $dbh->do("LOCK TABLE pay_batch IN SHARE ROW EXCLUSIVE MODE")
3787     or return "Cannot lock pay_batch: " . $dbh->errstr;
3788
3789   my %pay_batch = (
3790     'status' => 'O',
3791     'payby'  => FS::payby->payby2payment($payby),
3792   );
3793
3794   my $pay_batch = qsearchs( 'pay_batch', \%pay_batch );
3795
3796   unless ( $pay_batch ) {
3797     $pay_batch = new FS::pay_batch \%pay_batch;
3798     my $error = $pay_batch->insert;
3799     if ( $error ) {
3800       $dbh->rollback if $oldAutoCommit;
3801       die "error creating new batch: $error\n";
3802     }
3803   }
3804
3805   my $old_cust_pay_batch = qsearchs('cust_pay_batch', {
3806       'batchnum' => $pay_batch->batchnum,
3807       'custnum'  => $self->custnum,
3808   } );
3809
3810   foreach (qw( address1 address2 city state zip country payby payinfo paydate
3811                payname )) {
3812     $options{$_} = '' unless exists($options{$_});
3813   }
3814
3815   my $cust_pay_batch = new FS::cust_pay_batch ( {
3816     'batchnum' => $pay_batch->batchnum,
3817     'invnum'   => $invnum || 0,                    # is there a better value?
3818                                                    # this field should be
3819                                                    # removed...
3820                                                    # cust_bill_pay_batch now
3821     'custnum'  => $self->custnum,
3822     'last'     => $self->getfield('last'),
3823     'first'    => $self->getfield('first'),
3824     'address1' => $options{address1} || $self->address1,
3825     'address2' => $options{address2} || $self->address2,
3826     'city'     => $options{city}     || $self->city,
3827     'state'    => $options{state}    || $self->state,
3828     'zip'      => $options{zip}      || $self->zip,
3829     'country'  => $options{country}  || $self->country,
3830     'payby'    => $options{payby}    || $self->payby,
3831     'payinfo'  => $options{payinfo}  || $self->payinfo,
3832     'exp'      => $options{paydate}  || $self->paydate,
3833     'payname'  => $options{payname}  || $self->payname,
3834     'amount'   => $amount,                         # consolidating
3835   } );
3836   
3837   $cust_pay_batch->paybatchnum($old_cust_pay_batch->paybatchnum)
3838     if $old_cust_pay_batch;
3839
3840   my $error;
3841   if ($old_cust_pay_batch) {
3842     $error = $cust_pay_batch->replace($old_cust_pay_batch)
3843   } else {
3844     $error = $cust_pay_batch->insert;
3845   }
3846
3847   if ( $error ) {
3848     $dbh->rollback if $oldAutoCommit;
3849     die $error;
3850   }
3851
3852   my $unapplied = $self->total_credited + $self->total_unapplied_payments + $self->in_transit_payments;
3853   foreach my $cust_bill ($self->open_cust_bill) {
3854     #$dbh->commit or die $dbh->errstr if $oldAutoCommit;
3855     my $cust_bill_pay_batch = new FS::cust_bill_pay_batch {
3856       'invnum' => $cust_bill->invnum,
3857       'paybatchnum' => $cust_pay_batch->paybatchnum,
3858       'amount' => $cust_bill->owed,
3859       '_date' => time,
3860     };
3861     if ($unapplied >= $cust_bill_pay_batch->amount){
3862       $unapplied -= $cust_bill_pay_batch->amount;
3863       next;
3864     }else{
3865       $cust_bill_pay_batch->amount(sprintf ( "%.2f", 
3866                                    $cust_bill_pay_batch->amount - $unapplied ));      $unapplied = 0;
3867     }
3868     $error = $cust_bill_pay_batch->insert;
3869     if ( $error ) {
3870       $dbh->rollback if $oldAutoCommit;
3871       die $error;
3872     }
3873   }
3874
3875   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
3876   '';
3877 }
3878
3879 =item total_owed
3880
3881 Returns the total owed for this customer on all invoices
3882 (see L<FS::cust_bill/owed>).
3883
3884 =cut
3885
3886 sub total_owed {
3887   my $self = shift;
3888   $self->total_owed_date(2145859200); #12/31/2037
3889 }
3890
3891 =item total_owed_date TIME
3892
3893 Returns the total owed for this customer on all invoices with date earlier than
3894 TIME.  TIME is specified as a UNIX timestamp; see L<perlfunc/"time">).  Also
3895 see L<Time::Local> and L<Date::Parse> for conversion functions.
3896
3897 =cut
3898
3899 sub total_owed_date {
3900   my $self = shift;
3901   my $time = shift;
3902   my $total_bill = 0;
3903   foreach my $cust_bill (
3904     grep { $_->_date <= $time }
3905       qsearch('cust_bill', { 'custnum' => $self->custnum, } )
3906   ) {
3907     $total_bill += $cust_bill->owed;
3908   }
3909   sprintf( "%.2f", $total_bill );
3910 }
3911
3912 =item apply_payments_and_credits
3913
3914 Applies unapplied payments and credits.
3915
3916 In most cases, this new method should be used in place of sequential
3917 apply_payments and apply_credits methods.
3918
3919 If there is an error, returns the error, otherwise returns false.
3920
3921 =cut
3922
3923 sub apply_payments_and_credits {
3924   my $self = shift;
3925
3926   local $SIG{HUP} = 'IGNORE';
3927   local $SIG{INT} = 'IGNORE';
3928   local $SIG{QUIT} = 'IGNORE';
3929   local $SIG{TERM} = 'IGNORE';
3930   local $SIG{TSTP} = 'IGNORE';
3931   local $SIG{PIPE} = 'IGNORE';
3932
3933   my $oldAutoCommit = $FS::UID::AutoCommit;
3934   local $FS::UID::AutoCommit = 0;
3935   my $dbh = dbh;
3936
3937   $self->select_for_update; #mutex
3938
3939   foreach my $cust_bill ( $self->open_cust_bill ) {
3940     my $error = $cust_bill->apply_payments_and_credits;
3941     if ( $error ) {
3942       $dbh->rollback if $oldAutoCommit;
3943       return "Error applying: $error";
3944     }
3945   }
3946
3947   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
3948   ''; #no error
3949
3950 }
3951
3952 =item apply_credits OPTION => VALUE ...
3953
3954 Applies (see L<FS::cust_credit_bill>) unapplied credits (see L<FS::cust_credit>)
3955 to outstanding invoice balances in chronological order (or reverse
3956 chronological order if the I<order> option is set to B<newest>) and returns the
3957 value of any remaining unapplied credits available for refund (see
3958 L<FS::cust_refund>).
3959
3960 Dies if there is an error.
3961
3962 =cut
3963
3964 sub apply_credits {
3965   my $self = shift;
3966   my %opt = @_;
3967
3968   local $SIG{HUP} = 'IGNORE';
3969   local $SIG{INT} = 'IGNORE';
3970   local $SIG{QUIT} = 'IGNORE';
3971   local $SIG{TERM} = 'IGNORE';
3972   local $SIG{TSTP} = 'IGNORE';
3973   local $SIG{PIPE} = 'IGNORE';
3974
3975   my $oldAutoCommit = $FS::UID::AutoCommit;
3976   local $FS::UID::AutoCommit = 0;
3977   my $dbh = dbh;
3978
3979   $self->select_for_update; #mutex
3980
3981   unless ( $self->total_credited ) {
3982     $dbh->commit or die $dbh->errstr if $oldAutoCommit;
3983     return 0;
3984   }
3985
3986   my @credits = sort { $b->_date <=> $a->_date} (grep { $_->credited > 0 }
3987       qsearch('cust_credit', { 'custnum' => $self->custnum } ) );
3988
3989   my @invoices = $self->open_cust_bill;
3990   @invoices = sort { $b->_date <=> $a->_date } @invoices
3991     if defined($opt{'order'}) && $opt{'order'} eq 'newest';
3992
3993   my $credit;
3994   foreach my $cust_bill ( @invoices ) {
3995     my $amount;
3996
3997     if ( !defined($credit) || $credit->credited == 0) {
3998       $credit = pop @credits or last;
3999     }
4000
4001     if ($cust_bill->owed >= $credit->credited) {
4002       $amount=$credit->credited;
4003     }else{
4004       $amount=$cust_bill->owed;
4005     }
4006     
4007     my $cust_credit_bill = new FS::cust_credit_bill ( {
4008       'crednum' => $credit->crednum,
4009       'invnum'  => $cust_bill->invnum,
4010       'amount'  => $amount,
4011     } );
4012     my $error = $cust_credit_bill->insert;
4013     if ( $error ) {
4014       $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
4015       die $error;
4016     }
4017     
4018     redo if ($cust_bill->owed > 0);
4019
4020   }
4021
4022   my $total_credited = $self->total_credited;
4023
4024   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
4025
4026   return $total_credited;
4027 }
4028
4029 =item apply_payments
4030
4031 Applies (see L<FS::cust_bill_pay>) unapplied payments (see L<FS::cust_pay>)
4032 to outstanding invoice balances in chronological order.
4033
4034  #and returns the value of any remaining unapplied payments.
4035
4036 Dies if there is an error.
4037
4038 =cut
4039
4040 sub apply_payments {
4041   my $self = shift;
4042
4043   local $SIG{HUP} = 'IGNORE';
4044   local $SIG{INT} = 'IGNORE';
4045   local $SIG{QUIT} = 'IGNORE';
4046   local $SIG{TERM} = 'IGNORE';
4047   local $SIG{TSTP} = 'IGNORE';
4048   local $SIG{PIPE} = 'IGNORE';
4049
4050   my $oldAutoCommit = $FS::UID::AutoCommit;
4051   local $FS::UID::AutoCommit = 0;
4052   my $dbh = dbh;
4053
4054   $self->select_for_update; #mutex
4055
4056   #return 0 unless
4057
4058   my @payments = sort { $b->_date <=> $a->_date } ( grep { $_->unapplied > 0 }
4059       qsearch('cust_pay', { 'custnum' => $self->custnum } ) );
4060
4061   my @invoices = sort { $a->_date <=> $b->_date} (grep { $_->owed > 0 }
4062       qsearch('cust_bill', { 'custnum' => $self->custnum } ) );
4063
4064   my $payment;
4065
4066   foreach my $cust_bill ( @invoices ) {
4067     my $amount;
4068
4069     if ( !defined($payment) || $payment->unapplied == 0 ) {
4070       $payment = pop @payments or last;
4071     }
4072
4073     if ( $cust_bill->owed >= $payment->unapplied ) {
4074       $amount = $payment->unapplied;
4075     } else {
4076       $amount = $cust_bill->owed;
4077     }
4078
4079     my $cust_bill_pay = new FS::cust_bill_pay ( {
4080       'paynum' => $payment->paynum,
4081       'invnum' => $cust_bill->invnum,
4082       'amount' => $amount,
4083     } );
4084     my $error = $cust_bill_pay->insert;
4085     if ( $error ) {
4086       $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
4087       die $error;
4088     }
4089
4090     redo if ( $cust_bill->owed > 0);
4091
4092   }
4093
4094   my $total_unapplied_payments = $self->total_unapplied_payments;
4095
4096   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
4097
4098   return $total_unapplied_payments;
4099 }
4100
4101 =item total_credited
4102
4103 Returns the total outstanding credit (see L<FS::cust_credit>) for this
4104 customer.  See L<FS::cust_credit/credited>.
4105
4106 =cut
4107
4108 sub total_credited {
4109   my $self = shift;
4110   my $total_credit = 0;
4111   foreach my $cust_credit ( qsearch('cust_credit', {
4112     'custnum' => $self->custnum,
4113   } ) ) {
4114     $total_credit += $cust_credit->credited;
4115   }
4116   sprintf( "%.2f", $total_credit );
4117 }
4118
4119 =item total_unapplied_payments
4120
4121 Returns the total unapplied payments (see L<FS::cust_pay>) for this customer.
4122 See L<FS::cust_pay/unapplied>.
4123
4124 =cut
4125
4126 sub total_unapplied_payments {
4127   my $self = shift;
4128   my $total_unapplied = 0;
4129   foreach my $cust_pay ( qsearch('cust_pay', {
4130     'custnum' => $self->custnum,
4131   } ) ) {
4132     $total_unapplied += $cust_pay->unapplied;
4133   }
4134   sprintf( "%.2f", $total_unapplied );
4135 }
4136
4137 =item total_unapplied_refunds
4138
4139 Returns the total unrefunded refunds (see L<FS::cust_refund>) for this
4140 customer.  See L<FS::cust_refund/unapplied>.
4141
4142 =cut
4143
4144 sub total_unapplied_refunds {
4145   my $self = shift;
4146   my $total_unapplied = 0;
4147   foreach my $cust_refund ( qsearch('cust_refund', {
4148     'custnum' => $self->custnum,
4149   } ) ) {
4150     $total_unapplied += $cust_refund->unapplied;
4151   }
4152   sprintf( "%.2f", $total_unapplied );
4153 }
4154
4155 =item balance
4156
4157 Returns the balance for this customer (total_owed plus total_unrefunded, minus
4158 total_credited minus total_unapplied_payments).
4159
4160 =cut
4161
4162 sub balance {
4163   my $self = shift;
4164   sprintf( "%.2f",
4165       $self->total_owed
4166     + $self->total_unapplied_refunds
4167     - $self->total_credited
4168     - $self->total_unapplied_payments
4169   );
4170 }
4171
4172 =item balance_date TIME
4173
4174 Returns the balance for this customer, only considering invoices with date
4175 earlier than TIME (total_owed_date minus total_credited minus
4176 total_unapplied_payments).  TIME is specified as a UNIX timestamp; see
4177 L<perlfunc/"time">).  Also see L<Time::Local> and L<Date::Parse> for conversion
4178 functions.
4179
4180 =cut
4181
4182 sub balance_date {
4183   my $self = shift;
4184   my $time = shift;
4185   sprintf( "%.2f",
4186         $self->total_owed_date($time)
4187       + $self->total_unapplied_refunds
4188       - $self->total_credited
4189       - $self->total_unapplied_payments
4190   );
4191 }
4192
4193 =item in_transit_payments
4194
4195 Returns the total of requests for payments for this customer pending in 
4196 batches in transit to the bank.  See L<FS::pay_batch> and L<FS::cust_pay_batch>
4197
4198 =cut
4199
4200 sub in_transit_payments {
4201   my $self = shift;
4202   my $in_transit_payments = 0;
4203   foreach my $pay_batch ( qsearch('pay_batch', {
4204     'status' => 'I',
4205   } ) ) {
4206     foreach my $cust_pay_batch ( qsearch('cust_pay_batch', {
4207       'batchnum' => $pay_batch->batchnum,
4208       'custnum' => $self->custnum,
4209     } ) ) {
4210       $in_transit_payments += $cust_pay_batch->amount;
4211     }
4212   }
4213   sprintf( "%.2f", $in_transit_payments );
4214 }
4215
4216 =item paydate_monthyear
4217
4218 Returns a two-element list consisting of the month and year of this customer's
4219 paydate (credit card expiration date for CARD customers)
4220
4221 =cut
4222
4223 sub paydate_monthyear {
4224   my $self = shift;
4225   if ( $self->paydate  =~ /^(\d{4})-(\d{1,2})-\d{1,2}$/ ) { #Pg date format
4226     ( $2, $1 );
4227   } elsif ( $self->paydate =~ /^(\d{1,2})-(\d{1,2}-)?(\d{4}$)/ ) {
4228     ( $1, $3 );
4229   } else {
4230     ('', '');
4231   }
4232 }
4233
4234 =item invoicing_list [ ARRAYREF ]
4235
4236 If an arguement is given, sets these email addresses as invoice recipients
4237 (see L<FS::cust_main_invoice>).  Errors are not fatal and are not reported
4238 (except as warnings), so use check_invoicing_list first.
4239
4240 Returns a list of email addresses (with svcnum entries expanded).
4241
4242 Note: You can clear the invoicing list by passing an empty ARRAYREF.  You can
4243 check it without disturbing anything by passing nothing.
4244
4245 This interface may change in the future.
4246
4247 =cut
4248
4249 sub invoicing_list {
4250   my( $self, $arrayref ) = @_;
4251
4252   if ( $arrayref ) {
4253     my @cust_main_invoice;
4254     if ( $self->custnum ) {
4255       @cust_main_invoice = 
4256         qsearch( 'cust_main_invoice', { 'custnum' => $self->custnum } );
4257     } else {
4258       @cust_main_invoice = ();
4259     }
4260     foreach my $cust_main_invoice ( @cust_main_invoice ) {
4261       #warn $cust_main_invoice->destnum;
4262       unless ( grep { $cust_main_invoice->address eq $_ } @{$arrayref} ) {
4263         #warn $cust_main_invoice->destnum;
4264         my $error = $cust_main_invoice->delete;
4265         warn $error if $error;
4266       }
4267     }
4268     if ( $self->custnum ) {
4269       @cust_main_invoice = 
4270         qsearch( 'cust_main_invoice', { 'custnum' => $self->custnum } );
4271     } else {
4272       @cust_main_invoice = ();
4273     }
4274     my %seen = map { $_->address => 1 } @cust_main_invoice;
4275     foreach my $address ( @{$arrayref} ) {
4276       next if exists $seen{$address} && $seen{$address};
4277       $seen{$address} = 1;
4278       my $cust_main_invoice = new FS::cust_main_invoice ( {
4279         'custnum' => $self->custnum,
4280         'dest'    => $address,
4281       } );
4282       my $error = $cust_main_invoice->insert;
4283       warn $error if $error;
4284     }
4285   }
4286   
4287   if ( $self->custnum ) {
4288     map { $_->address }
4289       qsearch( 'cust_main_invoice', { 'custnum' => $self->custnum } );
4290   } else {
4291     ();
4292   }
4293
4294 }
4295
4296 =item check_invoicing_list ARRAYREF
4297
4298 Checks these arguements as valid input for the invoicing_list method.  If there
4299 is an error, returns the error, otherwise returns false.
4300
4301 =cut
4302
4303 sub check_invoicing_list {
4304   my( $self, $arrayref ) = @_;
4305
4306   foreach my $address ( @$arrayref ) {
4307
4308     if ($address eq 'FAX' and $self->getfield('fax') eq '') {
4309       return 'Can\'t add FAX invoice destination with a blank FAX number.';
4310     }
4311
4312     my $cust_main_invoice = new FS::cust_main_invoice ( {
4313       'custnum' => $self->custnum,
4314       'dest'    => $address,
4315     } );
4316     my $error = $self->custnum
4317                 ? $cust_main_invoice->check
4318                 : $cust_main_invoice->checkdest
4319     ;
4320     return $error if $error;
4321
4322   }
4323
4324   return "Email address required"
4325     if $conf->exists('cust_main-require_invoicing_list_email')
4326     && ! grep { $_ !~ /^([A-Z]+)$/ } @$arrayref;
4327
4328   '';
4329 }
4330
4331 =item set_default_invoicing_list
4332
4333 Sets the invoicing list to all accounts associated with this customer,
4334 overwriting any previous invoicing list.
4335
4336 =cut
4337
4338 sub set_default_invoicing_list {
4339   my $self = shift;
4340   $self->invoicing_list($self->all_emails);
4341 }
4342
4343 =item all_emails
4344
4345 Returns the email addresses of all accounts provisioned for this customer.
4346
4347 =cut
4348
4349 sub all_emails {
4350   my $self = shift;
4351   my %list;
4352   foreach my $cust_pkg ( $self->all_pkgs ) {
4353     my @cust_svc = qsearch('cust_svc', { 'pkgnum' => $cust_pkg->pkgnum } );
4354     my @svc_acct =
4355       map { qsearchs('svc_acct', { 'svcnum' => $_->svcnum } ) }
4356         grep { qsearchs('svc_acct', { 'svcnum' => $_->svcnum } ) }
4357           @cust_svc;
4358     $list{$_}=1 foreach map { $_->email } @svc_acct;
4359   }
4360   keys %list;
4361 }
4362
4363 =item invoicing_list_addpost
4364
4365 Adds postal invoicing to this customer.  If this customer is already configured
4366 to receive postal invoices, does nothing.
4367
4368 =cut
4369
4370 sub invoicing_list_addpost {
4371   my $self = shift;
4372   return if grep { $_ eq 'POST' } $self->invoicing_list;
4373   my @invoicing_list = $self->invoicing_list;
4374   push @invoicing_list, 'POST';
4375   $self->invoicing_list(\@invoicing_list);
4376 }
4377
4378 =item invoicing_list_emailonly
4379
4380 Returns the list of email invoice recipients (invoicing_list without non-email
4381 destinations such as POST and FAX).
4382
4383 =cut
4384
4385 sub invoicing_list_emailonly {
4386   my $self = shift;
4387   warn "$me invoicing_list_emailonly called"
4388     if $DEBUG;
4389   grep { $_ !~ /^([A-Z]+)$/ } $self->invoicing_list;
4390 }
4391
4392 =item invoicing_list_emailonly_scalar
4393
4394 Returns the list of email invoice recipients (invoicing_list without non-email
4395 destinations such as POST and FAX) as a comma-separated scalar.
4396
4397 =cut
4398
4399 sub invoicing_list_emailonly_scalar {
4400   my $self = shift;
4401   warn "$me invoicing_list_emailonly_scalar called"
4402     if $DEBUG;
4403   join(', ', $self->invoicing_list_emailonly);
4404 }
4405
4406 =item referral_cust_main [ DEPTH [ EXCLUDE_HASHREF ] ]
4407
4408 Returns an array of customers referred by this customer (referral_custnum set
4409 to this custnum).  If DEPTH is given, recurses up to the given depth, returning
4410 customers referred by customers referred by this customer and so on, inclusive.
4411 The default behavior is DEPTH 1 (no recursion).
4412
4413 =cut
4414
4415 sub referral_cust_main {
4416   my $self = shift;
4417   my $depth = @_ ? shift : 1;
4418   my $exclude = @_ ? shift : {};
4419
4420   my @cust_main =
4421     map { $exclude->{$_->custnum}++; $_; }
4422       grep { ! $exclude->{ $_->custnum } }
4423         qsearch( 'cust_main', { 'referral_custnum' => $self->custnum } );
4424
4425   if ( $depth > 1 ) {
4426     push @cust_main,
4427       map { $_->referral_cust_main($depth-1, $exclude) }
4428         @cust_main;
4429   }
4430
4431   @cust_main;
4432 }
4433
4434 =item referral_cust_main_ncancelled
4435
4436 Same as referral_cust_main, except only returns customers with uncancelled
4437 packages.
4438
4439 =cut
4440
4441 sub referral_cust_main_ncancelled {
4442   my $self = shift;
4443   grep { scalar($_->ncancelled_pkgs) } $self->referral_cust_main;
4444 }
4445
4446 =item referral_cust_pkg [ DEPTH ]
4447
4448 Like referral_cust_main, except returns a flat list of all unsuspended (and
4449 uncancelled) packages for each customer.  The number of items in this list may
4450 be useful for comission calculations (perhaps after a C<grep { my $pkgpart = $_->pkgpart; grep { $_ == $pkgpart } @commission_worthy_pkgparts> } $cust_main-> ).
4451
4452 =cut
4453
4454 sub referral_cust_pkg {
4455   my $self = shift;
4456   my $depth = @_ ? shift : 1;
4457
4458   map { $_->unsuspended_pkgs }
4459     grep { $_->unsuspended_pkgs }
4460       $self->referral_cust_main($depth);
4461 }
4462
4463 =item referring_cust_main
4464
4465 Returns the single cust_main record for the customer who referred this customer
4466 (referral_custnum), or false.
4467
4468 =cut
4469
4470 sub referring_cust_main {
4471   my $self = shift;
4472   return '' unless $self->referral_custnum;
4473   qsearchs('cust_main', { 'custnum' => $self->referral_custnum } );
4474 }
4475
4476 =item credit AMOUNT, REASON
4477
4478 Applies a credit to this customer.  If there is an error, returns the error,
4479 otherwise returns false.
4480
4481 =cut
4482
4483 sub credit {
4484   my( $self, $amount, $reason ) = @_;
4485   my $cust_credit = new FS::cust_credit {
4486     'custnum' => $self->custnum,
4487     'amount'  => $amount,
4488     'reason'  => $reason,
4489   };
4490   $cust_credit->insert;
4491 }
4492
4493 =item charge AMOUNT [ PKG [ COMMENT [ TAXCLASS ] ] ]
4494
4495 Creates a one-time charge for this customer.  If there is an error, returns
4496 the error, otherwise returns false.
4497
4498 =cut
4499
4500 sub charge {
4501   my $self = shift;
4502   my ( $amount, $pkg, $comment, $taxclass, $additional );
4503   if ( ref( $_[0] ) ) {
4504     $amount     = $_[0]->{amount};
4505     $pkg        = exists($_[0]->{pkg}) ? $_[0]->{pkg} : 'One-time charge';
4506     $comment    = exists($_[0]->{comment}) ? $_[0]->{comment}
4507                                            : '$'. sprintf("%.2f",$amount);
4508     $taxclass   = exists($_[0]->{taxclass}) ? $_[0]->{taxclass} : '';
4509     $additional = $_[0]->{additional};
4510   }else{
4511     $amount     = shift;
4512     $pkg        = @_ ? shift : 'One-time charge';
4513     $comment    = @_ ? shift : '$'. sprintf("%.2f",$amount);
4514     $taxclass   = @_ ? shift : '';
4515     $additional = [];
4516   }
4517
4518   local $SIG{HUP} = 'IGNORE';
4519   local $SIG{INT} = 'IGNORE';
4520   local $SIG{QUIT} = 'IGNORE';
4521   local $SIG{TERM} = 'IGNORE';
4522   local $SIG{TSTP} = 'IGNORE';
4523   local $SIG{PIPE} = 'IGNORE';
4524
4525   my $oldAutoCommit = $FS::UID::AutoCommit;
4526   local $FS::UID::AutoCommit = 0;
4527   my $dbh = dbh;
4528
4529   my $part_pkg = new FS::part_pkg ( {
4530     'pkg'      => $pkg,
4531     'comment'  => $comment,
4532     'plan'     => 'flat',
4533     'freq'     => 0,
4534     'disabled' => 'Y',
4535     'taxclass' => $taxclass,
4536   } );
4537
4538   my %options = ( ( map { ("additional_info$_" => $additional->[$_] ) }
4539                         ( 0 .. @$additional - 1 )
4540                   ),
4541                   'additional_count' => scalar(@$additional),
4542                   'setup_fee' => $amount,
4543                 );
4544
4545   my $error = $part_pkg->insert( options => \%options );
4546   if ( $error ) {
4547     $dbh->rollback if $oldAutoCommit;
4548     return $error;
4549   }
4550
4551   my $pkgpart = $part_pkg->pkgpart;
4552   my %type_pkgs = ( 'typenum' => $self->agent->typenum, 'pkgpart' => $pkgpart );
4553   unless ( qsearchs('type_pkgs', \%type_pkgs ) ) {
4554     my $type_pkgs = new FS::type_pkgs \%type_pkgs;
4555     $error = $type_pkgs->insert;
4556     if ( $error ) {
4557       $dbh->rollback if $oldAutoCommit;
4558       return $error;
4559     }
4560   }
4561
4562   my $cust_pkg = new FS::cust_pkg ( {
4563     'custnum' => $self->custnum,
4564     'pkgpart' => $pkgpart,
4565   } );
4566
4567   $error = $cust_pkg->insert;
4568   if ( $error ) {
4569     $dbh->rollback if $oldAutoCommit;
4570     return $error;
4571   }
4572
4573   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
4574   '';
4575
4576 }
4577
4578 =item cust_bill
4579
4580 Returns all the invoices (see L<FS::cust_bill>) for this customer.
4581
4582 =cut
4583
4584 sub cust_bill {
4585   my $self = shift;
4586   sort { $a->_date <=> $b->_date }
4587     qsearch('cust_bill', { 'custnum' => $self->custnum, } )
4588 }
4589
4590 =item open_cust_bill
4591
4592 Returns all the open (owed > 0) invoices (see L<FS::cust_bill>) for this
4593 customer.
4594
4595 =cut
4596
4597 sub open_cust_bill {
4598   my $self = shift;
4599   grep { $_->owed > 0 } $self->cust_bill;
4600 }
4601
4602 =item cust_credit
4603
4604 Returns all the credits (see L<FS::cust_credit>) for this customer.
4605
4606 =cut
4607
4608 sub cust_credit {
4609   my $self = shift;
4610   sort { $a->_date <=> $b->_date }
4611     qsearch( 'cust_credit', { 'custnum' => $self->custnum } )
4612 }
4613
4614 =item cust_pay
4615
4616 Returns all the payments (see L<FS::cust_pay>) for this customer.
4617
4618 =cut
4619
4620 sub cust_pay {
4621   my $self = shift;
4622   sort { $a->_date <=> $b->_date }
4623     qsearch( 'cust_pay', { 'custnum' => $self->custnum } )
4624 }
4625
4626 =item cust_pay_void
4627
4628 Returns all voided payments (see L<FS::cust_pay_void>) for this customer.
4629
4630 =cut
4631
4632 sub cust_pay_void {
4633   my $self = shift;
4634   sort { $a->_date <=> $b->_date }
4635     qsearch( 'cust_pay_void', { 'custnum' => $self->custnum } )
4636 }
4637
4638 =item cust_pay_batch
4639
4640 Returns all batched payments (see L<FS::cust_pay_void>) for this customer.
4641
4642 =cut
4643
4644 sub cust_pay_batch {
4645   my $self = shift;
4646   sort { $a->_date <=> $b->_date }
4647     qsearch( 'cust_pay_batch', { 'custnum' => $self->custnum } )
4648 }
4649
4650 =item cust_refund
4651
4652 Returns all the refunds (see L<FS::cust_refund>) for this customer.
4653
4654 =cut
4655
4656 sub cust_refund {
4657   my $self = shift;
4658   sort { $a->_date <=> $b->_date }
4659     qsearch( 'cust_refund', { 'custnum' => $self->custnum } )
4660 }
4661
4662 =item name
4663
4664 Returns a name string for this customer, either "Company (Last, First)" or
4665 "Last, First".
4666
4667 =cut
4668
4669 sub name {
4670   my $self = shift;
4671   my $name = $self->contact;
4672   $name = $self->company. " ($name)" if $self->company;
4673   $name;
4674 }
4675
4676 =item ship_name
4677
4678 Returns a name string for this (service/shipping) contact, either
4679 "Company (Last, First)" or "Last, First".
4680
4681 =cut
4682
4683 sub ship_name {
4684   my $self = shift;
4685   if ( $self->get('ship_last') ) { 
4686     my $name = $self->ship_contact;
4687     $name = $self->ship_company. " ($name)" if $self->ship_company;
4688     $name;
4689   } else {
4690     $self->name;
4691   }
4692 }
4693
4694 =item contact
4695
4696 Returns this customer's full (billing) contact name only, "Last, First"
4697
4698 =cut
4699
4700 sub contact {
4701   my $self = shift;
4702   $self->get('last'). ', '. $self->first;
4703 }
4704
4705 =item ship_contact
4706
4707 Returns this customer's full (shipping) contact name only, "Last, First"
4708
4709 =cut
4710
4711 sub ship_contact {
4712   my $self = shift;
4713   $self->get('ship_last')
4714     ? $self->get('ship_last'). ', '. $self->ship_first
4715     : $self->contact;
4716 }
4717
4718 =item country_full
4719
4720 Returns this customer's full country name
4721
4722 =cut
4723
4724 sub country_full {
4725   my $self = shift;
4726   code2country($self->country);
4727 }
4728
4729 =item cust_status
4730
4731 =item status
4732
4733 Returns a status string for this customer, currently:
4734
4735 =over 4
4736
4737 =item prospect - No packages have ever been ordered
4738
4739 =item active - One or more recurring packages is active
4740
4741 =item inactive - No active recurring packages, but otherwise unsuspended/uncancelled (the inactive status is new - previously inactive customers were mis-identified as cancelled)
4742
4743 =item suspended - All non-cancelled recurring packages are suspended
4744
4745 =item cancelled - All recurring packages are cancelled
4746
4747 =back
4748
4749 =cut
4750
4751 sub status { shift->cust_status(@_); }
4752
4753 sub cust_status {
4754   my $self = shift;
4755   for my $status (qw( prospect active inactive suspended cancelled )) {
4756     my $method = $status.'_sql';
4757     my $numnum = ( my $sql = $self->$method() ) =~ s/cust_main\.custnum/?/g;
4758     my $sth = dbh->prepare("SELECT $sql") or die dbh->errstr;
4759     $sth->execute( ($self->custnum) x $numnum )
4760       or die "Error executing 'SELECT $sql': ". $sth->errstr;
4761     return $status if $sth->fetchrow_arrayref->[0];
4762   }
4763 }
4764
4765 =item ucfirst_cust_status
4766
4767 =item ucfirst_status
4768
4769 Returns the status with the first character capitalized.
4770
4771 =cut
4772
4773 sub ucfirst_status { shift->ucfirst_cust_status(@_); }
4774
4775 sub ucfirst_cust_status {
4776   my $self = shift;
4777   ucfirst($self->cust_status);
4778 }
4779
4780 =item statuscolor
4781
4782 Returns a hex triplet color string for this customer's status.
4783
4784 =cut
4785
4786 use vars qw(%statuscolor);
4787 tie my %statuscolor, 'Tie::IxHash',
4788   'prospect'  => '7e0079', #'000000', #black?  naw, purple
4789   'active'    => '00CC00', #green
4790   'inactive'  => '0000CC', #blue
4791   'suspended' => 'FF9900', #yellow
4792   'cancelled' => 'FF0000', #red
4793 ;
4794
4795 sub statuscolor { shift->cust_statuscolor(@_); }
4796
4797 sub cust_statuscolor {
4798   my $self = shift;
4799   $statuscolor{$self->cust_status};
4800 }
4801
4802 =item tickets
4803
4804 Returns an array of hashes representing the customer's RT tickets.
4805
4806 =cut
4807
4808 sub tickets {
4809   my $self = shift;
4810
4811   my $num = $conf->config('cust_main-max_tickets') || 10;
4812   my @tickets = ();
4813
4814   unless ( $conf->config('ticket_system-custom_priority_field') ) {
4815
4816     @tickets = @{ FS::TicketSystem->customer_tickets($self->custnum, $num) };
4817
4818   } else {
4819
4820     foreach my $priority (
4821       $conf->config('ticket_system-custom_priority_field-values'), ''
4822     ) {
4823       last if scalar(@tickets) >= $num;
4824       push @tickets, 
4825         @{ FS::TicketSystem->customer_tickets( $self->custnum,
4826                                                $num - scalar(@tickets),
4827                                                $priority,
4828                                              )
4829          };
4830     }
4831   }
4832   (@tickets);
4833 }
4834
4835 # Return services representing svc_accts in customer support packages
4836 sub support_services {
4837   my $self = shift;
4838   my %packages = map { $_ => 1 } $conf->config('support_packages');
4839
4840   grep { $_->pkg_svc && $_->pkg_svc->primary_svc eq 'Y' }
4841     grep { $_->part_svc->svcdb eq 'svc_acct' }
4842     map { $_->cust_svc }
4843     grep { exists $packages{ $_->pkgpart } }
4844     $self->ncancelled_pkgs;
4845
4846 }
4847
4848 =back
4849
4850 =head1 CLASS METHODS
4851
4852 =over 4
4853
4854 =item statuses
4855
4856 Class method that returns the list of possible status strings for customers
4857 (see L<the status method|/status>).  For example:
4858
4859   @statuses = FS::cust_main->statuses();
4860
4861 =cut
4862
4863 sub statuses {
4864   #my $self = shift; #could be class...
4865   keys %statuscolor;
4866 }
4867
4868 =item prospect_sql
4869
4870 Returns an SQL expression identifying prospective cust_main records (customers
4871 with no packages ever ordered)
4872
4873 =cut
4874
4875 use vars qw($select_count_pkgs);
4876 $select_count_pkgs =
4877   "SELECT COUNT(*) FROM cust_pkg
4878     WHERE cust_pkg.custnum = cust_main.custnum";
4879
4880 sub select_count_pkgs_sql {
4881   $select_count_pkgs;
4882 }
4883
4884 sub prospect_sql { "
4885   0 = ( $select_count_pkgs )
4886 "; }
4887
4888 =item active_sql
4889
4890 Returns an SQL expression identifying active cust_main records (customers with
4891 no active recurring packages, but otherwise unsuspended/uncancelled).
4892
4893 =cut
4894
4895 sub active_sql { "
4896   0 < ( $select_count_pkgs AND ". FS::cust_pkg->active_sql. "
4897       )
4898 "; }
4899
4900 =item inactive_sql
4901
4902 Returns an SQL expression identifying inactive cust_main records (customers with
4903 active recurring packages).
4904
4905 =cut
4906
4907 sub inactive_sql { "
4908   0 = ( $select_count_pkgs AND ". FS::cust_pkg->active_sql. " )
4909   AND
4910   0 < ( $select_count_pkgs AND ". FS::cust_pkg->inactive_sql. " )
4911 "; }
4912
4913 =item susp_sql
4914 =item suspended_sql
4915
4916 Returns an SQL expression identifying suspended cust_main records.
4917
4918 =cut
4919
4920
4921 sub suspended_sql { susp_sql(@_); }
4922 sub susp_sql { "
4923     0 < ( $select_count_pkgs AND ". FS::cust_pkg->suspended_sql. " )
4924     AND
4925     0 = ( $select_count_pkgs AND ". FS::cust_pkg->active_sql. " )
4926 "; }
4927
4928 =item cancel_sql
4929 =item cancelled_sql
4930
4931 Returns an SQL expression identifying cancelled cust_main records.
4932
4933 =cut
4934
4935 sub cancelled_sql { cancel_sql(@_); }
4936 sub cancel_sql {
4937
4938   my $recurring_sql = FS::cust_pkg->recurring_sql;
4939   #my $recurring_sql = "
4940   #  '0' != ( select freq from part_pkg
4941   #             where cust_pkg.pkgpart = part_pkg.pkgpart )
4942   #";
4943
4944   "
4945     0 < ( $select_count_pkgs )
4946     AND 0 = ( $select_count_pkgs AND $recurring_sql
4947                   AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
4948             )
4949   ";
4950 }
4951
4952 =item uncancel_sql
4953 =item uncancelled_sql
4954
4955 Returns an SQL expression identifying un-cancelled cust_main records.
4956
4957 =cut
4958
4959 sub uncancelled_sql { uncancel_sql(@_); }
4960 sub uncancel_sql { "
4961   ( 0 < ( $select_count_pkgs
4962                    AND ( cust_pkg.cancel IS NULL
4963                          OR cust_pkg.cancel = 0
4964                        )
4965         )
4966     OR 0 = ( $select_count_pkgs )
4967   )
4968 "; }
4969
4970 =item balance_sql
4971
4972 Returns an SQL fragment to retreive the balance.
4973
4974 =cut
4975
4976 sub balance_sql { "
4977     COALESCE( ( SELECT SUM(charged) FROM cust_bill
4978                   WHERE cust_bill.custnum   = cust_main.custnum ), 0)
4979   - COALESCE( ( SELECT SUM(paid)    FROM cust_pay
4980                   WHERE cust_pay.custnum    = cust_main.custnum ), 0)
4981   - COALESCE( ( SELECT SUM(amount)  FROM cust_credit
4982                   WHERE cust_credit.custnum = cust_main.custnum ), 0)
4983   + COALESCE( ( SELECT SUM(refund)  FROM cust_refund
4984                    WHERE cust_refund.custnum = cust_main.custnum ), 0)
4985 "; }
4986
4987 =item balance_date_sql TIME
4988
4989 Returns an SQL fragment to retreive the balance for this customer, only
4990 considering invoices with date earlier than TIME. (total_owed_date minus total_credited minus
4991 total_unapplied_payments).  TIME is specified as an SQL fragment or a numeric
4992 UNIX timestamp; see L<perlfunc/"time">).  Also see L<Time::Local> and
4993 L<Date::Parse> for conversion functions.
4994
4995 =cut
4996
4997 sub balance_date_sql {
4998   my( $class, $time ) = @_;
4999
5000   my $owed_sql         = FS::cust_bill->owed_sql;
5001   my $unapp_refund_sql = FS::cust_refund->unapplied_sql;
5002   #my $unapp_credit_sql = FS::cust_credit->unapplied_sql;
5003   my $unapp_credit_sql = FS::cust_credit->credited_sql;
5004   my $unapp_pay_sql    = FS::cust_pay->unapplied_sql;
5005
5006   "
5007       COALESCE( ( SELECT SUM($owed_sql) FROM cust_bill
5008                     WHERE cust_bill.custnum   = cust_main.custnum
5009                       AND cust_bill._date    <= $time             )
5010                 ,0
5011               )
5012     + COALESCE( ( SELECT SUM($unapp_refund_sql) FROM cust_refund
5013                     WHERE cust_refund.custnum = cust_main.custnum )
5014                 ,0
5015               )
5016     - COALESCE( ( SELECT SUM($unapp_credit_sql) FROM cust_credit
5017                     WHERE cust_credit.custnum = cust_main.custnum )
5018                 ,0
5019               )
5020     - COALESCE( ( SELECT SUM($unapp_pay_sql) FROM cust_pay
5021                     WHERE cust_pay.custnum = cust_main.custnum )
5022                 ,0
5023               )
5024
5025   ";
5026
5027 }
5028
5029 =item fuzzy_search FUZZY_HASHREF [ HASHREF, SELECT, EXTRA_SQL, CACHE_OBJ ]
5030
5031 Performs a fuzzy (approximate) search and returns the matching FS::cust_main
5032 records.  Currently, I<first>, I<last> and/or I<company> may be specified (the
5033 appropriate ship_ field is also searched).
5034
5035 Additional options are the same as FS::Record::qsearch
5036
5037 =cut
5038
5039 sub fuzzy_search {
5040   my( $self, $fuzzy, $hash, @opt) = @_;
5041   #$self
5042   $hash ||= {};
5043   my @cust_main = ();
5044
5045   check_and_rebuild_fuzzyfiles();
5046   foreach my $field ( keys %$fuzzy ) {
5047
5048     my $all = $self->all_X($field);
5049     next unless scalar(@$all);
5050
5051     my %match = ();
5052     $match{$_}=1 foreach ( amatch( $fuzzy->{$field}, ['i'], @$all ) );
5053
5054     my @fcust = ();
5055     foreach ( keys %match ) {
5056       push @fcust, qsearch('cust_main', { %$hash, $field=>$_}, @opt);
5057       push @fcust, qsearch('cust_main', { %$hash, "ship_$field"=>$_}, @opt);
5058     }
5059     my %fsaw = ();
5060     push @cust_main, grep { ! $fsaw{$_->custnum}++ } @fcust;
5061   }
5062
5063   # we want the components of $fuzzy ANDed, not ORed, but still don't want dupes
5064   my %saw = ();
5065   @cust_main = grep { ++$saw{$_->custnum} == scalar(keys %$fuzzy) } @cust_main;
5066
5067   @cust_main;
5068
5069 }
5070
5071 =item masked FIELD
5072
5073 Returns a masked version of the named field
5074
5075 =cut
5076
5077 sub masked {
5078 my ($self,$field) = @_;
5079
5080 # Show last four
5081
5082 'x'x(length($self->getfield($field))-4).
5083   substr($self->getfield($field), (length($self->getfield($field))-4));
5084
5085 }
5086
5087 =back
5088
5089 =head1 SUBROUTINES
5090
5091 =over 4
5092
5093 =item smart_search OPTION => VALUE ...
5094
5095 Accepts the following options: I<search>, the string to search for.  The string
5096 will be searched for as a customer number, phone number, name or company name,
5097 as an exact, or, in some cases, a substring or fuzzy match (see the source code
5098 for the exact heuristics used); I<no_fuzzy_on_exact>, causes smart_search to
5099 skip fuzzy matching when an exact match is found.
5100
5101 Any additional options are treated as an additional qualifier on the search
5102 (i.e. I<agentnum>).
5103
5104 Returns a (possibly empty) array of FS::cust_main objects.
5105
5106 =cut
5107
5108 sub smart_search {
5109   my %options = @_;
5110
5111   #here is the agent virtualization
5112   my $agentnums_sql = $FS::CurrentUser::CurrentUser->agentnums_sql;
5113
5114   my @cust_main = ();
5115
5116   my $skip_fuzzy = delete $options{'no_fuzzy_on_exact'};
5117   my $search = delete $options{'search'};
5118   ( my $alphanum_search = $search ) =~ s/\W//g;
5119   
5120   if ( $alphanum_search =~ /^1?(\d{3})(\d{3})(\d{4})(\d*)$/ ) { #phone# search
5121
5122     #false laziness w/Record::ut_phone
5123     my $phonen = "$1-$2-$3";
5124     $phonen .= " x$4" if $4;
5125
5126     push @cust_main, qsearch( {
5127       'table'   => 'cust_main',
5128       'hashref' => { %options },
5129       'extra_sql' => ( scalar(keys %options) ? ' AND ' : ' WHERE ' ).
5130                      ' ( '.
5131                          join(' OR ', map "$_ = '$phonen'",
5132                                           qw( daytime night fax
5133                                               ship_daytime ship_night ship_fax )
5134                              ).
5135                      ' ) '.
5136                      " AND $agentnums_sql", #agent virtualization
5137     } );
5138
5139     unless ( @cust_main || $phonen =~ /x\d+$/ ) { #no exact match
5140       #try looking for matches with extensions unless one was specified
5141
5142       push @cust_main, qsearch( {
5143         'table'   => 'cust_main',
5144         'hashref' => { %options },
5145         'extra_sql' => ( scalar(keys %options) ? ' AND ' : ' WHERE ' ).
5146                        ' ( '.
5147                            join(' OR ', map "$_ LIKE '$phonen\%'",
5148                                             qw( daytime night
5149                                                 ship_daytime ship_night )
5150                                ).
5151                        ' ) '.
5152                        " AND $agentnums_sql", #agent virtualization
5153       } );
5154
5155     }
5156
5157   } elsif ( $search =~ /^\s*(\d+)\s*$/ ) { # customer # search
5158
5159     push @cust_main, qsearch( {
5160       'table'     => 'cust_main',
5161       'hashref'   => { 'custnum' => $1, %options },
5162       'extra_sql' => " AND $agentnums_sql", #agent virtualization
5163     } );
5164
5165   } elsif ( $search =~ /^\s*(\S.*\S)\s+\((.+), ([^,]+)\)\s*$/ ) {
5166
5167     my($company, $last, $first) = ( $1, $2, $3 );
5168
5169     # "Company (Last, First)"
5170     #this is probably something a browser remembered,
5171     #so just do an exact search
5172
5173     foreach my $prefix ( '', 'ship_' ) {
5174       push @cust_main, qsearch( {
5175         'table'     => 'cust_main',
5176         'hashref'   => { $prefix.'first'   => $first,
5177                          $prefix.'last'    => $last,
5178                          $prefix.'company' => $company,
5179                          %options,
5180                        },
5181         'extra_sql' => " AND $agentnums_sql",
5182       } );
5183     }
5184
5185   } elsif ( $search =~ /^\s*(\S.*\S)\s*$/ ) { # value search
5186                                               # try (ship_){last,company}
5187
5188     my $value = lc($1);
5189
5190     # # remove "(Last, First)" in "Company (Last, First)", otherwise the
5191     # # full strings the browser remembers won't work
5192     # $value =~ s/\([\w \,\.\-\']*\)$//; #false laziness w/Record::ut_name
5193
5194     use Lingua::EN::NameParse;
5195     my $NameParse = new Lingua::EN::NameParse(
5196              auto_clean     => 1,
5197              allow_reversed => 1,
5198     );
5199
5200     my($last, $first) = ( '', '' );
5201     #maybe disable this too and just rely on NameParse?
5202     if ( $value =~ /^(.+),\s*([^,]+)$/ ) { # Last, First
5203     
5204       ($last, $first) = ( $1, $2 );
5205     
5206     #} elsif  ( $value =~ /^(.+)\s+(.+)$/ ) {
5207     } elsif ( ! $NameParse->parse($value) ) {
5208
5209       my %name = $NameParse->components;
5210       $first = $name{'given_name_1'};
5211       $last  = $name{'surname_1'};
5212
5213     }
5214
5215     if ( $first && $last ) {
5216
5217       my($q_last, $q_first) = ( dbh->quote($last), dbh->quote($first) );
5218
5219       #exact
5220       my $sql = scalar(keys %options) ? ' AND ' : ' WHERE ';
5221       $sql .= "
5222         (     ( LOWER(last) = $q_last AND LOWER(first) = $q_first )
5223            OR ( LOWER(ship_last) = $q_last AND LOWER(ship_first) = $q_first )
5224         )";
5225
5226       push @cust_main, qsearch( {
5227         'table'     => 'cust_main',
5228         'hashref'   => \%options,
5229         'extra_sql' => "$sql AND $agentnums_sql", #agent virtualization
5230       } );
5231
5232       # or it just be something that was typed in... (try that in a sec)
5233
5234     }
5235
5236     my $q_value = dbh->quote($value);
5237
5238     #exact
5239     my $sql = scalar(keys %options) ? ' AND ' : ' WHERE ';
5240     $sql .= " (    LOWER(last)         = $q_value
5241                 OR LOWER(company)      = $q_value
5242                 OR LOWER(ship_last)    = $q_value
5243                 OR LOWER(ship_company) = $q_value
5244               )";
5245
5246     push @cust_main, qsearch( {
5247       'table'     => 'cust_main',
5248       'hashref'   => \%options,
5249       'extra_sql' => "$sql AND $agentnums_sql", #agent virtualization
5250     } );
5251
5252     #no exact match, trying substring/fuzzy
5253     #always do substring & fuzzy (unless they're explicity config'ed off)
5254     #getting complaints searches are not returning enough
5255     unless ( @cust_main  && $skip_fuzzy || $conf->exists('disable-fuzzy') ) {
5256
5257       #still some false laziness w/ search/cust_main.cgi
5258
5259       #substring
5260
5261       my @hashrefs = (
5262         { 'company'      => { op=>'ILIKE', value=>"%$value%" }, },
5263         { 'ship_company' => { op=>'ILIKE', value=>"%$value%" }, },
5264       );
5265
5266       if ( $first && $last ) {
5267
5268         push @hashrefs,
5269           { 'first'        => { op=>'ILIKE', value=>"%$first%" },
5270             'last'         => { op=>'ILIKE', value=>"%$last%" },
5271           },
5272           { 'ship_first'   => { op=>'ILIKE', value=>"%$first%" },
5273             'ship_last'    => { op=>'ILIKE', value=>"%$last%" },
5274           },
5275         ;
5276
5277       } else {
5278
5279         push @hashrefs,
5280           { 'last'         => { op=>'ILIKE', value=>"%$value%" }, },
5281           { 'ship_last'    => { op=>'ILIKE', value=>"%$value%" }, },
5282         ;
5283       }
5284
5285       foreach my $hashref ( @hashrefs ) {
5286
5287         push @cust_main, qsearch( {
5288           'table'     => 'cust_main',
5289           'hashref'   => { %$hashref,
5290                            %options,
5291                          },
5292           'extra_sql' => " AND $agentnums_sql", #agent virtualizaiton
5293         } );
5294
5295       }
5296
5297       #fuzzy
5298       my @fuzopts = (
5299         \%options,                #hashref
5300         '',                       #select
5301         " AND $agentnums_sql",    #extra_sql  #agent virtualization
5302       );
5303
5304       if ( $first && $last ) {
5305         push @cust_main, FS::cust_main->fuzzy_search(
5306           { 'last'   => $last,    #fuzzy hashref
5307             'first'  => $first }, #
5308           @fuzopts
5309         );
5310       }
5311       foreach my $field ( 'last', 'company' ) {
5312         push @cust_main,
5313           FS::cust_main->fuzzy_search( { $field => $value }, @fuzopts );
5314       }
5315
5316     }
5317
5318     #eliminate duplicates
5319     my %saw = ();
5320     @cust_main = grep { !$saw{$_->custnum}++ } @cust_main;
5321
5322   }
5323
5324   @cust_main;
5325
5326 }
5327
5328 =item check_and_rebuild_fuzzyfiles
5329
5330 =cut
5331
5332 use vars qw(@fuzzyfields);
5333 @fuzzyfields = ( 'last', 'first', 'company' );
5334
5335 sub check_and_rebuild_fuzzyfiles {
5336   my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
5337   rebuild_fuzzyfiles() if grep { ! -e "$dir/cust_main.$_" } @fuzzyfields
5338 }
5339
5340 =item rebuild_fuzzyfiles
5341
5342 =cut
5343
5344 sub rebuild_fuzzyfiles {
5345
5346   use Fcntl qw(:flock);
5347
5348   my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
5349   mkdir $dir, 0700 unless -d $dir;
5350
5351   foreach my $fuzzy ( @fuzzyfields ) {
5352
5353     open(LOCK,">>$dir/cust_main.$fuzzy")
5354       or die "can't open $dir/cust_main.$fuzzy: $!";
5355     flock(LOCK,LOCK_EX)
5356       or die "can't lock $dir/cust_main.$fuzzy: $!";
5357
5358     open (CACHE,">$dir/cust_main.$fuzzy.tmp")
5359       or die "can't open $dir/cust_main.$fuzzy.tmp: $!";
5360
5361     foreach my $field ( $fuzzy, "ship_$fuzzy" ) {
5362       my $sth = dbh->prepare("SELECT $field FROM cust_main".
5363                              " WHERE $field != '' AND $field IS NOT NULL");
5364       $sth->execute or die $sth->errstr;
5365
5366       while ( my $row = $sth->fetchrow_arrayref ) {
5367         print CACHE $row->[0]. "\n";
5368       }
5369
5370     } 
5371
5372     close CACHE or die "can't close $dir/cust_main.$fuzzy.tmp: $!";
5373   
5374     rename "$dir/cust_main.$fuzzy.tmp", "$dir/cust_main.$fuzzy";
5375     close LOCK;
5376   }
5377
5378 }
5379
5380 =item all_X
5381
5382 =cut
5383
5384 sub all_X {
5385   my( $self, $field ) = @_;
5386   my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
5387   open(CACHE,"<$dir/cust_main.$field")
5388     or die "can't open $dir/cust_main.$field: $!";
5389   my @array = map { chomp; $_; } <CACHE>;
5390   close CACHE;
5391   \@array;
5392 }
5393
5394 =item append_fuzzyfiles LASTNAME COMPANY
5395
5396 =cut
5397
5398 sub append_fuzzyfiles {
5399   #my( $first, $last, $company ) = @_;
5400
5401   &check_and_rebuild_fuzzyfiles;
5402
5403   use Fcntl qw(:flock);
5404
5405   my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
5406
5407   foreach my $field (qw( first last company )) {
5408     my $value = shift;
5409
5410     if ( $value ) {
5411
5412       open(CACHE,">>$dir/cust_main.$field")
5413         or die "can't open $dir/cust_main.$field: $!";
5414       flock(CACHE,LOCK_EX)
5415         or die "can't lock $dir/cust_main.$field: $!";
5416
5417       print CACHE "$value\n";
5418
5419       flock(CACHE,LOCK_UN)
5420         or die "can't unlock $dir/cust_main.$field: $!";
5421       close CACHE;
5422     }
5423
5424   }
5425
5426   1;
5427 }
5428
5429 =item batch_import
5430
5431 =cut
5432
5433 sub batch_import {
5434   my $param = shift;
5435   #warn join('-',keys %$param);
5436   my $fh = $param->{filehandle};
5437   my $agentnum = $param->{agentnum};
5438
5439   my $refnum = $param->{refnum};
5440   my $pkgpart = $param->{pkgpart};
5441
5442   #my @fields = @{$param->{fields}};
5443   my $format = $param->{'format'};
5444   my @fields;
5445   my $payby;
5446   if ( $format eq 'simple' ) {
5447     @fields = qw( cust_pkg.setup dayphone first last
5448                   address1 address2 city state zip comments );
5449     $payby = 'BILL';
5450   } elsif ( $format eq 'extended' ) {
5451     @fields = qw( agent_custid refnum
5452                   last first address1 address2 city state zip country
5453                   daytime night
5454                   ship_last ship_first ship_address1 ship_address2
5455                   ship_city ship_state ship_zip ship_country
5456                   payinfo paycvv paydate
5457                   invoicing_list
5458                   cust_pkg.pkgpart
5459                   svc_acct.username svc_acct._password 
5460                 );
5461     $payby = 'BILL';
5462   } else {
5463     die "unknown format $format";
5464   }
5465
5466   eval "use Text::CSV_XS;";
5467   die $@ if $@;
5468
5469   my $csv = new Text::CSV_XS;
5470   #warn $csv;
5471   #warn $fh;
5472
5473   my $imported = 0;
5474   #my $columns;
5475
5476   local $SIG{HUP} = 'IGNORE';
5477   local $SIG{INT} = 'IGNORE';
5478   local $SIG{QUIT} = 'IGNORE';
5479   local $SIG{TERM} = 'IGNORE';
5480   local $SIG{TSTP} = 'IGNORE';
5481   local $SIG{PIPE} = 'IGNORE';
5482
5483   my $oldAutoCommit = $FS::UID::AutoCommit;
5484   local $FS::UID::AutoCommit = 0;
5485   my $dbh = dbh;
5486   
5487   #while ( $columns = $csv->getline($fh) ) {
5488   my $line;
5489   while ( defined($line=<$fh>) ) {
5490
5491     $csv->parse($line) or do {
5492       $dbh->rollback if $oldAutoCommit;
5493       return "can't parse: ". $csv->error_input();
5494     };
5495
5496     my @columns = $csv->fields();
5497     #warn join('-',@columns);
5498
5499     my %cust_main = (
5500       agentnum => $agentnum,
5501       refnum   => $refnum,
5502       country  => $conf->config('countrydefault') || 'US',
5503       payby    => $payby, #default
5504       paydate  => '12/2037', #default
5505     );
5506     my $billtime = time;
5507     my %cust_pkg = ( pkgpart => $pkgpart );
5508     my %svc_acct = ();
5509     foreach my $field ( @fields ) {
5510
5511       if ( $field =~ /^cust_pkg\.(pkgpart|setup|bill|susp|adjourn|expire|cancel)$/ ) {
5512
5513         #$cust_pkg{$1} = str2time( shift @$columns );
5514         if ( $1 eq 'pkgpart' ) {
5515           $cust_pkg{$1} = shift @columns;
5516         } elsif ( $1 eq 'setup' ) {
5517           $billtime = str2time(shift @columns);
5518         } else {
5519           $cust_pkg{$1} = str2time( shift @columns );
5520         } 
5521
5522       } elsif ( $field =~ /^svc_acct\.(username|_password)$/ ) {
5523
5524         $svc_acct{$1} = shift @columns;
5525         
5526       } else {
5527
5528         #refnum interception
5529         if ( $field eq 'refnum' && $columns[0] !~ /^\s*(\d+)\s*$/ ) {
5530
5531           my $referral = $columns[0];
5532           my %hash = ( 'referral' => $referral,
5533                        'agentnum' => $agentnum,
5534                        'disabled' => '',
5535                      );
5536
5537           my $part_referral = qsearchs('part_referral', \%hash )
5538                               || new FS::part_referral \%hash;
5539
5540           unless ( $part_referral->refnum ) {
5541             my $error = $part_referral->insert;
5542             if ( $error ) {
5543               $dbh->rollback if $oldAutoCommit;
5544               return "can't auto-insert advertising source: $referral: $error";
5545             }
5546           }
5547
5548           $columns[0] = $part_referral->refnum;
5549         }
5550
5551         #$cust_main{$field} = shift @$columns; 
5552         $cust_main{$field} = shift @columns; 
5553       }
5554     }
5555
5556     $cust_main{'payby'} = 'CARD' if length($cust_main{'payinfo'});
5557
5558     my $invoicing_list = $cust_main{'invoicing_list'}
5559                            ? [ delete $cust_main{'invoicing_list'} ]
5560                            : [];
5561
5562     my $cust_main = new FS::cust_main ( \%cust_main );
5563
5564     use Tie::RefHash;
5565     tie my %hash, 'Tie::RefHash'; #this part is important
5566
5567     if ( $cust_pkg{'pkgpart'} ) {
5568       my $cust_pkg = new FS::cust_pkg ( \%cust_pkg );
5569
5570       my @svc_acct = ();
5571       if ( $svc_acct{'username'} ) {
5572         my $part_pkg = $cust_pkg->part_pkg;
5573         unless ( $part_pkg ) {
5574           $dbh->rollback if $oldAutoCommit;
5575           return "unknown pkgpart: ". $cust_pkg{'pkgpart'};
5576         } 
5577         $svc_acct{svcpart} = $part_pkg->svcpart( 'svc_acct' );
5578         push @svc_acct, new FS::svc_acct ( \%svc_acct )
5579       }
5580
5581       $hash{$cust_pkg} = \@svc_acct;
5582     }
5583
5584     my $error = $cust_main->insert( \%hash, $invoicing_list );
5585
5586     if ( $error ) {
5587       $dbh->rollback if $oldAutoCommit;
5588       return "can't insert customer for $line: $error";
5589     }
5590
5591     if ( $format eq 'simple' ) {
5592
5593       #false laziness w/bill.cgi
5594       $error = $cust_main->bill( 'time' => $billtime );
5595       if ( $error ) {
5596         $dbh->rollback if $oldAutoCommit;
5597         return "can't bill customer for $line: $error";
5598       }
5599   
5600       $error = $cust_main->apply_payments_and_credits;
5601       if ( $error ) {
5602         $dbh->rollback if $oldAutoCommit;
5603         return "can't bill customer for $line: $error";
5604       }
5605
5606       $error = $cust_main->collect();
5607       if ( $error ) {
5608         $dbh->rollback if $oldAutoCommit;
5609         return "can't collect customer for $line: $error";
5610       }
5611
5612     }
5613
5614     $imported++;
5615   }
5616
5617   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
5618
5619   return "Empty file!" unless $imported;
5620
5621   ''; #no error
5622
5623 }
5624
5625 =item batch_charge
5626
5627 =cut
5628
5629 sub batch_charge {
5630   my $param = shift;
5631   #warn join('-',keys %$param);
5632   my $fh = $param->{filehandle};
5633   my @fields = @{$param->{fields}};
5634
5635   eval "use Text::CSV_XS;";
5636   die $@ if $@;
5637
5638   my $csv = new Text::CSV_XS;
5639   #warn $csv;
5640   #warn $fh;
5641
5642   my $imported = 0;
5643   #my $columns;
5644
5645   local $SIG{HUP} = 'IGNORE';
5646   local $SIG{INT} = 'IGNORE';
5647   local $SIG{QUIT} = 'IGNORE';
5648   local $SIG{TERM} = 'IGNORE';
5649   local $SIG{TSTP} = 'IGNORE';
5650   local $SIG{PIPE} = 'IGNORE';
5651
5652   my $oldAutoCommit = $FS::UID::AutoCommit;
5653   local $FS::UID::AutoCommit = 0;
5654   my $dbh = dbh;
5655   
5656   #while ( $columns = $csv->getline($fh) ) {
5657   my $line;
5658   while ( defined($line=<$fh>) ) {
5659
5660     $csv->parse($line) or do {
5661       $dbh->rollback if $oldAutoCommit;
5662       return "can't parse: ". $csv->error_input();
5663     };
5664
5665     my @columns = $csv->fields();
5666     #warn join('-',@columns);
5667
5668     my %row = ();
5669     foreach my $field ( @fields ) {
5670       $row{$field} = shift @columns;
5671     }
5672
5673     my $cust_main = qsearchs('cust_main', { 'custnum' => $row{'custnum'} } );
5674     unless ( $cust_main ) {
5675       $dbh->rollback if $oldAutoCommit;
5676       return "unknown custnum $row{'custnum'}";
5677     }
5678
5679     if ( $row{'amount'} > 0 ) {
5680       my $error = $cust_main->charge($row{'amount'}, $row{'pkg'});
5681       if ( $error ) {
5682         $dbh->rollback if $oldAutoCommit;
5683         return $error;
5684       }
5685       $imported++;
5686     } elsif ( $row{'amount'} < 0 ) {
5687       my $error = $cust_main->credit( sprintf( "%.2f", 0-$row{'amount'} ),
5688                                       $row{'pkg'}                         );
5689       if ( $error ) {
5690         $dbh->rollback if $oldAutoCommit;
5691         return $error;
5692       }
5693       $imported++;
5694     } else {
5695       #hmm?
5696     }
5697
5698   }
5699
5700   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
5701
5702   return "Empty file!" unless $imported;
5703
5704   ''; #no error
5705
5706 }
5707
5708 =item notify CUSTOMER_OBJECT TEMPLATE_NAME OPTIONS
5709
5710 Sends a templated email notification to the customer (see L<Text::Template>).
5711
5712 OPTIONS is a hash and may include
5713
5714 I<from> - the email sender (default is invoice_from)
5715
5716 I<to> - comma-separated scalar or arrayref of recipients 
5717    (default is invoicing_list)
5718
5719 I<subject> - The subject line of the sent email notification
5720    (default is "Notice from company_name")
5721
5722 I<extra_fields> - a hashref of name/value pairs which will be substituted
5723    into the template
5724
5725 The following variables are vavailable in the template.
5726
5727 I<$first> - the customer first name
5728 I<$last> - the customer last name
5729 I<$company> - the customer company
5730 I<$payby> - a description of the method of payment for the customer
5731             # would be nice to use FS::payby::shortname
5732 I<$payinfo> - the account information used to collect for this customer
5733 I<$expdate> - the expiration of the customer payment in seconds from epoch
5734
5735 =cut
5736
5737 sub notify {
5738   my ($customer, $template, %options) = @_;
5739
5740   return unless $conf->exists($template);
5741
5742   my $from = $conf->config('invoice_from') if $conf->exists('invoice_from');
5743   $from = $options{from} if exists($options{from});
5744
5745   my $to = join(',', $customer->invoicing_list_emailonly);
5746   $to = $options{to} if exists($options{to});
5747   
5748   my $subject = "Notice from " . $conf->config('company_name')
5749     if $conf->exists('company_name');
5750   $subject = $options{subject} if exists($options{subject});
5751
5752   my $notify_template = new Text::Template (TYPE => 'ARRAY',
5753                                             SOURCE => [ map "$_\n",
5754                                               $conf->config($template)]
5755                                            )
5756     or die "can't create new Text::Template object: Text::Template::ERROR";
5757   $notify_template->compile()
5758     or die "can't compile template: Text::Template::ERROR";
5759
5760   $FS::notify_template::_template::company_name = $conf->config('company_name');
5761   $FS::notify_template::_template::company_address =
5762     join("\n", $conf->config('company_address') ). "\n";
5763
5764   my $paydate = $customer->paydate;
5765   $FS::notify_template::_template::first = $customer->first;
5766   $FS::notify_template::_template::last = $customer->last;
5767   $FS::notify_template::_template::company = $customer->company;
5768   $FS::notify_template::_template::payinfo = $customer->mask_payinfo;
5769   my $payby = $customer->payby;
5770   my ($payyear,$paymonth,$payday) = split (/-/,$paydate);
5771   my $expire_time = timelocal(0,0,0,$payday,--$paymonth,$payyear);
5772
5773   #credit cards expire at the end of the month/year of their exp date
5774   if ($payby eq 'CARD' || $payby eq 'DCRD') {
5775     $FS::notify_template::_template::payby = 'credit card';
5776     ($paymonth < 11) ? $paymonth++ : ($paymonth=0, $payyear++);
5777     $expire_time = timelocal(0,0,0,$payday,$paymonth,$payyear);
5778     $expire_time--;
5779   }elsif ($payby eq 'COMP') {
5780     $FS::notify_template::_template::payby = 'complimentary account';
5781   }else{
5782     $FS::notify_template::_template::payby = 'current method';
5783   }
5784   $FS::notify_template::_template::expdate = $expire_time;
5785
5786   for (keys %{$options{extra_fields}}){
5787     no strict "refs";
5788     ${"FS::notify_template::_template::$_"} = $options{extra_fields}->{$_};
5789   }
5790
5791   send_email(from => $from,
5792              to => $to,
5793              subject => $subject,
5794              body => $notify_template->fill_in( PACKAGE =>
5795                                                 'FS::notify_template::_template'                                              ),
5796             );
5797
5798 }
5799
5800 =item generate_letter CUSTOMER_OBJECT TEMPLATE_NAME OPTIONS
5801
5802 Generates a templated notification to the customer (see L<Text::Template>).
5803
5804 OPTIONS is a hash and may include
5805
5806 I<extra_fields> - a hashref of name/value pairs which will be substituted
5807    into the template.  These values may override values mentioned below
5808    and those from the customer record.
5809
5810 The following variables are available in the template instead of or in addition
5811 to the fields of the customer record.
5812
5813 I<$payby> - a description of the method of payment for the customer
5814             # would be nice to use FS::payby::shortname
5815 I<$payinfo> - the masked account information used to collect for this customer
5816 I<$expdate> - the expiration of the customer payment method in seconds from epoch
5817 I<$returnaddress> - the return address defaults to invoice_latexreturnaddress or company_address
5818
5819 =cut
5820
5821 sub generate_letter {
5822   my ($self, $template, %options) = @_;
5823
5824   return unless $conf->exists($template);
5825
5826   my $letter_template = new Text::Template
5827                         ( TYPE       => 'ARRAY',
5828                           SOURCE     => [ map "$_\n", $conf->config($template)],
5829                           DELIMITERS => [ '[@--', '--@]' ],
5830                         )
5831     or die "can't create new Text::Template object: Text::Template::ERROR";
5832
5833   $letter_template->compile()
5834     or die "can't compile template: Text::Template::ERROR";
5835
5836   my %letter_data = map { $_ => $self->$_ } $self->fields;
5837   $letter_data{payinfo} = $self->mask_payinfo;
5838
5839   #my $paydate = $self->paydate || '2037-12';
5840   my $paydate = $self->paydate =~ /^\S+$/ ? $self->paydate : '2037-12';
5841
5842   my $payby = $self->payby;
5843   my ($payyear,$paymonth,$payday) = split (/-/,$paydate);
5844   my $expire_time = timelocal(0,0,0,$payday,--$paymonth,$payyear);
5845
5846   #credit cards expire at the end of the month/year of their exp date
5847   if ($payby eq 'CARD' || $payby eq 'DCRD') {
5848     $letter_data{payby} = 'credit card';
5849     ($paymonth < 11) ? $paymonth++ : ($paymonth=0, $payyear++);
5850     $expire_time = timelocal(0,0,0,$payday,$paymonth,$payyear);
5851     $expire_time--;
5852   }elsif ($payby eq 'COMP') {
5853     $letter_data{payby} = 'complimentary account';
5854   }else{
5855     $letter_data{payby} = 'current method';
5856   }
5857   $letter_data{expdate} = $expire_time;
5858
5859   for (keys %{$options{extra_fields}}){
5860     $letter_data{$_} = $options{extra_fields}->{$_};
5861   }
5862
5863   unless(exists($letter_data{returnaddress})){
5864     my $retadd = join("\n", $conf->config_orbase( 'invoice_latexreturnaddress',
5865                                                   $self->agent_template)
5866                      );
5867     if ( length($retadd) ) {
5868       $letter_data{returnaddress} = $retadd;
5869     } elsif ( grep /\S/, $conf->config('company_address') ) {
5870       $letter_data{returnaddress} =
5871         join( '\\*'."\n", map s/( {2,})/'~' x length($1)/eg,
5872                           $conf->config('company_address')
5873         );
5874     } else {
5875       $letter_data{returnaddress} = '~';
5876     }
5877   }
5878
5879   $letter_data{conf_dir} = "$FS::UID::conf_dir/conf.$FS::UID::datasrc";
5880
5881   $letter_data{company_name} = $conf->config('company_name');
5882
5883   my $dir = $FS::UID::conf_dir."cache.". $FS::UID::datasrc;
5884   my $fh = new File::Temp( TEMPLATE => 'letter.'. $self->custnum. '.XXXXXXXX',
5885                            DIR      => $dir,
5886                            SUFFIX   => '.tex',
5887                            UNLINK   => 0,
5888                          ) or die "can't open temp file: $!\n";
5889
5890   $letter_template->fill_in( OUTPUT => $fh, HASH => \%letter_data );
5891   close $fh;
5892   $fh->filename =~ /^(.*).tex$/ or die "unparsable filename: ". $fh->filename;
5893   return $1;
5894 }
5895
5896 =item print_ps TEMPLATE 
5897
5898 Returns an postscript letter filled in from TEMPLATE, as a scalar.
5899
5900 =cut
5901
5902 sub print_ps {
5903   my $self = shift;
5904   my $file = $self->generate_letter(@_);
5905   FS::Misc::generate_ps($file);
5906 }
5907
5908 =item print TEMPLATE
5909
5910 Prints the filled in template.
5911
5912 TEMPLATE is the name of a L<Text::Template> to fill in and print.
5913
5914 =cut
5915
5916 sub queueable_print {
5917   my %opt = @_;
5918
5919   my $self = qsearchs('cust_main', { 'custnum' => $opt{custnum} } )
5920     or die "invalid customer number: " . $opt{custvnum};
5921
5922   my $error = $self->print( $opt{template} );
5923   die $error if $error;
5924 }
5925
5926 sub print {
5927   my ($self, $template) = (shift, shift);
5928   do_print [ $self->print_ps($template) ];
5929 }
5930
5931 sub agent_template {
5932   my $self = shift;
5933   $self->_agent_plandata('agent_templatename');
5934 }
5935
5936 sub agent_invoice_from {
5937   my $self = shift;
5938   $self->_agent_plandata('agent_invoice_from');
5939 }
5940
5941 sub _agent_plandata {
5942   my( $self, $option ) = @_;
5943
5944   #yuck.  this whole thing needs to be reconciled better with 1.9's idea of
5945   #agent-specific Conf
5946
5947   use FS::part_event::Condition;
5948   
5949   my $agentnum = $self->agentnum;
5950
5951   my $regexp = '';
5952   if ( driver_name =~ /^Pg/i ) {
5953     $regexp = '~';
5954   } elsif ( driver_name =~ /^mysql/i ) {
5955     $regexp = 'REGEXP';
5956   } else {
5957     die "don't know how to use regular expressions in ". driver_name. " databases";
5958   }
5959
5960   my $part_event_option =
5961     qsearchs({
5962       'select'    => 'part_event_option.*',
5963       'table'     => 'part_event_option',
5964       'addl_from' => q{
5965         LEFT JOIN part_event USING ( eventpart )
5966         LEFT JOIN part_event_option AS peo_agentnum
5967           ON ( part_event.eventpart = peo_agentnum.eventpart
5968                AND peo_agentnum.optionname = 'agentnum'
5969                AND peo_agentnum.optionvalue }. $regexp. q{ '(^|,)}. $agentnum. q{(,|$)'
5970              )
5971         LEFT JOIN part_event_option AS peo_cust_bill_age
5972           ON ( part_event.eventpart = peo_cust_bill_age.eventpart
5973                AND peo_cust_bill_age.optionname = 'cust_bill_age'
5974              )
5975       },
5976       #'hashref'   => { 'optionname' => $option },
5977       #'hashref'   => { 'part_event_option.optionname' => $option },
5978       'extra_sql' =>
5979         " WHERE part_event_option.optionname = ". dbh->quote($option).
5980         " AND action = 'cust_bill_send_agent' ".
5981         " AND ( disabled IS NULL OR disabled != 'Y' ) ".
5982         " AND peo_agentnum.optionname = 'agentnum' ".
5983         " AND agentnum IS NULL OR agentnum = $agentnum ".
5984         " ORDER BY
5985            CASE WHEN peo_cust_bill_age.optionname != 'cust_bill_age'
5986            THEN -1
5987            ELSE ". FS::part_event::Condition->age2seconds_sql('peo_cust_bill_age.optionvalue').
5988         " END
5989           , part_event.weight".
5990         " LIMIT 1"
5991     });
5992     
5993   unless ( $part_event_option ) {
5994     return $self->agent->invoice_template || ''
5995       if $option eq 'agent_templatename';
5996     return '';
5997   }
5998
5999   $part_event_option->optionvalue;
6000
6001 }
6002
6003 sub queued_bill {
6004   ## actual sub, not a method, designed to be called from the queue.
6005   ## sets up the customer, and calls the bill_and_collect
6006   my (%args) = @_; #, ($time, $invoice_time, $check_freq, $resetup) = @_;
6007   my $cust_main = qsearchs( 'cust_main', { custnum => $args{'custnum'} } );
6008       $cust_main->bill_and_collect(
6009         %args,
6010       );
6011 }
6012
6013 =back
6014
6015 =head1 BUGS
6016
6017 The delete method.
6018
6019 The delete method should possibly take an FS::cust_main object reference
6020 instead of a scalar customer number.
6021
6022 Bill and collect options should probably be passed as references instead of a
6023 list.
6024
6025 There should probably be a configuration file with a list of allowed credit
6026 card types.
6027
6028 No multiple currency support (probably a larger project than just this module).
6029
6030 payinfo_masked false laziness with cust_pay.pm and cust_refund.pm
6031
6032 Birthdates rely on negative epoch values.
6033
6034 The payby for card/check batches is broken.  With mixed batching, bad
6035 things will happen.
6036
6037 B<collect> I<invoice_time> should be renamed I<time>, like B<bill>.
6038
6039 =head1 SEE ALSO
6040
6041 L<FS::Record>, L<FS::cust_pkg>, L<FS::cust_bill>, L<FS::cust_credit>
6042 L<FS::agent>, L<FS::part_referral>, L<FS::cust_main_county>,
6043 L<FS::cust_main_invoice>, L<FS::UID>, schema.html from the base documentation.
6044
6045 =cut
6046
6047 1;
6048