1 package FS::cust_payby;
2 use base qw( FS::payinfo_Mixin FS::cust_main_Mixin FS::Record );
5 use Digest::SHA qw( sha512_base64 );
6 use Business::CreditCard qw( validate cardtype );
8 use FS::Msgcat qw( gettext );
9 use FS::Record; #qw( qsearch qsearchs );
14 our @encrypted_fields = ('payinfo', 'paycvv');
15 sub nohistory_fields { ('payinfo', 'paycvv'); }
17 our $ignore_expired_card = 0;
18 our $ignore_banned_card = 0;
19 our $ignore_invalid_card = 0;
22 install_callback FS::UID sub {
24 #yes, need it for stuff below (prolly should be cached)
25 $ignore_invalid_card = $conf->exists('allow_invalid_cards');
30 FS::cust_payby - Object methods for cust_payby records
36 $record = new FS::cust_payby \%hash;
37 $record = new FS::cust_payby { 'column' => 'value' };
39 $error = $record->insert;
41 $error = $new_record->replace($old_record);
43 $error = $record->delete;
45 $error = $record->check;
49 An FS::cust_payby object represents customer stored payment information.
50 FS::cust_payby inherits from FS::Record. The following fields are currently
124 Creates a new record. To add the record to the database, see L<"insert">.
126 Note that this stores the hash reference, not a distinct copy of the hash it
127 points to. You can ask the object for a copy with the I<hash> method.
131 # the new method can be inherited from FS::Record, if a table method is defined
133 sub table { 'cust_payby'; }
137 Adds this record to the database. If there is an error, returns the error,
138 otherwise returns false.
145 local $SIG{HUP} = 'IGNORE';
146 local $SIG{INT} = 'IGNORE';
147 local $SIG{QUIT} = 'IGNORE';
148 local $SIG{TERM} = 'IGNORE';
149 local $SIG{TSTP} = 'IGNORE';
150 local $SIG{PIPE} = 'IGNORE';
152 my $oldAutoCommit = $FS::UID::AutoCommit;
153 local $FS::UID::AutoCommit = 0;
156 my $error = $self->SUPER::insert;
158 $dbh->rollback if $oldAutoCommit;
162 if ( $self->payby =~ /^(CARD|CHEK)$/ ) {
163 # new auto card/check info, want to retry realtime_ invoice events
164 # (new customer? that's okay, they won't have any)
165 my $error = $self->cust_main->retry_realtime;
167 $dbh->rollback if $oldAutoCommit;
172 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
179 Delete this record from the database.
181 =item replace OLD_RECORD
183 Replaces the OLD_RECORD with this one in the database. If there is an error,
184 returns the error, otherwise returns false.
191 my $old = ( blessed($_[0]) && $_[0]->isa('FS::Record') )
193 : $self->replace_old;
195 if ( length($old->paycvv) && $self->paycvv =~ /^\s*[\*x]*\s*$/ ) {
196 $self->paycvv($old->paycvv);
199 if ( $self->payby =~ /^(CARD|DCRD)$/
200 && ( $self->payinfo =~ /xx/
201 || $self->payinfo =~ /^\s*N\/A\s+\(tokenized\)\s*$/
207 $self->payinfo($old->payinfo);
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);
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 );
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 );
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';
238 my $oldAutoCommit = $FS::UID::AutoCommit;
239 local $FS::UID::AutoCommit = 0;
242 my $error = $self->SUPER::replace($old);
244 $dbh->rollback if $oldAutoCommit;
248 if ( $self->payby =~ /^(CARD|CHEK)$/
249 && ( ( $self->get('payinfo') ne $old->get('payinfo')
250 && $self->get('payinfo') !~ /^99\d{14}$/
252 || grep { $self->get($_) ne $old->get($_) } qw(paydate payname)
257 # card/check/lec info has changed, want to retry realtime_ invoice events
258 my $error = $self->cust_main->retry_realtime;
260 $dbh->rollback if $oldAutoCommit;
265 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
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
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')
297 return $error if $error;
301 FS::payby->can_payby($self->table, $self->payby)
302 or return "Illegal payby: ". $self->payby;
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);
308 # Need some kind of global flag to accept invalid cards, for testing
310 #XXX if ( !$import && $check_payinfo && $self->payby =~ /^(CARD|DCRD)$/ ) {
311 if ( !$ignore_invalid_card &&
312 $check_payinfo && $self->payby =~ /^(CARD|DCRD)$/ ) {
314 my $payinfo = $self->payinfo;
316 $payinfo =~ /^(\d{13,16}|\d{8,9})$/
317 or return gettext('invalid_card'); #. ": ". $self->payinfo;
319 $self->payinfo($payinfo);
321 or return gettext('invalid_card'); # . ": ". $self->payinfo;
323 return gettext('unknown_card_type')
324 if $self->payinfo !~ /^99\d{14}$/ #token
325 && cardtype($self->payinfo) eq "Unknown";
327 unless ( $ignore_banned_card ) {
328 my $ban = FS::banned_pay->ban_search( %{ $self->_banned_pay_hashref } );
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;
338 return 'Banned credit card: banned on '.
339 time2str('%a %h %o at %r', $ban->_date).
340 ' by '. $ban->otaker.
341 ' (ban# '. $ban->bannum. ')';
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.";
352 $self->paycvv =~ /^(\d{3})$/
353 or return "CVV2 (CVC2/CID) is three digits.";
360 my $cardtype = cardtype($payinfo);
361 if ( $cardtype =~ /^(Switch|Solo)$/i ) {
363 return "Start date or issue number is required for $cardtype cards"
364 unless $self->paystart_month && $self->paystart_year or $self->payissue;
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;
370 return "Start year must be 1990 or later"
371 if $self->paystart_year
372 and $self->paystart_year < 1990;
374 return "Issue number must be beween 1 and 99"
376 and $self->payissue < 1 || $self->payissue > 99;
379 $self->paystart_month('');
380 $self->paystart_year('');
384 } elsif ( !$ignore_invalid_card &&
385 $check_payinfo && $self->payby =~ /^(CHEK|DCHK)$/ ) {
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';
397 $payinfo =~ /^(\d+)\@(\d+)$/ or return 'invalid echeck account@routing';
400 $self->payinfo($payinfo);
403 unless ( $ignore_banned_card ) {
404 my $ban = FS::banned_pay->ban_search( %{ $self->_banned_pay_hashref } );
406 if ( $ban->bantype eq 'warn' ) {
407 #or others depending on value of $ban->reason ?
408 return '_duplicate_ach' unless $self->override_ban_warn;
410 return 'Banned ACH account: banned on '.
411 time2str('%a %h %o at %r', $ban->_date).
412 ' by '. $ban->otaker.
413 ' (ban# '. $ban->bannum. ')';
418 # } elsif ( $self->payby eq 'PREPAY' ) {
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 } );
431 if ( $self->payby =~ /^(CHEK|DCHK)$/ ) {
435 } elsif ( $self->payby =~ /^(CARD|DCRD)$/ ) {
437 # shouldn't payinfo_check do this?
438 return "Expiration date required"
439 if $self->paydate eq '' || $self->paydate eq '-';
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" );
449 return "Illegal expiration date: ". $self->paydate;
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')
457 !$ignore_expired_card
458 && ( $y<$nowy || ( $y==$nowy && $1<$nowm ) );
462 if ( $self->payname eq '' && $self->payby !~ /^(CHEK|DCHK)$/ &&
463 ( ! $conf->exists('require_cardname')
464 || $self->payby !~ /^(CARD|DCRD)$/ )
466 $self->payname( $self->first. " ". $self->getfield('last') );
469 if ( $self->payby =~ /^(CHEK|DCHK)$/ ) {
470 $self->payname =~ /^([\w \,\.\-\']*)$/
471 or return gettext('illegal_name'). " payname: ". $self->payname;
474 $self->payname =~ /^([\w \,\.\-\'\&]*)$/
475 or return gettext('illegal_name'). " payname: ". $self->payname;
486 sub _banned_pay_hashref {
497 'payby' => $payby2ban{$self->payby},
498 'payinfo' => $self->payinfo,
499 #don't ever *search* on reason! #'reason' =>
503 sub _new_banned_pay_hashref {
505 my $hr = $self->_banned_pay_hashref;
506 $hr->{payinfo_hash} = 'SHA512';
507 $hr->{payinfo} = sha512_base64($hr->{payinfo});
511 =item paydate_mon_year
513 Returns a two element list consisting of the paydate month and year.
517 sub paydate_mon_year {
520 my $date = $self->paydate; # || '12-2037';
522 #false laziness w/elements/select-month_year.html
523 if ( $date =~ /^(\d{4})-(\d{1,2})-\d{1,2}$/ ) { #PostgreSQL date format
525 } elsif ( $date =~ /^(\d{1,2})-(\d{1,2}-)?(\d{4}$)/ ) {
528 warn "unrecognized expiration date format: $date";
539 my( $self, %opt ) = @_;
541 $opt{$_} = $self->$_() for qw( payinfo payname paydate );
543 if ( $self->locationnum ) {
544 my $cust_location = $self->cust_location;
545 $opt{$_} = $cust_location->$_() for qw( address1 address2 city state zip );
548 $self->cust_main->realtime_bop({
549 'method' => FS::payby->payby2bop( $self->payby ),
557 Returns a list of valid values for the paytype field (bank account type for
558 electronic check payment).
565 ('', 'Personal checking', 'Personal savings', 'Business checking', 'Business savings');
568 =item cgi_cust_payby_fields
570 Returns the field names used in the web interface (including some pseudo-fields).
574 sub cgi_cust_payby_fields {
576 [qw( payby payinfo paydate_month paydate_year paycvv payname weight
577 payinfo1 payinfo2 payinfo3 paytype paystate payname_CHEK )];
580 =item cgi_hash_callback HASHREF
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).
587 sub cgi_hash_callback {
594 $hashref->{payby} = $noauto{$hashref->{payby}}
595 if ! $hashref->{weight} && exists $noauto{$hashref->{payby}};
597 if ( $hashref->{payby} =~ /^(CHEK|DCHK)$/ ) {
599 unless ( grep $hashref->{$_}, qw(payinfo1 payinfo2 payinfo3 payname_CHEK)) {
604 $hashref->{payinfo} = $hashref->{payinfo1}. '@';
605 $hashref->{payinfo} .= $hashref->{payinfo3}.'.'
606 if $conf->config('echeck-country') eq 'CA';
607 $hashref->{payinfo} .= $hashref->{'payinfo2'};
609 $hashref->{payname} = $hashref->{'payname_CHEK'};
611 } elsif ( $hashref->{payby} =~ /^(CARD|DCRD)$/ ) {
613 unless ( grep $hashref->{$_}, qw( payinfo paycvv payname ) ) {
620 $hashref->{paydate}= $hashref->{paydate_month}. '-'. $hashref->{paydate_year};
628 Returns a qsearch hash expression to search for parameters specified in HASHREF.
647 my ($class, $params) = @_;
652 # initialize these to prevent warnings
654 'paydate_year' => '',
662 if ( $params->{'payby'} ) {
664 my @payby = ref( $params->{'payby'} )
665 ? @{ $params->{'payby'} }
666 : ( $params->{'payby'} );
668 @payby = grep /^([A-Z]{4})$/, @payby;
669 my $in_payby = 'IN(' . join(',', map {"'$_'"} @payby) . ')';
670 push @where, "cust_payby.payby $in_payby"
675 # paydate_year / paydate_month
678 if ( $params->{'paydate_year'} =~ /^(\d{4})$/ ) {
680 $params->{'paydate_month'} =~ /^(\d\d?)$/
681 or die "paydate_year without paydate_month?";
685 'cust_payby.paydate IS NOT NULL',
686 "cust_payby.paydate != ''",
687 "CAST(cust_payby.paydate AS timestamp) < CAST('$year-$month-01' AS timestamp )"
691 # setup queries, subs, etc. for the search
694 $orderby ||= 'ORDER BY custnum';
696 # here is the agent virtualization
698 $FS::CurrentUser::CurrentUser->agentnums_sql(table => 'cust_main');
700 my $extra_sql = scalar(@where) ? ' WHERE '. join(' AND ', @where) : '';
702 my $addl_from = ' LEFT JOIN cust_main USING ( custnum ) ';
703 # always make address fields available in results
704 for my $pre ('bill_', 'ship_') {
706 ' LEFT JOIN cust_location AS '.$pre.'location '.
707 'ON (cust_main.'.$pre.'locationnum = '.$pre.'location.locationnum) ';
710 my $count_query = "SELECT COUNT(*) FROM cust_payby $addl_from $extra_sql";
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'}),
720 my $select = join(', ', @select);
723 'table' => 'cust_payby',
725 'addl_from' => $addl_from,
727 'extra_sql' => $extra_sql,
728 'order_by' => $orderby,
729 'count_query' => $count_query,
741 L<FS::Record>, schema.html from the base documentation.