1 package FS::cust_payby;
2 use base qw( FS::payinfo_Mixin FS::cust_main_Mixin FS::Record );
5 use Scalar::Util qw( blessed );
6 use Digest::SHA qw( sha512_base64 );
7 use Business::CreditCard qw( validate cardtype );
9 use FS::Msgcat qw( gettext );
10 use FS::Misc qw( card_types );
11 use FS::Record; #qw( qsearch qsearchs );
16 our @encrypted_fields = ('payinfo', 'paycvv');
17 sub nohistory_fields { ('payinfo', 'paycvv'); }
19 our $ignore_expired_card = 0;
20 our $ignore_banned_card = 0;
21 our $ignore_invalid_card = 0;
22 our $ignore_cardtype = 0;
25 install_callback FS::UID sub {
27 #yes, need it for stuff below (prolly should be cached)
28 $ignore_invalid_card = $conf->exists('allow_invalid_cards');
33 FS::cust_payby - Object methods for cust_payby records
39 $record = new FS::cust_payby \%hash;
40 $record = new FS::cust_payby { 'column' => 'value' };
42 $error = $record->insert;
44 $error = $new_record->replace($old_record);
46 $error = $record->delete;
48 $error = $record->check;
52 An FS::cust_payby object represents customer stored payment information.
53 FS::cust_payby inherits from FS::Record. The following fields are currently
120 The credit card type (deduced from the card number).
130 Creates a new record. To add the record to the database, see L<"insert">.
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.
137 # the new method can be inherited from FS::Record, if a table method is defined
139 sub table { 'cust_payby'; }
143 Adds this record to the database. If there is an error, returns the error,
144 otherwise returns false.
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';
158 my $oldAutoCommit = $FS::UID::AutoCommit;
159 local $FS::UID::AutoCommit = 0;
162 my $error = $self->check_payinfo_cardtype if $self->payby =~/^(CARD|DCRD)$/;
163 $self->SUPER::insert unless $error;
166 $dbh->rollback if $oldAutoCommit;
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;
175 $dbh->rollback if $oldAutoCommit;
180 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
187 Delete this record from the database.
189 =item replace OLD_RECORD
191 Replaces the OLD_RECORD with this one in the database. If there is an error,
192 returns the error, otherwise returns false.
199 my $old = ( blessed($_[0]) && $_[0]->isa('FS::Record') )
201 : $self->replace_old;
203 if ( $self->payby =~ /^(CARD|DCRD)$/
204 && ( $self->payinfo =~ /xx/
205 || $self->payinfo =~ /^\s*N\/A\s+\(tokenized\)\s*$/
210 $self->payinfo($old->payinfo);
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);
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
229 $self->paycvv($old->paycvv);
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 );
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 );
245 if ( $self->payby =~ /^(CARD|DCRD)$/
246 && $old->payinfo ne $self->payinfo
247 && $old->paymask ne $self->paymask )
249 my $error = $self->check_payinfo_cardtype;
250 return $error if $error;
252 if ( $conf->exists('business-onlinepayment-verification') ) {
253 $error = $self->verify;
255 $error = $self->tokenize;
257 return $error if $error;
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';
268 my $oldAutoCommit = $FS::UID::AutoCommit;
269 local $FS::UID::AutoCommit = 0;
272 my $error = $self->SUPER::replace($old);
274 $dbh->rollback if $oldAutoCommit;
278 if ( $self->payby =~ /^(CARD|CHEK)$/
279 && ( ( $self->get('payinfo') ne $old->get('payinfo')
282 || grep { $self->get($_) ne $old->get($_) } qw(paydate payname)
287 # card/check/lec info has changed, want to retry realtime_ invoice events
288 my $error = $self->cust_main->retry_realtime;
290 $dbh->rollback if $oldAutoCommit;
295 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
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
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')
327 return $error if $error;
331 FS::payby->can_payby($self->table, $self->payby)
332 or return "Illegal payby: ". $self->payby;
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);
338 # Need some kind of global flag to accept invalid cards, for testing
340 #XXX if ( !$import && $check_payinfo && $self->payby =~ /^(CARD|DCRD)$/ ) {
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.
348 if ( !$ignore_invalid_card &&
349 $check_payinfo && $self->payby =~ /^(CARD|DCRD)$/ ) {
351 my $payinfo = $self->payinfo;
353 $payinfo =~ /^(\d{13,19}|\d{8,9})$/
354 or return gettext('invalid_card'); #. ": ". $self->payinfo;
356 $self->payinfo($payinfo);
358 or return gettext('invalid_card'); # . ": ". $self->payinfo;
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);
367 #return "paycardtype required ".
368 # "(can't derive from a token and no paymask w/prefix provided)"
372 $cardtype = cardtype($self->payinfo);
375 return gettext('unknown_card_type') if $cardtype eq "Unknown";
377 $self->set('paycardtype', $cardtype);
379 unless ( $ignore_banned_card ) {
380 my $ban = FS::banned_pay->ban_search( %{ $self->_banned_pay_hashref } );
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;
390 return 'Banned credit card: banned on '.
391 time2str('%a %h %o at %r', $ban->_date).
392 ' by '. $ban->otaker.
393 ' (ban# '. $ban->bannum. ')';
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.";
404 $self->paycvv =~ /^(\d{3})$/
405 or return "CVV2 (CVC2/CID) is three digits.";
412 if ( $cardtype =~ /^(Switch|Solo)$/i ) {
414 return "Start date or issue number is required for $cardtype cards"
415 unless $self->paystart_month && $self->paystart_year or $self->payissue;
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;
421 return "Start year must be 1990 or later"
422 if $self->paystart_year
423 and $self->paystart_year < 1990;
425 return "Issue number must be beween 1 and 99"
427 and $self->payissue < 1 || $self->payissue > 99;
430 $self->paystart_month('');
431 $self->paystart_year('');
435 } elsif ( !$ignore_invalid_card &&
436 $check_payinfo && $self->payby =~ /^(CHEK|DCHK)$/ ) {
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';
448 $payinfo =~ /^(\d+)\@(\d+)$/ or return 'invalid echeck account@routing';
451 $self->payinfo($payinfo);
454 unless ( $ignore_banned_card ) {
455 my $ban = FS::banned_pay->ban_search( %{ $self->_banned_pay_hashref } );
457 if ( $ban->bantype eq 'warn' ) {
458 #or others depending on value of $ban->reason ?
459 return '_duplicate_ach' unless $self->override_ban_warn;
461 return 'Banned ACH account: banned on '.
462 time2str('%a %h %o at %r', $ban->_date).
463 ' by '. $ban->otaker.
464 ' (ban# '. $ban->bannum. ')';
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));
475 $self->set('paycardtype', '');
478 # } elsif ( $self->payby eq 'PREPAY' ) {
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 } );
489 if ( $self->payby =~ /^(CHEK|DCHK)$/ ) {
493 } elsif ( $self->payby =~ /^(CARD|DCRD)$/ ) {
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 '-';
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" );
508 return "Illegal expiration date: ". $self->paydate;
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')
516 !$ignore_expired_card
517 && ( $y<$nowy || ( $y==$nowy && $1<$nowm ) );
521 if ( $self->payname eq '' && $self->payby !~ /^(CHEK|DCHK)$/ &&
522 ( ! $conf->exists('require_cardname')
523 || $self->payby !~ /^(CARD|DCRD)$/ )
525 $self->payname( $self->first. " ". $self->getfield('last') );
528 if ( $self->payby =~ /^(CHEK|DCHK)$/ ) {
529 $self->payname =~ /^([\w \,\.\-\']*)$/
530 or return gettext('illegal_name'). " payname: ". $self->payname;
533 $self->payname =~ /^([\w \,\.\-\'\&]*)$/
534 or return gettext('illegal_name'). " payname: ". $self->payname;
540 if ( ! $self->custpaybynum ) {
541 if ($conf->exists('business-onlinepayment-verification')) {
542 $error = $self->verify;
544 $error = $self->tokenize;
546 return $error if $error;
552 sub check_payinfo_cardtype {
555 return '' if $ignore_cardtype;
557 return '' unless $self->payby =~ /^(CARD|CHEK)$/;
559 my $payinfo = $self->payinfo;
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));
568 $self->set('paycardtype', '');
569 #return "paycardtype required ".
570 # "(can't derive from a token and no paymask w/prefix provided)";
575 my %bop_card_types = map { $_=>1 } values %{ card_types() };
576 my $cardtype = cardtype($payinfo);
577 $self->set('paycardtype', $cardtype);
579 return "$cardtype not accepted" unless $bop_card_types{$cardtype};
585 sub _banned_pay_hashref {
596 'payby' => $payby2ban{$self->payby},
597 'payinfo' => $self->payinfo,
598 #don't ever *search* on reason! #'reason' =>
602 sub _new_banned_pay_hashref {
604 my $hr = $self->_banned_pay_hashref;
605 $hr->{payinfo_hash} = 'SHA512';
606 $hr->{payinfo} = sha512_base64($hr->{payinfo});
610 =item paydate_mon_year
612 Returns a two element list consisting of the paydate month and year.
616 sub paydate_mon_year {
619 my $date = $self->paydate; # || '12-2037';
621 #false laziness w/elements/select-month_year.html
622 if ( $date =~ /^(\d{4})-(\d{1,2})-\d{1,2}$/ ) { #PostgreSQL date format
624 } elsif ( $date =~ /^(\d{1,2})-(\d{1,2}-)?(\d{4}$)/ ) {
627 warn "unrecognized expiration date format: $date";
635 Returns a one line text label for this payment type.
652 my $name = $self->payby =~ /^(CARD|DCRD)$/
653 && $self->paycardtype || FS::payby->shortname($self->payby);
655 ( $self->payby =~ /^(CARD|CHEK)$/ ? $weight{$self->weight}. ' automatic '
658 "$name: ". $self->paymask.
659 ( $self->payby =~ /^(CARD|DCRD)$/
660 ? ' Exp '. join('/', $self->paydate_mon_year)
668 Runs a L<realtime_bop|FS::cust_main::Billing_Realtime::realtime_bop> transaction on this card
673 my( $self, %opt ) = @_;
675 $self->cust_main->realtime_bop({
677 'cust_payby' => $self,
684 Runs a L<realtime_tokenize|FS::cust_main::Billing_Realtime::realtime_tokenize> transaction on this card
690 return '' unless $self->payby =~ /^(CARD|DCRD)$/;
692 $self->cust_main->realtime_tokenize({
693 'cust_payby' => $self,
700 Runs a L<realtime_verify_bop|FS::cust_main::Billing_Realtime/realtime_verify_bop> transaction on this card
706 return '' unless $self->payby =~ /^(CARD|DCRD)$/;
708 $self->cust_main->realtime_verify_bop({
709 'cust_payby' => $self,
716 Returns a list of valid values for the paytype field (bank account type for
717 electronic check payment).
724 ('', 'Personal checking', 'Personal savings', 'Business checking', 'Business savings');
727 =item cgi_cust_payby_fields
729 Returns the field names used in the web interface (including some pseudo-fields).
733 sub cgi_cust_payby_fields {
735 [qw( payby payinfo paydate_month paydate_year paycvv payname weight
736 payinfo1 payinfo2 payinfo3 paytype paystate payname_CHEK )];
739 =item cgi_hash_callback HASHREF OLD
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).
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
750 sub cgi_hash_callback {
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}};
764 if ( $hashref->{payby} =~ /^(CHEK|DCHK)$/ ) {
766 unless ( grep $hashref->{$_}, qw(payinfo1 payinfo2 payinfo3 payname_CHEK)) {
771 $hashref->{payinfo} = $hashref->{payinfo1}. '@';
772 $hashref->{payinfo} .= $hashref->{payinfo3}.'.'
773 if $conf->config('echeck-country') eq 'CA';
774 $hashref->{payinfo} .= $hashref->{'payinfo2'};
776 $hashref->{payname} = $hashref->{'payname_CHEK'};
778 } elsif ( $hashref->{payby} =~ /^(CARD|DCRD)$/ ) {
780 unless ( grep $hashref->{$_}, qw( payinfo paycvv payname ) ) {
787 $hashref->{paydate}= $hashref->{paydate_month}. '-'. $hashref->{paydate_year};
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);
803 Returns a qsearch hash expression to search for parameters specified in HASHREF.
822 my ($class, $params) = @_;
827 # initialize these to prevent warnings
829 'paydate_year' => '',
837 if ( $params->{'payby'} ) {
839 my @payby = ref( $params->{'payby'} )
840 ? @{ $params->{'payby'} }
841 : ( $params->{'payby'} );
843 @payby = grep /^([A-Z]{4})$/, @payby;
844 my $in_payby = 'IN(' . join(',', map {"'$_'"} @payby) . ')';
845 push @where, "cust_payby.payby $in_payby"
850 # paydate_year / paydate_month
853 if ( $params->{'paydate_year'} =~ /^(\d{4})$/ ) {
855 $params->{'paydate_month'} =~ /^(\d\d?)$/
856 or die "paydate_year without paydate_month?";
860 'cust_payby.paydate IS NOT NULL',
861 "cust_payby.paydate != ''",
862 "CAST(cust_payby.paydate AS timestamp) < CAST('$year-$month-01' AS timestamp )"
866 # setup queries, subs, etc. for the search
869 $orderby ||= 'ORDER BY custnum';
871 # here is the agent virtualization
873 $FS::CurrentUser::CurrentUser->agentnums_sql(table => 'cust_main');
875 my $extra_sql = scalar(@where) ? ' WHERE '. join(' AND ', @where) : '';
877 my $addl_from = ' LEFT JOIN cust_main USING ( custnum ) ';
878 # always make address fields available in results
879 for my $pre ('bill_', 'ship_') {
881 ' LEFT JOIN cust_location AS '.$pre.'location '.
882 'ON (cust_main.'.$pre.'locationnum = '.$pre.'location.locationnum) ';
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) ';
888 my $count_query = "SELECT COUNT(*) FROM cust_payby $addl_from $extra_sql";
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'}),
898 my $select = join(', ', @select);
901 'table' => 'cust_payby',
903 'addl_from' => $addl_from,
905 'extra_sql' => $extra_sql,
906 'order_by' => $orderby,
907 'count_query' => $count_query,
920 local $ignore_banned_card = 1;
921 local $ignore_expired_card = 1;
922 local $ignore_invalid_card = 1;
923 $class->upgrade_set_cardtype;
931 L<FS::Record>, schema.html from the base documentation.