multiple payment options (echeck fixes), RT#23741
[freeside.git] / FS / FS / cust_payby.pm
1 package FS::cust_payby;
2 use base qw( FS::payinfo_Mixin FS::cust_main_Mixin FS::Record );
3
4 use strict;
5 use Business::CreditCard qw( validate cardtype );
6 use FS::UID qw( dbh );
7 use FS::Msgcat qw( gettext );
8 use FS::Record; #qw( qsearch qsearchs );
9 use FS::payby;
10 use FS::cust_main;
11 use FS::banned_pay;
12
13 our @encrypted_fields = ('payinfo', 'paycvv');
14 sub nohistory_fields { ('payinfo', 'paycvv'); }
15
16 our $ignore_expired_card = 0;
17 our $ignore_banned_card = 0;
18 our $ignore_invalid_card = 0;
19
20 our $conf;
21 install_callback FS::UID sub { 
22   $conf = new FS::Conf;
23   #yes, need it for stuff below (prolly should be cached)
24   $ignore_invalid_card = $conf->exists('allow_invalid_cards');
25 };
26
27 =head1 NAME
28
29 FS::cust_payby - Object methods for cust_payby records
30
31 =head1 SYNOPSIS
32
33   use FS::cust_payby;
34
35   $record = new FS::cust_payby \%hash;
36   $record = new FS::cust_payby { 'column' => 'value' };
37
38   $error = $record->insert;
39
40   $error = $new_record->replace($old_record);
41
42   $error = $record->delete;
43
44   $error = $record->check;
45
46 =head1 DESCRIPTION
47
48 An FS::cust_payby object represents customer stored payment information.
49 FS::cust_payby inherits from FS::Record.  The following fields are currently
50 supported:
51
52 =over 4
53
54 =item custpaybynum
55
56 primary key
57
58 =item custnum
59
60 custnum
61
62 =item weight
63
64 weight
65
66 =item payby
67
68 payby
69
70 =item payinfo
71
72 payinfo
73
74 =item paycvv
75
76 paycvv
77
78 =item paymask
79
80 paymask
81
82 =item paydate
83
84 paydate
85
86 =item paystart_month
87
88 paystart_month
89
90 =item paystart_year
91
92 paystart_year
93
94 =item payissue
95
96 payissue
97
98 =item payname
99
100 payname
101
102 =item paystate
103
104 paystate
105
106 =item paytype
107
108 paytype
109
110 =item payip
111
112 payip
113
114
115 =back
116
117 =head1 METHODS
118
119 =over 4
120
121 =item new HASHREF
122
123 Creates a new record.  To add the record to the database, see L<"insert">.
124
125 Note that this stores the hash reference, not a distinct copy of the hash it
126 points to.  You can ask the object for a copy with the I<hash> method.
127
128 =cut
129
130 # the new method can be inherited from FS::Record, if a table method is defined
131
132 sub table { 'cust_payby'; }
133
134 =item insert
135
136 Adds this record to the database.  If there is an error, returns the error,
137 otherwise returns false.
138
139 =cut
140
141 sub insert {
142   my $self = shift;
143
144   local $SIG{HUP} = 'IGNORE';
145   local $SIG{INT} = 'IGNORE';
146   local $SIG{QUIT} = 'IGNORE';
147   local $SIG{TERM} = 'IGNORE';
148   local $SIG{TSTP} = 'IGNORE';
149   local $SIG{PIPE} = 'IGNORE';
150
151   my $oldAutoCommit = $FS::UID::AutoCommit;
152   local $FS::UID::AutoCommit = 0;
153   my $dbh = dbh;
154
155   my $error = $self->SUPER::insert;
156   if ( $error ) {
157     $dbh->rollback if $oldAutoCommit;
158     return $error;
159   }
160
161   if ( $self->payby =~ /^(CARD|CHEK)$/ ) {
162     # new auto card/check info, want to retry realtime_ invoice events
163     #  (new customer?  that's okay, they won't have any)
164     my $error = $self->cust_main->retry_realtime;
165     if ( $error ) {
166       $dbh->rollback if $oldAutoCommit;
167       return $error;
168     }
169   }
170
171   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
172   '';
173
174 }
175
176 =item delete
177
178 Delete this record from the database.
179
180 =item replace OLD_RECORD
181
182 Replaces the OLD_RECORD with this one in the database.  If there is an error,
183 returns the error, otherwise returns false.
184
185 =cut
186
187 sub replace {
188   my $self = shift;
189
190   my $old = ( blessed($_[0]) && $_[0]->isa('FS::Record') )
191               ? shift
192               : $self->replace_old;
193
194   if ( length($old->paycvv) && $self->paycvv =~ /^\s*[\*x]*\s*$/ ) {
195     $self->paycvv($old->paycvv);
196   }
197
198   if ( $self->payby =~ /^(CARD|DCRD)$/
199        && (    $self->payinfo =~ /xx/
200             || $self->payinfo =~ /^\s*N\/A\s+\(tokenized\)\s*$/
201           )
202      )
203   {
204 warn $self->payinfo;
205 warn $old->payinfo;
206     $self->payinfo($old->payinfo);
207
208   } elsif ( $self->payby =~ /^(CHEK|DCHK)$/ && $self->payinfo =~ /xx/ ) {
209     #fix for #3085 "edit of customer's routing code only surprisingly causes
210     #nothing to happen...
211     # this probably won't do the right thing when we don't have the
212     # public key (can't actually get the real $old->payinfo)
213     my($new_account, $new_aba) = split('@', $self->payinfo);
214     my($old_account, $old_aba) = split('@', $old->payinfo);
215     $new_account = $old_account if $new_account =~ /xx/;
216     $new_aba     = $old_aba     if $new_aba     =~ /xx/;
217     $self->payinfo($new_account.'@'.$new_aba);
218   }
219
220   local($ignore_expired_card) = 1
221     if $old->payby  =~ /^(CARD|DCRD)$/
222     && $self->payby =~ /^(CARD|DCRD)$/
223     && ( $old->payinfo eq $self->payinfo || $old->paymask eq $self->paymask );
224
225   local($ignore_banned_card) = 1
226     if (    $old->payby  =~ /^(CARD|DCRD)$/ && $self->payby =~ /^(CARD|DCRD)$/
227          || $old->payby  =~ /^(CHEK|DCHK)$/ && $self->payby =~ /^(CHEK|DCHK)$/ )
228     && ( $old->payinfo eq $self->payinfo || $old->paymask eq $self->paymask );
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 $error = $self->SUPER::replace($old);
242   if ( $error ) {
243     $dbh->rollback if $oldAutoCommit;
244     return $error;
245   }
246
247   if ( $self->payby =~ /^(CARD|CHEK)$/
248        && ( ( $self->get('payinfo') ne $old->get('payinfo')
249               && $self->get('payinfo') !~ /^99\d{14}$/ 
250             )
251             || grep { $self->get($_) ne $old->get($_) } qw(paydate payname)
252           )
253      )
254   {
255
256     # card/check/lec info has changed, want to retry realtime_ invoice events
257     my $error = $self->cust_main->retry_realtime;
258     if ( $error ) {
259       $dbh->rollback if $oldAutoCommit;
260       return $error;
261     }
262   }
263
264   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
265   '';
266
267 }
268
269 =item check
270
271 Checks all fields to make sure this is a valid record.  If there is
272 an error, returns the error, otherwise returns false.  Called by the insert
273 and replace methods.
274
275 =cut
276
277 sub check {
278   my $self = shift;
279
280   my $error = 
281     $self->ut_numbern('custpaybynum')
282     || $self->ut_foreign_key('custnum', 'cust_main', 'custnum')
283     || $self->ut_numbern('weight')
284     #encrypted #|| $self->ut_textn('payinfo')
285     #encrypted #|| $self->ut_textn('paycvv')
286 #    || $self->ut_textn('paymask') #XXX something
287     #later #|| $self->ut_textn('paydate')
288     || $self->ut_numbern('paystart_month')
289     || $self->ut_numbern('paystart_year')
290     || $self->ut_numbern('payissue')
291 #    || $self->ut_textn('payname') #XXX something
292     || $self->ut_alphan('paystate')
293     || $self->ut_textn('paytype')
294     || $self->ut_ipn('payip')
295   ;
296   return $error if $error;
297
298   ### from cust_main
299
300   FS::payby->can_payby($self->table, $self->payby)
301     or return "Illegal payby: ". $self->payby;
302
303   # If it is encrypted and the private key is not availaible then we can't
304   # check the credit card.
305   my $check_payinfo = ! $self->is_encrypted($self->payinfo);
306
307   # Need some kind of global flag to accept invalid cards, for testing
308   # on scrubbed data.
309   #XXX if ( !$import && $check_payinfo && $self->payby =~ /^(CARD|DCRD)$/ ) {
310   if ( !$ignore_invalid_card && 
311     $check_payinfo && $self->payby =~ /^(CARD|DCRD)$/ ) {
312
313     my $payinfo = $self->payinfo;
314     $payinfo =~ s/\D//g;
315     $payinfo =~ /^(\d{13,16}|\d{8,9})$/
316       or return gettext('invalid_card'); #. ": ". $self->payinfo;
317     $payinfo = $1;
318     $self->payinfo($payinfo);
319     validate($payinfo)
320       or return gettext('invalid_card'); # . ": ". $self->payinfo;
321
322     return gettext('unknown_card_type')
323       if $self->payinfo !~ /^99\d{14}$/ #token
324       && cardtype($self->payinfo) eq "Unknown";
325
326     unless ( $ignore_banned_card ) {
327       my $ban = FS::banned_pay->ban_search( %{ $self->_banned_pay_hashref } );
328       if ( $ban ) {
329         if ( $ban->bantype eq 'warn' ) {
330           #or others depending on value of $ban->reason ?
331           return '_duplicate_card'.
332                  ': disabled from'. time2str('%a %h %o at %r', $ban->_date).
333                  ' until '.         time2str('%a %h %o at %r', $ban->_end_date).
334                  ' (ban# '. $ban->bannum. ')'
335             unless $self->override_ban_warn;
336         } else {
337           return 'Banned credit card: banned on '.
338                  time2str('%a %h %o at %r', $ban->_date).
339                  ' by '. $ban->otaker.
340                  ' (ban# '. $ban->bannum. ')';
341         }
342       }
343     }
344
345     if (length($self->paycvv) && !$self->is_encrypted($self->paycvv)) {
346       if ( cardtype($self->payinfo) eq 'American Express card' ) {
347         $self->paycvv =~ /^(\d{4})$/
348           or return "CVV2 (CID) for American Express cards is four digits.";
349         $self->paycvv($1);
350       } else {
351         $self->paycvv =~ /^(\d{3})$/
352           or return "CVV2 (CVC2/CID) is three digits.";
353         $self->paycvv($1);
354       }
355     } else {
356       $self->paycvv('');
357     }
358
359     my $cardtype = cardtype($payinfo);
360     if ( $cardtype =~ /^(Switch|Solo)$/i ) {
361
362       return "Start date or issue number is required for $cardtype cards"
363         unless $self->paystart_month && $self->paystart_year or $self->payissue;
364
365       return "Start month must be between 1 and 12"
366         if $self->paystart_month
367            and $self->paystart_month < 1 || $self->paystart_month > 12;
368
369       return "Start year must be 1990 or later"
370         if $self->paystart_year
371            and $self->paystart_year < 1990;
372
373       return "Issue number must be beween 1 and 99"
374         if $self->payissue
375           and $self->payissue < 1 || $self->payissue > 99;
376
377     } else {
378       $self->paystart_month('');
379       $self->paystart_year('');
380       $self->payissue('');
381     }
382
383   } elsif ( !$ignore_invalid_card && 
384     $check_payinfo && $self->payby =~ /^(CHEK|DCHK)$/ ) {
385
386     my $payinfo = $self->payinfo;
387     $payinfo =~ s/[^\d\@\.]//g;
388     if ( $conf->config('echeck-country') eq 'CA' ) {
389       $payinfo =~ /^(\d+)\@(\d{5})\.(\d{3})$/
390         or return 'invalid echeck account@branch.bank';
391       $payinfo = "$1\@$2.$3";
392     } elsif ( $conf->config('echeck-country') eq 'US' ) {
393       $payinfo =~ /^(\d+)\@(\d{9})$/ or return 'invalid echeck account@aba';
394       $payinfo = "$1\@$2";
395     } else {
396       $payinfo =~ /^(\d+)\@(\d+)$/ or return 'invalid echeck account@routing';
397       $payinfo = "$1\@$2";
398     }
399     $self->payinfo($payinfo);
400     $self->paycvv('');
401
402     unless ( $ignore_banned_card ) {
403       my $ban = FS::banned_pay->ban_search( %{ $self->_banned_pay_hashref } );
404       if ( $ban ) {
405         if ( $ban->bantype eq 'warn' ) {
406           #or others depending on value of $ban->reason ?
407           return '_duplicate_ach' unless $self->override_ban_warn;
408         } else {
409           return 'Banned ACH account: banned on '.
410                  time2str('%a %h %o at %r', $ban->_date).
411                  ' by '. $ban->otaker.
412                  ' (ban# '. $ban->bannum. ')';
413         }
414       }
415     }
416
417 #  } elsif ( $self->payby eq 'PREPAY' ) {
418 #
419 #    my $payinfo = $self->payinfo;
420 #    $payinfo =~ s/\W//g; #anything else would just confuse things
421 #    $self->payinfo($payinfo);
422 #    $error = $self->ut_alpha('payinfo');
423 #    return "Illegal prepayment identifier: ". $self->payinfo if $error;
424 #    return "Unknown prepayment identifier"
425 #      unless qsearchs('prepay_credit', { 'identifier' => $self->payinfo } );
426 #    $self->paycvv('');
427
428   }
429
430   if ( $self->payby =~ /^(CHEK|DCHK)$/ ) {
431
432     $self->paydate('');
433
434   } elsif ( $self->payby =~ /^(CARD|DCRD)$/ ) {
435
436     # shouldn't payinfo_check do this?
437     return "Expiration date required"
438       if $self->paydate eq '' || $self->paydate eq '-';
439
440     my( $m, $y );
441     if ( $self->paydate =~ /^(\d{1,2})[\/\-](\d{2}(\d{2})?)$/ ) {
442       ( $m, $y ) = ( $1, length($2) == 4 ? $2 : "20$2" );
443     } elsif ( $self->paydate =~ /^19(\d{2})[\/\-](\d{1,2})[\/\-]\d+$/ ) {
444       ( $m, $y ) = ( $2, "19$1" );
445     } elsif ( $self->paydate =~ /^(20)?(\d{2})[\/\-](\d{1,2})[\/\-]\d+$/ ) {
446       ( $m, $y ) = ( $3, "20$2" );
447     } else {
448       return "Illegal expiration date: ". $self->paydate;
449     }
450     $m = sprintf('%02d',$m);
451     $self->paydate("$y-$m-01");
452     my($nowm,$nowy)=(localtime(time))[4,5]; $nowm++; $nowy+=1900;
453     return gettext('expired_card')
454       if #XXX !$import
455       #&&
456          !$ignore_expired_card 
457       && ( $y<$nowy || ( $y==$nowy && $1<$nowm ) );
458
459   }
460
461   if ( $self->payname eq '' && $self->payby !~ /^(CHEK|DCHK)$/ &&
462        ( ! $conf->exists('require_cardname')
463          || $self->payby !~ /^(CARD|DCRD)$/  ) 
464   ) {
465     $self->payname( $self->first. " ". $self->getfield('last') );
466   } else {
467
468     if ( $self->payby =~ /^(CHEK|DCHK)$/ ) {
469       $self->payname =~ /^([\w \,\.\-\']*)$/
470         or return gettext('illegal_name'). " payname: ". $self->payname;
471       $self->payname($1);
472     } else {
473       $self->payname =~ /^([\w \,\.\-\'\&]*)$/
474         or return gettext('illegal_name'). " payname: ". $self->payname;
475       $self->payname($1);
476     }
477
478   }
479
480   ###
481
482   $self->SUPER::check;
483 }
484
485 sub _banned_pay_hashref {
486   my $self = shift;
487
488   my %payby2ban = (
489     'CARD' => 'CARD',
490     'DCRD' => 'CARD',
491     'CHEK' => 'CHEK',
492     'DCHK' => 'CHEK'
493   );
494
495   {
496     'payby'   => $payby2ban{$self->payby},
497     'payinfo' => $self->payinfo,
498     #don't ever *search* on reason! #'reason'  =>
499   };
500 }
501
502 =item paydate_mon_year
503
504 Returns a two element list consisting of the paydate month and year.
505
506 =cut
507
508 sub paydate_mon_year {
509   my $self = shift;
510
511   my $date = $self->paydate; # || '12-2037';
512
513   #false laziness w/elements/select-month_year.html
514   if ( $date  =~ /^(\d{4})-(\d{1,2})-\d{1,2}$/ ) { #PostgreSQL date format
515     ( $2, $1 );
516   } elsif ( $date =~ /^(\d{1,2})-(\d{1,2}-)?(\d{4}$)/ ) {
517     ( $1, $3 );
518   } else {
519     warn "unrecognized expiration date format: $date";
520     ( '', '' );
521   }
522
523 }
524
525 =item realtime_bop
526
527 =cut
528
529 sub realtime_bop {
530   my( $self, %opt ) = @_;
531
532   $opt{$_} = $self->$_() for qw( payinfo payname paydate );
533
534   if ( $self->locationnum ) {
535     my $cust_location = $self->cust_location;
536     $opt{$_} = $cust_location->$_() for qw( address1 address2 city state zip );
537   }
538
539   $self->cust_main->realtime_bop({
540     'method' => FS::payby->payby2bop( $self->payby ),
541     %opt,
542   });
543
544 }
545
546 =item paytypes
547
548 Returns a list of valid values for the paytype field (bank account type for
549 electronic check payment).
550
551 =cut
552
553 sub paytypes {
554   #my $class = shift;
555
556   ('', 'Personal checking', 'Personal savings', 'Business checking', 'Business savings');
557 }
558
559 =item cgi_cust_payby_fields
560
561 Returns the field names used in the web interface (including some pseudo-fields).
562
563 =cut
564
565 sub cgi_cust_payby_fields {
566   #my $class = shift;
567   [qw( payby payinfo paydate_month paydate_year paycvv payname weight
568        payinfo1 payinfo2 payinfo3 paytype paystate payname_CHEK )];
569 }
570
571 =item cgi_hash_callback HASHREF
572
573 Subroutine (not a class or object method).  Processes a hash reference
574 of web interface contet (transfers the data from pseudo-fields to real fields).
575
576 =cut
577
578 sub cgi_hash_callback {
579   my $hashref = shift;
580
581   my %noauto = (
582     'CARD' => 'DCRD',
583     'CHEK' => 'DCHK',
584   );
585   $hashref->{payby} = $noauto{$hashref->{payby}}
586     if ! $hashref->{weight} && exists $noauto{$hashref->{payby}};
587
588   if ( $hashref->{payby} =~ /^(CHEK|DCHK)$/ ) {
589
590     unless ( grep $hashref->{$_}, qw(payinfo1 payinfo2 payinfo3 payname_CHEK)) {
591       %$hashref = ();
592       return;
593     }
594
595     $hashref->{payinfo} = $hashref->{payinfo1}. '@';
596     $hashref->{payinfo} .= $hashref->{payinfo3}.'.' 
597       if $conf->config('echeck-country') eq 'CA';
598     $hashref->{payinfo} .= $hashref->{'payinfo2'};
599
600     $hashref->{payname} = $hashref->{'payname_CHEK'};
601
602   } elsif ( $hashref->{payby} =~ /^(CARD|DCRD)$/ ) {
603
604     unless ( grep $hashref->{$_}, qw( payinfo paycvv payname ) ) {
605       %$hashref = ();
606       return;
607     }
608
609   }
610
611   $hashref->{paydate}= $hashref->{paydate_month}. '-'. $hashref->{paydate_year};
612
613 }
614
615 =item search_sql
616
617 Class method.
618
619 Returns a qsearch hash expression to search for parameters specified in HASHREF.
620 Valid paramters are:
621
622 =over 4
623
624 =item payby
625
626 listref
627
628 =item paydate_year
629
630 =item paydate_month
631
632
633 =back
634
635 =cut
636
637 sub search_sql {
638   my ($class, $params) = @_;
639
640   my @where = ();
641   my $orderby;
642
643   # initialize these to prevent warnings
644   $params = {
645     'paydate_year'  => '',
646     %$params
647   };
648
649   ###
650   # payby
651   ###
652
653   if ( $params->{'payby'} ) {
654
655     my @payby = ref( $params->{'payby'} )
656                   ? @{ $params->{'payby'} }
657                   :  ( $params->{'payby'} );
658
659     @payby = grep /^([A-Z]{4})$/, @payby;
660     my $in_payby = 'IN(' . join(',', map {"'$_'"} @payby) . ')';
661     push @where, "cust_payby.payby $in_payby"
662       if @payby;
663   }
664
665   ###
666   # paydate_year / paydate_month
667   ###
668
669   if ( $params->{'paydate_year'} =~ /^(\d{4})$/ ) {
670     my $year = $1;
671     $params->{'paydate_month'} =~ /^(\d\d?)$/
672       or die "paydate_year without paydate_month?";
673     my $month = $1;
674
675     push @where,
676       'cust_payby.paydate IS NOT NULL',
677       "cust_payby.paydate != ''",
678       "CAST(cust_payby.paydate AS timestamp) < CAST('$year-$month-01' AS timestamp )"
679 ;
680   }
681   ##
682   # setup queries, subs, etc. for the search
683   ##
684
685   $orderby ||= 'ORDER BY custnum';
686
687   # here is the agent virtualization
688   push @where,
689     $FS::CurrentUser::CurrentUser->agentnums_sql(table => 'cust_main');
690
691   my $extra_sql = scalar(@where) ? ' WHERE '. join(' AND ', @where) : '';
692
693   my $addl_from = ' LEFT JOIN cust_main USING ( custnum ) ';
694   # always make address fields available in results
695   for my $pre ('bill_', 'ship_') {
696     $addl_from .= 
697       ' LEFT JOIN cust_location AS '.$pre.'location '.
698       'ON (cust_main.'.$pre.'locationnum = '.$pre.'location.locationnum) ';
699   }
700
701   my $count_query = "SELECT COUNT(*) FROM cust_payby $addl_from $extra_sql";
702
703   my @select = ( 'cust_payby.*',
704                  #'cust_main.custnum',
705                  # there's a good chance that we'll need these
706                  'cust_main.bill_locationnum',
707                  'cust_main.ship_locationnum',
708                  FS::UI::Web::cust_sql_fields($params->{'cust_fields'}),
709                );
710
711   my $select = join(', ', @select);
712
713   my $sql_query = {
714     'table'         => 'cust_payby',
715     'select'        => $select,
716     'addl_from'     => $addl_from,
717     'hashref'       => {},
718     'extra_sql'     => $extra_sql,
719     'order_by'      => $orderby,
720     'count_query'   => $count_query,
721   };
722   $sql_query;
723
724 }
725
726 =back
727
728 =head1 BUGS
729
730 =head1 SEE ALSO
731
732 L<FS::Record>, schema.html from the base documentation.
733
734 =cut
735
736 1;
737