301eb61060a5f6719ac29d71ea8bc5a6f9a3cc9f
[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::Misc qw( card_types );
11 use FS::Record; #qw( qsearch qsearchs );
12 use FS::payby;
13 use FS::cust_main;
14 use FS::banned_pay;
15
16 our @encrypted_fields = ('payinfo', 'paycvv');
17 sub nohistory_fields { ('payinfo', 'paycvv'); }
18
19 our $ignore_expired_card = 0;
20 our $ignore_banned_card = 0;
21 our $ignore_invalid_card = 0;
22 our $ignore_cardtype = 0;
23
24 our $conf;
25 install_callback FS::UID sub { 
26   $conf = new FS::Conf;
27   #yes, need it for stuff below (prolly should be cached)
28   $ignore_invalid_card = $conf->exists('allow_invalid_cards');
29 };
30
31 =head1 NAME
32
33 FS::cust_payby - Object methods for cust_payby records
34
35 =head1 SYNOPSIS
36
37   use FS::cust_payby;
38
39   $record = new FS::cust_payby \%hash;
40   $record = new FS::cust_payby { 'column' => 'value' };
41
42   $error = $record->insert;
43
44   $error = $new_record->replace($old_record);
45
46   $error = $record->delete;
47
48   $error = $record->check;
49
50 =head1 DESCRIPTION
51
52 An FS::cust_payby object represents customer stored payment information.
53 FS::cust_payby inherits from FS::Record.  The following fields are currently
54 supported:
55
56 =over 4
57
58 =item custpaybynum
59
60 primary key
61
62 =item custnum
63
64 custnum
65
66 =item weight
67
68 weight
69
70 =item payby
71
72 payby
73
74 =item payinfo
75
76 payinfo
77
78 =item paycvv
79
80 paycvv
81
82 =item paymask
83
84 paymask
85
86 =item paydate
87
88 paydate
89
90 =item paystart_month
91
92 paystart_month
93
94 =item paystart_year
95
96 paystart_year
97
98 =item payissue
99
100 payissue
101
102 =item payname
103
104 payname
105
106 =item paystate
107
108 paystate
109
110 =item paytype
111
112 paytype
113
114 =item payip
115
116 payip
117
118 =item paycardtype
119
120 The credit card type (deduced from the card number).
121
122 =back
123
124 =head1 METHODS
125
126 =over 4
127
128 =item new HASHREF
129
130 Creates a new record.  To add the record to the database, see L<"insert">.
131
132 Note that this stores the hash reference, not a distinct copy of the hash it
133 points to.  You can ask the object for a copy with the I<hash> method.
134
135 =cut
136
137 # the new method can be inherited from FS::Record, if a table method is defined
138
139 sub table { 'cust_payby'; }
140
141 =item insert
142
143 Adds this record to the database.  If there is an error, returns the error,
144 otherwise returns false.
145
146 =cut
147
148 sub insert {
149   my $self = shift;
150
151   local $SIG{HUP} = 'IGNORE';
152   local $SIG{INT} = 'IGNORE';
153   local $SIG{QUIT} = 'IGNORE';
154   local $SIG{TERM} = 'IGNORE';
155   local $SIG{TSTP} = 'IGNORE';
156   local $SIG{PIPE} = 'IGNORE';
157
158   my $oldAutoCommit = $FS::UID::AutoCommit;
159   local $FS::UID::AutoCommit = 0;
160   my $dbh = dbh;
161
162   my $error =  $self->check_payinfo_cardtype if $self->payby =~/^(CARD|DCRD)$/;
163   $self->SUPER::insert unless $error;
164
165   if ( $error ) {
166     $dbh->rollback if $oldAutoCommit;
167     return $error;
168   }
169
170   if ( $self->payby =~ /^(CARD|CHEK)$/ ) {
171     # new auto card/check info, want to retry realtime_ invoice events
172     #  (new customer?  that's okay, they won't have any)
173     my $error = $self->cust_main->retry_realtime;
174     if ( $error ) {
175       $dbh->rollback if $oldAutoCommit;
176       return $error;
177     }
178   }
179
180   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
181   '';
182
183 }
184
185 =item delete
186
187 Delete this record from the database.
188
189 =item replace OLD_RECORD
190
191 Replaces the OLD_RECORD with this one in the database.  If there is an error,
192 returns the error, otherwise returns false.
193
194 =cut
195
196 sub replace {
197   my $self = shift;
198
199   my $old = ( blessed($_[0]) && $_[0]->isa('FS::Record') )
200               ? shift
201               : $self->replace_old;
202
203   if ( $self->payby =~ /^(CARD|DCRD)$/
204        && (    $self->payinfo =~ /xx/
205             || $self->payinfo =~ /^\s*N\/A\s+\(tokenized\)\s*$/
206           )
207      )
208   {
209
210     $self->payinfo($old->payinfo);
211
212   } elsif ( $self->payby =~ /^(CHEK|DCHK)$/ && $self->payinfo =~ /xx/ ) {
213     #fix for #3085 "edit of customer's routing code only surprisingly causes
214     #nothing to happen...
215     # this probably won't do the right thing when we don't have the
216     # public key (can't actually get the real $old->payinfo)
217     my($new_account, $new_aba) = split('@', $self->payinfo);
218     my($old_account, $old_aba) = split('@', $old->payinfo);
219     $new_account = $old_account if $new_account =~ /xx/;
220     $new_aba     = $old_aba     if $new_aba     =~ /xx/;
221     $self->payinfo($new_account.'@'.$new_aba);
222   }
223
224   # only unmask paycvv if payinfo stayed the same
225   if ( $self->payby =~ /^(CARD|DCRD)$/ and $self->paycvv =~ /^\s*[\*x]+\s*$/ ) {
226     if ( $old->payinfo eq $self->payinfo
227          && $old->paymask eq $self->paymask
228     ) {
229       $self->paycvv($old->paycvv);
230     } else {
231       $self->paycvv('');
232     }
233   }
234
235   local($ignore_expired_card) = 1
236     if $old->payby  =~ /^(CARD|DCRD)$/
237     && $self->payby =~ /^(CARD|DCRD)$/
238     && ( $old->payinfo eq $self->payinfo || $old->paymask eq $self->paymask );
239
240   local($ignore_banned_card) = 1
241     if (    $old->payby  =~ /^(CARD|DCRD)$/ && $self->payby =~ /^(CARD|DCRD)$/
242          || $old->payby  =~ /^(CHEK|DCHK)$/ && $self->payby =~ /^(CHEK|DCHK)$/ )
243     && ( $old->payinfo eq $self->payinfo || $old->paymask eq $self->paymask );
244
245   if (    $self->payby =~ /^(CARD|DCRD)$/
246        && $old->payinfo ne $self->payinfo
247        && $old->paymask ne $self->paymask )
248   {
249     my $error = $self->check_payinfo_cardtype;
250     return $error if $error;
251
252     if ( $conf->exists('business-onlinepayment-verification') ) {
253       $error = $self->verify;
254     } else {
255       $error = $self->tokenize;
256     }
257     return $error if $error;
258
259   }
260
261   local $SIG{HUP} = 'IGNORE';
262   local $SIG{INT} = 'IGNORE';
263   local $SIG{QUIT} = 'IGNORE';
264   local $SIG{TERM} = 'IGNORE';
265   local $SIG{TSTP} = 'IGNORE';
266   local $SIG{PIPE} = 'IGNORE';
267
268   my $oldAutoCommit = $FS::UID::AutoCommit;
269   local $FS::UID::AutoCommit = 0;
270   my $dbh = dbh;
271
272   my $error = $self->SUPER::replace($old);
273   if ( $error ) {
274     $dbh->rollback if $oldAutoCommit;
275     return $error;
276   }
277
278   if ( $self->payby =~ /^(CARD|CHEK)$/
279        && ( ( $self->get('payinfo') ne $old->get('payinfo')
280               && !$self->tokenized 
281             )
282             || grep { $self->get($_) ne $old->get($_) } qw(paydate payname)
283           )
284      )
285   {
286
287     # card/check/lec info has changed, want to retry realtime_ invoice events
288     my $error = $self->cust_main->retry_realtime;
289     if ( $error ) {
290       $dbh->rollback if $oldAutoCommit;
291       return $error;
292     }
293   }
294
295   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
296   '';
297
298 }
299
300 =item check
301
302 Checks all fields to make sure this is a valid record.  If there is
303 an error, returns the error, otherwise returns false.  Called by the insert
304 and replace methods.
305
306 =cut
307
308 sub check {
309   my $self = shift;
310
311   my $error = 
312     $self->ut_numbern('custpaybynum')
313     || $self->ut_foreign_key('custnum', 'cust_main', 'custnum')
314     || $self->ut_numbern('weight')
315     #encrypted #|| $self->ut_textn('payinfo')
316     #encrypted #|| $self->ut_textn('paycvv')
317 #    || $self->ut_textn('paymask') #XXX something
318     || $self->ut_numbern('paystart_month')
319     || $self->ut_numbern('paystart_year')
320     || $self->ut_numbern('payissue')
321 #    || $self->ut_textn('payname') #XXX something
322     || $self->ut_alphan('paystate')
323     || $self->ut_textn('paytype')
324     || $self->ut_ipn('payip')
325   ;
326   return $error if $error;
327
328   ### from cust_main
329
330   FS::payby->can_payby($self->table, $self->payby)
331     or return "Illegal payby: ". $self->payby;
332
333   # If it is encrypted and the private key is not availaible then we can't
334   # check the credit card.
335   my $check_payinfo = ! $self->is_encrypted($self->payinfo);
336
337   # Need some kind of global flag to accept invalid cards, for testing
338   # on scrubbed data.
339   #XXX if ( !$import && $check_payinfo && $self->payby =~ /^(CARD|DCRD)$/ ) {
340
341   # In this block: detect card type; reject credit card / account numbers that
342   # are impossible or banned; reject other payment features (date, CVV length)
343   # that are inappropriate for the card type.
344   # However, if the payinfo is encrypted then just detect card type and assume
345   # the other checks were already done.
346
347   if ( !$ignore_invalid_card && 
348     $check_payinfo && $self->payby =~ /^(CARD|DCRD)$/ ) {
349
350     my $payinfo = $self->payinfo;
351     $payinfo =~ s/\D//g;
352     $payinfo =~ /^(\d{13,19}|\d{8,9})$/
353       or return gettext('invalid_card'); #. ": ". $self->payinfo;
354     $payinfo = $1;
355     $self->payinfo($payinfo);
356     validate($payinfo)
357       or return gettext('invalid_card'); # . ": ". $self->payinfo;
358
359     # see parallel checks in check_payinfo_cardtype & payinfo_Mixin::payinfo_check
360     my $cardtype = $self->paycardtype;
361     if ( $self->tokenized ) {
362       $self->set('is_tokenized', 'Y'); #so we don't try to do it again
363       if ( $self->paymask =~ /^\d+x/ ) {
364         $cardtype = cardtype($self->paymask);
365       } else {
366         #return "paycardtype required ".
367         #       "(can't derive from a token and no paymask w/prefix provided)"
368         #  unless $cardtype;
369       }
370     } else {
371       $cardtype = cardtype($self->payinfo);
372     }
373     
374     return gettext('unknown_card_type') if $cardtype eq "Unknown";
375     
376     $self->set('paycardtype', $cardtype);
377
378     unless ( $ignore_banned_card ) {
379       my $ban = FS::banned_pay->ban_search( %{ $self->_banned_pay_hashref } );
380       if ( $ban ) {
381         if ( $ban->bantype eq 'warn' ) {
382           #or others depending on value of $ban->reason ?
383           return '_duplicate_card'.
384                  ': disabled from'. time2str('%a %h %o at %r', $ban->_date).
385                  ' until '.         time2str('%a %h %o at %r', $ban->_end_date).
386                  ' (ban# '. $ban->bannum. ')'
387             unless $self->override_ban_warn;
388         } else {
389           return 'Banned credit card: banned on '.
390                  time2str('%a %h %o at %r', $ban->_date).
391                  ' by '. $ban->otaker.
392                  ' (ban# '. $ban->bannum. ')';
393         }
394       }
395     }
396
397     if (length($self->paycvv) && !$self->is_encrypted($self->paycvv)) {
398       if ( $cardtype eq 'American Express card' ) {
399         $self->paycvv =~ /^(\d{4})$/
400           or return "CVV2 (CID) for American Express cards is four digits.";
401         $self->paycvv($1);
402       } else {
403         $self->paycvv =~ /^(\d{3})$/
404           or return "CVV2 (CVC2/CID) is three digits.";
405         $self->paycvv($1);
406       }
407     } else {
408       $self->paycvv('');
409     }
410
411     if ( $cardtype =~ /^(Switch|Solo)$/i ) {
412
413       return "Start date or issue number is required for $cardtype cards"
414         unless $self->paystart_month && $self->paystart_year or $self->payissue;
415
416       return "Start month must be between 1 and 12"
417         if $self->paystart_month
418            and $self->paystart_month < 1 || $self->paystart_month > 12;
419
420       return "Start year must be 1990 or later"
421         if $self->paystart_year
422            and $self->paystart_year < 1990;
423
424       return "Issue number must be beween 1 and 99"
425         if $self->payissue
426           and $self->payissue < 1 || $self->payissue > 99;
427
428     } else {
429       $self->paystart_month('');
430       $self->paystart_year('');
431       $self->payissue('');
432     }
433
434   } elsif ( !$ignore_invalid_card && 
435     $check_payinfo && $self->payby =~ /^(CHEK|DCHK)$/ ) {
436
437     my $payinfo = $self->payinfo;
438     $payinfo =~ s/[^\d\@\.]//g;
439     if ( $conf->config('echeck-country') eq 'CA' ) {
440       $payinfo =~ /^(\d+)\@(\d{5})\.(\d{3})$/
441         or return 'invalid echeck account@branch.bank';
442       $payinfo = "$1\@$2.$3";
443     } elsif ( $conf->config('echeck-country') eq 'US' ) {
444       $payinfo =~ /^(\d+)\@(\d{9})$/ or return 'invalid echeck account@aba';
445       $payinfo = "$1\@$2";
446     } else {
447       $payinfo =~ /^(\d+)\@(\d+)$/ or return 'invalid echeck account@routing';
448       $payinfo = "$1\@$2";
449     }
450     $self->payinfo($payinfo);
451     $self->paycvv('');
452
453     unless ( $ignore_banned_card ) {
454       my $ban = FS::banned_pay->ban_search( %{ $self->_banned_pay_hashref } );
455       if ( $ban ) {
456         if ( $ban->bantype eq 'warn' ) {
457           #or others depending on value of $ban->reason ?
458           return '_duplicate_ach' unless $self->override_ban_warn;
459         } else {
460           return 'Banned ACH account: banned on '.
461                  time2str('%a %h %o at %r', $ban->_date).
462                  ' by '. $ban->otaker.
463                  ' (ban# '. $ban->bannum. ')';
464         }
465       }
466     }
467
468   } elsif ( $self->payby =~ /^CARD|DCRD$/ and $self->paymask ) {
469     # either ignoring invalid cards, or we can't decrypt the payinfo, but
470     # try to detect the card type anyway. this never returns failure, so
471     # the contract of $ignore_invalid_cards is maintained.
472     $self->set('paycardtype', cardtype($self->paymask));
473   } else {
474     $self->set('paycardtype', '');
475   }
476
477 #  } elsif ( $self->payby eq 'PREPAY' ) {
478 #
479 #    my $payinfo = $self->payinfo;
480 #    $payinfo =~ s/\W//g; #anything else would just confuse things
481 #    $self->payinfo($payinfo);
482 #    $error = $self->ut_alpha('payinfo');
483 #    return "Illegal prepayment identifier: ". $self->payinfo if $error;
484 #    return "Unknown prepayment identifier"
485 #      unless qsearchs('prepay_credit', { 'identifier' => $self->payinfo } );
486 #    $self->paycvv('');
487
488   if ( $self->payby =~ /^(CHEK|DCHK)$/ ) {
489
490     $self->paydate('');
491
492   } elsif ( $self->payby =~ /^(CARD|DCRD)$/ ) {
493
494     # shouldn't payinfo_check do this?
495     # (except we don't ever call payinfo_check from here)
496     return "Expiration date required"
497       if $self->paydate eq '' || $self->paydate eq '-';
498
499     my( $m, $y );
500     if ( $self->paydate =~ /^(\d{1,2})[\/\-](\d{2}(\d{2})?)$/ ) {
501       ( $m, $y ) = ( $1, length($2) == 4 ? $2 : "20$2" );
502     } elsif ( $self->paydate =~ /^19(\d{2})[\/\-](\d{1,2})[\/\-]\d+$/ ) {
503       ( $m, $y ) = ( $2, "19$1" );
504     } elsif ( $self->paydate =~ /^(20)?(\d{2})[\/\-](\d{1,2})[\/\-]\d+$/ ) {
505       ( $m, $y ) = ( $3, "20$2" );
506     } else {
507       return "Illegal expiration date: ". $self->paydate;
508     }
509     $m = sprintf('%02d',$m);
510     $self->paydate("$y-$m-01");
511     my($nowm,$nowy)=(localtime(time))[4,5]; $nowm++; $nowy+=1900;
512     return gettext('expired_card')
513       if #XXX !$import
514       #&&
515          !$ignore_expired_card 
516       && ( $y<$nowy || ( $y==$nowy && $1<$nowm ) );
517
518   }
519
520   if ( $self->payname eq '' && $self->payby !~ /^(CHEK|DCHK)$/ &&
521        ( ! $conf->exists('require_cardname')
522          || $self->payby !~ /^(CARD|DCRD)$/  ) 
523   ) {
524     $self->payname( $self->first. " ". $self->getfield('last') );
525   } else {
526
527     if ( $self->payby =~ /^(CHEK|DCHK)$/ ) {
528       $self->payname =~ /^([\w \,\.\-\']*)$/
529         or return gettext('illegal_name'). " payname: ". $self->payname;
530       $self->payname($1);
531     } else {
532       $self->payname =~ /^([\w \,\.\-\'\&]*)$/
533         or return gettext('illegal_name'). " payname: ". $self->payname;
534       $self->payname($1);
535     }
536
537   }
538
539   if ( ! $self->custpaybynum ) {
540     if ($conf->exists('business-onlinepayment-verification')) {
541       $error = $self->verify;
542     } else {
543       $error = $self->tokenize;
544     }
545     return $error if $error;
546   }
547
548   $error = $self->ut_daten('paydate');
549   return $error if $error;
550
551   $self->SUPER::check;
552 }
553
554 sub check_payinfo_cardtype {
555   my $self = shift;
556
557   return '' if $ignore_cardtype;
558
559   return '' unless $self->payby =~ /^(CARD|CHEK)$/;
560
561   my $payinfo = $self->payinfo;
562   $payinfo =~ s/\D//g;
563
564   # see parallel checks in cust_payby::check & payinfo_Mixin::payinfo_check
565   if ( $self->tokenized($payinfo) ) {
566     $self->set('is_tokenized', 'Y'); #so we don't try to do it again
567     if ( $self->paymask =~ /^\d+x/ ) {
568       $self->set('paycardtype', cardtype($self->paymask));
569     } else {
570       $self->set('paycardtype', '');
571       #return "paycardtype required ".
572       #       "(can't derive from a token and no paymask w/prefix provided)";
573     }
574     return '';
575   }
576
577   my %bop_card_types = map { $_=>1 } values %{ card_types() };
578   my $cardtype = cardtype($payinfo);
579   $self->set('paycardtype', $cardtype);
580
581   return "$cardtype not accepted" unless $bop_card_types{$cardtype};
582
583   '';
584
585 }
586
587 sub _banned_pay_hashref {
588   my $self = shift;
589
590   my %payby2ban = (
591     'CARD' => 'CARD',
592     'DCRD' => 'CARD',
593     'CHEK' => 'CHEK',
594     'DCHK' => 'CHEK'
595   );
596
597   {
598     'payby'   => $payby2ban{$self->payby},
599     'payinfo' => $self->payinfo,
600     #don't ever *search* on reason! #'reason'  =>
601   };
602 }
603
604 sub _new_banned_pay_hashref {
605   my $self = shift;
606   my $hr = $self->_banned_pay_hashref;
607   $hr->{payinfo_hash} = 'SHA512';
608   $hr->{payinfo} = sha512_base64($hr->{payinfo});
609   $hr;
610 }
611
612 =item paydate_mon_year
613
614 Returns a two element list consisting of the paydate month and year.
615
616 =cut
617
618 sub paydate_mon_year {
619   my $self = shift;
620
621   my $date = $self->paydate; # || '12-2037';
622
623   #false laziness w/elements/select-month_year.html
624   if ( $date  =~ /^(\d{4})-(\d{1,2})-\d{1,2}$/ ) { #PostgreSQL date format
625     ( $2, $1 );
626   } elsif ( $date =~ /^(\d{1,2})-(\d{1,2}-)?(\d{4}$)/ ) {
627     ( $1, $3 );
628   } else {
629     warn "unrecognized expiration date format: $date";
630     ( '', '' );
631   }
632
633 }
634
635 =item label
636
637 Returns a one line text label for this payment type.
638
639 =cut
640
641 my %weight = (
642   1 => 'Primary',
643   2 => 'Secondary',
644   3 => 'Tertiary',
645   4 => 'Fourth',
646   5 => 'Fifth',
647   6 => 'Sixth',
648   7 => 'Seventh',
649 );
650
651 sub label {
652   my $self = shift;
653
654   my $name = $self->payby =~ /^(CARD|DCRD)$/
655               && $self->paycardtype || FS::payby->shortname($self->payby);
656
657   ( $self->payby =~ /^(CARD|CHEK)$/  ? $weight{$self->weight}. ' automatic '
658                                      : 'Manual '
659   ).
660   "$name: ". $self->paymask.
661   ( $self->payby =~ /^(CARD|DCRD)$/
662       ? ' Exp '. join('/', $self->paydate_mon_year)
663       : ''
664   );
665
666 }
667
668 =item realtime_bop
669
670 Runs a L<realtime_bop|FS::cust_main::Billing_Realtime::realtime_bop> transaction on this card
671
672 =cut
673
674 sub realtime_bop {
675   my( $self, %opt ) = @_;
676
677   $self->cust_main->realtime_bop({
678     %opt,
679     'cust_payby' => $self,
680   });
681
682 }
683
684 =item tokenize
685
686 Runs a L<realtime_tokenize|FS::cust_main::Billing_Realtime::realtime_tokenize> transaction on this card
687
688 =cut
689
690 sub tokenize {
691   my $self = shift;
692   return '' unless $self->payby =~ /^(CARD|DCRD)$/;
693
694   $self->cust_main->realtime_tokenize({
695     'cust_payby' => $self,
696   });
697
698 }
699
700 =item verify 
701
702 Runs a L<realtime_verify_bop|FS::cust_main::Billing_Realtime/realtime_verify_bop> transaction on this card
703
704 =cut
705
706 sub verify {
707   my $self = shift;
708   return '' unless $self->payby =~ /^(CARD|DCRD)$/;
709
710   $self->cust_main->realtime_verify_bop({
711     'cust_payby' => $self,
712   });
713
714 }
715
716 =item paytypes
717
718 Returns a list of valid values for the paytype field (bank account type for
719 electronic check payment).
720
721 =cut
722
723 sub paytypes {
724   #my $class = shift;
725
726   ('', 'Personal checking', 'Personal savings', 'Business checking', 'Business savings');
727 }
728
729 =item cgi_cust_payby_fields
730
731 Returns the field names used in the web interface (including some pseudo-fields).
732
733 =cut
734
735 sub cgi_cust_payby_fields {
736   #my $class = shift;
737   [qw( payby payinfo paydate_month paydate_year paycvv payname weight
738        payinfo1 payinfo2 payinfo3 paytype paystate payname_CHEK )];
739 }
740
741 =item cgi_hash_callback HASHREF OLD
742
743 Subroutine (not a class or object method).  Processes a hash reference
744 of web interface contet (transfers the data from pseudo-fields to real fields).
745
746 If OLD object is passed, also preserves locationnum, paystart_month, paystart_year,
747 payissue and payip.  If the new field is blank but the old is not, the old field 
748 will be preserved.
749
750 =cut
751
752 sub cgi_hash_callback {
753   my $hashref = shift;
754   my $old = shift;
755
756   my %noauto = (
757     'CARD' => 'DCRD',
758     'CHEK' => 'DCHK',
759   );
760   # the payby selector gives the choice of CARD or CHEK (or others, but
761   # those are the ones with auto and on-demand versions). if the user didn't
762   # choose a weight, then they mean DCRD/DCHK.
763   $hashref->{payby} = $noauto{$hashref->{payby}}
764     if ! $hashref->{weight} && exists $noauto{$hashref->{payby}};
765
766   if ( $hashref->{payby} =~ /^(CHEK|DCHK)$/ ) {
767
768     unless ( grep $hashref->{$_}, qw(payinfo1 payinfo2 payinfo3 payname_CHEK)) {
769       %$hashref = ();
770       return;
771     }
772
773     $hashref->{payinfo} = $hashref->{payinfo1}. '@';
774     $hashref->{payinfo} .= $hashref->{payinfo3}.'.' 
775       if $conf->config('echeck-country') eq 'CA';
776     $hashref->{payinfo} .= $hashref->{'payinfo2'};
777
778     $hashref->{payname} = $hashref->{'payname_CHEK'};
779
780   } elsif ( $hashref->{payby} =~ /^(CARD|DCRD)$/ ) {
781
782     unless ( grep $hashref->{$_}, qw( payinfo paycvv payname ) ) {
783       %$hashref = ();
784       return;
785     }
786
787   }
788
789   $hashref->{paydate}= $hashref->{paydate_month}. '-'. $hashref->{paydate_year};
790
791   if ($old) {
792     foreach my $field ( qw(locationnum paystart_month paystart_year payissue payip) ) {
793       next if $hashref->{$field};
794       next unless $old->get($field);
795       $hashref->{$field} = $old->get($field);
796     }
797   }
798
799 }
800
801 =item search_sql
802
803 Class method.
804
805 Returns a qsearch hash expression to search for parameters specified in HASHREF.
806 Valid paramters are:
807
808 =over 4
809
810 =item payby
811
812 listref
813
814 =item paydate_year
815
816 =item paydate_month
817
818
819 =back
820
821 =cut
822
823 sub search_sql {
824   my ($class, $params) = @_;
825
826   my @where = ();
827   my $orderby;
828
829   # initialize these to prevent warnings
830   $params = {
831     'paydate_year'  => '',
832     %$params
833   };
834
835   ###
836   # payby
837   ###
838
839   if ( $params->{'payby'} ) {
840
841     my @payby = ref( $params->{'payby'} )
842                   ? @{ $params->{'payby'} }
843                   :  ( $params->{'payby'} );
844
845     @payby = grep /^([A-Z]{4})$/, @payby;
846     my $in_payby = 'IN(' . join(',', map {"'$_'"} @payby) . ')';
847     push @where, "cust_payby.payby $in_payby"
848       if @payby;
849   }
850
851   ###
852   # paydate_year / paydate_month
853   ###
854
855   if ( $params->{'paydate_year'} =~ /^(\d{4})$/ ) {
856     my $year = $1;
857     $params->{'paydate_month'} =~ /^(\d\d?)$/
858       or die "paydate_year without paydate_month?";
859     my $month = $1;
860
861     push @where,
862       'cust_payby.paydate IS NOT NULL',
863       "cust_payby.paydate != ''",
864       "CAST(cust_payby.paydate AS timestamp) < CAST('$year-$month-01' AS timestamp )"
865 ;
866   }
867   ##
868   # setup queries, subs, etc. for the search
869   ##
870
871   $orderby ||= 'ORDER BY custnum';
872
873   # here is the agent virtualization
874   push @where,
875     $FS::CurrentUser::CurrentUser->agentnums_sql(table => 'cust_main');
876
877   my $extra_sql = scalar(@where) ? ' WHERE '. join(' AND ', @where) : '';
878
879   my $addl_from = ' LEFT JOIN cust_main USING ( custnum ) ';
880   # always make address fields available in results
881   for my $pre ('bill_', 'ship_') {
882     $addl_from .= 
883       ' LEFT JOIN cust_location AS '.$pre.'location '.
884       'ON (cust_main.'.$pre.'locationnum = '.$pre.'location.locationnum) ';
885   }
886   # always make referral available in results
887   #   (maybe we should be using FS::UI::Web::join_cust_main instead?)
888   $addl_from .= ' LEFT JOIN (select refnum, referral from part_referral) AS part_referral_x ON (cust_main.refnum = part_referral_x.refnum) ';
889
890   my $count_query = "SELECT COUNT(*) FROM cust_payby $addl_from $extra_sql";
891
892   my @select = ( 'cust_payby.*',
893                  #'cust_main.custnum',
894                  # there's a good chance that we'll need these
895                  'cust_main.bill_locationnum',
896                  'cust_main.ship_locationnum',
897                  FS::UI::Web::cust_sql_fields($params->{'cust_fields'}),
898                );
899
900   my $select = join(', ', @select);
901
902   my $sql_query = {
903     'table'         => 'cust_payby',
904     'select'        => $select,
905     'addl_from'     => $addl_from,
906     'hashref'       => {},
907     'extra_sql'     => $extra_sql,
908     'order_by'      => $orderby,
909     'count_query'   => $count_query,
910   };
911   $sql_query;
912
913 }
914
915 =back
916
917 =item count_autobill_cards
918
919 Returns the number of unexpired cards configured for autobill
920
921 =cut
922
923 sub count_autobill_cards {
924   shift->count("
925     weight > 0
926     AND payby IN ('CARD','DCRD')
927     AND paydate > '".DateTime->now->ymd."'
928   ");
929 }
930
931 =item count_autobill_checks
932
933 Returns the number of check accounts configured for autobill
934
935 =cut
936
937 sub count_autobill_checks {
938   shift->count("
939     weight > 0
940     AND payby IN ('CHEK','DCHEK')
941   ");
942 }
943
944 sub _upgrade_data {
945
946   my $class = shift;
947   local $ignore_banned_card = 1;
948   local $ignore_expired_card = 1;
949   local $ignore_invalid_card = 1;
950   $class->upgrade_set_cardtype;
951   $class->_upgrade_data_paydate_edgebug;
952
953 }
954
955 =item _upgrade_data_paydate_edgebug
956
957 Correct bad data injected into payment expire date column by Edge browser bug
958
959 The month and year values may have an extra character injected into form POST
960 data by Edge browser.  It was possible for some bad month values to slip
961 past data validation.
962
963 If the stored value was out of range, it was causing payments screen to crash.
964 We can detect and fix this by dropping the second digit.
965
966 If the stored value is is 11 or 12, it's possible the user inputted a 1.  In
967 this case, the payment method will fail to authorize, but the record will
968 not cause crashdumps for being out of range.
969
970 In short, check for any expiration month > 12, and drop the extra digit
971
972 =cut
973
974 sub _upgrade_data_paydate_edgebug {
975   my $journal_label = 'cust_payby_paydate_edgebug';
976   return if FS::upgrade_journal->is_done( $journal_label );
977
978   my $oldAutoCommit = $FS::UID::AutoCommit;
979   local $FS::UID::AutoCommit = 0;
980
981   for my $row (
982     FS::Record::qsearch(
983       cust_payby => { paydate => { op => '!=', value => '' }}
984     )
985   ) {
986     next unless $row->ut_daten('paydate');
987
988     # paydate column stored in database has failed date validation
989     my $bad_paydate = $row->paydate;
990
991     my @date = split /[\-\/]/, $bad_paydate;
992     @date = @date[2,0,1] if $date[2] > 1900;
993
994     # Only autocorrecting when month > 12 - notify operator
995     unless ( $date[1] > 12 ) {
996       die sprintf(
997         'Unable to correct bad paydate stored in cust_payby row '.
998         'custpaybynum(%s) custnum(%s) paydate(%s)',
999         $row->custpaybynum,
1000         $row->custnum,
1001         $bad_paydate,
1002       );
1003     }
1004
1005     $date[1] = substr( $date[1], 0, 1 );
1006     $row->paydate( join('-', @date ));
1007
1008     if ( my $error = $row->replace ) {
1009       die sprintf(
1010         'Failed to autocorrect bad paydate stored in cust_payby row '.
1011         'custpaybynum(%s) custnum(%s) paydate(%s) - error: %s',
1012         $row->custpaybynum,
1013         $row->custnum,
1014         $bad_paydate,
1015         $error
1016       );
1017     }
1018
1019     warn sprintf(
1020       'Autocorrected bad paydate stored in cust_payby row '.
1021       "custpaybynum(%s) custnum(%s) old-paydate(%s) new-paydate(%s)\n",
1022       $row->custpaybynum,
1023       $row->custnum,
1024       $bad_paydate,
1025       $row->paydate,
1026     );
1027
1028   }
1029
1030   FS::upgrade_journal->set_done( $journal_label );
1031   dbh->commit unless $oldAutoCommit;
1032 }
1033
1034 =head1 BUGS
1035
1036 =head1 SEE ALSO
1037
1038 L<FS::Record>, schema.html from the base documentation.
1039
1040 =cut
1041
1042 1;
1043