add skip_dcontext_suffix to skip CDRs with dcontext ending in a definable string...
[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
119 =back
120
121 =head1 METHODS
122
123 =over 4
124
125 =item new HASHREF
126
127 Creates a new record.  To add the record to the database, see L<"insert">.
128
129 Note that this stores the hash reference, not a distinct copy of the hash it
130 points to.  You can ask the object for a copy with the I<hash> method.
131
132 =cut
133
134 # the new method can be inherited from FS::Record, if a table method is defined
135
136 sub table { 'cust_payby'; }
137
138 =item insert
139
140 Adds this record to the database.  If there is an error, returns the error,
141 otherwise returns false.
142
143 =cut
144
145 sub insert {
146   my $self = shift;
147
148   local $SIG{HUP} = 'IGNORE';
149   local $SIG{INT} = 'IGNORE';
150   local $SIG{QUIT} = 'IGNORE';
151   local $SIG{TERM} = 'IGNORE';
152   local $SIG{TSTP} = 'IGNORE';
153   local $SIG{PIPE} = 'IGNORE';
154
155   my $oldAutoCommit = $FS::UID::AutoCommit;
156   local $FS::UID::AutoCommit = 0;
157   my $dbh = dbh;
158
159   my $error =  $self->check_payinfo_cardtype
160             || $self->SUPER::insert;
161   if ( $error ) {
162     $dbh->rollback if $oldAutoCommit;
163     return $error;
164   }
165
166   if ( $self->payby =~ /^(CARD|CHEK)$/ ) {
167     # new auto card/check info, want to retry realtime_ invoice events
168     #  (new customer?  that's okay, they won't have any)
169     my $error = $self->cust_main->retry_realtime;
170     if ( $error ) {
171       $dbh->rollback if $oldAutoCommit;
172       return $error;
173     }
174   }
175
176   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
177   '';
178
179 }
180
181 =item delete
182
183 Delete this record from the database.
184
185 =item replace OLD_RECORD
186
187 Replaces the OLD_RECORD with this one in the database.  If there is an error,
188 returns the error, otherwise returns false.
189
190 =cut
191
192 sub replace {
193   my $self = shift;
194
195   my $old = ( blessed($_[0]) && $_[0]->isa('FS::Record') )
196               ? shift
197               : $self->replace_old;
198
199   if ( $self->payby =~ /^(CARD|DCRD)$/
200        && (    $self->payinfo =~ /xx/
201             || $self->payinfo =~ /^\s*N\/A\s+\(tokenized\)\s*$/
202           )
203      )
204   {
205
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   # only unmask paycvv if payinfo stayed the same
221   if ( $self->payby =~ /^(CARD|DCRD)$/ and $self->paycvv =~ /^\s*[\*x]+\s*$/ ) {
222     if ( $old->payinfo eq $self->payinfo
223          && $old->paymask eq $self->paymask
224     ) {
225       $self->paycvv($old->paycvv);
226     } else {
227       $self->paycvv('');
228     }
229   }
230
231   local($ignore_expired_card) = 1
232     if $old->payby  =~ /^(CARD|DCRD)$/
233     && $self->payby =~ /^(CARD|DCRD)$/
234     && ( $old->payinfo eq $self->payinfo || $old->paymask eq $self->paymask );
235
236   local($ignore_banned_card) = 1
237     if (    $old->payby  =~ /^(CARD|DCRD)$/ && $self->payby =~ /^(CARD|DCRD)$/
238          || $old->payby  =~ /^(CHEK|DCHK)$/ && $self->payby =~ /^(CHEK|DCHK)$/ )
239     && ( $old->payinfo eq $self->payinfo || $old->paymask eq $self->paymask );
240
241   if (    $self->payby =~ /^(CARD|DCRD)$/
242        && $old->payinfo ne $self->payinfo
243        && $old->paymask ne $self->paymask )
244   {
245     my $error = $self->check_payinfo_cardtype;
246     return $error if $error;
247
248     if ( $conf->exists('business-onlinepayment-verification') ) {
249       $error = $self->verify;
250       return $error if $error;
251     }
252   }
253
254   local $SIG{HUP} = 'IGNORE';
255   local $SIG{INT} = 'IGNORE';
256   local $SIG{QUIT} = 'IGNORE';
257   local $SIG{TERM} = 'IGNORE';
258   local $SIG{TSTP} = 'IGNORE';
259   local $SIG{PIPE} = 'IGNORE';
260
261   my $oldAutoCommit = $FS::UID::AutoCommit;
262   local $FS::UID::AutoCommit = 0;
263   my $dbh = dbh;
264
265   my $error = $self->SUPER::replace($old);
266   if ( $error ) {
267     $dbh->rollback if $oldAutoCommit;
268     return $error;
269   }
270
271   if ( $self->payby =~ /^(CARD|CHEK)$/
272        && ( ( $self->get('payinfo') ne $old->get('payinfo')
273               && $self->get('payinfo') !~ /^99\d{14}$/ 
274             )
275             || grep { $self->get($_) ne $old->get($_) } qw(paydate payname)
276           )
277      )
278   {
279
280     # card/check/lec info has changed, want to retry realtime_ invoice events
281     my $error = $self->cust_main->retry_realtime;
282     if ( $error ) {
283       $dbh->rollback if $oldAutoCommit;
284       return $error;
285     }
286   }
287
288   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
289   '';
290
291 }
292
293 =item check
294
295 Checks all fields to make sure this is a valid record.  If there is
296 an error, returns the error, otherwise returns false.  Called by the insert
297 and replace methods.
298
299 =cut
300
301 sub check {
302   my $self = shift;
303
304   my $error = 
305     $self->ut_numbern('custpaybynum')
306     || $self->ut_foreign_key('custnum', 'cust_main', 'custnum')
307     || $self->ut_numbern('weight')
308     #encrypted #|| $self->ut_textn('payinfo')
309     #encrypted #|| $self->ut_textn('paycvv')
310 #    || $self->ut_textn('paymask') #XXX something
311     #later #|| $self->ut_textn('paydate')
312     || $self->ut_numbern('paystart_month')
313     || $self->ut_numbern('paystart_year')
314     || $self->ut_numbern('payissue')
315 #    || $self->ut_textn('payname') #XXX something
316     || $self->ut_alphan('paystate')
317     || $self->ut_textn('paytype')
318     || $self->ut_ipn('payip')
319   ;
320   return $error if $error;
321
322   ### from cust_main
323
324   FS::payby->can_payby($self->table, $self->payby)
325     or return "Illegal payby: ". $self->payby;
326
327   # If it is encrypted and the private key is not availaible then we can't
328   # check the credit card.
329   my $check_payinfo = ! $self->is_encrypted($self->payinfo);
330
331   # Need some kind of global flag to accept invalid cards, for testing
332   # on scrubbed data.
333   #XXX if ( !$import && $check_payinfo && $self->payby =~ /^(CARD|DCRD)$/ ) {
334   if ( !$ignore_invalid_card && 
335     $check_payinfo && $self->payby =~ /^(CARD|DCRD)$/ ) {
336
337     my $payinfo = $self->payinfo;
338     $payinfo =~ s/\D//g;
339     $payinfo =~ /^(\d{13,16}|\d{8,9})$/
340       or return gettext('invalid_card'); #. ": ". $self->payinfo;
341     $payinfo = $1;
342     $self->payinfo($payinfo);
343     validate($payinfo)
344       or return gettext('invalid_card'); # . ": ". $self->payinfo;
345
346     return gettext('unknown_card_type')
347       if $self->payinfo !~ /^99\d{14}$/ #token
348       && cardtype($self->payinfo) eq "Unknown";
349
350     unless ( $ignore_banned_card ) {
351       my $ban = FS::banned_pay->ban_search( %{ $self->_banned_pay_hashref } );
352       if ( $ban ) {
353         if ( $ban->bantype eq 'warn' ) {
354           #or others depending on value of $ban->reason ?
355           return '_duplicate_card'.
356                  ': disabled from'. time2str('%a %h %o at %r', $ban->_date).
357                  ' until '.         time2str('%a %h %o at %r', $ban->_end_date).
358                  ' (ban# '. $ban->bannum. ')'
359             unless $self->override_ban_warn;
360         } else {
361           return 'Banned credit card: banned on '.
362                  time2str('%a %h %o at %r', $ban->_date).
363                  ' by '. $ban->otaker.
364                  ' (ban# '. $ban->bannum. ')';
365         }
366       }
367     }
368
369     if (length($self->paycvv) && !$self->is_encrypted($self->paycvv)) {
370       if ( cardtype($self->payinfo) eq 'American Express card' ) {
371         $self->paycvv =~ /^(\d{4})$/
372           or return "CVV2 (CID) for American Express cards is four digits.";
373         $self->paycvv($1);
374       } else {
375         $self->paycvv =~ /^(\d{3})$/
376           or return "CVV2 (CVC2/CID) is three digits.";
377         $self->paycvv($1);
378       }
379     } else {
380       $self->paycvv('');
381     }
382
383     my $cardtype = cardtype($payinfo);
384     if ( $cardtype =~ /^(Switch|Solo)$/i ) {
385
386       return "Start date or issue number is required for $cardtype cards"
387         unless $self->paystart_month && $self->paystart_year or $self->payissue;
388
389       return "Start month must be between 1 and 12"
390         if $self->paystart_month
391            and $self->paystart_month < 1 || $self->paystart_month > 12;
392
393       return "Start year must be 1990 or later"
394         if $self->paystart_year
395            and $self->paystart_year < 1990;
396
397       return "Issue number must be beween 1 and 99"
398         if $self->payissue
399           and $self->payissue < 1 || $self->payissue > 99;
400
401     } else {
402       $self->paystart_month('');
403       $self->paystart_year('');
404       $self->payissue('');
405     }
406
407   } elsif ( !$ignore_invalid_card && 
408     $check_payinfo && $self->payby =~ /^(CHEK|DCHK)$/ ) {
409
410     my $payinfo = $self->payinfo;
411     $payinfo =~ s/[^\d\@\.]//g;
412     if ( $conf->config('echeck-country') eq 'CA' ) {
413       $payinfo =~ /^(\d+)\@(\d{5})\.(\d{3})$/
414         or return 'invalid echeck account@branch.bank';
415       $payinfo = "$1\@$2.$3";
416     } elsif ( $conf->config('echeck-country') eq 'US' ) {
417       $payinfo =~ /^(\d+)\@(\d{9})$/ or return 'invalid echeck account@aba';
418       $payinfo = "$1\@$2";
419     } else {
420       $payinfo =~ /^(\d+)\@(\d+)$/ or return 'invalid echeck account@routing';
421       $payinfo = "$1\@$2";
422     }
423     $self->payinfo($payinfo);
424     $self->paycvv('');
425
426     unless ( $ignore_banned_card ) {
427       my $ban = FS::banned_pay->ban_search( %{ $self->_banned_pay_hashref } );
428       if ( $ban ) {
429         if ( $ban->bantype eq 'warn' ) {
430           #or others depending on value of $ban->reason ?
431           return '_duplicate_ach' unless $self->override_ban_warn;
432         } else {
433           return 'Banned ACH account: banned on '.
434                  time2str('%a %h %o at %r', $ban->_date).
435                  ' by '. $ban->otaker.
436                  ' (ban# '. $ban->bannum. ')';
437         }
438       }
439     }
440
441 #  } elsif ( $self->payby eq 'PREPAY' ) {
442 #
443 #    my $payinfo = $self->payinfo;
444 #    $payinfo =~ s/\W//g; #anything else would just confuse things
445 #    $self->payinfo($payinfo);
446 #    $error = $self->ut_alpha('payinfo');
447 #    return "Illegal prepayment identifier: ". $self->payinfo if $error;
448 #    return "Unknown prepayment identifier"
449 #      unless qsearchs('prepay_credit', { 'identifier' => $self->payinfo } );
450 #    $self->paycvv('');
451
452   }
453
454   if ( $self->payby =~ /^(CHEK|DCHK)$/ ) {
455
456     $self->paydate('');
457
458   } elsif ( $self->payby =~ /^(CARD|DCRD)$/ ) {
459
460     # shouldn't payinfo_check do this?
461     return "Expiration date required"
462       if $self->paydate eq '' || $self->paydate eq '-';
463
464     my( $m, $y );
465     if ( $self->paydate =~ /^(\d{1,2})[\/\-](\d{2}(\d{2})?)$/ ) {
466       ( $m, $y ) = ( $1, length($2) == 4 ? $2 : "20$2" );
467     } elsif ( $self->paydate =~ /^19(\d{2})[\/\-](\d{1,2})[\/\-]\d+$/ ) {
468       ( $m, $y ) = ( $2, "19$1" );
469     } elsif ( $self->paydate =~ /^(20)?(\d{2})[\/\-](\d{1,2})[\/\-]\d+$/ ) {
470       ( $m, $y ) = ( $3, "20$2" );
471     } else {
472       return "Illegal expiration date: ". $self->paydate;
473     }
474     $m = sprintf('%02d',$m);
475     $self->paydate("$y-$m-01");
476     my($nowm,$nowy)=(localtime(time))[4,5]; $nowm++; $nowy+=1900;
477     return gettext('expired_card')
478       if #XXX !$import
479       #&&
480          !$ignore_expired_card 
481       && ( $y<$nowy || ( $y==$nowy && $1<$nowm ) );
482
483   }
484
485   if ( $self->payname eq '' && $self->payby !~ /^(CHEK|DCHK)$/ &&
486        ( ! $conf->exists('require_cardname')
487          || $self->payby !~ /^(CARD|DCRD)$/  ) 
488   ) {
489     $self->payname( $self->first. " ". $self->getfield('last') );
490   } else {
491
492     if ( $self->payby =~ /^(CHEK|DCHK)$/ ) {
493       $self->payname =~ /^([\w \,\.\-\']*)$/
494         or return gettext('illegal_name'). " payname: ". $self->payname;
495       $self->payname($1);
496     } else {
497       $self->payname =~ /^([\w \,\.\-\'\&]*)$/
498         or return gettext('illegal_name'). " payname: ". $self->payname;
499       $self->payname($1);
500     }
501
502   }
503
504   if ( ! $self->custpaybynum
505        && $conf->exists('business-onlinepayment-verification') ) {
506     $error = $self->verify;
507     return $error if $error;
508   }
509
510   $self->SUPER::check;
511 }
512
513 sub check_payinfo_cardtype {
514   my $self = shift;
515
516   return '' if $ignore_cardtype;
517
518   return '' unless $self->payby =~ /^(CARD|CHEK)$/;
519
520   my $payinfo = $self->payinfo;
521   $payinfo =~ s/\D//g;
522
523   return '' if $payinfo =~ /^99\d{14}$/; #token
524
525   my %bop_card_types = map { $_=>1 } values %{ card_types() };
526   my $cardtype = cardtype($payinfo);
527
528   return "$cardtype not accepted" unless $bop_card_types{$cardtype};
529
530   '';
531
532 }
533
534 sub _banned_pay_hashref {
535   my $self = shift;
536
537   my %payby2ban = (
538     'CARD' => 'CARD',
539     'DCRD' => 'CARD',
540     'CHEK' => 'CHEK',
541     'DCHK' => 'CHEK'
542   );
543
544   {
545     'payby'   => $payby2ban{$self->payby},
546     'payinfo' => $self->payinfo,
547     #don't ever *search* on reason! #'reason'  =>
548   };
549 }
550
551 sub _new_banned_pay_hashref {
552   my $self = shift;
553   my $hr = $self->_banned_pay_hashref;
554   $hr->{payinfo_hash} = 'SHA512';
555   $hr->{payinfo} = sha512_base64($hr->{payinfo});
556   $hr;
557 }
558
559 =item paydate_mon_year
560
561 Returns a two element list consisting of the paydate month and year.
562
563 =cut
564
565 sub paydate_mon_year {
566   my $self = shift;
567
568   my $date = $self->paydate; # || '12-2037';
569
570   #false laziness w/elements/select-month_year.html
571   if ( $date  =~ /^(\d{4})-(\d{1,2})-\d{1,2}$/ ) { #PostgreSQL date format
572     ( $2, $1 );
573   } elsif ( $date =~ /^(\d{1,2})-(\d{1,2}-)?(\d{4}$)/ ) {
574     ( $1, $3 );
575   } else {
576     warn "unrecognized expiration date format: $date";
577     ( '', '' );
578   }
579
580 }
581
582 =item label
583
584 Returns a one line text label for this payment type.
585
586 =cut
587
588 my %weight = (
589   1 => 'Primary',
590   2 => 'Secondary',
591   3 => 'Tertiary',
592   4 => 'Fourth',
593   5 => 'Fifth',
594   6 => 'Sixth',
595   7 => 'Seventh',
596 );
597
598 sub label {
599   my $self = shift;
600
601   my $name = $self->payby =~ /^(CARD|DCRD)$/
602               && cardtype($self->paymask) || FS::payby->shortname($self->payby);
603
604   ( $self->payby =~ /^(CARD|CHEK)$/  ? $weight{$self->weight}. ' automatic '
605                                      : 'Manual '
606   ).
607   "$name: ". $self->paymask.
608   ( $self->payby =~ /^(CARD|DCRD)$/
609       ? ' Exp '. join('/', $self->paydate_mon_year)
610       : ''
611   );
612
613 }
614
615 =item realtime_bop
616
617 =cut
618
619 sub realtime_bop {
620   my( $self, %opt ) = @_;
621
622   $opt{$_} = $self->$_() for qw( payinfo payname paydate );
623
624   if ( $self->locationnum ) {
625     my $cust_location = $self->cust_location;
626     $opt{$_} = $cust_location->$_() for qw( address1 address2 city state zip );
627   }
628
629   $self->cust_main->realtime_bop({
630     'method' => FS::payby->payby2bop( $self->payby ),
631     %opt,
632   });
633
634 }
635
636 =item verify 
637
638 =cut
639
640 sub verify {
641   my $self = shift;
642   return '' unless $self->payby =~ /^(CARD|DCRD)$/;
643
644   my %opt = ();
645
646   # false laziness with check
647   my( $m, $y );
648   if ( $self->paydate =~ /^(\d{1,2})[\/\-](\d{2}(\d{2})?)$/ ) {
649     ( $m, $y ) = ( $1, length($2) == 4 ? $2 : "20$2" );
650   } elsif ( $self->paydate =~ /^19(\d{2})[\/\-](\d{1,2})[\/\-]\d+$/ ) {
651     ( $m, $y ) = ( $2, "19$1" );
652   } elsif ( $self->paydate =~ /^(20)?(\d{2})[\/\-](\d{1,2})[\/\-]\d+$/ ) {
653     ( $m, $y ) = ( $3, "20$2" );
654   } else {
655     return "Illegal expiration date: ". $self->paydate;
656   }
657   $m = sprintf('%02d',$m);
658   $opt{paydate} = "$y-$m-01";
659
660   $opt{$_} = $self->$_() for qw( payinfo payname paycvv );
661
662   if ( $self->locationnum ) {
663     my $cust_location = $self->cust_location;
664     $opt{$_} = $cust_location->$_() for qw( address1 address2 city state zip );
665   }
666
667   $self->cust_main->realtime_verify_bop({
668     'method' => FS::payby->payby2bop( $self->payby ),
669     %opt,
670   });
671
672 }
673
674 =item paytypes
675
676 Returns a list of valid values for the paytype field (bank account type for
677 electronic check payment).
678
679 =cut
680
681 sub paytypes {
682   #my $class = shift;
683
684   ('', 'Personal checking', 'Personal savings', 'Business checking', 'Business savings');
685 }
686
687 =item cgi_cust_payby_fields
688
689 Returns the field names used in the web interface (including some pseudo-fields).
690
691 =cut
692
693 sub cgi_cust_payby_fields {
694   #my $class = shift;
695   [qw( payby payinfo paydate_month paydate_year paycvv payname weight
696        payinfo1 payinfo2 payinfo3 paytype paystate payname_CHEK )];
697 }
698
699 =item cgi_hash_callback HASHREF OLD
700
701 Subroutine (not a class or object method).  Processes a hash reference
702 of web interface contet (transfers the data from pseudo-fields to real fields).
703
704 If OLD object is passed, also preserves locationnum, paystart_month, paystart_year,
705 payissue and payip.  If the new field is blank but the old is not, the old field 
706 will be preserved.
707
708 =cut
709
710 sub cgi_hash_callback {
711   my $hashref = shift;
712   my $old = shift;
713
714   my %noauto = (
715     'CARD' => 'DCRD',
716     'CHEK' => 'DCHK',
717   );
718   # the payby selector gives the choice of CARD or CHEK (or others, but
719   # those are the ones with auto and on-demand versions). if the user didn't
720   # choose a weight, then they mean DCRD/DCHK.
721   $hashref->{payby} = $noauto{$hashref->{payby}}
722     if ! $hashref->{weight} && exists $noauto{$hashref->{payby}};
723
724   if ( $hashref->{payby} =~ /^(CHEK|DCHK)$/ ) {
725
726     unless ( grep $hashref->{$_}, qw(payinfo1 payinfo2 payinfo3 payname_CHEK)) {
727       %$hashref = ();
728       return;
729     }
730
731     $hashref->{payinfo} = $hashref->{payinfo1}. '@';
732     $hashref->{payinfo} .= $hashref->{payinfo3}.'.' 
733       if $conf->config('echeck-country') eq 'CA';
734     $hashref->{payinfo} .= $hashref->{'payinfo2'};
735
736     $hashref->{payname} = $hashref->{'payname_CHEK'};
737
738   } elsif ( $hashref->{payby} =~ /^(CARD|DCRD)$/ ) {
739
740     unless ( grep $hashref->{$_}, qw( payinfo paycvv payname ) ) {
741       %$hashref = ();
742       return;
743     }
744
745   }
746
747   $hashref->{paydate}= $hashref->{paydate_month}. '-'. $hashref->{paydate_year};
748
749   if ($old) {
750     foreach my $field ( qw(locationnum paystart_month paystart_year payissue payip) ) {
751       next if $hashref->{$field};
752       next unless $old->get($field);
753       $hashref->{$field} = $old->get($field);
754     }
755   }
756
757 }
758
759 =item search_sql
760
761 Class method.
762
763 Returns a qsearch hash expression to search for parameters specified in HASHREF.
764 Valid paramters are:
765
766 =over 4
767
768 =item payby
769
770 listref
771
772 =item paydate_year
773
774 =item paydate_month
775
776
777 =back
778
779 =cut
780
781 sub search_sql {
782   my ($class, $params) = @_;
783
784   my @where = ();
785   my $orderby;
786
787   # initialize these to prevent warnings
788   $params = {
789     'paydate_year'  => '',
790     %$params
791   };
792
793   ###
794   # payby
795   ###
796
797   if ( $params->{'payby'} ) {
798
799     my @payby = ref( $params->{'payby'} )
800                   ? @{ $params->{'payby'} }
801                   :  ( $params->{'payby'} );
802
803     @payby = grep /^([A-Z]{4})$/, @payby;
804     my $in_payby = 'IN(' . join(',', map {"'$_'"} @payby) . ')';
805     push @where, "cust_payby.payby $in_payby"
806       if @payby;
807   }
808
809   ###
810   # paydate_year / paydate_month
811   ###
812
813   if ( $params->{'paydate_year'} =~ /^(\d{4})$/ ) {
814     my $year = $1;
815     $params->{'paydate_month'} =~ /^(\d\d?)$/
816       or die "paydate_year without paydate_month?";
817     my $month = $1;
818
819     push @where,
820       'cust_payby.paydate IS NOT NULL',
821       "cust_payby.paydate != ''",
822       "CAST(cust_payby.paydate AS timestamp) < CAST('$year-$month-01' AS timestamp )"
823 ;
824   }
825   ##
826   # setup queries, subs, etc. for the search
827   ##
828
829   $orderby ||= 'ORDER BY custnum';
830
831   # here is the agent virtualization
832   push @where,
833     $FS::CurrentUser::CurrentUser->agentnums_sql(table => 'cust_main');
834
835   my $extra_sql = scalar(@where) ? ' WHERE '. join(' AND ', @where) : '';
836
837   my $addl_from = ' LEFT JOIN cust_main USING ( custnum ) ';
838   # always make address fields available in results
839   for my $pre ('bill_', 'ship_') {
840     $addl_from .= 
841       ' LEFT JOIN cust_location AS '.$pre.'location '.
842       'ON (cust_main.'.$pre.'locationnum = '.$pre.'location.locationnum) ';
843   }
844   # always make referral available in results
845   #   (maybe we should be using FS::UI::Web::join_cust_main instead?)
846   $addl_from .= ' LEFT JOIN (select refnum, referral from part_referral) AS part_referral_x ON (cust_main.refnum = part_referral_x.refnum) ';
847
848   my $count_query = "SELECT COUNT(*) FROM cust_payby $addl_from $extra_sql";
849
850   my @select = ( 'cust_payby.*',
851                  #'cust_main.custnum',
852                  # there's a good chance that we'll need these
853                  'cust_main.bill_locationnum',
854                  'cust_main.ship_locationnum',
855                  FS::UI::Web::cust_sql_fields($params->{'cust_fields'}),
856                );
857
858   my $select = join(', ', @select);
859
860   my $sql_query = {
861     'table'         => 'cust_payby',
862     'select'        => $select,
863     'addl_from'     => $addl_from,
864     'hashref'       => {},
865     'extra_sql'     => $extra_sql,
866     'order_by'      => $orderby,
867     'count_query'   => $count_query,
868   };
869   $sql_query;
870
871 }
872
873 =back
874
875 =head1 BUGS
876
877 =head1 SEE ALSO
878
879 L<FS::Record>, schema.html from the base documentation.
880
881 =cut
882
883 1;
884