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