ba4b94c7dff751fa7654cf274b8e061e2b5a060b
[freeside.git] / FS / FS / cust_main.pm
1 #this is so kludgy i'd be embarassed if it wasn't cybercash's fault
2 package main;
3 use vars qw($paymentserversecret $paymentserverport $paymentserverhost);
4
5 package FS::cust_main;
6
7 use strict;
8 use vars qw( @ISA $conf $lpr $processor $xaction $E_NoErr $invoice_from
9              $smtpmachine $Debug $bop_processor $bop_login $bop_password
10              $bop_action @bop_options);
11 use Safe;
12 use Carp;
13 use Time::Local;
14 use Date::Format;
15 #use Date::Manip;
16 use Mail::Internet;
17 use Mail::Header;
18 use Business::CreditCard;
19 use FS::UID qw( getotaker dbh );
20 use FS::Record qw( qsearchs qsearch dbdef );
21 use FS::cust_pkg;
22 use FS::cust_bill;
23 use FS::cust_bill_pkg;
24 use FS::cust_pay;
25 use FS::cust_credit;
26 use FS::cust_pay_batch;
27 use FS::part_referral;
28 use FS::cust_main_county;
29 use FS::agent;
30 use FS::cust_main_invoice;
31 use FS::prepay_credit;
32
33 @ISA = qw( FS::Record );
34
35 $Debug = 0;
36 #$Debug = 1;
37
38 #ask FS::UID to run this stuff for us later
39 $FS::UID::callback{'FS::cust_main'} = sub { 
40   $conf = new FS::Conf;
41   $lpr = $conf->config('lpr');
42   $invoice_from = $conf->config('invoice_from');
43   $smtpmachine = $conf->config('smtpmachine');
44
45   if ( $conf->exists('cybercash3.2') ) {
46     require CCMckLib3_2;
47       #qw($MCKversion %Config InitConfig CCError CCDebug CCDebug2);
48     require CCMckDirectLib3_2;
49       #qw(SendCC2_1Server);
50     require CCMckErrno3_2;
51       #qw(MCKGetErrorMessage $E_NoErr);
52     import CCMckErrno3_2 qw($E_NoErr);
53
54     my $merchant_conf;
55     ($merchant_conf,$xaction)= $conf->config('cybercash3.2');
56     my $status = &CCMckLib3_2::InitConfig($merchant_conf);
57     if ( $status != $E_NoErr ) {
58       warn "CCMckLib3_2::InitConfig error:\n";
59       foreach my $key (keys %CCMckLib3_2::Config) {
60         warn "  $key => $CCMckLib3_2::Config{$key}\n"
61       }
62       my($errmsg) = &CCMckErrno3_2::MCKGetErrorMessage($status);
63       die "CCMckLib3_2::InitConfig fatal error: $errmsg\n";
64     }
65     $processor='cybercash3.2';
66   } elsif ( $conf->exists('cybercash2') ) {
67     require CCLib;
68       #qw(sendmserver);
69     ( $main::paymentserverhost, 
70       $main::paymentserverport, 
71       $main::paymentserversecret,
72       $xaction,
73     ) = $conf->config('cybercash2');
74     $processor='cybercash2';
75   } elsif ( $conf->exists('business-onlinepayment') ) {
76     ( $bop_processor,
77       $bop_login,
78       $bop_password,
79       $bop_action,
80       @bop_options
81     ) = $conf->config('business-onlinepayment');
82     $bop_action ||= 'normal authorization';
83     eval "use Business::OnlinePayment";  
84     $processor="Business::OnlinePayment::$bop_processor";
85   }
86 };
87
88 =head1 NAME
89
90 FS::cust_main - Object methods for cust_main records
91
92 =head1 SYNOPSIS
93
94   use FS::cust_main;
95
96   $record = new FS::cust_main \%hash;
97   $record = new FS::cust_main { 'column' => 'value' };
98
99   $error = $record->insert;
100
101   $error = $new_record->replace($old_record);
102
103   $error = $record->delete;
104
105   $error = $record->check;
106
107   @cust_pkg = $record->all_pkgs;
108
109   @cust_pkg = $record->ncancelled_pkgs;
110
111   $error = $record->bill;
112   $error = $record->bill %options;
113   $error = $record->bill 'time' => $time;
114
115   $error = $record->collect;
116   $error = $record->collect %options;
117   $error = $record->collect 'invoice_time'   => $time,
118                             'batch_card'     => 'yes',
119                             'report_badcard' => 'yes',
120                           ;
121
122 =head1 DESCRIPTION
123
124 An FS::cust_main object represents a customer.  FS::cust_main inherits from 
125 FS::Record.  The following fields are currently supported:
126
127 =over 4
128
129 =item custnum - primary key (assigned automatically for new customers)
130
131 =item agentnum - agent (see L<FS::agent>)
132
133 =item refnum - referral (see L<FS::part_referral>)
134
135 =item first - name
136
137 =item last - name
138
139 =item ss - social security number (optional)
140
141 =item company - (optional)
142
143 =item address1
144
145 =item address2 - (optional)
146
147 =item city
148
149 =item county - (optional, see L<FS::cust_main_county>)
150
151 =item state - (see L<FS::cust_main_county>)
152
153 =item zip
154
155 =item country - (see L<FS::cust_main_county>)
156
157 =item daytime - phone (optional)
158
159 =item night - phone (optional)
160
161 =item fax - phone (optional)
162
163 =item ship_first - name
164
165 =item ship_last - name
166
167 =item ship_company - (optional)
168
169 =item ship_address1
170
171 =item ship_address2 - (optional)
172
173 =item ship_city
174
175 =item ship_county - (optional, see L<FS::cust_main_county>)
176
177 =item ship_state - (see L<FS::cust_main_county>)
178
179 =item ship_zip
180
181 =item ship_country - (see L<FS::cust_main_county>)
182
183 =item ship_daytime - phone (optional)
184
185 =item ship_night - phone (optional)
186
187 =item ship_fax - phone (optional)
188
189 =item payby - `CARD' (credit cards), `BILL' (billing), `COMP' (free), or `PREPAY' (special billing type: applies a credit - see L<FS::prepay_credit> and sets billing type to BILL)
190
191 =item payinfo - card number, P.O., comp issuer (4-8 lowercase alphanumerics; think username) or prepayment identifier (see L<FS::prepay_credit>)
192
193 =item paydate - expiration date, mm/yyyy, m/yyyy, mm/yy or m/yy
194
195 =item payname - name on card or billing name
196
197 =item tax - tax exempt, empty or `Y'
198
199 =item otaker - order taker (assigned automatically, see L<FS::UID>)
200
201 =item comments - comments (optional)
202
203 =back
204
205 =head1 METHODS
206
207 =over 4
208
209 =item new HASHREF
210
211 Creates a new customer.  To add the customer to the database, see L<"insert">.
212
213 Note that this stores the hash reference, not a distinct copy of the hash it
214 points to.  You can ask the object for a copy with the I<hash> method.
215
216 =cut
217
218 sub table { 'cust_main'; }
219
220 =item insert [ CUST_PKG_HASHREF [ , INVOICING_LIST_ARYREF ] ]
221
222 Adds this customer to the database.  If there is an error, returns the error,
223 otherwise returns false.
224
225 CUST_PKG_HASHREF: If you pass a Tie::RefHash data structure to the insert
226 method containing FS::cust_pkg and FS::svc_I<tablename> objects, all records
227 are inserted atomicly, or the transaction is rolled back (this requries a 
228 transactional database).  Passing an empty hash reference is equivalent to
229 not supplying this parameter.  There should be a better explanation of this,
230 but until then, here's an example:
231
232   use Tie::RefHash;
233   tie %hash, 'Tie::RefHash'; #this part is important
234   %hash = (
235     $cust_pkg => [ $svc_acct ],
236     ...
237   );
238   $cust_main->insert( \%hash );
239
240 INVOICING_LIST_ARYREF: If you pass an arrarref to the insert method, it will
241 be set as the invoicing list (see L<"invoicing_list">).  Errors return as
242 expected and rollback the entire transaction; it is not necessary to call 
243 check_invoicing_list first.  The invoicing_list is set after the records in the
244 CUST_PKG_HASHREF above are inserted, so it is now possible set set an
245 invoicing_list destination to the newly-created svc_acct.  Here's an example:
246
247   $cust_main->insert( {}, [ $email, 'POST' ] );
248
249 =cut
250
251 sub insert {
252   my $self = shift;
253   my @param = @_;
254
255   local $SIG{HUP} = 'IGNORE';
256   local $SIG{INT} = 'IGNORE';
257   local $SIG{QUIT} = 'IGNORE';
258   local $SIG{TERM} = 'IGNORE';
259   local $SIG{TSTP} = 'IGNORE';
260   local $SIG{PIPE} = 'IGNORE';
261
262   my $oldAutoCommit = $FS::UID::AutoCommit;
263   local $FS::UID::AutoCommit = 0;
264   my $dbh = dbh;
265
266   my $amount = 0;
267   my $seconds = 0;
268   if ( $self->payby eq 'PREPAY' ) {
269     $self->payby('BILL');
270     my $prepay_credit = qsearchs(
271       'prepay_credit',
272       { 'identifier' => $self->payinfo },
273       '',
274       'FOR UPDATE'
275     );
276     warn "WARNING: can't find pre-found prepay_credit: ". $self->payinfo
277       unless $prepay_credit;
278     $amount = $prepay_credit->amount;
279     $seconds = $prepay_credit->seconds;
280     my $error = $prepay_credit->delete;
281     if ( $error ) {
282       $dbh->rollback if $oldAutoCommit;
283       return "removing prepay_credit (transaction rolled back): $error";
284     }
285   }
286
287   my $error = $self->SUPER::insert;
288   if ( $error ) {
289     $dbh->rollback if $oldAutoCommit;
290     return "inserting cust_main record (transaction rolled back): $error";
291   }
292
293   if ( @param ) { # CUST_PKG_HASHREF
294     my $cust_pkgs = shift @param;
295     foreach my $cust_pkg ( keys %$cust_pkgs ) {
296       $cust_pkg->custnum( $self->custnum );
297       $error = $cust_pkg->insert;
298       if ( $error ) {
299         $dbh->rollback if $oldAutoCommit;
300         return "inserting cust_pkg (transaction rolled back): $error";
301       }
302       foreach my $svc_something ( @{$cust_pkgs->{$cust_pkg}} ) {
303         $svc_something->pkgnum( $cust_pkg->pkgnum );
304         if ( $seconds && $svc_something->isa('FS::svc_acct') ) {
305           $svc_something->seconds( $svc_something->seconds + $seconds );
306           $seconds = 0;
307         }
308         $error = $svc_something->insert;
309         if ( $error ) {
310           $dbh->rollback if $oldAutoCommit;
311           return "inserting svc_ (transaction rolled back): $error";
312         }
313       }
314     }
315   }
316
317   if ( $seconds ) {
318     $dbh->rollback if $oldAutoCommit;
319     return "No svc_acct record to apply pre-paid time";
320   }
321
322   if ( @param ) { # INVOICING_LIST_ARYREF
323     my $invoicing_list = shift @param;
324     $error = $self->check_invoicing_list( $invoicing_list );
325     if ( $error ) {
326       $dbh->rollback if $oldAutoCommit;
327       return "checking invoicing_list (transaction rolled back): $error";
328     }
329     $self->invoicing_list( $invoicing_list );
330   }
331
332   if ( $amount ) {
333     my $cust_credit = new FS::cust_credit {
334       'custnum' => $self->custnum,
335       'amount'  => $amount,
336     };
337     $error = $cust_credit->insert;
338     if ( $error ) {
339       $dbh->rollback if $oldAutoCommit;
340       return "inserting credit (transaction rolled back): $error";
341     }
342   }
343
344   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
345   '';
346
347 }
348
349 =item delete NEW_CUSTNUM
350
351 This deletes the customer.  If there is an error, returns the error, otherwise
352 returns false.
353
354 This will completely remove all traces of the customer record.  This is not
355 what you want when a customer cancels service; for that, cancel all of the
356 customer's packages (see L<FS::cust_pkg/cancel>).
357
358 If the customer has any packages, you need to pass a new (valid) customer
359 number for those packages to be transferred to.
360
361 You can't delete a customer with invoices (see L<FS::cust_bill>),
362 or credits (see L<FS::cust_credit>).
363
364 =cut
365
366 sub delete {
367   my $self = shift;
368
369   local $SIG{HUP} = 'IGNORE';
370   local $SIG{INT} = 'IGNORE';
371   local $SIG{QUIT} = 'IGNORE';
372   local $SIG{TERM} = 'IGNORE';
373   local $SIG{TSTP} = 'IGNORE';
374   local $SIG{PIPE} = 'IGNORE';
375
376   my $oldAutoCommit = $FS::UID::AutoCommit;
377   local $FS::UID::AutoCommit = 0;
378   my $dbh = dbh;
379
380   if ( qsearch( 'cust_bill', { 'custnum' => $self->custnum } ) ) {
381     $dbh->rollback if $oldAutoCommit;
382     return "Can't delete a customer with invoices";
383   }
384   if ( qsearch( 'cust_credit', { 'custnum' => $self->custnum } ) ) {
385     $dbh->rollback if $oldAutoCommit;
386     return "Can't delete a customer with credits";
387   }
388
389   my @cust_pkg = qsearch( 'cust_pkg', { 'custnum' => $self->custnum } );
390   if ( @cust_pkg ) {
391     my $new_custnum = shift;
392     unless ( qsearchs( 'cust_main', { 'custnum' => $new_custnum } ) ) {
393       $dbh->rollback if $oldAutoCommit;
394       return "Invalid new customer number: $new_custnum";
395     }
396     foreach my $cust_pkg ( @cust_pkg ) {
397       my %hash = $cust_pkg->hash;
398       $hash{'custnum'} = $new_custnum;
399       my $new_cust_pkg = new FS::cust_pkg ( \%hash );
400       my $error = $new_cust_pkg->replace($cust_pkg);
401       if ( $error ) {
402         $dbh->rollback if $oldAutoCommit;
403         return $error;
404       }
405     }
406   }
407   foreach my $cust_main_invoice (
408     qsearch( 'cust_main_invoice', { 'custnum' => $self->custnum } )
409   ) {
410     my $error = $cust_main_invoice->delete;
411     if ( $error ) {
412       $dbh->rollback if $oldAutoCommit;
413       return $error;
414     }
415   }
416
417   my $error = $self->SUPER::delete;
418   if ( $error ) {
419     $dbh->rollback if $oldAutoCommit;
420     return $error;
421   }
422
423   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
424   '';
425
426 }
427
428 =item replace OLD_RECORD [ INVOICING_LIST_ARYREF ]
429
430 Replaces the OLD_RECORD with this one in the database.  If there is an error,
431 returns the error, otherwise returns false.
432
433 INVOICING_LIST_ARYREF: If you pass an arrarref to the insert method, it will
434 be set as the invoicing list (see L<"invoicing_list">).  Errors return as
435 expected and rollback the entire transaction; it is not necessary to call 
436 check_invoicing_list first.  Here's an example:
437
438   $new_cust_main->replace( $old_cust_main, [ $email, 'POST' ] );
439
440 =cut
441
442 sub replace {
443   my $self = shift;
444   my $old = shift;
445   my @param = @_;
446
447   local $SIG{HUP} = 'IGNORE';
448   local $SIG{INT} = 'IGNORE';
449   local $SIG{QUIT} = 'IGNORE';
450   local $SIG{TERM} = 'IGNORE';
451   local $SIG{TSTP} = 'IGNORE';
452   local $SIG{PIPE} = 'IGNORE';
453
454   my $oldAutoCommit = $FS::UID::AutoCommit;
455   local $FS::UID::AutoCommit = 0;
456   my $dbh = dbh;
457
458   my $error = $self->SUPER::replace($old);
459
460   if ( $error ) {
461     $dbh->rollback if $oldAutoCommit;
462     return $error;
463   }
464
465   if ( @param ) { # INVOICING_LIST_ARYREF
466     my $invoicing_list = shift @param;
467     $error = $self->check_invoicing_list( $invoicing_list );
468     if ( $error ) {
469       $dbh->rollback if $oldAutoCommit;
470       return $error;
471     }
472     $self->invoicing_list( $invoicing_list );
473   }
474
475   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
476   '';
477
478 }
479
480 =item check
481
482 Checks all fields to make sure this is a valid customer record.  If there is
483 an error, returns the error, otherwise returns false.  Called by the insert
484 and repalce methods.
485
486 =cut
487
488 sub check {
489   my $self = shift;
490
491   my $error =
492     $self->ut_numbern('custnum')
493     || $self->ut_number('agentnum')
494     || $self->ut_number('refnum')
495     || $self->ut_name('last')
496     || $self->ut_name('first')
497     || $self->ut_textn('company')
498     || $self->ut_text('address1')
499     || $self->ut_textn('address2')
500     || $self->ut_text('city')
501     || $self->ut_textn('county')
502     || $self->ut_textn('state')
503     || $self->ut_country('country')
504     || $self->ut_anything('comments')
505   ;
506   #barf.  need message catalogs.  i18n.  etc.
507   $error .= "Please select a referral."
508     if $error =~ /^Illegal or empty \(numeric\) refnum: /;
509   return $error if $error;
510
511   return "Unknown agent"
512     unless qsearchs( 'agent', { 'agentnum' => $self->agentnum } );
513
514   return "Unknown referral"
515     unless qsearchs( 'part_referral', { 'refnum' => $self->refnum } );
516
517   if ( $self->ss eq '' ) {
518     $self->ss('');
519   } else {
520     my $ss = $self->ss;
521     $ss =~ s/\D//g;
522     $ss =~ /^(\d{3})(\d{2})(\d{4})$/
523       or return "Illegal social security number: ". $self->ss;
524     $self->ss("$1-$2-$3");
525   }
526
527   unless ( qsearchs('cust_main_county', {
528     'country' => $self->country,
529     'state'   => '',
530    } ) ) {
531     return "Unknown state/county/country: ".
532       $self->state. "/". $self->county. "/". $self->country
533       unless qsearchs('cust_main_county',{
534         'state'   => $self->state,
535         'county'  => $self->county,
536         'country' => $self->country,
537       } );
538   }
539
540   $error =
541     $self->ut_phonen('daytime', $self->country)
542     || $self->ut_phonen('night', $self->country)
543     || $self->ut_phonen('fax', $self->country)
544     || $self->ut_zip('zip', $self->country)
545   ;
546   return $error if $error;
547
548   my @addfields = qw(
549     last first company address1 address2 city county state zip
550     country daytime night fax
551   );
552
553   if ( defined $self->dbdef_table->column('ship_last') ) {
554     if ( grep { $self->getfield($_) ne $self->getfield("ship_$_") } @addfields
555          && grep $self->getfield("ship_$_"), grep $_ ne 'state', @addfields
556        )
557     {
558       my $error =
559         $self->ut_name('ship_last')
560         || $self->ut_name('ship_first')
561         || $self->ut_textn('ship_company')
562         || $self->ut_text('ship_address1')
563         || $self->ut_textn('ship_address2')
564         || $self->ut_text('ship_city')
565         || $self->ut_textn('ship_county')
566         || $self->ut_textn('ship_state')
567         || $self->ut_country('ship_country')
568       ;
569       return $error if $error;
570
571       #false laziness with above
572       unless ( qsearchs('cust_main_county', {
573         'country' => $self->ship_country,
574         'state'   => '',
575        } ) ) {
576         return "Unknown ship_state/ship_county/ship_country: ".
577           $self->ship_state. "/". $self->ship_county. "/". $self->ship_country
578           unless qsearchs('cust_main_county',{
579             'state'   => $self->ship_state,
580             'county'  => $self->ship_county,
581             'country' => $self->ship_country,
582           } );
583       }
584       #eofalse
585
586       $error =
587         $self->ut_phonen('ship_daytime', $self->ship_country)
588         || $self->ut_phonen('ship_night', $self->ship_country)
589         || $self->ut_phonen('ship_fax', $self->ship_country)
590         || $self->ut_zip('ship_zip', $self->ship_country)
591       ;
592       return $error if $error;
593
594     } else { # ship_ info eq billing info, so don't store dup info in database
595       $self->setfield("ship_$_", '')
596         foreach qw( last first company address1 address2 city county state zip
597                     country daytime night fax );
598     }
599   }
600
601   $self->payby =~ /^(CARD|BILL|COMP|PREPAY)$/
602     or return "Illegal payby: ". $self->payby;
603   $self->payby($1);
604
605   if ( $self->payby eq 'CARD' ) {
606
607     my $payinfo = $self->payinfo;
608     $payinfo =~ s/\D//g;
609     $payinfo =~ /^(\d{13,16})$/
610       or return "Illegal credit card number: ". $self->payinfo;
611     $payinfo = $1;
612     $self->payinfo($payinfo);
613     validate($payinfo)
614       or return "Illegal credit card number: ". $self->payinfo;
615     return "Unknown card type" if cardtype($self->payinfo) eq "Unknown";
616
617   } elsif ( $self->payby eq 'BILL' ) {
618
619     $error = $self->ut_textn('payinfo');
620     return "Illegal P.O. number: ". $self->payinfo if $error;
621
622   } elsif ( $self->payby eq 'COMP' ) {
623
624     $error = $self->ut_textn('payinfo');
625     return "Illegal comp account issuer: ". $self->payinfo if $error;
626
627   } elsif ( $self->payby eq 'PREPAY' ) {
628
629     my $payinfo = $self->payinfo;
630     $payinfo =~ s/\W//g; #anything else would just confuse things
631     $self->payinfo($payinfo);
632     $error = $self->ut_alpha('payinfo');
633     return "Illegal prepayment identifier: ". $self->payinfo if $error;
634     return "Unknown prepayment identifier"
635       unless qsearchs('prepay_credit', { 'identifier' => $self->payinfo } );
636
637   }
638
639   if ( $self->paydate eq '' || $self->paydate eq '-' ) {
640     return "Expriation date required"
641       unless $self->payby eq 'BILL' || $self->payby eq 'PREPAY';
642     $self->paydate('');
643   } else {
644     $self->paydate =~ /^(\d{1,2})[\/\-](\d{2}(\d{2})?)$/
645       or return "Illegal expiration date: ". $self->paydate;
646     if ( length($2) == 4 ) {
647       $self->paydate("$2-$1-01");
648     } else {
649       $self->paydate("20$2-$1-01");
650     }
651   }
652
653   if ( $self->payname eq '' ) {
654     $self->payname( $self->first. " ". $self->getfield('last') );
655   } else {
656     $self->payname =~ /^([\w \,\.\-\']+)$/
657       or return "Illegal billing name: ". $self->payname;
658     $self->payname($1);
659   }
660
661   $self->tax =~ /^(Y?)$/ or return "Illegal tax: ". $self->tax;
662   $self->tax($1);
663
664   $self->otaker(getotaker);
665
666   ''; #no error
667 }
668
669 =item all_pkgs
670
671 Returns all packages (see L<FS::cust_pkg>) for this customer.
672
673 =cut
674
675 sub all_pkgs {
676   my $self = shift;
677   qsearch( 'cust_pkg', { 'custnum' => $self->custnum });
678 }
679
680 =item ncancelled_pkgs
681
682 Returns all non-cancelled packages (see L<FS::cust_pkg>) for this customer.
683
684 =cut
685
686 sub ncancelled_pkgs {
687   my $self = shift;
688   @{ [ # force list context
689     qsearch( 'cust_pkg', {
690       'custnum' => $self->custnum,
691       'cancel'  => '',
692     }),
693     qsearch( 'cust_pkg', {
694       'custnum' => $self->custnum,
695       'cancel'  => 0,
696     }),
697   ] };
698 }
699
700 =item bill OPTIONS
701
702 Generates invoices (see L<FS::cust_bill>) for this customer.  Usually used in
703 conjunction with the collect method.
704
705 The only currently available option is `time', which bills the customer as if
706 it were that time.  It is specified as a UNIX timestamp; see
707 L<perlfunc/"time">).  Also see L<Time::Local> and L<Date::Parse> for conversion
708 functions.
709
710 If there is an error, returns the error, otherwise returns false.
711
712 =cut
713
714 sub bill {
715   my( $self, %options ) = @_;
716   my $time = $options{'time'} || time;
717
718   my $error;
719
720   #put below somehow?
721   local $SIG{HUP} = 'IGNORE';
722   local $SIG{INT} = 'IGNORE';
723   local $SIG{QUIT} = 'IGNORE';
724   local $SIG{TERM} = 'IGNORE';
725   local $SIG{TSTP} = 'IGNORE';
726   local $SIG{PIPE} = 'IGNORE';
727
728   my $oldAutoCommit = $FS::UID::AutoCommit;
729   local $FS::UID::AutoCommit = 0;
730   my $dbh = dbh;
731
732   # find the packages which are due for billing, find out how much they are
733   # & generate invoice database.
734  
735   my( $total_setup, $total_recur ) = ( 0, 0 );
736   my @cust_bill_pkg;
737
738   foreach my $cust_pkg (
739     qsearch('cust_pkg',{'custnum'=> $self->getfield('custnum') } )
740   ) {
741
742     next if $cust_pkg->getfield('cancel');  
743
744     #? to avoid use of uninitialized value errors... ?
745     $cust_pkg->setfield('bill', '')
746       unless defined($cust_pkg->bill);
747  
748     my $part_pkg = qsearchs( 'part_pkg', { 'pkgpart' => $cust_pkg->pkgpart } );
749
750     #so we don't modify cust_pkg record unnecessarily
751     my $cust_pkg_mod_flag = 0;
752     my %hash = $cust_pkg->hash;
753     my $old_cust_pkg = new FS::cust_pkg \%hash;
754
755     # bill setup
756     my $setup = 0;
757     unless ( $cust_pkg->setup ) {
758       my $setup_prog = $part_pkg->getfield('setup');
759       $setup_prog =~ /^(.*)$/ #presumably trusted
760         or die "Illegal setup for package ". $cust_pkg->pkgnum. ": $setup_prog";
761       $setup_prog = $1;
762       my $cpt = new Safe;
763       #$cpt->permit(); #what is necessary?
764       $cpt->share(qw( $cust_pkg )); #can $cpt now use $cust_pkg methods?
765       $setup = $cpt->reval($setup_prog);
766       unless ( defined($setup) ) {
767         warn "Error reval-ing part_pkg->setup pkgpart ", 
768              $part_pkg->pkgpart, ": $@";
769       } else {
770         $cust_pkg->setfield('setup',$time);
771         $cust_pkg_mod_flag=1; 
772       }
773     }
774
775     #bill recurring fee
776     my $recur = 0;
777     my $sdate;
778     if ( $part_pkg->getfield('freq') > 0 &&
779          ! $cust_pkg->getfield('susp') &&
780          ( $cust_pkg->getfield('bill') || 0 ) < $time
781     ) {
782       my $recur_prog = $part_pkg->getfield('recur');
783       $recur_prog =~ /^(.*)$/ #presumably trusted
784         or die "Illegal recur for package ". $cust_pkg->pkgnum. ": $recur_prog";
785       $recur_prog = $1;
786       my $cpt = new Safe;
787       #$cpt->permit(); #what is necessary?
788       $cpt->share(qw( $cust_pkg )); #can $cpt now use $cust_pkg methods?
789       $recur = $cpt->reval($recur_prog);
790       unless ( defined($recur) ) {
791         warn "Error reval-ing part_pkg->recur pkgpart ",
792              $part_pkg->pkgpart, ": $@";
793       } else {
794         #change this bit to use Date::Manip? CAREFUL with timezones (see
795         # mailing list archive)
796         #$sdate=$cust_pkg->bill || time;
797         #$sdate=$cust_pkg->bill || $time;
798         $sdate = $cust_pkg->bill || $cust_pkg->setup || $time;
799         my ($sec,$min,$hour,$mday,$mon,$year) =
800           (localtime($sdate) )[0,1,2,3,4,5];
801         $mon += $part_pkg->getfield('freq');
802         until ( $mon < 12 ) { $mon -= 12; $year++; }
803         $cust_pkg->setfield('bill',
804           timelocal($sec,$min,$hour,$mday,$mon,$year));
805         $cust_pkg_mod_flag = 1; 
806       }
807     }
808
809     warn "setup is undefined" unless defined($setup);
810     warn "recur is undefined" unless defined($recur);
811     warn "cust_pkg bill is undefined" unless defined($cust_pkg->bill);
812
813     if ( $cust_pkg_mod_flag ) {
814       $error=$cust_pkg->replace($old_cust_pkg);
815       if ( $error ) { #just in case
816         warn "Error modifying pkgnum ", $cust_pkg->pkgnum, ": $error";
817       } else {
818         $setup = sprintf( "%.2f", $setup );
819         $recur = sprintf( "%.2f", $recur );
820         my $cust_bill_pkg = new FS::cust_bill_pkg ({
821           'pkgnum' => $cust_pkg->pkgnum,
822           'setup'  => $setup,
823           'recur'  => $recur,
824           'sdate'  => $sdate,
825           'edate'  => $cust_pkg->bill,
826         });
827         push @cust_bill_pkg, $cust_bill_pkg;
828         $total_setup += $setup;
829         $total_recur += $recur;
830       }
831     }
832
833   }
834
835   my $charged = sprintf( "%.2f", $total_setup + $total_recur );
836
837   unless ( @cust_bill_pkg ) {
838     $dbh->commit or die $dbh->errstr if $oldAutoCommit;
839     return '';
840   }
841
842   unless ( $self->getfield('tax') =~ /Y/i
843            || $self->getfield('payby') eq 'COMP'
844   ) {
845     my $cust_main_county = qsearchs('cust_main_county',{
846         'state'   => $self->state,
847         'county'  => $self->county,
848         'country' => $self->country,
849     } );
850     my $tax = sprintf( "%.2f",
851       $charged * ( $cust_main_county->getfield('tax') / 100 )
852     );
853     $charged = sprintf( "%.2f", $charged+$tax );
854
855     my $cust_bill_pkg = new FS::cust_bill_pkg ({
856       'pkgnum' => 0,
857       'setup'  => $tax,
858       'recur'  => 0,
859       'sdate'  => '',
860       'edate'  => '',
861     });
862     push @cust_bill_pkg, $cust_bill_pkg;
863   }
864
865   my $cust_bill = new FS::cust_bill ( {
866     'custnum' => $self->getfield('custnum'),
867     '_date' => $time,
868     'charged' => $charged,
869   } );
870   $error = $cust_bill->insert;
871   if ( $error ) {
872     $dbh->rollback if $oldAutoCommit;
873     return "$error for customer #". $self->custnum;
874   }
875
876   my $invnum = $cust_bill->invnum;
877   my $cust_bill_pkg;
878   foreach $cust_bill_pkg ( @cust_bill_pkg ) {
879     $cust_bill_pkg->setfield( 'invnum', $invnum );
880     $error = $cust_bill_pkg->insert;
881     #shouldn't happen, but how else tohandle this?
882     if ( $error ) {
883       $dbh->rollback if $oldAutoCommit;
884       return "$error for customer #". $self->custnum;
885     }
886   }
887   
888   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
889   ''; #no error
890 }
891
892 =item collect OPTIONS
893
894 (Attempt to) collect money for this customer's outstanding invoices (see
895 L<FS::cust_bill>).  Usually used after the bill method.
896
897 Depending on the value of `payby', this may print an invoice (`BILL'), charge
898 a credit card (`CARD'), or just add any necessary (pseudo-)payment (`COMP').
899
900 If there is an error, returns the error, otherwise returns false.
901
902 Currently available options are:
903
904 invoice_time - Use this time when deciding when to print invoices and
905 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>
906 for conversion functions.
907
908 batch_card - Set this true to batch cards (see L<cust_pay_batch>).  By
909 default, cards are processed immediately, which will generate an error if
910 CyberCash is not installed.
911
912 report_badcard - Set this true if you want bad card transactions to
913 return an error.  By default, they don't.
914
915 =cut
916
917 sub collect {
918   my( $self, %options ) = @_;
919   my $invoice_time = $options{'invoice_time'} || time;
920
921   #put below somehow?
922   local $SIG{HUP} = 'IGNORE';
923   local $SIG{INT} = 'IGNORE';
924   local $SIG{QUIT} = 'IGNORE';
925   local $SIG{TERM} = 'IGNORE';
926   local $SIG{TSTP} = 'IGNORE';
927   local $SIG{PIPE} = 'IGNORE';
928
929   my $oldAutoCommit = $FS::UID::AutoCommit;
930   local $FS::UID::AutoCommit = 0;
931   my $dbh = dbh;
932
933   my $total_owed = $self->balance;
934   warn "collect: total owed $total_owed " if $Debug;
935   unless ( $total_owed > 0 ) { #redundant?????
936     $dbh->rollback if $oldAutoCommit;
937     return '';
938   }
939
940   foreach my $cust_bill (
941     qsearch('cust_bill', { 'custnum' => $self->custnum, } )
942   ) {
943
944     #this has to be before next's
945     my $amount = sprintf( "%.2f", $total_owed < $cust_bill->owed
946                                   ? $total_owed
947                                   : $cust_bill->owed
948     );
949     $total_owed = sprintf( "%.2f", $total_owed - $amount );
950
951     next unless $cust_bill->owed > 0;
952
953     next if qsearchs( 'cust_pay_batch', { 'invnum' => $cust_bill->invnum } );
954
955     warn "invnum ". $cust_bill->invnum. " (owed ". $cust_bill->owed. ", amount $amount, total_owed $total_owed)" if $Debug;
956
957     next unless $amount > 0;
958
959     if ( $self->payby eq 'BILL' ) {
960
961       #30 days 2592000
962       my $since = $invoice_time - ( $cust_bill->_date || 0 );
963       #warn "$invoice_time ", $cust_bill->_date, " $since";
964       if ( $since >= 0 #don't print future invoices
965            && ( $cust_bill->printed * 2592000 ) <= $since
966       ) {
967
968         #my @print_text = $cust_bill->print_text; #( date )
969         my @invoicing_list = $self->invoicing_list;
970         if ( grep { $_ ne 'POST' } @invoicing_list ) { #email invoice
971           $ENV{SMTPHOSTS} = $smtpmachine;
972           $ENV{MAILADDRESS} = $invoice_from;
973           my $header = new Mail::Header ( [
974             "From: $invoice_from",
975             "To: ". join(', ', grep { $_ ne 'POST' } @invoicing_list ),
976             "Sender: $invoice_from",
977             "Reply-To: $invoice_from",
978             "Date: ". time2str("%a, %d %b %Y %X %z", time),
979             "Subject: Invoice",
980           ] );
981           my $message = new Mail::Internet (
982             'Header' => $header,
983             'Body' => [ $cust_bill->print_text ], #( date)
984           );
985           $message->smtpsend or die "Can't send invoice email!"; #die?  warn?
986
987         } elsif ( ! @invoicing_list || grep { $_ eq 'POST' } @invoicing_list ) {
988           open(LPR, "|$lpr") or die "Can't open pipe to $lpr: $!";
989           print LPR $cust_bill->print_text; #( date )
990           close LPR
991             or die $! ? "Error closing $lpr: $!"
992                          : "Exit status $? from $lpr";
993         }
994
995         my %hash = $cust_bill->hash;
996         $hash{'printed'}++;
997         my $new_cust_bill = new FS::cust_bill(\%hash);
998         my $error = $new_cust_bill->replace($cust_bill);
999         warn "Error updating $cust_bill->printed: $error" if $error;
1000
1001       }
1002
1003     } elsif ( $self->payby eq 'COMP' ) {
1004       my $cust_pay = new FS::cust_pay ( {
1005          'invnum' => $cust_bill->invnum,
1006          'paid' => $amount,
1007          '_date' => '',
1008          'payby' => 'COMP',
1009          'payinfo' => $self->payinfo,
1010          'paybatch' => ''
1011       } );
1012       my $error = $cust_pay->insert;
1013       if ( $error ) {
1014         $dbh->rollback if $oldAutoCommit;
1015         return 'Error COMPing invnum #'. $cust_bill->invnum. ": $error";
1016       }
1017
1018
1019     } elsif ( $self->payby eq 'CARD' ) {
1020
1021       if ( $options{'batch_card'} ne 'yes' ) {
1022
1023         unless ( $processor ) {
1024           $dbh->rollback if $oldAutoCommit;
1025           return "Real time card processing not enabled!";
1026         }
1027
1028         my $address = $self->address1;
1029         $address .= ", ". $self->address2 if $self->address2;
1030
1031         #fix exp. date
1032         #$self->paydate =~ /^(\d+)\/\d*(\d{2})$/;
1033         $self->paydate =~ /^\d{2}(\d{2})[\/\-](\d+)[\/\-]\d+$/;
1034         my $exp = "$2/$1";
1035
1036         if ( $processor =~ /^cybercash/ ) {
1037
1038           #fix exp. date for cybercash
1039           #$self->paydate =~ /^(\d+)\/\d*(\d{2})$/;
1040           $self->paydate =~ /^\d{2}(\d{2})[\/\-](\d+)[\/\-]\d+$/;
1041           my $exp = "$2/$1";
1042
1043           my $paybatch = $cust_bill->invnum. 
1044                          '-' . time2str("%y%m%d%H%M%S", time);
1045
1046           my $payname = $self->payname ||
1047                         $self->getfield('first'). ' '. $self->getfield('last');
1048
1049
1050           my $country = $self->country eq 'US' ? 'USA' : $self->country;
1051
1052           my @full_xaction = ( $xaction,
1053             'Order-ID'     => $paybatch,
1054             'Amount'       => "usd $amount",
1055             'Card-Number'  => $self->getfield('payinfo'),
1056             'Card-Name'    => $payname,
1057             'Card-Address' => $address,
1058             'Card-City'    => $self->getfield('city'),
1059             'Card-State'   => $self->getfield('state'),
1060             'Card-Zip'     => $self->getfield('zip'),
1061             'Card-Country' => $country,
1062             'Card-Exp'     => $exp,
1063           );
1064
1065           my %result;
1066           if ( $processor eq 'cybercash2' ) {
1067             $^W=0; #CCLib isn't -w safe, ugh!
1068             %result = &CCLib::sendmserver(@full_xaction);
1069             $^W=1;
1070           } elsif ( $processor eq 'cybercash3.2' ) {
1071             %result = &CCMckDirectLib3_2::SendCC2_1Server(@full_xaction);
1072           } else {
1073             $dbh->rollback if $oldAutoCommit;
1074             return "Unknown real-time processor $processor";
1075           }
1076          
1077           #if ( $result{'MActionCode'} == 7 ) { #cybercash smps v.1.1.3
1078           #if ( $result{'action-code'} == 7 ) { #cybercash smps v.2.1
1079           if ( $result{'MStatus'} eq 'success' ) { #cybercash smps v.2 or 3
1080             my $cust_pay = new FS::cust_pay ( {
1081                'invnum'   => $cust_bill->invnum,
1082                'paid'     => $amount,
1083                '_date'     => '',
1084                'payby'    => 'CARD',
1085                'payinfo'  => $self->payinfo,
1086                'paybatch' => "$processor:$paybatch",
1087             } );
1088             my $error = $cust_pay->insert;
1089             if ( $error ) {
1090               # gah, even with transactions.
1091               $dbh->commit if $oldAutoCommit; #well.
1092               my $e = 'WARNING: Card debited but database not updated - '.
1093                       'error applying payment, invnum #' . $cust_bill->invnum.
1094                       " (CyberCash Order-ID $paybatch): $error";
1095               warn $e;
1096               return $e;
1097             }
1098           } elsif ( $result{'Mstatus'} ne 'failure-bad-money'
1099                  || $options{'report_badcard'} ) {
1100              $dbh->commit if $oldAutoCommit;
1101              return 'Cybercash error, invnum #' . 
1102                $cust_bill->invnum. ':'. $result{'MErrMsg'};
1103           } else {
1104             $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1105             return '';
1106           }
1107
1108         } elsif ( $processor =~ /^Business::OnlinePayment::(.*)$/ ) {
1109
1110           my($payname, $payfirst, $paylast);
1111           if ( $self->payname ) {
1112             $payname = $self->payname;
1113             $payname =~ /^\s*([\w \,\.\-\']*\w)?\s+([\w\,\.\-\']+)$/
1114               or do {
1115                       $dbh->rollback if $oldAutoCommit;
1116                       return "Illegal payname $payname";
1117                     };
1118             ($payfirst, $paylast) = ($1, $2);
1119           } else {
1120             $payfirst = $self->getfield('first');
1121             $paylast = $self->getfield('first');
1122             $payname =  "$payfirst $paylast";
1123           }
1124         
1125           my $transaction = new Business::OnlinePayment( $1, @bop_options );
1126           $transaction->content(
1127             'type'           => 'CC',
1128             'login'          => $bop_login,
1129             'password'       => $bop_password,
1130             'action'         => $bop_action,
1131             'amount'         => $amount,
1132             'invoice_number' => $cust_bill->invnum,
1133             'customer_id'    => $self->custnum,
1134             'last_name'      => $paylast,
1135             'first_name'     => $payfirst,
1136             'name'           => $payname,
1137             'address'        => $address,
1138             'city'           => $self->city,
1139             'state'          => $self->state,
1140             'zip'            => $self->zip,
1141             'country'        => $self->country,
1142             'card_number'    => $self->payinfo,
1143             'expiration'     => $exp,
1144           );
1145           $transaction->submit();
1146
1147           if ( $transaction->is_success()) {
1148             my $cust_pay = new FS::cust_pay ( {
1149                'invnum'   => $cust_bill->invnum,
1150                'paid'     => $amount,
1151                '_date'     => '',
1152                'payby'    => 'CARD',
1153                'payinfo'  => $self->payinfo,
1154                'paybatch' => "$processor:". $transaction->authorization,
1155             } );
1156             my $error = $cust_pay->insert;
1157             if ( $error ) {
1158               # gah, even with transactions.
1159               $dbh->commit if $oldAutoCommit; #well.
1160               my $e = 'WARNING: Card debited but database not updated - '.
1161                       'error applying payment, invnum #' . $cust_bill->invnum.
1162                       " ($processor): $error";
1163               warn $e;
1164               return $e;
1165             }
1166           } elsif ( $options{'report_badcard'} ) {
1167             $dbh->commit if $oldAutoCommit;
1168             return "$processor error, invnum #". $cust_bill->invnum. ': '.
1169                    $transaction->result_code. ": ". $transaction->error_message;
1170           } else {
1171             $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1172             return ''
1173           }
1174
1175         } else {
1176           $dbh->rollback if $oldAutoCommit;
1177           return "Unknown real-time processor $processor\n";
1178         }
1179
1180       } else { #batch card
1181
1182        my $cust_pay_batch = new FS::cust_pay_batch ( {
1183          'invnum'   => $cust_bill->getfield('invnum'),
1184          'custnum'  => $self->getfield('custnum'),
1185          'last'     => $self->getfield('last'),
1186          'first'    => $self->getfield('first'),
1187          'address1' => $self->getfield('address1'),
1188          'address2' => $self->getfield('address2'),
1189          'city'     => $self->getfield('city'),
1190          'state'    => $self->getfield('state'),
1191          'zip'      => $self->getfield('zip'),
1192          'country'  => $self->getfield('country'),
1193          'trancode' => 77,
1194          'cardnum'  => $self->getfield('payinfo'),
1195          'exp'      => $self->getfield('paydate'),
1196          'payname'  => $self->getfield('payname'),
1197          'amount'   => $amount,
1198        } );
1199        my $error = $cust_pay_batch->insert;
1200        if ( $error ) {
1201          $dbh->rollback if $oldAutoCommit;
1202          return "Error adding to cust_pay_batch: $error";
1203        }
1204
1205       }
1206
1207     } else {
1208       $dbh->rollback if $oldAutoCommit;
1209       return "Unknown payment type ". $self->payby;
1210     }
1211
1212   }
1213   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1214   '';
1215
1216 }
1217
1218 =item total_owed
1219
1220 Returns the total owed for this customer on all invoices
1221 (see L<FS::cust_bill>).
1222
1223 =cut
1224
1225 sub total_owed {
1226   my $self = shift;
1227   my $total_bill = 0;
1228   foreach my $cust_bill ( qsearch('cust_bill', {
1229     'custnum' => $self->custnum,
1230   } ) ) {
1231     $total_bill += $cust_bill->owed;
1232   }
1233   sprintf( "%.2f", $total_bill );
1234 }
1235
1236 =item total_credited
1237
1238 Returns the total credits (see L<FS::cust_credit>) for this customer.
1239
1240 =cut
1241
1242 sub total_credited {
1243   my $self = shift;
1244   my $total_credit = 0;
1245   foreach my $cust_credit ( qsearch('cust_credit', {
1246     'custnum' => $self->custnum,
1247   } ) ) {
1248     $total_credit += $cust_credit->credited;
1249   }
1250   sprintf( "%.2f", $total_credit );
1251 }
1252
1253 =item balance
1254
1255 Returns the balance for this customer (total owed minus total credited).
1256
1257 =cut
1258
1259 sub balance {
1260   my $self = shift;
1261   sprintf( "%.2f", $self->total_owed - $self->total_credited );
1262 }
1263
1264 =item invoicing_list [ ARRAYREF ]
1265
1266 If an arguement is given, sets these email addresses as invoice recipients
1267 (see L<FS::cust_main_invoice>).  Errors are not fatal and are not reported
1268 (except as warnings), so use check_invoicing_list first.
1269
1270 Returns a list of email addresses (with svcnum entries expanded).
1271
1272 Note: You can clear the invoicing list by passing an empty ARRAYREF.  You can
1273 check it without disturbing anything by passing nothing.
1274
1275 This interface may change in the future.
1276
1277 =cut
1278
1279 sub invoicing_list {
1280   my( $self, $arrayref ) = @_;
1281   if ( $arrayref ) {
1282     my @cust_main_invoice;
1283     if ( $self->custnum ) {
1284       @cust_main_invoice = 
1285         qsearch( 'cust_main_invoice', { 'custnum' => $self->custnum } );
1286     } else {
1287       @cust_main_invoice = ();
1288     }
1289     foreach my $cust_main_invoice ( @cust_main_invoice ) {
1290       #warn $cust_main_invoice->destnum;
1291       unless ( grep { $cust_main_invoice->address eq $_ } @{$arrayref} ) {
1292         #warn $cust_main_invoice->destnum;
1293         my $error = $cust_main_invoice->delete;
1294         warn $error if $error;
1295       }
1296     }
1297     if ( $self->custnum ) {
1298       @cust_main_invoice = 
1299         qsearch( 'cust_main_invoice', { 'custnum' => $self->custnum } );
1300     } else {
1301       @cust_main_invoice = ();
1302     }
1303     foreach my $address ( @{$arrayref} ) {
1304       unless ( grep { $address eq $_->address } @cust_main_invoice ) {
1305         my $cust_main_invoice = new FS::cust_main_invoice ( {
1306           'custnum' => $self->custnum,
1307           'dest'    => $address,
1308         } );
1309         my $error = $cust_main_invoice->insert;
1310         warn $error if $error;
1311       } 
1312     }
1313   }
1314   if ( $self->custnum ) {
1315     map { $_->address }
1316       qsearch( 'cust_main_invoice', { 'custnum' => $self->custnum } );
1317   } else {
1318     ();
1319   }
1320 }
1321
1322 =item check_invoicing_list ARRAYREF
1323
1324 Checks these arguements as valid input for the invoicing_list method.  If there
1325 is an error, returns the error, otherwise returns false.
1326
1327 =cut
1328
1329 sub check_invoicing_list {
1330   my( $self, $arrayref ) = @_;
1331   foreach my $address ( @{$arrayref} ) {
1332     my $cust_main_invoice = new FS::cust_main_invoice ( {
1333       'custnum' => $self->custnum,
1334       'dest'    => $address,
1335     } );
1336     my $error = $self->custnum
1337                 ? $cust_main_invoice->check
1338                 : $cust_main_invoice->checkdest
1339     ;
1340     return $error if $error;
1341   }
1342   '';
1343 }
1344
1345 =back
1346
1347 =head1 SUBROUTINES
1348
1349 =over 4
1350
1351 =item rebuild_fuzzyfile
1352
1353 =cut
1354
1355 sub rebuild_fuzzyfiles {
1356   my @all_last = map $_->getfield('last'), qsearch('cust_main', {});
1357   push @all_last,
1358                  grep $_, map $_->getfield('ship_last'), qsearch('cust_main',{})
1359       if defined dbdef->table('cust_main')->column('ship_last');
1360 #  open(
1361
1362 }
1363
1364 =back
1365
1366 =head1 VERSION
1367
1368 $Id: cust_main.pm,v 1.21 2001-08-26 05:06:19 ivan Exp $
1369
1370 =head1 BUGS
1371
1372 The delete method.
1373
1374 The delete method should possibly take an FS::cust_main object reference
1375 instead of a scalar customer number.
1376
1377 Bill and collect options should probably be passed as references instead of a
1378 list.
1379
1380 CyberCash v2 forces us to define some variables in package main.
1381
1382 There should probably be a configuration file with a list of allowed credit
1383 card types.
1384
1385 CyberCash is the only processor.
1386
1387 No multiple currency support (probably a larger project than just this module).
1388
1389 =head1 SEE ALSO
1390
1391 L<FS::Record>, L<FS::cust_pkg>, L<FS::cust_bill>, L<FS::cust_credit>
1392 L<FS::cust_pay_batch>, L<FS::agent>, L<FS::part_referral>,
1393 L<FS::cust_main_county>, L<FS::cust_main_invoice>,
1394 L<FS::UID>, schema.html from the base documentation.
1395
1396 =cut
1397
1398 1;
1399
1400