RT# 77964 - Fixed error where deferring date did not work when waive setup fee was...
[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     #later #|| $self->ut_textn('paydate')
319     || $self->ut_numbern('paystart_month')
320     || $self->ut_numbern('paystart_year')
321     || $self->ut_numbern('payissue')
322 #    || $self->ut_textn('payname') #XXX something
323     || $self->ut_alphan('paystate')
324     || $self->ut_textn('paytype')
325     || $self->ut_ipn('payip')
326   ;
327   return $error if $error;
328
329   ### from cust_main
330
331   FS::payby->can_payby($self->table, $self->payby)
332     or return "Illegal payby: ". $self->payby;
333
334   # If it is encrypted and the private key is not availaible then we can't
335   # check the credit card.
336   my $check_payinfo = ! $self->is_encrypted($self->payinfo);
337
338   # Need some kind of global flag to accept invalid cards, for testing
339   # on scrubbed data.
340   #XXX if ( !$import && $check_payinfo && $self->payby =~ /^(CARD|DCRD)$/ ) {
341
342   # In this block: detect card type; reject credit card / account numbers that
343   # are impossible or banned; reject other payment features (date, CVV length)
344   # that are inappropriate for the card type.
345   # However, if the payinfo is encrypted then just detect card type and assume
346   # the other checks were already done.
347
348   if ( !$ignore_invalid_card && 
349     $check_payinfo && $self->payby =~ /^(CARD|DCRD)$/ ) {
350
351     my $payinfo = $self->payinfo;
352     $payinfo =~ s/\D//g;
353     $payinfo =~ /^(\d{13,19}|\d{8,9})$/
354       or return gettext('invalid_card'); #. ": ". $self->payinfo;
355     $payinfo = $1;
356     $self->payinfo($payinfo);
357     validate($payinfo)
358       or return gettext('invalid_card'); # . ": ". $self->payinfo;
359
360     # see parallel checks in check_payinfo_cardtype & payinfo_Mixin::payinfo_check
361     my $cardtype = $self->paycardtype;
362     if ( $self->tokenized ) {
363       $self->set('is_tokenized', 'Y'); #so we don't try to do it again
364       if ( $self->paymask =~ /^\d+x/ ) {
365         $cardtype = cardtype($self->paymask);
366       } else {
367         #return "paycardtype required ".
368         #       "(can't derive from a token and no paymask w/prefix provided)"
369         #  unless $cardtype;
370       }
371     } else {
372       $cardtype = cardtype($self->payinfo);
373     }
374     
375     return gettext('unknown_card_type') if $cardtype eq "Unknown";
376     
377     $self->set('paycardtype', $cardtype);
378
379     unless ( $ignore_banned_card ) {
380       my $ban = FS::banned_pay->ban_search( %{ $self->_banned_pay_hashref } );
381       if ( $ban ) {
382         if ( $ban->bantype eq 'warn' ) {
383           #or others depending on value of $ban->reason ?
384           return '_duplicate_card'.
385                  ': disabled from'. time2str('%a %h %o at %r', $ban->_date).
386                  ' until '.         time2str('%a %h %o at %r', $ban->_end_date).
387                  ' (ban# '. $ban->bannum. ')'
388             unless $self->override_ban_warn;
389         } else {
390           return 'Banned credit card: banned on '.
391                  time2str('%a %h %o at %r', $ban->_date).
392                  ' by '. $ban->otaker.
393                  ' (ban# '. $ban->bannum. ')';
394         }
395       }
396     }
397
398     if (length($self->paycvv) && !$self->is_encrypted($self->paycvv)) {
399       if ( $cardtype eq 'American Express card' ) {
400         $self->paycvv =~ /^(\d{4})$/
401           or return "CVV2 (CID) for American Express cards is four digits.";
402         $self->paycvv($1);
403       } else {
404         $self->paycvv =~ /^(\d{3})$/
405           or return "CVV2 (CVC2/CID) is three digits.";
406         $self->paycvv($1);
407       }
408     } else {
409       $self->paycvv('');
410     }
411
412     if ( $cardtype =~ /^(Switch|Solo)$/i ) {
413
414       return "Start date or issue number is required for $cardtype cards"
415         unless $self->paystart_month && $self->paystart_year or $self->payissue;
416
417       return "Start month must be between 1 and 12"
418         if $self->paystart_month
419            and $self->paystart_month < 1 || $self->paystart_month > 12;
420
421       return "Start year must be 1990 or later"
422         if $self->paystart_year
423            and $self->paystart_year < 1990;
424
425       return "Issue number must be beween 1 and 99"
426         if $self->payissue
427           and $self->payissue < 1 || $self->payissue > 99;
428
429     } else {
430       $self->paystart_month('');
431       $self->paystart_year('');
432       $self->payissue('');
433     }
434
435   } elsif ( !$ignore_invalid_card && 
436     $check_payinfo && $self->payby =~ /^(CHEK|DCHK)$/ ) {
437
438     my $payinfo = $self->payinfo;
439     $payinfo =~ s/[^\d\@\.]//g;
440     if ( $conf->config('echeck-country') eq 'CA' ) {
441       $payinfo =~ /^(\d+)\@(\d{5})\.(\d{3})$/
442         or return 'invalid echeck account@branch.bank';
443       $payinfo = "$1\@$2.$3";
444     } elsif ( $conf->config('echeck-country') eq 'US' ) {
445       $payinfo =~ /^(\d+)\@(\d{9})$/ or return 'invalid echeck account@aba';
446       $payinfo = "$1\@$2";
447     } else {
448       $payinfo =~ /^(\d+)\@(\d+)$/ or return 'invalid echeck account@routing';
449       $payinfo = "$1\@$2";
450     }
451     $self->payinfo($payinfo);
452     $self->paycvv('');
453
454     unless ( $ignore_banned_card ) {
455       my $ban = FS::banned_pay->ban_search( %{ $self->_banned_pay_hashref } );
456       if ( $ban ) {
457         if ( $ban->bantype eq 'warn' ) {
458           #or others depending on value of $ban->reason ?
459           return '_duplicate_ach' unless $self->override_ban_warn;
460         } else {
461           return 'Banned ACH account: banned on '.
462                  time2str('%a %h %o at %r', $ban->_date).
463                  ' by '. $ban->otaker.
464                  ' (ban# '. $ban->bannum. ')';
465         }
466       }
467     }
468
469   } elsif ( $self->payby =~ /^CARD|DCRD$/ and $self->paymask ) {
470     # either ignoring invalid cards, or we can't decrypt the payinfo, but
471     # try to detect the card type anyway. this never returns failure, so
472     # the contract of $ignore_invalid_cards is maintained.
473     $self->set('paycardtype', cardtype($self->paymask));
474   } else {
475     $self->set('paycardtype', '');
476   }
477
478 #  } elsif ( $self->payby eq 'PREPAY' ) {
479 #
480 #    my $payinfo = $self->payinfo;
481 #    $payinfo =~ s/\W//g; #anything else would just confuse things
482 #    $self->payinfo($payinfo);
483 #    $error = $self->ut_alpha('payinfo');
484 #    return "Illegal prepayment identifier: ". $self->payinfo if $error;
485 #    return "Unknown prepayment identifier"
486 #      unless qsearchs('prepay_credit', { 'identifier' => $self->payinfo } );
487 #    $self->paycvv('');
488
489   if ( $self->payby =~ /^(CHEK|DCHK)$/ ) {
490
491     $self->paydate('');
492
493   } elsif ( $self->payby =~ /^(CARD|DCRD)$/ ) {
494
495     # shouldn't payinfo_check do this?
496     # (except we don't ever call payinfo_check from here)
497     return "Expiration date required"
498       if $self->paydate eq '' || $self->paydate eq '-';
499
500     my( $m, $y );
501     if ( $self->paydate =~ /^(\d{1,2})[\/\-](\d{2}(\d{2})?)$/ ) {
502       ( $m, $y ) = ( $1, length($2) == 4 ? $2 : "20$2" );
503     } elsif ( $self->paydate =~ /^19(\d{2})[\/\-](\d{1,2})[\/\-]\d+$/ ) {
504       ( $m, $y ) = ( $2, "19$1" );
505     } elsif ( $self->paydate =~ /^(20)?(\d{2})[\/\-](\d{1,2})[\/\-]\d+$/ ) {
506       ( $m, $y ) = ( $3, "20$2" );
507     } else {
508       return "Illegal expiration date: ". $self->paydate;
509     }
510     $m = sprintf('%02d',$m);
511     $self->paydate("$y-$m-01");
512     my($nowm,$nowy)=(localtime(time))[4,5]; $nowm++; $nowy+=1900;
513     return gettext('expired_card')
514       if #XXX !$import
515       #&&
516          !$ignore_expired_card 
517       && ( $y<$nowy || ( $y==$nowy && $1<$nowm ) );
518
519   }
520
521   if ( $self->payname eq '' && $self->payby !~ /^(CHEK|DCHK)$/ &&
522        ( ! $conf->exists('require_cardname')
523          || $self->payby !~ /^(CARD|DCRD)$/  ) 
524   ) {
525     $self->payname( $self->first. " ". $self->getfield('last') );
526   } else {
527
528     if ( $self->payby =~ /^(CHEK|DCHK)$/ ) {
529       $self->payname =~ /^([\w \,\.\-\']*)$/
530         or return gettext('illegal_name'). " payname: ". $self->payname;
531       $self->payname($1);
532     } else {
533       $self->payname =~ /^([\w \,\.\-\'\&]*)$/
534         or return gettext('illegal_name'). " payname: ". $self->payname;
535       $self->payname($1);
536     }
537
538   }
539
540   if ( ! $self->custpaybynum ) {
541     if ($conf->exists('business-onlinepayment-verification')) {
542       $error = $self->verify;
543     } else {
544       $error = $self->tokenize;
545     }
546     return $error if $error;
547   }
548
549   $self->SUPER::check;
550 }
551
552 sub check_payinfo_cardtype {
553   my $self = shift;
554
555   return '' if $ignore_cardtype;
556
557   return '' unless $self->payby =~ /^(CARD|CHEK)$/;
558
559   my $payinfo = $self->payinfo;
560   $payinfo =~ s/\D//g;
561
562   # see parallel checks in cust_payby::check & payinfo_Mixin::payinfo_check
563   if ( $self->tokenized($payinfo) ) {
564     $self->set('is_tokenized', 'Y'); #so we don't try to do it again
565     if ( $self->paymask =~ /^\d+x/ ) {
566       $self->set('paycardtype', cardtype($self->paymask));
567     } else {
568       $self->set('paycardtype', '');
569       #return "paycardtype required ".
570       #       "(can't derive from a token and no paymask w/prefix provided)";
571     }
572     return '';
573   }
574
575   my %bop_card_types = map { $_=>1 } values %{ card_types() };
576   my $cardtype = cardtype($payinfo);
577   $self->set('paycardtype', $cardtype);
578
579   return "$cardtype not accepted" unless $bop_card_types{$cardtype};
580
581   '';
582
583 }
584
585 sub _banned_pay_hashref {
586   my $self = shift;
587
588   my %payby2ban = (
589     'CARD' => 'CARD',
590     'DCRD' => 'CARD',
591     'CHEK' => 'CHEK',
592     'DCHK' => 'CHEK'
593   );
594
595   {
596     'payby'   => $payby2ban{$self->payby},
597     'payinfo' => $self->payinfo,
598     #don't ever *search* on reason! #'reason'  =>
599   };
600 }
601
602 sub _new_banned_pay_hashref {
603   my $self = shift;
604   my $hr = $self->_banned_pay_hashref;
605   $hr->{payinfo_hash} = 'SHA512';
606   $hr->{payinfo} = sha512_base64($hr->{payinfo});
607   $hr;
608 }
609
610 =item paydate_mon_year
611
612 Returns a two element list consisting of the paydate month and year.
613
614 =cut
615
616 sub paydate_mon_year {
617   my $self = shift;
618
619   my $date = $self->paydate; # || '12-2037';
620
621   #false laziness w/elements/select-month_year.html
622   if ( $date  =~ /^(\d{4})-(\d{1,2})-\d{1,2}$/ ) { #PostgreSQL date format
623     ( $2, $1 );
624   } elsif ( $date =~ /^(\d{1,2})-(\d{1,2}-)?(\d{4}$)/ ) {
625     ( $1, $3 );
626   } else {
627     warn "unrecognized expiration date format: $date";
628     ( '', '' );
629   }
630
631 }
632
633 =item label
634
635 Returns a one line text label for this payment type.
636
637 =cut
638
639 my %weight = (
640   1 => 'Primary',
641   2 => 'Secondary',
642   3 => 'Tertiary',
643   4 => 'Fourth',
644   5 => 'Fifth',
645   6 => 'Sixth',
646   7 => 'Seventh',
647 );
648
649 sub label {
650   my $self = shift;
651
652   my $name = $self->payby =~ /^(CARD|DCRD)$/
653               && $self->paycardtype || FS::payby->shortname($self->payby);
654
655   ( $self->payby =~ /^(CARD|CHEK)$/  ? $weight{$self->weight}. ' automatic '
656                                      : 'Manual '
657   ).
658   "$name: ". $self->paymask.
659   ( $self->payby =~ /^(CARD|DCRD)$/
660       ? ' Exp '. join('/', $self->paydate_mon_year)
661       : ''
662   );
663
664 }
665
666 =item realtime_bop
667
668 Runs a L<realtime_bop|FS::cust_main::Billing_Realtime::realtime_bop> transaction on this card
669
670 =cut
671
672 sub realtime_bop {
673   my( $self, %opt ) = @_;
674
675   $self->cust_main->realtime_bop({
676     %opt,
677     'cust_payby' => $self,
678   });
679
680 }
681
682 =item tokenize
683
684 Runs a L<realtime_tokenize|FS::cust_main::Billing_Realtime::realtime_tokenize> transaction on this card
685
686 =cut
687
688 sub tokenize {
689   my $self = shift;
690   return '' unless $self->payby =~ /^(CARD|DCRD)$/;
691
692   $self->cust_main->realtime_tokenize({
693     'cust_payby' => $self,
694   });
695
696 }
697
698 =item verify 
699
700 Runs a L<realtime_verify_bop|FS::cust_main::Billing_Realtime/realtime_verify_bop> transaction on this card
701
702 =cut
703
704 sub verify {
705   my $self = shift;
706   return '' unless $self->payby =~ /^(CARD|DCRD)$/;
707
708   $self->cust_main->realtime_verify_bop({
709     'cust_payby' => $self,
710   });
711
712 }
713
714 =item paytypes
715
716 Returns a list of valid values for the paytype field (bank account type for
717 electronic check payment).
718
719 =cut
720
721 sub paytypes {
722   #my $class = shift;
723
724   ('', 'Personal checking', 'Personal savings', 'Business checking', 'Business savings');
725 }
726
727 =item cgi_cust_payby_fields
728
729 Returns the field names used in the web interface (including some pseudo-fields).
730
731 =cut
732
733 sub cgi_cust_payby_fields {
734   #my $class = shift;
735   [qw( payby payinfo paydate_month paydate_year paycvv payname weight
736        payinfo1 payinfo2 payinfo3 paytype paystate payname_CHEK )];
737 }
738
739 =item cgi_hash_callback HASHREF OLD
740
741 Subroutine (not a class or object method).  Processes a hash reference
742 of web interface contet (transfers the data from pseudo-fields to real fields).
743
744 If OLD object is passed, also preserves locationnum, paystart_month, paystart_year,
745 payissue and payip.  If the new field is blank but the old is not, the old field 
746 will be preserved.
747
748 =cut
749
750 sub cgi_hash_callback {
751   my $hashref = shift;
752   my $old = shift;
753
754   my %noauto = (
755     'CARD' => 'DCRD',
756     'CHEK' => 'DCHK',
757   );
758   # the payby selector gives the choice of CARD or CHEK (or others, but
759   # those are the ones with auto and on-demand versions). if the user didn't
760   # choose a weight, then they mean DCRD/DCHK.
761   $hashref->{payby} = $noauto{$hashref->{payby}}
762     if ! $hashref->{weight} && exists $noauto{$hashref->{payby}};
763
764   if ( $hashref->{payby} =~ /^(CHEK|DCHK)$/ ) {
765
766     unless ( grep $hashref->{$_}, qw(payinfo1 payinfo2 payinfo3 payname_CHEK)) {
767       %$hashref = ();
768       return;
769     }
770
771     $hashref->{payinfo} = $hashref->{payinfo1}. '@';
772     $hashref->{payinfo} .= $hashref->{payinfo3}.'.' 
773       if $conf->config('echeck-country') eq 'CA';
774     $hashref->{payinfo} .= $hashref->{'payinfo2'};
775
776     $hashref->{payname} = $hashref->{'payname_CHEK'};
777
778   } elsif ( $hashref->{payby} =~ /^(CARD|DCRD)$/ ) {
779
780     unless ( grep $hashref->{$_}, qw( payinfo paycvv payname ) ) {
781       %$hashref = ();
782       return;
783     }
784
785   }
786
787   $hashref->{paydate}= $hashref->{paydate_month}. '-'. $hashref->{paydate_year};
788
789   if ($old) {
790     foreach my $field ( qw(locationnum paystart_month paystart_year payissue payip) ) {
791       next if $hashref->{$field};
792       next unless $old->get($field);
793       $hashref->{$field} = $old->get($field);
794     }
795   }
796
797 }
798
799 =item search_sql
800
801 Class method.
802
803 Returns a qsearch hash expression to search for parameters specified in HASHREF.
804 Valid paramters are:
805
806 =over 4
807
808 =item payby
809
810 listref
811
812 =item paydate_year
813
814 =item paydate_month
815
816
817 =back
818
819 =cut
820
821 sub search_sql {
822   my ($class, $params) = @_;
823
824   my @where = ();
825   my $orderby;
826
827   # initialize these to prevent warnings
828   $params = {
829     'paydate_year'  => '',
830     %$params
831   };
832
833   ###
834   # payby
835   ###
836
837   if ( $params->{'payby'} ) {
838
839     my @payby = ref( $params->{'payby'} )
840                   ? @{ $params->{'payby'} }
841                   :  ( $params->{'payby'} );
842
843     @payby = grep /^([A-Z]{4})$/, @payby;
844     my $in_payby = 'IN(' . join(',', map {"'$_'"} @payby) . ')';
845     push @where, "cust_payby.payby $in_payby"
846       if @payby;
847   }
848
849   ###
850   # paydate_year / paydate_month
851   ###
852
853   if ( $params->{'paydate_year'} =~ /^(\d{4})$/ ) {
854     my $year = $1;
855     $params->{'paydate_month'} =~ /^(\d\d?)$/
856       or die "paydate_year without paydate_month?";
857     my $month = $1;
858
859     push @where,
860       'cust_payby.paydate IS NOT NULL',
861       "cust_payby.paydate != ''",
862       "CAST(cust_payby.paydate AS timestamp) < CAST('$year-$month-01' AS timestamp )"
863 ;
864   }
865   ##
866   # setup queries, subs, etc. for the search
867   ##
868
869   $orderby ||= 'ORDER BY custnum';
870
871   # here is the agent virtualization
872   push @where,
873     $FS::CurrentUser::CurrentUser->agentnums_sql(table => 'cust_main');
874
875   my $extra_sql = scalar(@where) ? ' WHERE '. join(' AND ', @where) : '';
876
877   my $addl_from = ' LEFT JOIN cust_main USING ( custnum ) ';
878   # always make address fields available in results
879   for my $pre ('bill_', 'ship_') {
880     $addl_from .= 
881       ' LEFT JOIN cust_location AS '.$pre.'location '.
882       'ON (cust_main.'.$pre.'locationnum = '.$pre.'location.locationnum) ';
883   }
884   # always make referral available in results
885   #   (maybe we should be using FS::UI::Web::join_cust_main instead?)
886   $addl_from .= ' LEFT JOIN (select refnum, referral from part_referral) AS part_referral_x ON (cust_main.refnum = part_referral_x.refnum) ';
887
888   my $count_query = "SELECT COUNT(*) FROM cust_payby $addl_from $extra_sql";
889
890   my @select = ( 'cust_payby.*',
891                  #'cust_main.custnum',
892                  # there's a good chance that we'll need these
893                  'cust_main.bill_locationnum',
894                  'cust_main.ship_locationnum',
895                  FS::UI::Web::cust_sql_fields($params->{'cust_fields'}),
896                );
897
898   my $select = join(', ', @select);
899
900   my $sql_query = {
901     'table'         => 'cust_payby',
902     'select'        => $select,
903     'addl_from'     => $addl_from,
904     'hashref'       => {},
905     'extra_sql'     => $extra_sql,
906     'order_by'      => $orderby,
907     'count_query'   => $count_query,
908   };
909   $sql_query;
910
911 }
912
913 =back
914
915 =cut
916
917 sub _upgrade_data {
918
919   my $class = shift;
920   local $ignore_banned_card = 1;
921   local $ignore_expired_card = 1;
922   local $ignore_invalid_card = 1;
923   $class->upgrade_set_cardtype;
924
925 }
926
927 =head1 BUGS
928
929 =head1 SEE ALSO
930
931 L<FS::Record>, schema.html from the base documentation.
932
933 =cut
934
935 1;
936