users-allow_comp config value to control creation of complimentary accounts and minor...
[freeside.git] / FS / FS / cust_main.pm
1 package FS::cust_main;
2
3 use strict;
4 use vars qw( @ISA $conf $Debug $import );
5 use Safe;
6 use Carp;
7 BEGIN {
8   eval "use Time::Local;";
9   die "Time::Local version 1.05 required with Perl versions before 5.6"
10     if $] < 5.006 && !defined($Time::Local::VERSION);
11   eval "use Time::Local qw(timelocal timelocal_nocheck);";
12 }
13 use Date::Format;
14 #use Date::Manip;
15 use Business::CreditCard;
16 use FS::UID qw( getotaker dbh );
17 use FS::Record qw( qsearchs qsearch dbdef );
18 use FS::cust_pkg;
19 use FS::cust_bill;
20 use FS::cust_bill_pkg;
21 use FS::cust_pay;
22 use FS::cust_credit;
23 use FS::part_referral;
24 use FS::cust_main_county;
25 use FS::agent;
26 use FS::cust_main_invoice;
27 use FS::cust_credit_bill;
28 use FS::cust_bill_pay;
29 use FS::prepay_credit;
30 use FS::queue;
31 use FS::part_pkg;
32 use FS::part_bill_event;
33 use FS::cust_bill_event;
34 use FS::cust_tax_exempt;
35 use FS::type_pkgs;
36 use FS::Msgcat qw(gettext);
37
38 @ISA = qw( FS::Record );
39
40 $Debug = 0;
41 #$Debug = 1;
42
43 $import = 0;
44
45 #ask FS::UID to run this stuff for us later
46 $FS::UID::callback{'FS::cust_main'} = sub { 
47   $conf = new FS::Conf;
48   #yes, need it for stuff below (prolly should be cached)
49 };
50
51 sub _cache {
52   my $self = shift;
53   my ( $hashref, $cache ) = @_;
54   if ( exists $hashref->{'pkgnum'} ) {
55 #    #@{ $self->{'_pkgnum'} } = ();
56     my $subcache = $cache->subcache( 'pkgnum', 'cust_pkg', $hashref->{custnum});
57     $self->{'_pkgnum'} = $subcache;
58     #push @{ $self->{'_pkgnum'} },
59     FS::cust_pkg->new_or_cached($hashref, $subcache) if $hashref->{pkgnum};
60   }
61 }
62
63 =head1 NAME
64
65 FS::cust_main - Object methods for cust_main records
66
67 =head1 SYNOPSIS
68
69   use FS::cust_main;
70
71   $record = new FS::cust_main \%hash;
72   $record = new FS::cust_main { 'column' => 'value' };
73
74   $error = $record->insert;
75
76   $error = $new_record->replace($old_record);
77
78   $error = $record->delete;
79
80   $error = $record->check;
81
82   @cust_pkg = $record->all_pkgs;
83
84   @cust_pkg = $record->ncancelled_pkgs;
85
86   @cust_pkg = $record->suspended_pkgs;
87
88   $error = $record->bill;
89   $error = $record->bill %options;
90   $error = $record->bill 'time' => $time;
91
92   $error = $record->collect;
93   $error = $record->collect %options;
94   $error = $record->collect 'invoice_time'   => $time,
95                             'batch_card'     => 'yes',
96                             'report_badcard' => 'yes',
97                           ;
98
99 =head1 DESCRIPTION
100
101 An FS::cust_main object represents a customer.  FS::cust_main inherits from 
102 FS::Record.  The following fields are currently supported:
103
104 =over 4
105
106 =item custnum - primary key (assigned automatically for new customers)
107
108 =item agentnum - agent (see L<FS::agent>)
109
110 =item refnum - Advertising source (see L<FS::part_referral>)
111
112 =item first - name
113
114 =item last - name
115
116 =item ss - social security number (optional)
117
118 =item company - (optional)
119
120 =item address1
121
122 =item address2 - (optional)
123
124 =item city
125
126 =item county - (optional, see L<FS::cust_main_county>)
127
128 =item state - (see L<FS::cust_main_county>)
129
130 =item zip
131
132 =item country - (see L<FS::cust_main_county>)
133
134 =item daytime - phone (optional)
135
136 =item night - phone (optional)
137
138 =item fax - phone (optional)
139
140 =item ship_first - name
141
142 =item ship_last - name
143
144 =item ship_company - (optional)
145
146 =item ship_address1
147
148 =item ship_address2 - (optional)
149
150 =item ship_city
151
152 =item ship_county - (optional, see L<FS::cust_main_county>)
153
154 =item ship_state - (see L<FS::cust_main_county>)
155
156 =item ship_zip
157
158 =item ship_country - (see L<FS::cust_main_county>)
159
160 =item ship_daytime - phone (optional)
161
162 =item ship_night - phone (optional)
163
164 =item ship_fax - phone (optional)
165
166 =item payby - `CARD' (credit cards), `CHEK' (electronic check), `LECB' (Phone bill billing), `BILL' (billing), `COMP' (free), or `PREPAY' (special billing type: applies a credit - see L<FS::prepay_credit> and sets billing type to BILL)
167
168 =item payinfo - card number, P.O., comp issuer (4-8 lowercase alphanumerics; think username) or prepayment identifier (see L<FS::prepay_credit>)
169
170 =item paydate - expiration date, mm/yyyy, m/yyyy, mm/yy or m/yy
171
172 =item payname - name on card or billing name
173
174 =item tax - tax exempt, empty or `Y'
175
176 =item otaker - order taker (assigned automatically, see L<FS::UID>)
177
178 =item comments - comments (optional)
179
180 =back
181
182 =head1 METHODS
183
184 =over 4
185
186 =item new HASHREF
187
188 Creates a new customer.  To add the customer to the database, see L<"insert">.
189
190 Note that this stores the hash reference, not a distinct copy of the hash it
191 points to.  You can ask the object for a copy with the I<hash> method.
192
193 =cut
194
195 sub table { 'cust_main'; }
196
197 =item insert [ CUST_PKG_HASHREF [ , INVOICING_LIST_ARYREF ] ]
198
199 Adds this customer to the database.  If there is an error, returns the error,
200 otherwise returns false.
201
202 CUST_PKG_HASHREF: If you pass a Tie::RefHash data structure to the insert
203 method containing FS::cust_pkg and FS::svc_I<tablename> objects, all records
204 are inserted atomicly, or the transaction is rolled back.  Passing an empty
205 hash reference is equivalent to not supplying this parameter.  There should be
206 a better explanation of this, but until then, here's an example:
207
208   use Tie::RefHash;
209   tie %hash, 'Tie::RefHash'; #this part is important
210   %hash = (
211     $cust_pkg => [ $svc_acct ],
212     ...
213   );
214   $cust_main->insert( \%hash );
215
216 INVOICING_LIST_ARYREF: If you pass an arrarref to the insert method, it will
217 be set as the invoicing list (see L<"invoicing_list">).  Errors return as
218 expected and rollback the entire transaction; it is not necessary to call 
219 check_invoicing_list first.  The invoicing_list is set after the records in the
220 CUST_PKG_HASHREF above are inserted, so it is now possible to set an
221 invoicing_list destination to the newly-created svc_acct.  Here's an example:
222
223   $cust_main->insert( {}, [ $email, 'POST' ] );
224
225 =cut
226
227 sub insert {
228   my $self = shift;
229   my $cust_pkgs = @_ ? shift : {};
230   my $invoicing_list = @_ ? shift : '';
231
232   local $SIG{HUP} = 'IGNORE';
233   local $SIG{INT} = 'IGNORE';
234   local $SIG{QUIT} = 'IGNORE';
235   local $SIG{TERM} = 'IGNORE';
236   local $SIG{TSTP} = 'IGNORE';
237   local $SIG{PIPE} = 'IGNORE';
238
239   my $oldAutoCommit = $FS::UID::AutoCommit;
240   local $FS::UID::AutoCommit = 0;
241   my $dbh = dbh;
242
243   my $amount = 0;
244   my $seconds = 0;
245   if ( $self->payby eq 'PREPAY' ) {
246     $self->payby('BILL');
247     my $prepay_credit = qsearchs(
248       'prepay_credit',
249       { 'identifier' => $self->payinfo },
250       '',
251       'FOR UPDATE'
252     );
253     warn "WARNING: can't find pre-found prepay_credit: ". $self->payinfo
254       unless $prepay_credit;
255     $amount = $prepay_credit->amount;
256     $seconds = $prepay_credit->seconds;
257     my $error = $prepay_credit->delete;
258     if ( $error ) {
259       $dbh->rollback if $oldAutoCommit;
260       return "removing prepay_credit (transaction rolled back): $error";
261     }
262   }
263
264   my $error = $self->SUPER::insert;
265   if ( $error ) {
266     $dbh->rollback if $oldAutoCommit;
267     #return "inserting cust_main record (transaction rolled back): $error";
268     return $error;
269   }
270
271   # invoicing list
272   if ( $invoicing_list ) {
273     $error = $self->check_invoicing_list( $invoicing_list );
274     if ( $error ) {
275       $dbh->rollback if $oldAutoCommit;
276       return "checking invoicing_list (transaction rolled back): $error";
277     }
278     $self->invoicing_list( $invoicing_list );
279   }
280
281   # packages
282   foreach my $cust_pkg ( keys %$cust_pkgs ) {
283     $cust_pkg->custnum( $self->custnum );
284     $error = $cust_pkg->insert;
285     if ( $error ) {
286       $dbh->rollback if $oldAutoCommit;
287       return "inserting cust_pkg (transaction rolled back): $error";
288     }
289     foreach my $svc_something ( @{$cust_pkgs->{$cust_pkg}} ) {
290       $svc_something->pkgnum( $cust_pkg->pkgnum );
291       if ( $seconds && $svc_something->isa('FS::svc_acct') ) {
292         $svc_something->seconds( $svc_something->seconds + $seconds );
293         $seconds = 0;
294       }
295       $error = $svc_something->insert;
296       if ( $error ) {
297         $dbh->rollback if $oldAutoCommit;
298         #return "inserting svc_ (transaction rolled back): $error";
299         return $error;
300       }
301     }
302   }
303
304   if ( $seconds ) {
305     $dbh->rollback if $oldAutoCommit;
306     return "No svc_acct record to apply pre-paid time";
307   }
308
309   if ( $amount ) {
310     my $cust_credit = new FS::cust_credit {
311       'custnum' => $self->custnum,
312       'amount'  => $amount,
313     };
314     $error = $cust_credit->insert;
315     if ( $error ) {
316       $dbh->rollback if $oldAutoCommit;
317       return "inserting credit (transaction rolled back): $error";
318     }
319   }
320
321   $error = $self->queue_fuzzyfiles_update;
322   if ( $error ) {
323     $dbh->rollback if $oldAutoCommit;
324     return "updating fuzzy search cache: $error";
325   }
326
327   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
328   '';
329
330 }
331
332 =item delete NEW_CUSTNUM
333
334 This deletes the customer.  If there is an error, returns the error, otherwise
335 returns false.
336
337 This will completely remove all traces of the customer record.  This is not
338 what you want when a customer cancels service; for that, cancel all of the
339 customer's packages (see L</cancel>).
340
341 If the customer has any uncancelled packages, you need to pass a new (valid)
342 customer number for those packages to be transferred to.  Cancelled packages
343 will be deleted.  Did I mention that this is NOT what you want when a customer
344 cancels service and that you really should be looking see L<FS::cust_pkg/cancel>?
345
346 You can't delete a customer with invoices (see L<FS::cust_bill>),
347 or credits (see L<FS::cust_credit>), payments (see L<FS::cust_pay>) or
348 refunds (see L<FS::cust_refund>).
349
350 =cut
351
352 sub delete {
353   my $self = shift;
354
355   local $SIG{HUP} = 'IGNORE';
356   local $SIG{INT} = 'IGNORE';
357   local $SIG{QUIT} = 'IGNORE';
358   local $SIG{TERM} = 'IGNORE';
359   local $SIG{TSTP} = 'IGNORE';
360   local $SIG{PIPE} = 'IGNORE';
361
362   my $oldAutoCommit = $FS::UID::AutoCommit;
363   local $FS::UID::AutoCommit = 0;
364   my $dbh = dbh;
365
366   if ( qsearch( 'cust_bill', { 'custnum' => $self->custnum } ) ) {
367     $dbh->rollback if $oldAutoCommit;
368     return "Can't delete a customer with invoices";
369   }
370   if ( qsearch( 'cust_credit', { 'custnum' => $self->custnum } ) ) {
371     $dbh->rollback if $oldAutoCommit;
372     return "Can't delete a customer with credits";
373   }
374   if ( qsearch( 'cust_pay', { 'custnum' => $self->custnum } ) ) {
375     $dbh->rollback if $oldAutoCommit;
376     return "Can't delete a customer with payments";
377   }
378   if ( qsearch( 'cust_refund', { 'custnum' => $self->custnum } ) ) {
379     $dbh->rollback if $oldAutoCommit;
380     return "Can't delete a customer with refunds";
381   }
382
383   my @cust_pkg = $self->ncancelled_pkgs;
384   if ( @cust_pkg ) {
385     my $new_custnum = shift;
386     unless ( qsearchs( 'cust_main', { 'custnum' => $new_custnum } ) ) {
387       $dbh->rollback if $oldAutoCommit;
388       return "Invalid new customer number: $new_custnum";
389     }
390     foreach my $cust_pkg ( @cust_pkg ) {
391       my %hash = $cust_pkg->hash;
392       $hash{'custnum'} = $new_custnum;
393       my $new_cust_pkg = new FS::cust_pkg ( \%hash );
394       my $error = $new_cust_pkg->replace($cust_pkg);
395       if ( $error ) {
396         $dbh->rollback if $oldAutoCommit;
397         return $error;
398       }
399     }
400   }
401   my @cancelled_cust_pkg = $self->all_pkgs;
402   foreach my $cust_pkg ( @cancelled_cust_pkg ) {
403     my $error = $cust_pkg->delete;
404     if ( $error ) {
405       $dbh->rollback if $oldAutoCommit;
406       return $error;
407     }
408   }
409
410   foreach my $cust_main_invoice ( #(email invoice destinations, not invoices)
411     qsearch( 'cust_main_invoice', { 'custnum' => $self->custnum } )
412   ) {
413     my $error = $cust_main_invoice->delete;
414     if ( $error ) {
415       $dbh->rollback if $oldAutoCommit;
416       return $error;
417     }
418   }
419
420   my $error = $self->SUPER::delete;
421   if ( $error ) {
422     $dbh->rollback if $oldAutoCommit;
423     return $error;
424   }
425
426   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
427   '';
428
429 }
430
431 =item replace OLD_RECORD [ INVOICING_LIST_ARYREF ]
432
433 Replaces the OLD_RECORD with this one in the database.  If there is an error,
434 returns the error, otherwise returns false.
435
436 INVOICING_LIST_ARYREF: If you pass an arrarref to the insert method, it will
437 be set as the invoicing list (see L<"invoicing_list">).  Errors return as
438 expected and rollback the entire transaction; it is not necessary to call 
439 check_invoicing_list first.  Here's an example:
440
441   $new_cust_main->replace( $old_cust_main, [ $email, 'POST' ] );
442
443 =cut
444
445 sub replace {
446   my $self = shift;
447   my $old = shift;
448   my @param = @_;
449
450   local $SIG{HUP} = 'IGNORE';
451   local $SIG{INT} = 'IGNORE';
452   local $SIG{QUIT} = 'IGNORE';
453   local $SIG{TERM} = 'IGNORE';
454   local $SIG{TSTP} = 'IGNORE';
455   local $SIG{PIPE} = 'IGNORE';
456
457   if ( $self->payby eq 'COMP' && $self->payby ne $old->payby
458        && $conf->config('users-allow_comp')                  ) {
459     return "You are not permitted to create complimentary accounts."
460       unless grep { $_ eq getotaker } $conf->config('users-allow_comp');
461   }
462
463   my $oldAutoCommit = $FS::UID::AutoCommit;
464   local $FS::UID::AutoCommit = 0;
465   my $dbh = dbh;
466
467   my $error = $self->SUPER::replace($old);
468
469   if ( $error ) {
470     $dbh->rollback if $oldAutoCommit;
471     return $error;
472   }
473
474   if ( @param ) { # INVOICING_LIST_ARYREF
475     my $invoicing_list = shift @param;
476     $error = $self->check_invoicing_list( $invoicing_list );
477     if ( $error ) {
478       $dbh->rollback if $oldAutoCommit;
479       return $error;
480     }
481     $self->invoicing_list( $invoicing_list );
482   }
483
484   if ( $self->payby =~ /^(CARD|CHEK|LECB)$/ &&
485        grep { $self->get($_) ne $old->get($_) } qw(payinfo paydate payname) ) {
486     # card/check/lec info has changed, want to retry realtime_ invoice events
487     my $error = $self->retry_realtime;
488     if ( $error ) {
489       $dbh->rollback if $oldAutoCommit;
490       return $error;
491     }
492   }
493
494   $error = $self->queue_fuzzyfiles_update;
495   if ( $error ) {
496     $dbh->rollback if $oldAutoCommit;
497     return "updating fuzzy search cache: $error";
498   }
499
500   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
501   '';
502
503 }
504
505 =item queue_fuzzyfiles_update
506
507 Used by insert & replace to update the fuzzy search cache
508
509 =cut
510
511 sub queue_fuzzyfiles_update {
512   my $self = shift;
513
514   local $SIG{HUP} = 'IGNORE';
515   local $SIG{INT} = 'IGNORE';
516   local $SIG{QUIT} = 'IGNORE';
517   local $SIG{TERM} = 'IGNORE';
518   local $SIG{TSTP} = 'IGNORE';
519   local $SIG{PIPE} = 'IGNORE';
520
521   my $oldAutoCommit = $FS::UID::AutoCommit;
522   local $FS::UID::AutoCommit = 0;
523   my $dbh = dbh;
524
525   my $queue = new FS::queue { 'job' => 'FS::cust_main::append_fuzzyfiles' };
526   my $error = $queue->insert($self->getfield('last'), $self->company);
527   if ( $error ) {
528     $dbh->rollback if $oldAutoCommit;
529     return "queueing job (transaction rolled back): $error";
530   }
531
532   if ( defined $self->dbdef_table->column('ship_last') && $self->ship_last ) {
533     $queue = new FS::queue { 'job' => 'FS::cust_main::append_fuzzyfiles' };
534     $error = $queue->insert($self->getfield('ship_last'), $self->ship_company);
535     if ( $error ) {
536       $dbh->rollback if $oldAutoCommit;
537       return "queueing job (transaction rolled back): $error";
538     }
539   }
540
541   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
542   '';
543
544 }
545
546 =item check
547
548 Checks all fields to make sure this is a valid customer record.  If there is
549 an error, returns the error, otherwise returns false.  Called by the insert
550 and repalce methods.
551
552 =cut
553
554 sub check {
555   my $self = shift;
556
557   #warn "BEFORE: \n". $self->_dump;
558
559   my $error =
560     $self->ut_numbern('custnum')
561     || $self->ut_number('agentnum')
562     || $self->ut_number('refnum')
563     || $self->ut_name('last')
564     || $self->ut_name('first')
565     || $self->ut_textn('company')
566     || $self->ut_text('address1')
567     || $self->ut_textn('address2')
568     || $self->ut_text('city')
569     || $self->ut_textn('county')
570     || $self->ut_textn('state')
571     || $self->ut_country('country')
572     || $self->ut_anything('comments')
573     || $self->ut_numbern('referral_custnum')
574   ;
575   #barf.  need message catalogs.  i18n.  etc.
576   $error .= "Please select an advertising source."
577     if $error =~ /^Illegal or empty \(numeric\) refnum: /;
578   return $error if $error;
579
580   return "Unknown agent"
581     unless qsearchs( 'agent', { 'agentnum' => $self->agentnum } );
582
583   return "Unknown refnum"
584     unless qsearchs( 'part_referral', { 'refnum' => $self->refnum } );
585
586   return "Unknown referring custnum ". $self->referral_custnum
587     unless ! $self->referral_custnum 
588            || qsearchs( 'cust_main', { 'custnum' => $self->referral_custnum } );
589
590   if ( $self->ss eq '' ) {
591     $self->ss('');
592   } else {
593     my $ss = $self->ss;
594     $ss =~ s/\D//g;
595     $ss =~ /^(\d{3})(\d{2})(\d{4})$/
596       or return "Illegal social security number: ". $self->ss;
597     $self->ss("$1-$2-$3");
598   }
599
600
601 # bad idea to disable, causes billing to fail because of no tax rates later
602 #  unless ( $import ) {
603     unless ( qsearch('cust_main_county', {
604       'country' => $self->country,
605       'state'   => '',
606      } ) ) {
607       return "Unknown state/county/country: ".
608         $self->state. "/". $self->county. "/". $self->country
609         unless qsearch('cust_main_county',{
610           'state'   => $self->state,
611           'county'  => $self->county,
612           'country' => $self->country,
613         } );
614     }
615 #  }
616
617   $error =
618     $self->ut_phonen('daytime', $self->country)
619     || $self->ut_phonen('night', $self->country)
620     || $self->ut_phonen('fax', $self->country)
621     || $self->ut_zip('zip', $self->country)
622   ;
623   return $error if $error;
624
625   my @addfields = qw(
626     last first company address1 address2 city county state zip
627     country daytime night fax
628   );
629
630   if ( defined $self->dbdef_table->column('ship_last') ) {
631     if ( scalar ( grep { $self->getfield($_) ne $self->getfield("ship_$_") }
632                        @addfields )
633          && scalar ( grep { $self->getfield("ship_$_") ne '' } @addfields )
634        )
635     {
636       my $error =
637         $self->ut_name('ship_last')
638         || $self->ut_name('ship_first')
639         || $self->ut_textn('ship_company')
640         || $self->ut_text('ship_address1')
641         || $self->ut_textn('ship_address2')
642         || $self->ut_text('ship_city')
643         || $self->ut_textn('ship_county')
644         || $self->ut_textn('ship_state')
645         || $self->ut_country('ship_country')
646       ;
647       return $error if $error;
648
649       #false laziness with above
650       unless ( qsearchs('cust_main_county', {
651         'country' => $self->ship_country,
652         'state'   => '',
653        } ) ) {
654         return "Unknown ship_state/ship_county/ship_country: ".
655           $self->ship_state. "/". $self->ship_county. "/". $self->ship_country
656           unless qsearchs('cust_main_county',{
657             'state'   => $self->ship_state,
658             'county'  => $self->ship_county,
659             'country' => $self->ship_country,
660           } );
661       }
662       #eofalse
663
664       $error =
665         $self->ut_phonen('ship_daytime', $self->ship_country)
666         || $self->ut_phonen('ship_night', $self->ship_country)
667         || $self->ut_phonen('ship_fax', $self->ship_country)
668         || $self->ut_zip('ship_zip', $self->ship_country)
669       ;
670       return $error if $error;
671
672     } else { # ship_ info eq billing info, so don't store dup info in database
673       $self->setfield("ship_$_", '')
674         foreach qw( last first company address1 address2 city county state zip
675                     country daytime night fax );
676     }
677   }
678
679   $self->payby =~ /^(CARD|CHEK|LECB|BILL|COMP|PREPAY)$/
680     or return "Illegal payby: ". $self->payby;
681   $self->payby($1);
682
683   if ( $self->payby eq 'CARD' ) {
684
685     my $payinfo = $self->payinfo;
686     $payinfo =~ s/\D//g;
687     $payinfo =~ /^(\d{13,16})$/
688       or return gettext('invalid_card'); # . ": ". $self->payinfo;
689     $payinfo = $1;
690     $self->payinfo($payinfo);
691     validate($payinfo)
692       or return gettext('invalid_card'); # . ": ". $self->payinfo;
693     return gettext('unknown_card_type')
694       if cardtype($self->payinfo) eq "Unknown";
695
696   } elsif ( $self->payby eq 'CHEK' ) {
697
698     my $payinfo = $self->payinfo;
699     $payinfo =~ s/[^\d\@]//g;
700     $payinfo =~ /^(\d+)\@(\d{9})$/ or return 'invalid echeck account@aba';
701     $payinfo = "$1\@$2";
702     $self->payinfo($payinfo);
703
704   } elsif ( $self->payby eq 'LECB' ) {
705
706     my $payinfo = $self->payinfo;
707     $payinfo =~ s/\D//g;
708     $payinfo =~ /^1?(\d{10})$/ or return 'invalid btn billing telephone number';
709     $payinfo = $1;
710     $self->payinfo($payinfo);
711
712   } elsif ( $self->payby eq 'BILL' ) {
713
714     $error = $self->ut_textn('payinfo');
715     return "Illegal P.O. number: ". $self->payinfo if $error;
716
717   } elsif ( $self->payby eq 'COMP' ) {
718
719     if ( !$self->custnum && $conf->config('users-allow_comp') ) {
720       return "You are not permitted to create complimentary accounts."
721         unless grep { $_ eq getotaker } $conf->config('users-allow_comp');
722     }
723
724     $error = $self->ut_textn('payinfo');
725     return "Illegal comp account issuer: ". $self->payinfo if $error;
726
727   } elsif ( $self->payby eq 'PREPAY' ) {
728
729     my $payinfo = $self->payinfo;
730     $payinfo =~ s/\W//g; #anything else would just confuse things
731     $self->payinfo($payinfo);
732     $error = $self->ut_alpha('payinfo');
733     return "Illegal prepayment identifier: ". $self->payinfo if $error;
734     return "Unknown prepayment identifier"
735       unless qsearchs('prepay_credit', { 'identifier' => $self->payinfo } );
736
737   }
738
739   if ( $self->paydate eq '' || $self->paydate eq '-' ) {
740     return "Expriation date required"
741       unless $self->payby =~ /^(BILL|PREPAY|CHEK|LECB)$/;
742     $self->paydate('');
743   } else {
744     $self->paydate =~ /^(\d{1,2})[\/\-](\d{2}(\d{2})?)$/
745       or return "Illegal expiration date: ". $self->paydate;
746     my $y = length($2) == 4 ? $2 : "20$2";
747     $self->paydate("$y-$1-01");
748     my($nowm,$nowy)=(localtime(time))[4,5]; $nowm++; $nowy+=1900;
749     return gettext('expired_card')
750       if !$import && ( $y<$nowy || ( $y==$nowy && $1<$nowm ) );
751   }
752
753   if ( $self->payname eq '' && $self->payby ne 'CHEK' &&
754        ( ! $conf->exists('require_cardname') || $self->payby ne 'CARD' ) ) {
755     $self->payname( $self->first. " ". $self->getfield('last') );
756   } else {
757     $self->payname =~ /^([\w \,\.\-\']+)$/
758       or return gettext('illegal_name'). " payname: ". $self->payname;
759     $self->payname($1);
760   }
761
762   $self->tax =~ /^(Y?)$/ or return "Illegal tax: ". $self->tax;
763   $self->tax($1);
764
765   $self->otaker(getotaker);
766
767   #warn "AFTER: \n". $self->_dump;
768
769   ''; #no error
770 }
771
772 =item all_pkgs
773
774 Returns all packages (see L<FS::cust_pkg>) for this customer.
775
776 =cut
777
778 sub all_pkgs {
779   my $self = shift;
780   if ( $self->{'_pkgnum'} ) {
781     values %{ $self->{'_pkgnum'}->cache };
782   } else {
783     qsearch( 'cust_pkg', { 'custnum' => $self->custnum });
784   }
785 }
786
787 =item ncancelled_pkgs
788
789 Returns all non-cancelled packages (see L<FS::cust_pkg>) for this customer.
790
791 =cut
792
793 sub ncancelled_pkgs {
794   my $self = shift;
795   if ( $self->{'_pkgnum'} ) {
796     grep { ! $_->getfield('cancel') } values %{ $self->{'_pkgnum'}->cache };
797   } else {
798     @{ [ # force list context
799       qsearch( 'cust_pkg', {
800         'custnum' => $self->custnum,
801         'cancel'  => '',
802       }),
803       qsearch( 'cust_pkg', {
804         'custnum' => $self->custnum,
805         'cancel'  => 0,
806       }),
807     ] };
808   }
809 }
810
811 =item suspended_pkgs
812
813 Returns all suspended packages (see L<FS::cust_pkg>) for this customer.
814
815 =cut
816
817 sub suspended_pkgs {
818   my $self = shift;
819   grep { $_->susp } $self->ncancelled_pkgs;
820 }
821
822 =item unflagged_suspended_pkgs
823
824 Returns all unflagged suspended packages (see L<FS::cust_pkg>) for this
825 customer (thouse packages without the `manual_flag' set).
826
827 =cut
828
829 sub unflagged_suspended_pkgs {
830   my $self = shift;
831   return $self->suspended_pkgs
832     unless dbdef->table('cust_pkg')->column('manual_flag');
833   grep { ! $_->manual_flag } $self->suspended_pkgs;
834 }
835
836 =item unsuspended_pkgs
837
838 Returns all unsuspended (and uncancelled) packages (see L<FS::cust_pkg>) for
839 this customer.
840
841 =cut
842
843 sub unsuspended_pkgs {
844   my $self = shift;
845   grep { ! $_->susp } $self->ncancelled_pkgs;
846 }
847
848 =item unsuspend
849
850 Unsuspends all unflagged suspended packages (see L</unflagged_suspended_pkgs>
851 and L<FS::cust_pkg>) for this customer.  Always returns a list: an empty list
852 on success or a list of errors.
853
854 =cut
855
856 sub unsuspend {
857   my $self = shift;
858   grep { $_->unsuspend } $self->suspended_pkgs;
859 }
860
861 =item suspend
862
863 Suspends all unsuspended packages (see L<FS::cust_pkg>) for this customer.
864 Always returns a list: an empty list on success or a list of errors.
865
866 =cut
867
868 sub suspend {
869   my $self = shift;
870   grep { $_->suspend } $self->unsuspended_pkgs;
871 }
872
873 =item cancel
874
875 Cancels all uncancelled packages (see L<FS::cust_pkg>) for this customer.
876 Always returns a list: an empty list on success or a list of errors.
877
878 =cut
879
880 sub cancel {
881   my $self = shift;
882   grep { $_->cancel } $self->ncancelled_pkgs;
883 }
884
885 =item agent
886
887 Returns the agent (see L<FS::agent>) for this customer.
888
889 =cut
890
891 sub agent {
892   my $self = shift;
893   qsearchs( 'agent', { 'agentnum' => $self->agentnum } );
894 }
895
896 =item bill OPTIONS
897
898 Generates invoices (see L<FS::cust_bill>) for this customer.  Usually used in
899 conjunction with the collect method.
900
901 Options are passed as name-value pairs.
902
903 The only currently available option is `time', which bills the customer as if
904 it were that time.  It is specified as a UNIX timestamp; see
905 L<perlfunc/"time">).  Also see L<Time::Local> and L<Date::Parse> for conversion
906 functions.  For example:
907
908  use Date::Parse;
909  ...
910  $cust_main->bill( 'time' => str2time('April 20th, 2001') );
911
912 If there is an error, returns the error, otherwise returns false.
913
914 =cut
915
916 sub bill {
917   my( $self, %options ) = @_;
918   my $time = $options{'time'} || time;
919
920   my $error;
921
922   #put below somehow?
923   local $SIG{HUP} = 'IGNORE';
924   local $SIG{INT} = 'IGNORE';
925   local $SIG{QUIT} = 'IGNORE';
926   local $SIG{TERM} = 'IGNORE';
927   local $SIG{TSTP} = 'IGNORE';
928   local $SIG{PIPE} = 'IGNORE';
929
930   my $oldAutoCommit = $FS::UID::AutoCommit;
931   local $FS::UID::AutoCommit = 0;
932   my $dbh = dbh;
933
934   # find the packages which are due for billing, find out how much they are
935   # & generate invoice database.
936  
937   my( $total_setup, $total_recur ) = ( 0, 0 );
938   #my( $taxable_setup, $taxable_recur ) = ( 0, 0 );
939   my @cust_bill_pkg = ();
940   my $tax = 0;##
941   #my $taxable_charged = 0;##
942   #my $charged = 0;##
943
944   foreach my $cust_pkg (
945     qsearch('cust_pkg', { 'custnum' => $self->custnum } )
946   ) {
947
948     #NO!! next if $cust_pkg->cancel;  
949     next if $cust_pkg->getfield('cancel');  
950
951     #? to avoid use of uninitialized value errors... ?
952     $cust_pkg->setfield('bill', '')
953       unless defined($cust_pkg->bill);
954  
955     my $part_pkg = $cust_pkg->part_pkg;
956
957     #so we don't modify cust_pkg record unnecessarily
958     my $cust_pkg_mod_flag = 0;
959     my %hash = $cust_pkg->hash;
960     my $old_cust_pkg = new FS::cust_pkg \%hash;
961
962     # bill setup
963     my $setup = 0;
964     unless ( $cust_pkg->setup ) {
965       my $setup_prog = $part_pkg->getfield('setup');
966       $setup_prog =~ /^(.*)$/ or do {
967         $dbh->rollback if $oldAutoCommit;
968         return "Illegal setup for pkgpart ". $part_pkg->pkgpart.
969                ": $setup_prog";
970       };
971       $setup_prog = $1;
972       $setup_prog = '0' if $setup_prog =~ /^\s*$/;
973
974         #my $cpt = new Safe;
975         ##$cpt->permit(); #what is necessary?
976         #$cpt->share(qw( $cust_pkg )); #can $cpt now use $cust_pkg methods?
977         #$setup = $cpt->reval($setup_prog);
978       $setup = eval $setup_prog;
979       unless ( defined($setup) ) {
980         $dbh->rollback if $oldAutoCommit;
981         return "Error eval-ing part_pkg->setup pkgpart ". $part_pkg->pkgpart.
982                "(expression $setup_prog): $@";
983       }
984       $cust_pkg->setfield('setup',$time);
985       $cust_pkg_mod_flag=1; 
986     }
987
988     #bill recurring fee
989     my $recur = 0;
990     my $sdate;
991     if ( $part_pkg->getfield('freq') > 0 &&
992          ! $cust_pkg->getfield('susp') &&
993          ( $cust_pkg->getfield('bill') || 0 ) <= $time
994     ) {
995       my $recur_prog = $part_pkg->getfield('recur');
996       $recur_prog =~ /^(.*)$/ or do {
997         $dbh->rollback if $oldAutoCommit;
998         return "Illegal recur for pkgpart ". $part_pkg->pkgpart.
999                ": $recur_prog";
1000       };
1001       $recur_prog = $1;
1002       $recur_prog = '0' if $recur_prog =~ /^\s*$/;
1003
1004       # shared with $recur_prog
1005       $sdate = $cust_pkg->bill || $cust_pkg->setup || $time;
1006
1007         #my $cpt = new Safe;
1008         ##$cpt->permit(); #what is necessary?
1009         #$cpt->share(qw( $cust_pkg )); #can $cpt now use $cust_pkg methods?
1010         #$recur = $cpt->reval($recur_prog);
1011       $recur = eval $recur_prog;
1012       unless ( defined($recur) ) {
1013         $dbh->rollback if $oldAutoCommit;
1014         return "Error eval-ing part_pkg->recur pkgpart ".  $part_pkg->pkgpart.
1015                "(expression $recur_prog): $@";
1016       }
1017       #change this bit to use Date::Manip? CAREFUL with timezones (see
1018       # mailing list archive)
1019       my ($sec,$min,$hour,$mday,$mon,$year) =
1020         (localtime($sdate) )[0,1,2,3,4,5];
1021
1022       #pro-rating magic - if $recur_prog fiddles $sdate, want to use that
1023       # only for figuring next bill date, nothing else, so, reset $sdate again
1024       # here
1025       $sdate = $cust_pkg->bill || $cust_pkg->setup || $time;
1026       $cust_pkg->last_bill($sdate)
1027         if $cust_pkg->dbdef_table->column('last_bill');
1028
1029       $mon += $part_pkg->freq;
1030       until ( $mon < 12 ) { $mon -= 12; $year++; }
1031       $cust_pkg->setfield('bill',
1032         timelocal_nocheck($sec,$min,$hour,$mday,$mon,$year));
1033       $cust_pkg_mod_flag = 1; 
1034     }
1035
1036     warn "\$setup is undefined" unless defined($setup);
1037     warn "\$recur is undefined" unless defined($recur);
1038     warn "\$cust_pkg->bill is undefined" unless defined($cust_pkg->bill);
1039
1040     my $taxable_charged = 0;
1041     if ( $cust_pkg_mod_flag ) {
1042       $error=$cust_pkg->replace($old_cust_pkg);
1043       if ( $error ) { #just in case
1044         $dbh->rollback if $oldAutoCommit;
1045         return "Error modifying pkgnum ". $cust_pkg->pkgnum. ": $error";
1046       }
1047       $setup = sprintf( "%.2f", $setup );
1048       $recur = sprintf( "%.2f", $recur );
1049       if ( $setup < 0 ) {
1050         $dbh->rollback if $oldAutoCommit;
1051         return "negative setup $setup for pkgnum ". $cust_pkg->pkgnum;
1052       }
1053       if ( $recur < 0 ) {
1054         $dbh->rollback if $oldAutoCommit;
1055         return "negative recur $recur for pkgnum ". $cust_pkg->pkgnum;
1056       }
1057       if ( $setup > 0 || $recur > 0 ) {
1058         my $cust_bill_pkg = new FS::cust_bill_pkg ({
1059           'pkgnum' => $cust_pkg->pkgnum,
1060           'setup'  => $setup,
1061           'recur'  => $recur,
1062           'sdate'  => $sdate,
1063           'edate'  => $cust_pkg->bill,
1064         });
1065         push @cust_bill_pkg, $cust_bill_pkg;
1066         $total_setup += $setup;
1067         $total_recur += $recur;
1068         $taxable_charged += $setup
1069           unless $part_pkg->setuptax =~ /^Y$/i;
1070         $taxable_charged += $recur
1071           unless $part_pkg->recurtax =~ /^Y$/i;
1072           
1073         unless ( $self->tax =~ /Y/i
1074                  || $self->payby eq 'COMP'
1075                  || $taxable_charged == 0 ) {
1076
1077           my $cust_main_county = qsearchs('cust_main_county',{
1078               'state'    => $self->state,
1079               'county'   => $self->county,
1080               'country'  => $self->country,
1081               'taxclass' => $part_pkg->taxclass,
1082           } );
1083           $cust_main_county ||= qsearchs('cust_main_county',{
1084               'state'    => $self->state,
1085               'county'   => $self->county,
1086               'country'  => $self->country,
1087               'taxclass' => '',
1088           } );
1089           unless ( $cust_main_county ) {
1090             $dbh->rollback if $oldAutoCommit;
1091             return
1092               "fatal: can't find tax rate for state/county/country/taxclass ".
1093               join('/', ( map $self->$_(), qw(state county country) ),
1094                         $part_pkg->taxclass ).  "\n";
1095           }
1096
1097           if ( $cust_main_county->exempt_amount ) {
1098             my ($mon,$year) = (localtime($sdate) )[4,5];
1099             $mon++;
1100             my $freq = $part_pkg->freq || 1;
1101             my $taxable_per_month = sprintf("%.2f", $taxable_charged / $freq );
1102             foreach my $which_month ( 1 .. $freq ) {
1103               my %hash = (
1104                 'custnum' => $self->custnum,
1105                 'taxnum'  => $cust_main_county->taxnum,
1106                 'year'    => 1900+$year,
1107                 'month'   => $mon++,
1108               );
1109               #until ( $mon < 12 ) { $mon -= 12; $year++; }
1110               until ( $mon < 13 ) { $mon -= 12; $year++; }
1111               my $cust_tax_exempt =
1112                 qsearchs('cust_tax_exempt', \%hash)
1113                 || new FS::cust_tax_exempt( { %hash, 'amount' => 0 } );
1114               my $remaining_exemption = sprintf("%.2f",
1115                 $cust_main_county->exempt_amount - $cust_tax_exempt->amount );
1116               if ( $remaining_exemption > 0 ) {
1117                 my $addl = $remaining_exemption > $taxable_per_month
1118                   ? $taxable_per_month
1119                   : $remaining_exemption;
1120                 $taxable_charged -= $addl;
1121                 my $new_cust_tax_exempt = new FS::cust_tax_exempt ( {
1122                   $cust_tax_exempt->hash,
1123                   'amount' => sprintf("%.2f", $cust_tax_exempt->amount + $addl),
1124                 } );
1125                 $error = $new_cust_tax_exempt->exemptnum
1126                   ? $new_cust_tax_exempt->replace($cust_tax_exempt)
1127                   : $new_cust_tax_exempt->insert;
1128                 if ( $error ) {
1129                   $dbh->rollback if $oldAutoCommit;
1130                   return "fatal: can't update cust_tax_exempt: $error";
1131                 }
1132
1133               } # if $remaining_exemption > 0
1134
1135             } #foreach $which_month
1136
1137           } #if $cust_main_county->exempt_amount
1138
1139           $taxable_charged = sprintf( "%.2f", $taxable_charged);
1140           $tax += $taxable_charged * $cust_main_county->tax / 100
1141
1142         } #unless $self->tax =~ /Y/i
1143           #       || $self->payby eq 'COMP'
1144           #       || $taxable_charged == 0
1145
1146       } #if $setup > 0 || $recur > 0
1147       
1148     } #if $cust_pkg_mod_flag
1149
1150   } #foreach my $cust_pkg
1151
1152   my $charged = sprintf( "%.2f", $total_setup + $total_recur );
1153 #  my $taxable_charged = sprintf( "%.2f", $taxable_setup + $taxable_recur );
1154
1155   unless ( @cust_bill_pkg ) { #don't create invoices with no line items
1156     $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1157     return '';
1158   } 
1159
1160 #  unless ( $self->tax =~ /Y/i
1161 #           || $self->payby eq 'COMP'
1162 #           || $taxable_charged == 0 ) {
1163 #    my $cust_main_county = qsearchs('cust_main_county',{
1164 #        'state'   => $self->state,
1165 #        'county'  => $self->county,
1166 #        'country' => $self->country,
1167 #    } ) or die "fatal: can't find tax rate for state/county/country ".
1168 #               $self->state. "/". $self->county. "/". $self->country. "\n";
1169 #    my $tax = sprintf( "%.2f",
1170 #      $taxable_charged * ( $cust_main_county->getfield('tax') / 100 )
1171 #    );
1172
1173   $tax = sprintf("%.2f", $tax);
1174   if ( $tax > 0 ) {
1175     $charged = sprintf( "%.2f", $charged+$tax );
1176
1177     my $cust_bill_pkg = new FS::cust_bill_pkg ({
1178       'pkgnum' => 0,
1179       'setup'  => $tax,
1180       'recur'  => 0,
1181       'sdate'  => '',
1182       'edate'  => '',
1183     });
1184     push @cust_bill_pkg, $cust_bill_pkg;
1185   }
1186 #  }
1187
1188   my $cust_bill = new FS::cust_bill ( {
1189     'custnum' => $self->custnum,
1190     '_date'   => $time,
1191     'charged' => $charged,
1192   } );
1193   $error = $cust_bill->insert;
1194   if ( $error ) {
1195     $dbh->rollback if $oldAutoCommit;
1196     return "can't create invoice for customer #". $self->custnum. ": $error";
1197   }
1198
1199   my $invnum = $cust_bill->invnum;
1200   my $cust_bill_pkg;
1201   foreach $cust_bill_pkg ( @cust_bill_pkg ) {
1202     #warn $invnum;
1203     $cust_bill_pkg->invnum($invnum);
1204     $error = $cust_bill_pkg->insert;
1205     if ( $error ) {
1206       $dbh->rollback if $oldAutoCommit;
1207       return "can't create invoice line item for customer #". $self->custnum.
1208              ": $error";
1209     }
1210   }
1211   
1212   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1213   ''; #no error
1214 }
1215
1216 =item collect OPTIONS
1217
1218 (Attempt to) collect money for this customer's outstanding invoices (see
1219 L<FS::cust_bill>).  Usually used after the bill method.
1220
1221 Depending on the value of `payby', this may print an invoice (`BILL'), charge
1222 a credit card (`CARD'), or just add any necessary (pseudo-)payment (`COMP').
1223
1224 Most actions are now triggered by invoice events; see L<FS::part_bill_event>
1225 and the invoice events web interface.
1226
1227 If there is an error, returns the error, otherwise returns false.
1228
1229 Options are passed as name-value pairs.
1230
1231 Currently available options are:
1232
1233 invoice_time - Use this time when deciding when to print invoices and
1234 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>
1235 for conversion functions.
1236
1237 retry - Retry card/echeck/LEC transactions even when not scheduled by invoice
1238 events.
1239
1240 retry_card - Deprecated alias for 'retry'
1241
1242 batch_card - This option is deprecated.  See the invoice events web interface
1243 to control whether cards are batched or run against a realtime gateway.
1244
1245 report_badcard - This option is deprecated.
1246
1247 force_print - This option is deprecated; see the invoice events web interface.
1248
1249 =cut
1250
1251 sub collect {
1252   my( $self, %options ) = @_;
1253   my $invoice_time = $options{'invoice_time'} || time;
1254
1255   #put below somehow?
1256   local $SIG{HUP} = 'IGNORE';
1257   local $SIG{INT} = 'IGNORE';
1258   local $SIG{QUIT} = 'IGNORE';
1259   local $SIG{TERM} = 'IGNORE';
1260   local $SIG{TSTP} = 'IGNORE';
1261   local $SIG{PIPE} = 'IGNORE';
1262
1263   my $oldAutoCommit = $FS::UID::AutoCommit;
1264   local $FS::UID::AutoCommit = 0;
1265   my $dbh = dbh;
1266
1267   my $balance = $self->balance;
1268   warn "collect customer". $self->custnum. ": balance $balance" if $Debug;
1269   unless ( $balance > 0 ) { #redundant?????
1270     $dbh->rollback if $oldAutoCommit; #hmm
1271     return '';
1272   }
1273
1274   if ( exists($options{'retry_card'}) ) {
1275     carp 'retry_card option passed to collect is deprecated; use retry';
1276     $options{'retry'} ||= $options{'retry_card'};
1277   }
1278   if ( exists($options{'retry'}) && $options{'retry'} ) {
1279     my $error = $self->retry_realtime;
1280     if ( $error ) {
1281       $dbh->rollback if $oldAutoCommit;
1282       return $error;
1283     }
1284   }
1285
1286   foreach my $cust_bill ( $self->cust_bill ) {
1287
1288     #this has to be before next's
1289     my $amount = sprintf( "%.2f", $balance < $cust_bill->owed
1290                                   ? $balance
1291                                   : $cust_bill->owed
1292     );
1293     $balance = sprintf( "%.2f", $balance - $amount );
1294
1295     next unless $cust_bill->owed > 0;
1296
1297     # don't try to charge for the same invoice if it's already in a batch
1298     #next if qsearchs( 'cust_pay_batch', { 'invnum' => $cust_bill->invnum } );
1299
1300     warn "invnum ". $cust_bill->invnum. " (owed ". $cust_bill->owed. ", amount $amount, balance $balance)" if $Debug;
1301
1302     next unless $amount > 0;
1303
1304
1305     foreach my $part_bill_event (
1306       sort {    $a->seconds   <=> $b->seconds
1307              || $a->weight    <=> $b->weight
1308              || $a->eventpart <=> $b->eventpart }
1309         grep { $_->seconds <= ( $invoice_time - $cust_bill->_date )
1310                && ! qsearchs( 'cust_bill_event', {
1311                                 'invnum'    => $cust_bill->invnum,
1312                                 'eventpart' => $_->eventpart,
1313                                 'status'    => 'done',
1314                                                                    } )
1315              }
1316           qsearch('part_bill_event', { 'payby'    => $self->payby,
1317                                        'disabled' => '',           } )
1318     ) {
1319
1320       last unless $cust_bill->owed > 0; #don't run subsequent events if owed=0
1321
1322       warn "calling invoice event (". $part_bill_event->eventcode. ")\n"
1323         if $Debug;
1324       my $cust_main = $self; #for callback
1325       my $error = eval $part_bill_event->eventcode;
1326
1327       my $status = '';
1328       my $statustext = '';
1329       if ( $@ ) {
1330         $status = 'failed';
1331         $statustext = $@;
1332       } elsif ( $error ) {
1333         $status = 'done';
1334         $statustext = $error;
1335       } else {
1336         $status = 'done'
1337       }
1338
1339       #add cust_bill_event
1340       my $cust_bill_event = new FS::cust_bill_event {
1341         'invnum'     => $cust_bill->invnum,
1342         'eventpart'  => $part_bill_event->eventpart,
1343         #'_date'      => $invoice_time,
1344         '_date'      => time,
1345         'status'     => $status,
1346         'statustext' => $statustext,
1347       };
1348       $error = $cust_bill_event->insert;
1349       if ( $error ) {
1350         #$dbh->rollback if $oldAutoCommit;
1351         #return "error: $error";
1352
1353         # gah, even with transactions.
1354         $dbh->commit if $oldAutoCommit; #well.
1355         my $e = 'WARNING: Event run but database not updated - '.
1356                 'error inserting cust_bill_event, invnum #'. $cust_bill->invnum.
1357                 ', eventpart '. $part_bill_event->eventpart.
1358                 ": $error";
1359         warn $e;
1360         return $e;
1361       }
1362
1363
1364     }
1365
1366   }
1367
1368   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1369   '';
1370
1371 }
1372
1373 =item retry_realtime
1374
1375 Schedules realtime credit card / electronic check / LEC billing events for
1376 for retry.  Useful if card information has changed or manual retry is desired.
1377 The 'collect' method must be called to actually retry the transaction.
1378
1379 Implementation details: For each of this customer's open invoices, changes
1380 the status of the first "done" (with statustext error) realtime processing
1381 event to "failed".
1382
1383 =cut
1384
1385 sub retry_realtime {
1386   my $self = shift;
1387
1388   local $SIG{HUP} = 'IGNORE';
1389   local $SIG{INT} = 'IGNORE';
1390   local $SIG{QUIT} = 'IGNORE';
1391   local $SIG{TERM} = 'IGNORE';
1392   local $SIG{TSTP} = 'IGNORE';
1393   local $SIG{PIPE} = 'IGNORE';
1394
1395   my $oldAutoCommit = $FS::UID::AutoCommit;
1396   local $FS::UID::AutoCommit = 0;
1397   my $dbh = dbh;
1398
1399   foreach my $cust_bill (
1400     grep { $_->cust_bill_event }
1401       $self->open_cust_bill
1402   ) {
1403     my @cust_bill_event =
1404       sort { $a->part_bill_event->seconds <=> $b->part_bill_event->seconds }
1405         grep {
1406                #$_->part_bill_event->plan eq 'realtime-card'
1407                $_->part_bill_event->eventcode =~
1408                    /\$cust_bill\->realtime_(card|ach|lec)/
1409                  && $_->status eq 'done'
1410                  && $_->statustext
1411              }
1412           $cust_bill->cust_bill_event;
1413     next unless @cust_bill_event;
1414     my $error = $cust_bill_event[0]->retry;
1415     if ( $error ) {
1416       $dbh->rollback if $oldAutoCommit;
1417       return "error scheduling invoice event for retry: $error";
1418     }
1419
1420   }
1421
1422   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1423   '';
1424
1425 }
1426
1427 =item total_owed
1428
1429 Returns the total owed for this customer on all invoices
1430 (see L<FS::cust_bill/owed>).
1431
1432 =cut
1433
1434 sub total_owed {
1435   my $self = shift;
1436   $self->total_owed_date(2145859200); #12/31/2037
1437 }
1438
1439 =item total_owed_date TIME
1440
1441 Returns the total owed for this customer on all invoices with date earlier than
1442 TIME.  TIME is specified as a UNIX timestamp; see L<perlfunc/"time">).  Also
1443 see L<Time::Local> and L<Date::Parse> for conversion functions.
1444
1445 =cut
1446
1447 sub total_owed_date {
1448   my $self = shift;
1449   my $time = shift;
1450   my $total_bill = 0;
1451   foreach my $cust_bill (
1452     grep { $_->_date <= $time }
1453       qsearch('cust_bill', { 'custnum' => $self->custnum, } )
1454   ) {
1455     $total_bill += $cust_bill->owed;
1456   }
1457   sprintf( "%.2f", $total_bill );
1458 }
1459
1460 =item apply_credits
1461
1462 Applies (see L<FS::cust_credit_bill>) unapplied credits (see L<FS::cust_credit>)
1463 to outstanding invoice balances in chronological order and returns the value
1464 of any remaining unapplied credits available for refund
1465 (see L<FS::cust_refund>).
1466
1467 =cut
1468
1469 sub apply_credits {
1470   my $self = shift;
1471
1472   return 0 unless $self->total_credited;
1473
1474   my @credits = sort { $b->_date <=> $a->_date} (grep { $_->credited > 0 }
1475       qsearch('cust_credit', { 'custnum' => $self->custnum } ) );
1476
1477   my @invoices = sort { $a->_date <=> $b->_date} (grep { $_->owed > 0 }
1478       qsearch('cust_bill', { 'custnum' => $self->custnum } ) );
1479
1480   my $credit;
1481
1482   foreach my $cust_bill ( @invoices ) {
1483     my $amount;
1484
1485     if ( !defined($credit) || $credit->credited == 0) {
1486       $credit = pop @credits or last;
1487     }
1488
1489     if ($cust_bill->owed >= $credit->credited) {
1490       $amount=$credit->credited;
1491     }else{
1492       $amount=$cust_bill->owed;
1493     }
1494     
1495     my $cust_credit_bill = new FS::cust_credit_bill ( {
1496       'crednum' => $credit->crednum,
1497       'invnum'  => $cust_bill->invnum,
1498       'amount'  => $amount,
1499     } );
1500     my $error = $cust_credit_bill->insert;
1501     die $error if $error;
1502     
1503     redo if ($cust_bill->owed > 0);
1504
1505   }
1506
1507   return $self->total_credited;
1508 }
1509
1510 =item apply_payments
1511
1512 Applies (see L<FS::cust_bill_pay>) unapplied payments (see L<FS::cust_pay>)
1513 to outstanding invoice balances in chronological order.
1514
1515  #and returns the value of any remaining unapplied payments.
1516
1517 =cut
1518
1519 sub apply_payments {
1520   my $self = shift;
1521
1522   #return 0 unless
1523
1524   my @payments = sort { $b->_date <=> $a->_date } ( grep { $_->unapplied > 0 }
1525       qsearch('cust_pay', { 'custnum' => $self->custnum } ) );
1526
1527   my @invoices = sort { $a->_date <=> $b->_date} (grep { $_->owed > 0 }
1528       qsearch('cust_bill', { 'custnum' => $self->custnum } ) );
1529
1530   my $payment;
1531
1532   foreach my $cust_bill ( @invoices ) {
1533     my $amount;
1534
1535     if ( !defined($payment) || $payment->unapplied == 0 ) {
1536       $payment = pop @payments or last;
1537     }
1538
1539     if ( $cust_bill->owed >= $payment->unapplied ) {
1540       $amount = $payment->unapplied;
1541     } else {
1542       $amount = $cust_bill->owed;
1543     }
1544
1545     my $cust_bill_pay = new FS::cust_bill_pay ( {
1546       'paynum' => $payment->paynum,
1547       'invnum' => $cust_bill->invnum,
1548       'amount' => $amount,
1549     } );
1550     my $error = $cust_bill_pay->insert;
1551     die $error if $error;
1552
1553     redo if ( $cust_bill->owed > 0);
1554
1555   }
1556
1557   return $self->total_unapplied_payments;
1558 }
1559
1560 =item total_credited
1561
1562 Returns the total outstanding credit (see L<FS::cust_credit>) for this
1563 customer.  See L<FS::cust_credit/credited>.
1564
1565 =cut
1566
1567 sub total_credited {
1568   my $self = shift;
1569   my $total_credit = 0;
1570   foreach my $cust_credit ( qsearch('cust_credit', {
1571     'custnum' => $self->custnum,
1572   } ) ) {
1573     $total_credit += $cust_credit->credited;
1574   }
1575   sprintf( "%.2f", $total_credit );
1576 }
1577
1578 =item total_unapplied_payments
1579
1580 Returns the total unapplied payments (see L<FS::cust_pay>) for this customer.
1581 See L<FS::cust_pay/unapplied>.
1582
1583 =cut
1584
1585 sub total_unapplied_payments {
1586   my $self = shift;
1587   my $total_unapplied = 0;
1588   foreach my $cust_pay ( qsearch('cust_pay', {
1589     'custnum' => $self->custnum,
1590   } ) ) {
1591     $total_unapplied += $cust_pay->unapplied;
1592   }
1593   sprintf( "%.2f", $total_unapplied );
1594 }
1595
1596 =item balance
1597
1598 Returns the balance for this customer (total_owed minus total_credited
1599 minus total_unapplied_payments).
1600
1601 =cut
1602
1603 sub balance {
1604   my $self = shift;
1605   sprintf( "%.2f",
1606     $self->total_owed - $self->total_credited - $self->total_unapplied_payments
1607   );
1608 }
1609
1610 =item balance_date TIME
1611
1612 Returns the balance for this customer, only considering invoices with date
1613 earlier than TIME (total_owed_date minus total_credited minus
1614 total_unapplied_payments).  TIME is specified as a UNIX timestamp; see
1615 L<perlfunc/"time">).  Also see L<Time::Local> and L<Date::Parse> for conversion
1616 functions.
1617
1618 =cut
1619
1620 sub balance_date {
1621   my $self = shift;
1622   my $time = shift;
1623   sprintf( "%.2f",
1624     $self->total_owed_date($time)
1625       - $self->total_credited
1626       - $self->total_unapplied_payments
1627   );
1628 }
1629
1630 =item invoicing_list [ ARRAYREF ]
1631
1632 If an arguement is given, sets these email addresses as invoice recipients
1633 (see L<FS::cust_main_invoice>).  Errors are not fatal and are not reported
1634 (except as warnings), so use check_invoicing_list first.
1635
1636 Returns a list of email addresses (with svcnum entries expanded).
1637
1638 Note: You can clear the invoicing list by passing an empty ARRAYREF.  You can
1639 check it without disturbing anything by passing nothing.
1640
1641 This interface may change in the future.
1642
1643 =cut
1644
1645 sub invoicing_list {
1646   my( $self, $arrayref ) = @_;
1647   if ( $arrayref ) {
1648     my @cust_main_invoice;
1649     if ( $self->custnum ) {
1650       @cust_main_invoice = 
1651         qsearch( 'cust_main_invoice', { 'custnum' => $self->custnum } );
1652     } else {
1653       @cust_main_invoice = ();
1654     }
1655     foreach my $cust_main_invoice ( @cust_main_invoice ) {
1656       #warn $cust_main_invoice->destnum;
1657       unless ( grep { $cust_main_invoice->address eq $_ } @{$arrayref} ) {
1658         #warn $cust_main_invoice->destnum;
1659         my $error = $cust_main_invoice->delete;
1660         warn $error if $error;
1661       }
1662     }
1663     if ( $self->custnum ) {
1664       @cust_main_invoice = 
1665         qsearch( 'cust_main_invoice', { 'custnum' => $self->custnum } );
1666     } else {
1667       @cust_main_invoice = ();
1668     }
1669     my %seen = map { $_->address => 1 } @cust_main_invoice;
1670     foreach my $address ( @{$arrayref} ) {
1671       next if exists $seen{$address} && $seen{$address};
1672       $seen{$address} = 1;
1673       my $cust_main_invoice = new FS::cust_main_invoice ( {
1674         'custnum' => $self->custnum,
1675         'dest'    => $address,
1676       } );
1677       my $error = $cust_main_invoice->insert;
1678       warn $error if $error;
1679     }
1680   }
1681   if ( $self->custnum ) {
1682     map { $_->address }
1683       qsearch( 'cust_main_invoice', { 'custnum' => $self->custnum } );
1684   } else {
1685     ();
1686   }
1687 }
1688
1689 =item check_invoicing_list ARRAYREF
1690
1691 Checks these arguements as valid input for the invoicing_list method.  If there
1692 is an error, returns the error, otherwise returns false.
1693
1694 =cut
1695
1696 sub check_invoicing_list {
1697   my( $self, $arrayref ) = @_;
1698   foreach my $address ( @{$arrayref} ) {
1699     my $cust_main_invoice = new FS::cust_main_invoice ( {
1700       'custnum' => $self->custnum,
1701       'dest'    => $address,
1702     } );
1703     my $error = $self->custnum
1704                 ? $cust_main_invoice->check
1705                 : $cust_main_invoice->checkdest
1706     ;
1707     return $error if $error;
1708   }
1709   '';
1710 }
1711
1712 =item set_default_invoicing_list
1713
1714 Sets the invoicing list to all accounts associated with this customer,
1715 overwriting any previous invoicing list.
1716
1717 =cut
1718
1719 sub set_default_invoicing_list {
1720   my $self = shift;
1721   $self->invoicing_list($self->all_emails);
1722 }
1723
1724 =item all_emails
1725
1726 Returns the email addresses of all accounts provisioned for this customer.
1727
1728 =cut
1729
1730 sub all_emails {
1731   my $self = shift;
1732   my %list;
1733   foreach my $cust_pkg ( $self->all_pkgs ) {
1734     my @cust_svc = qsearch('cust_svc', { 'pkgnum' => $cust_pkg->pkgnum } );
1735     my @svc_acct =
1736       map { qsearchs('svc_acct', { 'svcnum' => $_->svcnum } ) }
1737         grep { qsearchs('svc_acct', { 'svcnum' => $_->svcnum } ) }
1738           @cust_svc;
1739     $list{$_}=1 foreach map { $_->email } @svc_acct;
1740   }
1741   keys %list;
1742 }
1743
1744 =item invoicing_list_addpost
1745
1746 Adds postal invoicing to this customer.  If this customer is already configured
1747 to receive postal invoices, does nothing.
1748
1749 =cut
1750
1751 sub invoicing_list_addpost {
1752   my $self = shift;
1753   return if grep { $_ eq 'POST' } $self->invoicing_list;
1754   my @invoicing_list = $self->invoicing_list;
1755   push @invoicing_list, 'POST';
1756   $self->invoicing_list(\@invoicing_list);
1757 }
1758
1759 =item referral_cust_main [ DEPTH [ EXCLUDE_HASHREF ] ]
1760
1761 Returns an array of customers referred by this customer (referral_custnum set
1762 to this custnum).  If DEPTH is given, recurses up to the given depth, returning
1763 customers referred by customers referred by this customer and so on, inclusive.
1764 The default behavior is DEPTH 1 (no recursion).
1765
1766 =cut
1767
1768 sub referral_cust_main {
1769   my $self = shift;
1770   my $depth = @_ ? shift : 1;
1771   my $exclude = @_ ? shift : {};
1772
1773   my @cust_main =
1774     map { $exclude->{$_->custnum}++; $_; }
1775       grep { ! $exclude->{ $_->custnum } }
1776         qsearch( 'cust_main', { 'referral_custnum' => $self->custnum } );
1777
1778   if ( $depth > 1 ) {
1779     push @cust_main,
1780       map { $_->referral_cust_main($depth-1, $exclude) }
1781         @cust_main;
1782   }
1783
1784   @cust_main;
1785 }
1786
1787 =item referral_cust_main_ncancelled
1788
1789 Same as referral_cust_main, except only returns customers with uncancelled
1790 packages.
1791
1792 =cut
1793
1794 sub referral_cust_main_ncancelled {
1795   my $self = shift;
1796   grep { scalar($_->ncancelled_pkgs) } $self->referral_cust_main;
1797 }
1798
1799 =item referral_cust_pkg [ DEPTH ]
1800
1801 Like referral_cust_main, except returns a flat list of all unsuspended (and
1802 uncancelled) packages for each customer.  The number of items in this list may
1803 be useful for comission calculations (perhaps after a C<grep { my $pkgpart = $_->pkgpart; grep { $_ == $pkgpart } @commission_worthy_pkgparts> } $cust_main-> ).
1804
1805 =cut
1806
1807 sub referral_cust_pkg {
1808   my $self = shift;
1809   my $depth = @_ ? shift : 1;
1810
1811   map { $_->unsuspended_pkgs }
1812     grep { $_->unsuspended_pkgs }
1813       $self->referral_cust_main($depth);
1814 }
1815
1816 =item credit AMOUNT, REASON
1817
1818 Applies a credit to this customer.  If there is an error, returns the error,
1819 otherwise returns false.
1820
1821 =cut
1822
1823 sub credit {
1824   my( $self, $amount, $reason ) = @_;
1825   my $cust_credit = new FS::cust_credit {
1826     'custnum' => $self->custnum,
1827     'amount'  => $amount,
1828     'reason'  => $reason,
1829   };
1830   $cust_credit->insert;
1831 }
1832
1833 =item charge AMOUNT [ PKG [ COMMENT [ TAXCLASS ] ] ]
1834
1835 Creates a one-time charge for this customer.  If there is an error, returns
1836 the error, otherwise returns false.
1837
1838 =cut
1839
1840 sub charge {
1841   my ( $self, $amount ) = ( shift, shift );
1842   my $pkg      = @_ ? shift : 'One-time charge';
1843   my $comment  = @_ ? shift : '$'. sprintf("%.2f",$amount);
1844   my $taxclass = @_ ? shift : '';
1845
1846   local $SIG{HUP} = 'IGNORE';
1847   local $SIG{INT} = 'IGNORE';
1848   local $SIG{QUIT} = 'IGNORE';
1849   local $SIG{TERM} = 'IGNORE';
1850   local $SIG{TSTP} = 'IGNORE';
1851   local $SIG{PIPE} = 'IGNORE';
1852
1853   my $oldAutoCommit = $FS::UID::AutoCommit;
1854   local $FS::UID::AutoCommit = 0;
1855   my $dbh = dbh;
1856
1857   my $part_pkg = new FS::part_pkg ( {
1858     'pkg'      => $pkg,
1859     'comment'  => $comment,
1860     'setup'    => $amount,
1861     'freq'     => 0,
1862     'recur'    => '0',
1863     'disabled' => 'Y',
1864     'taxclass' => $taxclass,
1865   } );
1866
1867   my $error = $part_pkg->insert;
1868   if ( $error ) {
1869     $dbh->rollback if $oldAutoCommit;
1870     return $error;
1871   }
1872
1873   my $pkgpart = $part_pkg->pkgpart;
1874   my %type_pkgs = ( 'typenum' => $self->agent->typenum, 'pkgpart' => $pkgpart );
1875   unless ( qsearchs('type_pkgs', \%type_pkgs ) ) {
1876     my $type_pkgs = new FS::type_pkgs \%type_pkgs;
1877     $error = $type_pkgs->insert;
1878     if ( $error ) {
1879       $dbh->rollback if $oldAutoCommit;
1880       return $error;
1881     }
1882   }
1883
1884   my $cust_pkg = new FS::cust_pkg ( {
1885     'custnum' => $self->custnum,
1886     'pkgpart' => $pkgpart,
1887   } );
1888
1889   $error = $cust_pkg->insert;
1890   if ( $error ) {
1891     $dbh->rollback if $oldAutoCommit;
1892     return $error;
1893   }
1894
1895   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1896   '';
1897
1898 }
1899
1900 =item cust_bill
1901
1902 Returns all the invoices (see L<FS::cust_bill>) for this customer.
1903
1904 =cut
1905
1906 sub cust_bill {
1907   my $self = shift;
1908   sort { $a->_date <=> $b->_date }
1909     qsearch('cust_bill', { 'custnum' => $self->custnum, } )
1910 }
1911
1912 =item open_cust_bill
1913
1914 Returns all the open (owed > 0) invoices (see L<FS::cust_bill>) for this
1915 customer.
1916
1917 =cut
1918
1919 sub open_cust_bill {
1920   my $self = shift;
1921   grep { $_->owed > 0 } $self->cust_bill;
1922 }
1923
1924 =back
1925
1926 =head1 SUBROUTINES
1927
1928 =over 4
1929
1930 =item check_and_rebuild_fuzzyfiles
1931
1932 =cut
1933
1934 sub check_and_rebuild_fuzzyfiles {
1935   my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
1936   -e "$dir/cust_main.last" && -e "$dir/cust_main.company"
1937     or &rebuild_fuzzyfiles;
1938 }
1939
1940 =item rebuild_fuzzyfiles
1941
1942 =cut
1943
1944 sub rebuild_fuzzyfiles {
1945
1946   use Fcntl qw(:flock);
1947
1948   my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
1949
1950   #last
1951
1952   open(LASTLOCK,">>$dir/cust_main.last")
1953     or die "can't open $dir/cust_main.last: $!";
1954   flock(LASTLOCK,LOCK_EX)
1955     or die "can't lock $dir/cust_main.last: $!";
1956
1957   my @all_last = map $_->getfield('last'), qsearch('cust_main', {});
1958   push @all_last,
1959                  grep $_, map $_->getfield('ship_last'), qsearch('cust_main',{})
1960     if defined dbdef->table('cust_main')->column('ship_last');
1961
1962   open (LASTCACHE,">$dir/cust_main.last.tmp")
1963     or die "can't open $dir/cust_main.last.tmp: $!";
1964   print LASTCACHE join("\n", @all_last), "\n";
1965   close LASTCACHE or die "can't close $dir/cust_main.last.tmp: $!";
1966
1967   rename "$dir/cust_main.last.tmp", "$dir/cust_main.last";
1968   close LASTLOCK;
1969
1970   #company
1971
1972   open(COMPANYLOCK,">>$dir/cust_main.company")
1973     or die "can't open $dir/cust_main.company: $!";
1974   flock(COMPANYLOCK,LOCK_EX)
1975     or die "can't lock $dir/cust_main.company: $!";
1976
1977   my @all_company = grep $_ ne '', map $_->company, qsearch('cust_main',{});
1978   push @all_company,
1979        grep $_ ne '', map $_->ship_company, qsearch('cust_main', {})
1980     if defined dbdef->table('cust_main')->column('ship_last');
1981
1982   open (COMPANYCACHE,">$dir/cust_main.company.tmp")
1983     or die "can't open $dir/cust_main.company.tmp: $!";
1984   print COMPANYCACHE join("\n", @all_company), "\n";
1985   close COMPANYCACHE or die "can't close $dir/cust_main.company.tmp: $!";
1986
1987   rename "$dir/cust_main.company.tmp", "$dir/cust_main.company";
1988   close COMPANYLOCK;
1989
1990 }
1991
1992 =item all_last
1993
1994 =cut
1995
1996 sub all_last {
1997   my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
1998   open(LASTCACHE,"<$dir/cust_main.last")
1999     or die "can't open $dir/cust_main.last: $!";
2000   my @array = map { chomp; $_; } <LASTCACHE>;
2001   close LASTCACHE;
2002   \@array;
2003 }
2004
2005 =item all_company
2006
2007 =cut
2008
2009 sub all_company {
2010   my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
2011   open(COMPANYCACHE,"<$dir/cust_main.company")
2012     or die "can't open $dir/cust_main.last: $!";
2013   my @array = map { chomp; $_; } <COMPANYCACHE>;
2014   close COMPANYCACHE;
2015   \@array;
2016 }
2017
2018 =item append_fuzzyfiles LASTNAME COMPANY
2019
2020 =cut
2021
2022 sub append_fuzzyfiles {
2023   my( $last, $company ) = @_;
2024
2025   &check_and_rebuild_fuzzyfiles;
2026
2027   use Fcntl qw(:flock);
2028
2029   my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
2030
2031   if ( $last ) {
2032
2033     open(LAST,">>$dir/cust_main.last")
2034       or die "can't open $dir/cust_main.last: $!";
2035     flock(LAST,LOCK_EX)
2036       or die "can't lock $dir/cust_main.last: $!";
2037
2038     print LAST "$last\n";
2039
2040     flock(LAST,LOCK_UN)
2041       or die "can't unlock $dir/cust_main.last: $!";
2042     close LAST;
2043   }
2044
2045   if ( $company ) {
2046
2047     open(COMPANY,">>$dir/cust_main.company")
2048       or die "can't open $dir/cust_main.company: $!";
2049     flock(COMPANY,LOCK_EX)
2050       or die "can't lock $dir/cust_main.company: $!";
2051
2052     print COMPANY "$company\n";
2053
2054     flock(COMPANY,LOCK_UN)
2055       or die "can't unlock $dir/cust_main.company: $!";
2056
2057     close COMPANY;
2058   }
2059
2060   1;
2061 }
2062
2063 =item batch_import
2064
2065 =cut
2066
2067 sub batch_import {
2068   my $param = shift;
2069   #warn join('-',keys %$param);
2070   my $fh = $param->{filehandle};
2071   my $agentnum = $param->{agentnum};
2072   my $refnum = $param->{refnum};
2073   my $pkgpart = $param->{pkgpart};
2074   my @fields = @{$param->{fields}};
2075
2076   eval "use Date::Parse;";
2077   die $@ if $@;
2078   eval "use Text::CSV_XS;";
2079   die $@ if $@;
2080
2081   my $csv = new Text::CSV_XS;
2082   #warn $csv;
2083   #warn $fh;
2084
2085   my $imported = 0;
2086   #my $columns;
2087
2088   local $SIG{HUP} = 'IGNORE';
2089   local $SIG{INT} = 'IGNORE';
2090   local $SIG{QUIT} = 'IGNORE';
2091   local $SIG{TERM} = 'IGNORE';
2092   local $SIG{TSTP} = 'IGNORE';
2093   local $SIG{PIPE} = 'IGNORE';
2094
2095   my $oldAutoCommit = $FS::UID::AutoCommit;
2096   local $FS::UID::AutoCommit = 0;
2097   my $dbh = dbh;
2098   
2099   #while ( $columns = $csv->getline($fh) ) {
2100   my $line;
2101   while ( defined($line=<$fh>) ) {
2102
2103     $csv->parse($line) or do {
2104       $dbh->rollback if $oldAutoCommit;
2105       return "can't parse: ". $csv->error_input();
2106     };
2107
2108     my @columns = $csv->fields();
2109     #warn join('-',@columns);
2110
2111     my %cust_main = (
2112       agentnum => $agentnum,
2113       refnum   => $refnum,
2114       country  => 'US', #default
2115       payby    => 'BILL', #default
2116       paydate  => '12/2037', #default
2117     );
2118     my $billtime = time;
2119     my %cust_pkg = ( pkgpart => $pkgpart );
2120     foreach my $field ( @fields ) {
2121       if ( $field =~ /^cust_pkg\.(setup|bill|susp|expire|cancel)$/ ) {
2122         #$cust_pkg{$1} = str2time( shift @$columns );
2123         if ( $1 eq 'setup' ) {
2124           $billtime = str2time(shift @columns);
2125         } else {
2126           $cust_pkg{$1} = str2time( shift @columns );
2127         }
2128       } else {
2129         #$cust_main{$field} = shift @$columns; 
2130         $cust_main{$field} = shift @columns; 
2131       }
2132     }
2133
2134     my $cust_pkg = new FS::cust_pkg ( \%cust_pkg ) if $pkgpart;
2135     my $cust_main = new FS::cust_main ( \%cust_main );
2136     use Tie::RefHash;
2137     tie my %hash, 'Tie::RefHash'; #this part is important
2138     $hash{$cust_pkg} = [] if $pkgpart;
2139     my $error = $cust_main->insert( \%hash );
2140
2141     if ( $error ) {
2142       $dbh->rollback if $oldAutoCommit;
2143       return "can't insert customer for $line: $error";
2144     }
2145
2146     #false laziness w/bill.cgi
2147     $error = $cust_main->bill( 'time' => $billtime );
2148     if ( $error ) {
2149       $dbh->rollback if $oldAutoCommit;
2150       return "can't bill customer for $line: $error";
2151     }
2152
2153     $cust_main->apply_payments;
2154     $cust_main->apply_credits;
2155
2156     $error = $cust_main->collect();
2157     if ( $error ) {
2158       $dbh->rollback if $oldAutoCommit;
2159       return "can't collect customer for $line: $error";
2160     }
2161
2162     $imported++;
2163   }
2164
2165   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2166
2167   return "Empty file!" unless $imported;
2168
2169   ''; #no error
2170
2171 }
2172
2173 =item batch_charge
2174
2175 =cut
2176
2177 sub batch_charge {
2178   my $param = shift;
2179   #warn join('-',keys %$param);
2180   my $fh = $param->{filehandle};
2181   my @fields = @{$param->{fields}};
2182
2183   eval "use Date::Parse;";
2184   die $@ if $@;
2185   eval "use Text::CSV_XS;";
2186   die $@ if $@;
2187
2188   my $csv = new Text::CSV_XS;
2189   #warn $csv;
2190   #warn $fh;
2191
2192   my $imported = 0;
2193   #my $columns;
2194
2195   local $SIG{HUP} = 'IGNORE';
2196   local $SIG{INT} = 'IGNORE';
2197   local $SIG{QUIT} = 'IGNORE';
2198   local $SIG{TERM} = 'IGNORE';
2199   local $SIG{TSTP} = 'IGNORE';
2200   local $SIG{PIPE} = 'IGNORE';
2201
2202   my $oldAutoCommit = $FS::UID::AutoCommit;
2203   local $FS::UID::AutoCommit = 0;
2204   my $dbh = dbh;
2205   
2206   #while ( $columns = $csv->getline($fh) ) {
2207   my $line;
2208   while ( defined($line=<$fh>) ) {
2209
2210     $csv->parse($line) or do {
2211       $dbh->rollback if $oldAutoCommit;
2212       return "can't parse: ". $csv->error_input();
2213     };
2214
2215     my @columns = $csv->fields();
2216     #warn join('-',@columns);
2217
2218     my %row = ();
2219     foreach my $field ( @fields ) {
2220       $row{$field} = shift @columns;
2221     }
2222
2223     my $cust_main = qsearchs('cust_main', { 'custnum' => $row{'custnum'} } );
2224     unless ( $cust_main ) {
2225       $dbh->rollback if $oldAutoCommit;
2226       return "unknown custnum $row{'custnum'}";
2227     }
2228
2229     if ( $row{'amount'} > 0 ) {
2230       my $error = $cust_main->charge($row{'amount'}, $row{'pkg'});
2231       if ( $error ) {
2232         $dbh->rollback if $oldAutoCommit;
2233         return $error;
2234       }
2235       $imported++;
2236     } elsif ( $row{'amount'} < 0 ) {
2237       my $error = $cust_main->credit( sprintf( "%.2f", 0-$row{'amount'} ),
2238                                       $row{'pkg'}                         );
2239       if ( $error ) {
2240         $dbh->rollback if $oldAutoCommit;
2241         return $error;
2242       }
2243       $imported++;
2244     } else {
2245       #hmm?
2246     }
2247
2248   }
2249
2250   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2251
2252   return "Empty file!" unless $imported;
2253
2254   ''; #no error
2255
2256 }
2257
2258 =back
2259
2260 =head1 BUGS
2261
2262 The delete method.
2263
2264 The delete method should possibly take an FS::cust_main object reference
2265 instead of a scalar customer number.
2266
2267 Bill and collect options should probably be passed as references instead of a
2268 list.
2269
2270 There should probably be a configuration file with a list of allowed credit
2271 card types.
2272
2273 No multiple currency support (probably a larger project than just this module).
2274
2275 =head1 SEE ALSO
2276
2277 L<FS::Record>, L<FS::cust_pkg>, L<FS::cust_bill>, L<FS::cust_credit>
2278 L<FS::agent>, L<FS::part_referral>, L<FS::cust_main_county>,
2279 L<FS::cust_main_invoice>, L<FS::UID>, schema.html from the base documentation.
2280
2281 =cut
2282
2283 1;
2284