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