From a77a43c3e472c12a2a343d92fd96611a00a704b6 Mon Sep 17 00:00:00 2001 From: Jonathan Prykop Date: Fri, 29 Jul 2016 20:35:16 -0500 Subject: RT#71513: Card tokenization in v4+ --- FS/FS/cust_main/Billing_Realtime.pm | 58 +++++++++++++++++++++---------------- FS/FS/log_context.pm | 3 +- 2 files changed, 35 insertions(+), 26 deletions(-) diff --git a/FS/FS/cust_main/Billing_Realtime.pm b/FS/FS/cust_main/Billing_Realtime.pm index c49e150b9..cb7299bbb 100644 --- a/FS/FS/cust_main/Billing_Realtime.pm +++ b/FS/FS/cust_main/Billing_Realtime.pm @@ -355,6 +355,35 @@ sub _bop_content { \%content; } +sub _tokenize_card { + my ($self,$transaction,$payinfo,$log) = @_; + + if ( $transaction->can('card_token') + and $transaction->card_token + and $payinfo !~ /^99\d{14}$/ #not already tokenized + ) { + + my @cust_payby = $self->cust_payby('CARD','DCRD'); + @cust_payby = grep { $payinfo == $_->payinfo } @cust_payby; + if (@cust_payby > 1) { + $log->error('Multiple matching card numbers for cust '.$self->custnum.', could not tokenize card'); + } elsif (@cust_payby) { + my $cust_payby = $cust_payby[0]; + $cust_payby->payinfo($transaction->card_token); + my $error = $cust_payby->replace; + if ( $error ) { + $log->error('Error storing token for cust '.$self->custnum.', cust_payby '.$cust_payby->custpaybynum.': '.$error); + } else { + $log->debug('Tokenized card for cust '.$self->custnum.', cust_payby '.$cust_payby->custpaybynum); + } + } else { + $log->debug('No matching card numbers for cust '.$self->custnum.', could not tokenize card'); + } + + } + +} + my %bop_method2payby = ( 'CC' => 'CARD', 'ECHECK' => 'CHEK', @@ -369,6 +398,8 @@ sub realtime_bop { unless $FS::UID::AutoCommit; local($DEBUG) = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG; + + my $log = FS::Log->new('FS::cust_main::Billing_Realtime::realtime_bop'); my %options = (); if (ref($_[0]) eq 'HASH') { @@ -774,18 +805,7 @@ sub realtime_bop { # Tokenize ### - - if ( $transaction->can('card_token') && $transaction->card_token ) { - - if ( $options{'payinfo'} eq $self->payinfo ) { - $self->payinfo($transaction->card_token); - my $error = $self->replace; - if ( $error ) { - warn "WARNING: error storing token: $error, but proceeding anyway\n"; - } - } - - } + $self->_tokenize_card($transaction,$options{'payinfo'},$log); ### # result handling @@ -2103,19 +2123,7 @@ sub realtime_verify_bop { # Tokenize ### - if ( $transaction->can('card_token') && $transaction->card_token ) { - - if ( $options{'payinfo'} eq $self->payinfo ) { - $self->payinfo($transaction->card_token); - my $error = $self->replace; - if ( $error ) { - my $warning = "WARNING: error storing token: $error, but proceeding anyway\n"; - $log->warning($warning); - warn $warning; - } - } - - } + $self->_tokenize_card($transaction,$options{'payinfo'},$log); ### # result handling diff --git a/FS/FS/log_context.pm b/FS/FS/log_context.pm index afd67ccc0..1d98ac1c7 100644 --- a/FS/FS/log_context.pm +++ b/FS/FS/log_context.pm @@ -5,10 +5,10 @@ use base qw( FS::Record ); use FS::Record qw( qsearch qsearchs ); my @contexts = ( qw( - test bill_and_collect FS::cust_main::Billing::bill_and_collect FS::cust_main::Billing::bill + FS::cust_main::Billing_Realtime::realtime_bop FS::cust_main::Billing_Realtime::realtime_verify_bop FS::pay_batch::import_from_gateway FS::part_pkg @@ -26,6 +26,7 @@ my @contexts = ( qw( upgrade_taxable_billpkgnum freeside-paymentech-upload freeside-paymentech-download + test ) ); =head1 NAME -- cgit v1.2.1 From 80542a7f5c52ac2f631adc82d0e4326554200793 Mon Sep 17 00:00:00 2001 From: Jonathan Prykop Date: Tue, 11 Oct 2016 20:43:13 -0500 Subject: 71513: Card tokenization in v4+ --- FS/FS/cust_main.pm | 7 + FS/FS/cust_main/Billing_Realtime.pm | 342 +++++++++++++++++++++++++----------- FS/FS/cust_payby.pm | 65 ++++--- FS/FS/log_context.pm | 1 + FS/FS/payinfo_Mixin.pm | 3 +- httemplate/misc/process/payment.cgi | 9 +- 6 files changed, 282 insertions(+), 145 deletions(-) diff --git a/FS/FS/cust_main.pm b/FS/FS/cust_main.pm index e1f73bf59..eac6c7575 100644 --- a/FS/FS/cust_main.pm +++ b/FS/FS/cust_main.pm @@ -4679,6 +4679,10 @@ CHEK only CHEK only +=item saved_cust_payby + +scalar reference, for returning saved object + =back =cut @@ -4875,6 +4879,9 @@ PAYBYLOOP: return $error; } + ${$opt{'saved_cust_payby'}} = $new + if $opt{'saved_cust_payby'}; + $dbh->commit or die $dbh->errstr if $oldAutoCommit; ''; diff --git a/FS/FS/cust_main/Billing_Realtime.pm b/FS/FS/cust_main/Billing_Realtime.pm index cb7299bbb..81b00aa72 100644 --- a/FS/FS/cust_main/Billing_Realtime.pm +++ b/FS/FS/cust_main/Billing_Realtime.pm @@ -111,6 +111,8 @@ I allows payment capture to unlock export jobs =cut +# Currently only used by ClientAPI +# NOT 4.x COMPATIBLE (see below) sub realtime_collect { my( $self, %options ) = @_; @@ -124,6 +126,7 @@ sub realtime_collect { $options{amount} = $self->balance unless exists( $options{amount} ); return '' unless $options{amount} > 0; + #### NOT 4.x COMPATIBLE $options{method} = FS::payby->payby2bop($self->payby) unless exists( $options{method} ); @@ -137,16 +140,14 @@ Runs a realtime credit card or ACH (electronic check) transaction via a Business::OnlinePayment realtime gateway. See L for supported gateways. -Required arguments in the hashref are I, and I +Required arguments in the hashref are I and either +I or I, I and (as applicable for method) +I, I, I, I, I, I and I. Available methods are: I, I, or I Available optional arguments are: I, I, I, I, I, I, I -The additional options I, I, I, I, I, -I, I and I are also available. Any of these options, -if set, will override the value from the customer record. - I is a free-text field passed to the gateway. It defaults to the value defined by the business-onlinepayment-description configuration option, or "Internet services" if that is unset. @@ -279,11 +280,6 @@ sub _bop_defaults { } } - unless ( exists( $options->{'payinfo'} ) ) { - $options->{'payinfo'} = $self->payinfo; - $options->{'paymask'} = $self->paymask; - } - # Default invoice number if the customer has exactly one open invoice. unless ( $options->{'invnum'} || $options->{'no_invnum'} ) { $options->{'invnum'} = ''; @@ -291,14 +287,50 @@ sub _bop_defaults { $options->{'invnum'} = $open[0]->invnum if scalar(@open) == 1; } - $options->{payname} = $self->payname unless exists( $options->{payname} ); +} + +sub _bop_cust_payby_options { + my ($self,$options) = @_; + my $cust_payby = $options->{'cust_payby'}; + if ($cust_payby) { + + $options->{'method'} = FS::payby->payby2bop( $cust_payby->payby ); + + if ($cust_payby->payby =~ /^(CARD|DCRD)$/) { + # false laziness with cust_payby->check + # which might not have been run yet + my( $m, $y ); + if ( $cust_payby->paydate =~ /^(\d{1,2})[\/\-](\d{2}(\d{2})?)$/ ) { + ( $m, $y ) = ( $1, length($2) == 4 ? $2 : "20$2" ); + } elsif ( $cust_payby->paydate =~ /^19(\d{2})[\/\-](\d{1,2})[\/\-]\d+$/ ) { + ( $m, $y ) = ( $2, "19$1" ); + } elsif ( $cust_payby->paydate =~ /^(20)?(\d{2})[\/\-](\d{1,2})[\/\-]\d+$/ ) { + ( $m, $y ) = ( $3, "20$2" ); + } else { + return "Illegal expiration date: ". $cust_payby->paydate; + } + $m = sprintf('%02d',$m); + $options->{paydate} = "$y-$m-01"; + } else { + $options->{paydate} = ''; + } + + $options->{$_} = $cust_payby->$_() + for qw( payinfo paycvv paymask paystart_month paystart_year + payissue payname paystate paytype payip ); + + if ( $cust_payby->locationnum ) { + my $cust_location = $cust_payby->cust_location; + $options->{$_} = $cust_location->$_() for qw( address1 address2 city state zip ); + } + } } sub _bop_content { my ($self, $options) = @_; my %content = (); - my $payip = exists($options->{'payip'}) ? $options->{'payip'} : $self->payip; + my $payip = $options->{'payip'}; $content{customer_ip} = $payip if length($payip); $content{invoice_number} = $options->{'invnum'} @@ -325,26 +357,14 @@ sub _bop_content { $content{name} = $payname; - $content{address} = exists($options->{'address1'}) - ? $options->{'address1'} - : $self->address1; - my $address2 = exists($options->{'address2'}) - ? $options->{'address2'} - : $self->address2; + $content{address} = $options->{'address1'}; + my $address2 = $options->{'address2'}; $content{address} .= ", ". $address2 if length($address2); - $content{city} = exists($options->{city}) - ? $options->{city} - : $self->city; - $content{state} = exists($options->{state}) - ? $options->{state} - : $self->state; - $content{zip} = exists($options->{zip}) - ? $options->{'zip'} - : $self->zip; - $content{country} = exists($options->{country}) - ? $options->{country} - : $self->country; + $content{city} = $options->{'city'}; + $content{state} = $options->{'state'}; + $content{zip} = $options->{'zip'}; + $content{country} = $options->{'country'}; $content{phone} = $self->daytime || $self->night; @@ -356,28 +376,24 @@ sub _bop_content { } sub _tokenize_card { - my ($self,$transaction,$payinfo,$log) = @_; + my ($self,$transaction,$cust_payby,$log,%opt) = @_; - if ( $transaction->can('card_token') + if ( $cust_payby + and $transaction->can('card_token') and $transaction->card_token - and $payinfo !~ /^99\d{14}$/ #not already tokenized + and $cust_payby->payinfo !~ /^99\d{14}$/ #not already tokenized ) { - my @cust_payby = $self->cust_payby('CARD','DCRD'); - @cust_payby = grep { $payinfo == $_->payinfo } @cust_payby; - if (@cust_payby > 1) { - $log->error('Multiple matching card numbers for cust '.$self->custnum.', could not tokenize card'); - } elsif (@cust_payby) { - my $cust_payby = $cust_payby[0]; - $cust_payby->payinfo($transaction->card_token); - my $error = $cust_payby->replace; - if ( $error ) { - $log->error('Error storing token for cust '.$self->custnum.', cust_payby '.$cust_payby->custpaybynum.': '.$error); - } else { - $log->debug('Tokenized card for cust '.$self->custnum.', cust_payby '.$cust_payby->custpaybynum); - } + $cust_payby->payinfo($transaction->card_token); + + my $error; + $error = $cust_payby->replace if $opt{'replace'}; + if ( $error ) { + $log->error('Error storing token for cust '.$self->custnum.', cust_payby '.$cust_payby->custpaybynum.': '.$error); + return $error; } else { - $log->debug('No matching card numbers for cust '.$self->custnum.', could not tokenize card'); + $log->debug('Tokenized card for cust '.$self->custnum.', cust_payby '.$cust_payby->custpaybynum); + return ''; } } @@ -411,6 +427,8 @@ sub realtime_bop { $options{amount} = $amount; } + # set fields from passed cust_payby + $self->_bop_cust_payby_options(\%options); ### # optional credit card surcharge @@ -450,6 +468,9 @@ sub realtime_bop { $self->_bop_defaults(\%options); + return "Missing payinfo" + unless $options{'payinfo'}; + ### # set trans_is_recur based on invnum if there is one ### @@ -535,29 +556,19 @@ sub realtime_bop { if ( $options{method} eq 'CC' ) { $content{card_number} = $options{payinfo}; - $paydate = exists($options{'paydate'}) - ? $options{'paydate'} - : $self->paydate; + $paydate = $options{'paydate'}; $paydate =~ /^\d{2}(\d{2})[\/\-](\d+)[\/\-]\d+$/; $content{expiration} = "$2/$1"; $content{cvv2} = $options{'paycvv'} if length($options{'paycvv'}); - my $paystart_month = exists($options{'paystart_month'}) - ? $options{'paystart_month'} - : $self->paystart_month; - - my $paystart_year = exists($options{'paystart_year'}) - ? $options{'paystart_year'} - : $self->paystart_year; - + my $paystart_month = $options{'paystart_month'}; + my $paystart_year = $options{'paystart_year'}; $content{card_start} = "$paystart_month/$paystart_year" if $paystart_month && $paystart_year; - my $payissue = exists($options{'payissue'}) - ? $options{'payissue'} - : $self->payissue; + my $payissue = $options{'payissue'}; $content{issue_number} = $payissue if $payissue; if ( $self->_bop_recurring_billing( @@ -576,13 +587,8 @@ sub realtime_bop { ( $content{account_number}, $content{routing_code} ) = split('@', $options{payinfo}); $content{bank_name} = $options{payname}; - $content{bank_state} = exists($options{'paystate'}) - ? $options{'paystate'} - : $self->getfield('paystate'); - $content{account_type}= - (exists($options{'paytype'}) && $options{'paytype'}) - ? uc($options{'paytype'}) - : uc($self->getfield('paytype')) || 'PERSONAL CHECKING'; + $content{bank_state} = $options{'paystate'}; + $content{account_type}= uc($options{'paytype'}) || 'PERSONAL CHECKING'; $content{company} = $self->company if $self->company; @@ -805,7 +811,8 @@ sub realtime_bop { # Tokenize ### - $self->_tokenize_card($transaction,$options{'payinfo'},$log); + my $error = $self->_tokenize_card($transaction,$options{'cust_payby'},$log,'replace' => 1); + return $error if $error; ### # result handling @@ -1721,21 +1728,14 @@ successful, immediatly reverses the authorization). Returns the empty string if the authorization was sucessful, or an error message otherwise. -I +Option I should be passed, even if it's not yet been inserted. +Object will be tokenized if possible, but that change will not be +updated in database (must be inserted/replaced afterwards.) -I - -I specifies the expiration date for a credit card overriding the -value from the customer record or the payment record. Specified as yyyy-mm-dd - -#The additional options I, I, I, I, -#I are also available. Any of these options, -#if set, will override the value from the customer record. +Currently only succeeds for Business::OnlinePayment CC transactions. =cut -#Available methods are: I or I - #some false laziness w/realtime_bop and realtime_refund_bop, not enough to make #it worth merging but some useful small subs should be pulled out sub realtime_verify_bop { @@ -1756,6 +1756,10 @@ sub realtime_verify_bop { warn " $_ => $options{$_}\n" foreach keys %options; } + # set fields from passed cust_payby + return "No cust_payby" unless $options{'cust_payby'}; + $self->_bop_cust_payby_options(\%options); + ### # select a gateway ### @@ -1802,43 +1806,33 @@ sub realtime_verify_bop { if ( $options{method} eq 'CC' ) { $content{card_number} = $options{payinfo}; - $paydate = exists($options{'paydate'}) - ? $options{'paydate'} - : $self->paydate; + $paydate = $options{'paydate'}; $paydate =~ /^\d{2}(\d{2})[\/\-](\d+)[\/\-]\d+$/; $content{expiration} = "$2/$1"; $content{cvv2} = $options{'paycvv'} if length($options{'paycvv'}); - my $paystart_month = exists($options{'paystart_month'}) - ? $options{'paystart_month'} - : $self->paystart_month; - - my $paystart_year = exists($options{'paystart_year'}) - ? $options{'paystart_year'} - : $self->paystart_year; + my $paystart_month = $options{'paystart_month'}; + my $paystart_year = $options{'paystart_year'}; $content{card_start} = "$paystart_month/$paystart_year" if $paystart_month && $paystart_year; - my $payissue = exists($options{'payissue'}) - ? $options{'payissue'} - : $self->payissue; + my $payissue = $options{'payissue'}; $content{issue_number} = $payissue if $payissue; } elsif ( $options{method} eq 'ECHECK' ){ - - #nop for checks (though it shouldn't be called...) - + #cannot verify, move along (though it shouldn't be called...) + return ''; } else { - die "unknown method ". $options{method}; + return "unknown method ". $options{method}; } - } elsif ( $namespace eq 'Business::OnlineThirdPartyPayment' ) { - #move along + #cannot verify, move along + return ''; } else { - die "unknown namespace $namespace"; + return "unknown namespace $namespace"; } ### @@ -1847,6 +1841,7 @@ sub realtime_verify_bop { my $error; my $transaction; #need this back so we can do _tokenize_card + # don't mutex the customer here, because they might be uncommitted. and # this is only verification. it doesn't matter if they have other # unfinished verifications. @@ -1859,12 +1854,10 @@ sub realtime_verify_bop { 'payinfo' => $options{payinfo}, 'paymask' => $options{paymask}, 'paydate' => $paydate, - #'recurring_billing' => $content{recurring_billing}, 'pkgnum' => $options{'pkgnum'}, 'status' => 'new', 'gatewaynum' => $payment_gateway->gatewaynum || '', 'session_id' => $options{session_id} || '', - #'jobnum' => $options{depend_jobnum} || '', }; $cust_pay_pending->payunique( $options{payunique} ) if defined($options{payunique}) && length($options{payunique}); @@ -1905,12 +1898,9 @@ sub realtime_verify_bop { 'action' => 'Authorization Only', 'description' => $options{'description'}, 'amount' => '1.00', - #'invoice_number' => $options{'invnum'}, 'customer_id' => $self->custnum, %$bop_content, 'reference' => $cust_pay_pending->paypendingnum, #for now - 'callback_url' => $payment_gateway->gateway_callback_url, - 'cancel_url' => $payment_gateway->gateway_cancel_url, 'email' => $email, %content, #after ); @@ -2123,7 +2113,9 @@ sub realtime_verify_bop { # Tokenize ### - $self->_tokenize_card($transaction,$options{'payinfo'},$log); + #important that we not pass replace option here, + #because cust_payby->replace uses realtime_verify_bop! + $self->_tokenize_card($transaction,$options{'cust_payby'},$log); ### # result handling @@ -2135,6 +2127,144 @@ sub realtime_verify_bop { } +=item realtime_tokenize [ OPTION => VALUE ... ] + +If possible, runs a tokenize transaction. +In order to be possible, a credit card cust_payby record +must be passed and a Business::OnlinePayment gateway capable +of Tokenize transactions must be configured for this user. + +Returns the empty string if the authorization was sucessful +or was not possible (thus allowing this to be safely called with +non-tokenizable records/gateways, without having to perform separate tests), +or an error message otherwise. + +Option I should be passed, even if it's not yet been inserted. +Object will be tokenized if possible, but that change will not be +updated in database (must be inserted/replaced afterwards.) + +=cut + +sub realtime_tokenize { + my $self = shift; + + local($DEBUG) = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG; + my $log = FS::Log->new('FS::cust_main::Billing_Realtime::realtime_tokenize'); + + my %options = (); + if (ref($_[0]) eq 'HASH') { + %options = %{$_[0]}; + } else { + %options = @_; + } + + # set fields from passed cust_payby + return "No cust_payby" unless $options{'cust_payby'}; + $self->_bop_cust_payby_options(\%options); + return '' unless $options{method} eq 'CC'; + return '' if $options{payinfo} =~ /^99\d{14}$/; #already tokenized + + ### + # select a gateway + ### + + $options{'nofatal'} = 1; + my $payment_gateway = $self->_payment_gateway( \%options ); + return '' unless $payment_gateway; + my $namespace = $payment_gateway->gateway_namespace; + return '' unless $namespace eq 'Business::OnlinePayment'; + + eval "use $namespace"; + return $@ if $@; + + ### + # check for tokenize ability + ### + + # just create transaction now, so it loads gateway_module + my $transaction = new $namespace( $payment_gateway->gateway_module, + $self->_bop_options(\%options), + ); + + my %supported_actions = $transaction->info('supported_actions'); + return '' unless $supported_actions{'CC'} and grep(/^Tokenize$/,@{$supported_actions{'CC'}}); + + ### + # check for banned credit card/ACH + ### + + my $ban = FS::banned_pay->ban_search( + 'payby' => $bop_method2payby{'CC'}, + 'payinfo' => $options{payinfo}, + ); + return "Banned credit card" if $ban && $ban->bantype ne 'warn'; + + ### + # massage data + ### + + my $bop_content = $self->_bop_content(\%options); + return $bop_content unless ref($bop_content); + + my $paydate = ''; + my %content = (); + + $content{card_number} = $options{payinfo}; + $paydate = $options{'paydate'}; + $paydate =~ /^\d{2}(\d{2})[\/\-](\d+)[\/\-]\d+$/; + $content{expiration} = "$2/$1"; + + $content{cvv2} = $options{'paycvv'} + if length($options{'paycvv'}); + + my $paystart_month = $options{'paystart_month'}; + my $paystart_year = $options{'paystart_year'}; + + $content{card_start} = "$paystart_month/$paystart_year" + if $paystart_month && $paystart_year; + + my $payissue = $options{'payissue'}; + $content{issue_number} = $payissue if $payissue; + + ### + # run transaction + ### + + my $error; + + # no cust_pay_pending---this is not a financial transaction + + $transaction->content( + 'type' => 'CC', + $self->_bop_auth(\%options), + 'action' => 'Tokenize', + 'description' => $options{'description'}, + 'customer_id' => $self->custnum, + %$bop_content, + %content, #after + ); + + # no $BOP_TESTING handling for this + $transaction->test_transaction(1) + if $conf->exists('business-onlinepayment-test_transaction'); + $transaction->submit(); + + if ( $transaction->card_token() ) { # no is_success flag + + #important that we not pass replace option here, + #because cust_payby->replace uses realtime_tokenize! + $self->_tokenize_card($transaction,$options{'cust_payby'},$log); + + } else { + + $error = $transaction->error_message || 'Unknown error'; + + } + + return $error; + +} + =back =head1 BUGS diff --git a/FS/FS/cust_payby.pm b/FS/FS/cust_payby.pm index e4a1d193c..626fc9fe9 100644 --- a/FS/FS/cust_payby.pm +++ b/FS/FS/cust_payby.pm @@ -250,8 +250,11 @@ sub replace { if ( $conf->exists('business-onlinepayment-verification') ) { $error = $self->verify; - return $error if $error; + } else { + $error = $self->tokenize; } + return $error if $error; + } local $SIG{HUP} = 'IGNORE'; @@ -521,9 +524,12 @@ sub check { } - if ( ! $self->custpaybynum - && $conf->exists('business-onlinepayment-verification') ) { - $error = $self->verify; + if ( ! $self->custpaybynum ) { + if ($conf->exists('business-onlinepayment-verification')) { + $error = $self->verify; + } else { + $error = $self->tokenize; + } return $error if $error; } @@ -638,59 +644,48 @@ sub label { =item realtime_bop +Runs a L transaction on this card + =cut sub realtime_bop { my( $self, %opt ) = @_; - $opt{$_} = $self->$_() for qw( payinfo payname paydate ); - - if ( $self->locationnum ) { - my $cust_location = $self->cust_location; - $opt{$_} = $cust_location->$_() for qw( address1 address2 city state zip ); - } - $self->cust_main->realtime_bop({ - 'method' => FS::payby->payby2bop( $self->payby ), %opt, + 'cust_payby' => $self, }); } -=item verify +=item tokenize + +Runs a L transaction on this card =cut -sub verify { +sub tokenize { my $self = shift; return '' unless $self->payby =~ /^(CARD|DCRD)$/; - my %opt = (); + $self->cust_main->realtime_tokenize({ + 'cust_payby' => $self, + }); - # false laziness with check - my( $m, $y ); - if ( $self->paydate =~ /^(\d{1,2})[\/\-](\d{2}(\d{2})?)$/ ) { - ( $m, $y ) = ( $1, length($2) == 4 ? $2 : "20$2" ); - } elsif ( $self->paydate =~ /^19(\d{2})[\/\-](\d{1,2})[\/\-]\d+$/ ) { - ( $m, $y ) = ( $2, "19$1" ); - } elsif ( $self->paydate =~ /^(20)?(\d{2})[\/\-](\d{1,2})[\/\-]\d+$/ ) { - ( $m, $y ) = ( $3, "20$2" ); - } else { - return "Illegal expiration date: ". $self->paydate; - } - $m = sprintf('%02d',$m); - $opt{paydate} = "$y-$m-01"; +} - $opt{$_} = $self->$_() for qw( payinfo payname paycvv ); +=item verify - if ( $self->locationnum ) { - my $cust_location = $self->cust_location; - $opt{$_} = $cust_location->$_() for qw( address1 address2 city state zip ); - } +Runs a L transaction on this card + +=cut + +sub verify { + my $self = shift; + return '' unless $self->payby =~ /^(CARD|DCRD)$/; $self->cust_main->realtime_verify_bop({ - 'method' => FS::payby->payby2bop( $self->payby ), - %opt, + 'cust_payby' => $self, }); } diff --git a/FS/FS/log_context.pm b/FS/FS/log_context.pm index 1d98ac1c7..51aa79de5 100644 --- a/FS/FS/log_context.pm +++ b/FS/FS/log_context.pm @@ -9,6 +9,7 @@ my @contexts = ( qw( FS::cust_main::Billing::bill_and_collect FS::cust_main::Billing::bill FS::cust_main::Billing_Realtime::realtime_bop + FS::cust_main::Billing_Realtime::realtime_tokenize FS::cust_main::Billing_Realtime::realtime_verify_bop FS::pay_batch::import_from_gateway FS::part_pkg diff --git a/FS/FS/payinfo_Mixin.pm b/FS/FS/payinfo_Mixin.pm index 5f7ce3550..3a32ad5b2 100644 --- a/FS/FS/payinfo_Mixin.pm +++ b/FS/FS/payinfo_Mixin.pm @@ -67,8 +67,9 @@ sub payinfo { my($self,$payinfo) = @_; if ( defined($payinfo) ) { + $self->paymask($self->mask_payinfo) unless $self->payinfo =~ /^99\d{14}$/; #make sure old mask is set $self->setfield('payinfo', $payinfo); - $self->paymask($self->mask_payinfo) unless $payinfo =~ /^99\d{14}$/; #token + $self->paymask($self->mask_payinfo) unless $payinfo =~ /^99\d{14}$/; #remask unless tokenizing } else { $self->getfield('payinfo'); } diff --git a/httemplate/misc/process/payment.cgi b/httemplate/misc/process/payment.cgi index 852becb9d..74ca7348f 100644 --- a/httemplate/misc/process/payment.cgi +++ b/httemplate/misc/process/payment.cgi @@ -72,7 +72,7 @@ $cgi->param('discount_term') =~ /^(\d*)$/ or errorpage("illegal discount_term"); my $discount_term = $1; -my( $payinfo, $paycvv, $month, $year, $payname ); +my( $cust_payby, $payinfo, $paycvv, $month, $year, $payname ); my $paymask = ''; if ( (my $custpaybynum = scalar($cgi->param('custpaybynum'))) > 0 ) { @@ -80,10 +80,11 @@ if ( (my $custpaybynum = scalar($cgi->param('custpaybynum'))) > 0 ) { # use stored cust_payby info ## - my $cust_payby = qsearchs('cust_payby', { custnum => $custnum, + $cust_payby = qsearchs('cust_payby', { custnum => $custnum, custpaybynum => $custpaybynum, } ) or die "unknown custpaybynum $custpaybynum"; + # not needed for realtime_bop, but still needed for batch_card $payinfo = $cust_payby->payinfo; $paymask = $cust_payby->paymask; $paycvv = $cust_payby->paycvv; # pass it if we got it, running a transaction will clear it @@ -164,7 +165,7 @@ if ( (my $custpaybynum = scalar($cgi->param('custpaybynum'))) > 0 ) { die "unknown payby $payby"; } - # save first, for proper tokenization later + # save first, for proper tokenization if ( $cgi->param('save') ) { my %saveopt; @@ -181,6 +182,7 @@ if ( (my $custpaybynum = scalar($cgi->param('custpaybynum'))) > 0 ) { } my $error = $cust_main->save_cust_payby( + 'saved_cust_payby' => \$cust_payby, 'payment_payby' => $payby, 'auto' => scalar($cgi->param('auto')), 'weight' => scalar($cgi->param('weight')), @@ -220,6 +222,7 @@ if ( $cgi->param('batch') ) { } else { $error = $cust_main->realtime_bop( $FS::payby::payby2bop{$payby}, $amount, + 'cust_payby' => $cust_payby, # if defined, will override passed payinfo, etc 'quiet' => 1, 'manual' => 1, 'balance' => $balance, -- cgit v1.2.1 From 16498ac263bf5f3e90e23b866706ada768486f40 Mon Sep 17 00:00:00 2001 From: Jonathan Prykop Date: Sat, 29 Oct 2016 12:02:31 -0500 Subject: 71513: Card tokenization [checkpoint, not ready for backport] --- FS/FS/ClientAPI/MyAccount.pm | 2 +- FS/FS/cust_main.pm | 2 +- FS/FS/cust_main/Billing_Realtime.pm | 58 ++++++++++++++++++++++++------------- FS/FS/cust_payby.pm | 6 ++-- FS/FS/payinfo_Mixin.pm | 18 +++++++++--- httemplate/misc/process/payment.cgi | 2 +- 6 files changed, 58 insertions(+), 30 deletions(-) diff --git a/FS/FS/ClientAPI/MyAccount.pm b/FS/FS/ClientAPI/MyAccount.pm index 7c17ae39e..091d6ac68 100644 --- a/FS/FS/ClientAPI/MyAccount.pm +++ b/FS/FS/ClientAPI/MyAccount.pm @@ -1022,7 +1022,7 @@ sub validate_payment { validate($payinfo) or return { 'error' => gettext('invalid_card') }; # . ": ". $self->payinfo return { 'error' => gettext('unknown_card_type') } - if $payinfo !~ /^99\d{14}$/ && cardtype($payinfo) eq "Unknown"; + if !$cust_main->tokenized($payinfo) && cardtype($payinfo) eq "Unknown"; if ( length($p->{'paycvv'}) && $p->{'paycvv'} !~ /^\s*$/ ) { if ( cardtype($payinfo) eq 'American Express card' ) { diff --git a/FS/FS/cust_main.pm b/FS/FS/cust_main.pm index eac6c7575..a2c0ee8d4 100644 --- a/FS/FS/cust_main.pm +++ b/FS/FS/cust_main.pm @@ -2128,7 +2128,7 @@ sub check_payinfo_cardtype { my $payinfo = $self->payinfo; $payinfo =~ s/\D//g; - return '' if $payinfo =~ /^99\d{14}$/; #token + return '' if $self->tokenized($payinfo); #token my %bop_card_types = map { $_=>1 } values %{ card_types() }; my $cardtype = cardtype($payinfo); diff --git a/FS/FS/cust_main/Billing_Realtime.pm b/FS/FS/cust_main/Billing_Realtime.pm index 81b00aa72..09e2dfac3 100644 --- a/FS/FS/cust_main/Billing_Realtime.pm +++ b/FS/FS/cust_main/Billing_Realtime.pm @@ -376,14 +376,18 @@ sub _bop_content { } sub _tokenize_card { - my ($self,$transaction,$cust_payby,$log,%opt) = @_; + my ($self,$transaction,$options,$log,%opt) = @_; + # options is for entire process, so we can update payinfo + # opt is just for this call, only key is replace + my $cust_payby = $options->{'cust_payby'}; if ( $cust_payby and $transaction->can('card_token') and $transaction->card_token - and $cust_payby->payinfo !~ /^99\d{14}$/ #not already tokenized + and !$cust_payby->tokenized #not already tokenized ) { + $options->{'payinfo'} = $transaction->card_token; $cust_payby->payinfo($transaction->card_token); my $error; @@ -400,6 +404,18 @@ sub _tokenize_card { } +# only store payinfo in cust_pay/cust_pay_pending +# if it's a tokenized card or if processor requires card for void +sub _cust_pay_opts { + my ($self,$payby,$payinfo,$transaction) = @_; + ( (($payby eq 'CARD') && $self->tokenized($payinfo)) + || (($payby eq 'CARD') && $transaction->info('CC_void_requires_card')) + || (($payby eq 'CHEK') && $transaction->info('ECHECK_void_requires_account')) + ) + ? ('payinfo' => $payinfo) + : (); +} + my %bop_method2payby = ( 'CC' => 'CARD', 'ECHECK' => 'CHEK', @@ -665,12 +681,15 @@ sub realtime_bop { #okay, good to go, if we're a duplicate, cust_pay_pending will kick us out + my $transaction = new $namespace( $payment_gateway->gateway_module, + $self->_bop_options(\%options), + ); + my $cust_pay_pending = new FS::cust_pay_pending { 'custnum' => $self->custnum, 'paid' => $options{amount}, '_date' => '', 'payby' => $bop_method2payby{$options{method}}, - 'payinfo' => $options{payinfo}, 'paymask' => $options{paymask}, 'paydate' => $paydate, 'recurring_billing' => $content{recurring_billing}, @@ -679,6 +698,7 @@ sub realtime_bop { 'gatewaynum' => $payment_gateway->gatewaynum || '', 'session_id' => $options{session_id} || '', 'jobnum' => $options{depend_jobnum} || '', + $self->_cust_pay_opts($options{payinfo},$transaction), }; $cust_pay_pending->payunique( $options{payunique} ) if defined($options{payunique}) && length($options{payunique}); @@ -695,10 +715,6 @@ sub realtime_bop { my( $action1, $action2 ) = split( /\s*\,\s*/, $payment_gateway->gateway_action ); - my $transaction = new $namespace( $payment_gateway->gateway_module, - $self->_bop_options(\%options), - ); - $transaction->content( 'type' => $options{method}, $self->_bop_auth(\%options), @@ -811,7 +827,7 @@ sub realtime_bop { # Tokenize ### - my $error = $self->_tokenize_card($transaction,$options{'cust_payby'},$log,'replace' => 1); + my $error = $self->_tokenize_card($transaction,\%options,$log,'replace' => 1); return $error if $error; ### @@ -849,9 +865,7 @@ sub fake_bop { 'paid' => $options{amount}, '_date' => '', 'payby' => $bop_method2payby{$options{method}}, - #'payinfo' => $payinfo, 'payinfo' => '4111111111111111', - #'paydate' => $paydate, 'paydate' => '2012-05-01', 'processor' => 'FakeProcessor', 'auth' => '54', @@ -925,7 +939,6 @@ sub _realtime_bop_result { 'paid' => $cust_pay_pending->paid, '_date' => '', 'payby' => $cust_pay_pending->payby, - 'payinfo' => $options{'payinfo'}, 'paymask' => $options{'paymask'} || $cust_pay_pending->paymask, 'paydate' => $cust_pay_pending->paydate, 'pkgnum' => $cust_pay_pending->pkgnum, @@ -935,6 +948,7 @@ sub _realtime_bop_result { 'auth' => $transaction->authorization, 'order_number' => $order_number || '', 'no_auto_apply' => $options{'no_auto_apply'} ? 'Y' : '', + $self->_cust_pay_opts($options{payinfo},$transaction), } ); #doesn't hurt to know, even though the dup check is in cust_pay_pending now $cust_pay->payunique( $options{payunique} ) @@ -1840,7 +1854,9 @@ sub realtime_verify_bop { ### my $error; - my $transaction; #need this back so we can do _tokenize_card + my $transaction = new $namespace( $payment_gateway->gateway_module, + $self->_bop_options(\%options), + ); #need this back so we can do _tokenize_card # don't mutex the customer here, because they might be uncommitted. and # this is only verification. it doesn't matter if they have other @@ -1851,13 +1867,13 @@ sub realtime_verify_bop { 'paid' => '1.00', '_date' => '', 'payby' => $bop_method2payby{'CC'}, - 'payinfo' => $options{payinfo}, 'paymask' => $options{paymask}, 'paydate' => $paydate, 'pkgnum' => $options{'pkgnum'}, 'status' => 'new', 'gatewaynum' => $payment_gateway->gatewaynum || '', 'session_id' => $options{session_id} || '', + $self->_cust_pay_opts($options{payinfo},$transaction), }; $cust_pay_pending->payunique( $options{payunique} ) if defined($options{payunique}) && length($options{payunique}); @@ -1888,10 +1904,6 @@ sub realtime_verify_bop { if $DEBUG > 1; warn Dumper($cust_pay_pending) if $DEBUG > 2; - $transaction = new $namespace( $payment_gateway->gateway_module, - $self->_bop_options(\%options), - ); - $transaction->content( 'type' => 'CC', $self->_bop_auth(\%options), @@ -2115,7 +2127,7 @@ sub realtime_verify_bop { #important that we not pass replace option here, #because cust_payby->replace uses realtime_verify_bop! - $self->_tokenize_card($transaction,$options{'cust_payby'},$log); + $self->_tokenize_card($transaction,\%options,$log); ### # result handling @@ -2162,7 +2174,7 @@ sub realtime_tokenize { return "No cust_payby" unless $options{'cust_payby'}; $self->_bop_cust_payby_options(\%options); return '' unless $options{method} eq 'CC'; - return '' if $options{payinfo} =~ /^99\d{14}$/; #already tokenized + return '' if $self->tokenized($options{payinfo}); #already tokenized ### # select a gateway @@ -2253,7 +2265,7 @@ sub realtime_tokenize { #important that we not pass replace option here, #because cust_payby->replace uses realtime_tokenize! - $self->_tokenize_card($transaction,$options{'cust_payby'},$log); + $self->_tokenize_card($transaction,\%options,$log); } else { @@ -2265,6 +2277,12 @@ sub realtime_tokenize { } +sub tokenized { + my $this = shift; + my $payinfo = shift; + $payinfo =~ /^99\d{14}$/; +} + =back =head1 BUGS diff --git a/FS/FS/cust_payby.pm b/FS/FS/cust_payby.pm index 626fc9fe9..53608cf64 100644 --- a/FS/FS/cust_payby.pm +++ b/FS/FS/cust_payby.pm @@ -276,7 +276,7 @@ sub replace { if ( $self->payby =~ /^(CARD|CHEK)$/ && ( ( $self->get('payinfo') ne $old->get('payinfo') - && $self->get('payinfo') !~ /^99\d{14}$/ + && !$self->tokenized ) || grep { $self->get($_) ne $old->get($_) } qw(paydate payname) ) @@ -357,7 +357,7 @@ sub check { or return gettext('invalid_card'); # . ": ". $self->payinfo; my $cardtype = cardtype($payinfo); - $cardtype = 'Tokenized' if $self->payinfo =~ /^99\d{14}$/; #token + $cardtype = 'Tokenized' if $self->tokenized; #token return gettext('unknown_card_type') if $cardtype eq "Unknown"; @@ -546,7 +546,7 @@ sub check_payinfo_cardtype { my $payinfo = $self->payinfo; $payinfo =~ s/\D//g; - if ( $payinfo =~ /^99\d{14}$/ ) { + if ( $self->tokenized($payinfo) ) { $self->set('paycardtype', 'Tokenized'); return ''; } diff --git a/FS/FS/payinfo_Mixin.pm b/FS/FS/payinfo_Mixin.pm index 3a32ad5b2..a0a2cbcc9 100644 --- a/FS/FS/payinfo_Mixin.pm +++ b/FS/FS/payinfo_Mixin.pm @@ -67,9 +67,9 @@ sub payinfo { my($self,$payinfo) = @_; if ( defined($payinfo) ) { - $self->paymask($self->mask_payinfo) unless $self->payinfo =~ /^99\d{14}$/; #make sure old mask is set + $self->paymask($self->mask_payinfo) unless $self->tokenized; #make sure old mask is set $self->setfield('payinfo', $payinfo); - $self->paymask($self->mask_payinfo) unless $payinfo =~ /^99\d{14}$/; #remask unless tokenizing + $self->paymask($self->mask_payinfo) unless $self->tokenized($payinfo); #remask unless tokenizing } else { $self->getfield('payinfo'); } @@ -130,7 +130,7 @@ sub mask_payinfo { # Check to see if it's encrypted... if ( ref($self) && $self->is_encrypted($payinfo) ) { return 'N/A'; - } elsif ( $payinfo =~ /^99\d{14}$/ || $payinfo eq 'N/A' ) { #token + } elsif ( $self->tokenized($payinfo) || $payinfo eq 'N/A' ) { #token return 'N/A (tokenized)'; #? } else { # if not, mask it... @@ -198,7 +198,7 @@ sub payinfo_check { my $payinfo = $self->payinfo; my $cardtype = cardtype($payinfo); - $cardtype = 'Tokenized' if $payinfo =~ /^99\d{14}$/; + $cardtype = 'Tokenized' if $self->tokenized; $self->set('paycardtype', $cardtype); if ( $ignore_masked_payinfo and $self->mask_payinfo eq $self->payinfo ) { @@ -233,6 +233,7 @@ sub payinfo_check { } } + return ''; } =item payby_payinfo_pretty [ LOCALE ] @@ -453,6 +454,15 @@ sub process_set_cardtype { } } +sub tokenized { + my $self = shift; + my $payinfo = scalar(@_) ? shift : $self->payinfo; + ## or just $self->cust_main->tokenized($payinfo) ?? + ## everything that currently uses this mixin is linked to cust_main, + ## but just in case, false laziness w/ FS::cust_main::Billing_Realtime + $payinfo =~ /^99\d{14}$/; +} + =back =head1 BUGS diff --git a/httemplate/misc/process/payment.cgi b/httemplate/misc/process/payment.cgi index 74ca7348f..84687f019 100644 --- a/httemplate/misc/process/payment.cgi +++ b/httemplate/misc/process/payment.cgi @@ -135,7 +135,7 @@ if ( (my $custpaybynum = scalar($cgi->param('custpaybynum'))) > 0 ) { validate($payinfo) or errorpage(gettext('invalid_card')); - unless ( $payinfo =~ /^99\d{14}$/ ) { #token + unless ( $cust_main->tokenized($payinfo) ) { #token my $cardtype = cardtype($payinfo); -- cgit v1.2.1 From 1185daff43389fe53ad43e84962329a63d31523e Mon Sep 17 00:00:00 2001 From: Jonathan Prykop Date: Sat, 5 Nov 2016 01:20:35 -0500 Subject: 71513: Card tokenization [bug fixes to previous checkpoint] --- FS/FS/cust_main/Billing_Realtime.pm | 115 +++++++++++++++++++----------------- FS/FS/payinfo_Mixin.pm | 13 ++-- httemplate/misc/process/payment.cgi | 5 ++ 3 files changed, 76 insertions(+), 57 deletions(-) diff --git a/FS/FS/cust_main/Billing_Realtime.pm b/FS/FS/cust_main/Billing_Realtime.pm index 09e2dfac3..407b9ca14 100644 --- a/FS/FS/cust_main/Billing_Realtime.pm +++ b/FS/FS/cust_main/Billing_Realtime.pm @@ -376,44 +376,16 @@ sub _bop_content { } sub _tokenize_card { - my ($self,$transaction,$options,$log,%opt) = @_; - # options is for entire process, so we can update payinfo - # opt is just for this call, only key is replace - - my $cust_payby = $options->{'cust_payby'}; - if ( $cust_payby - and $transaction->can('card_token') + my ($self,$transaction,$options) = @_; + if ( $transaction->can('card_token') and $transaction->card_token - and !$cust_payby->tokenized #not already tokenized + and !$self->tokenized($options->{'payinfo'}) ) { - - $options->{'payinfo'} = $transaction->card_token; - $cust_payby->payinfo($transaction->card_token); - - my $error; - $error = $cust_payby->replace if $opt{'replace'}; - if ( $error ) { - $log->error('Error storing token for cust '.$self->custnum.', cust_payby '.$cust_payby->custpaybynum.': '.$error); - return $error; - } else { - $log->debug('Tokenized card for cust '.$self->custnum.', cust_payby '.$cust_payby->custpaybynum); - return ''; - } - + $options->{'payinfo'} = $transaction->card_token; #for creating cust_pay + $options->{'cust_payby'}->payinfo($transaction->card_token) if $options->{'cust_payby'}; + return $transaction->card_token; } - -} - -# only store payinfo in cust_pay/cust_pay_pending -# if it's a tokenized card or if processor requires card for void -sub _cust_pay_opts { - my ($self,$payby,$payinfo,$transaction) = @_; - ( (($payby eq 'CARD') && $self->tokenized($payinfo)) - || (($payby eq 'CARD') && $transaction->info('CC_void_requires_card')) - || (($payby eq 'CHEK') && $transaction->info('ECHECK_void_requires_account')) - ) - ? ('payinfo' => $payinfo) - : (); + return ''; } my %bop_method2payby = ( @@ -681,15 +653,12 @@ sub realtime_bop { #okay, good to go, if we're a duplicate, cust_pay_pending will kick us out - my $transaction = new $namespace( $payment_gateway->gateway_module, - $self->_bop_options(\%options), - ); - my $cust_pay_pending = new FS::cust_pay_pending { 'custnum' => $self->custnum, 'paid' => $options{amount}, '_date' => '', 'payby' => $bop_method2payby{$options{method}}, + 'payinfo' => $options{payinfo}, 'paymask' => $options{paymask}, 'paydate' => $paydate, 'recurring_billing' => $content{recurring_billing}, @@ -698,7 +667,6 @@ sub realtime_bop { 'gatewaynum' => $payment_gateway->gatewaynum || '', 'session_id' => $options{session_id} || '', 'jobnum' => $options{depend_jobnum} || '', - $self->_cust_pay_opts($options{payinfo},$transaction), }; $cust_pay_pending->payunique( $options{payunique} ) if defined($options{payunique}) && length($options{payunique}); @@ -715,6 +683,10 @@ sub realtime_bop { my( $action1, $action2 ) = split( /\s*\,\s*/, $payment_gateway->gateway_action ); + my $transaction = new $namespace( $payment_gateway->gateway_module, + $self->_bop_options(\%options), + ); + $transaction->content( 'type' => $options{method}, $self->_bop_auth(\%options), @@ -819,6 +791,8 @@ sub realtime_bop { ) { my $error = $self->remove_cvv_from_cust_payby($options{payinfo}); if ( $error ) { + $log->critical('Error removing cvv for cust '.$self->custnum.': '.$error); + #not returning error, should at least attempt to handle results of an otherwise valid transaction warn "WARNING: error removing cvv: $error\n"; } } @@ -827,8 +801,15 @@ sub realtime_bop { # Tokenize ### - my $error = $self->_tokenize_card($transaction,\%options,$log,'replace' => 1); - return $error if $error; + if (my $card_token = $self->_tokenize_card($transaction,\%options)) { + # cpp will be replaced in _realtime_bop_result + $cust_pay_pending->payinfo($card_token); + if ($options{'cust_payby'} and my $error = $options{'cust_payby'}->replace) { + $log->critical('Error storing token for cust '.$self->custnum.', cust_payby '.$options{'cust_payby'}->custpaybynum.': '.$error); + #not returning error, should at least attempt to handle results of an otherwise valid transaction + #this leaves real card number in cust_payby, but can't do much else if cust_payby won't replace + } + } ### # result handling @@ -925,7 +906,7 @@ sub _realtime_bop_result { or return "no payment gateway in arguments to _realtime_bop_result"; $cust_pay_pending->status($transaction->is_success() ? 'captured' : 'declined'); - my $cpp_captured_err = $cust_pay_pending->replace; + my $cpp_captured_err = $cust_pay_pending->replace; #also saves tokenization return $cpp_captured_err if $cpp_captured_err; if ( $transaction->is_success() ) { @@ -939,6 +920,7 @@ sub _realtime_bop_result { 'paid' => $cust_pay_pending->paid, '_date' => '', 'payby' => $cust_pay_pending->payby, + 'payinfo' => $options{'payinfo'}, 'paymask' => $options{'paymask'} || $cust_pay_pending->paymask, 'paydate' => $cust_pay_pending->paydate, 'pkgnum' => $cust_pay_pending->pkgnum, @@ -948,7 +930,6 @@ sub _realtime_bop_result { 'auth' => $transaction->authorization, 'order_number' => $order_number || '', 'no_auto_apply' => $options{'no_auto_apply'} ? 'Y' : '', - $self->_cust_pay_opts($options{payinfo},$transaction), } ); #doesn't hurt to know, even though the dup check is in cust_pay_pending now $cust_pay->payunique( $options{payunique} ) @@ -1854,9 +1835,7 @@ sub realtime_verify_bop { ### my $error; - my $transaction = new $namespace( $payment_gateway->gateway_module, - $self->_bop_options(\%options), - ); #need this back so we can do _tokenize_card + my $transaction; #need this back so we can do _tokenize_card # don't mutex the customer here, because they might be uncommitted. and # this is only verification. it doesn't matter if they have other @@ -1867,13 +1846,13 @@ sub realtime_verify_bop { 'paid' => '1.00', '_date' => '', 'payby' => $bop_method2payby{'CC'}, + 'payinfo' => $options{payinfo}, 'paymask' => $options{paymask}, 'paydate' => $paydate, 'pkgnum' => $options{'pkgnum'}, 'status' => 'new', 'gatewaynum' => $payment_gateway->gatewaynum || '', 'session_id' => $options{session_id} || '', - $self->_cust_pay_opts($options{payinfo},$transaction), }; $cust_pay_pending->payunique( $options{payunique} ) if defined($options{payunique}) && length($options{payunique}); @@ -1904,6 +1883,10 @@ sub realtime_verify_bop { if $DEBUG > 1; warn Dumper($cust_pay_pending) if $DEBUG > 2; + $transaction = new $namespace( $payment_gateway->gateway_module, + $self->_bop_options(\%options), + ); + $transaction->content( 'type' => 'CC', $self->_bop_auth(\%options), @@ -2121,13 +2104,23 @@ sub realtime_verify_bop { } } + ### + # remove paycvv here? need to find out if a reversed auth + # counts as an initial transaction for paycvv retention requirements + ### + ### # Tokenize ### - #important that we not pass replace option here, + #important that we not replace cust_payby here, #because cust_payby->replace uses realtime_verify_bop! - $self->_tokenize_card($transaction,\%options,$log); + if (my $card_token = $self->_tokenize_card($transaction,\%options)) { + $cust_pay_pending->payinfo($card_token); + my $cpp_token_err = $cust_pay_pending->replace; + #this leaves real card number in cust_payby, but can't do much else if cust_payby won't replace + return $cpp_token_err if $cpp_token_err; + } ### # result handling @@ -2263,9 +2256,16 @@ sub realtime_tokenize { if ( $transaction->card_token() ) { # no is_success flag - #important that we not pass replace option here, + # realtime_tokenize should not clear paycvv at this time. it might be + # needed for the first transaction, and a tokenize isn't actually a + # transaction that hits the gateway. at some point in the future, card + # fortress should take on the "store paycvv until first transaction" + # functionality and we should fix this in freeside, but i that's a bigger + # project for another time. + + #important that we not replace cust_payby here, #because cust_payby->replace uses realtime_tokenize! - $self->_tokenize_card($transaction,\%options,$log); + $self->_tokenize_card($transaction,\%options); } else { @@ -2277,10 +2277,19 @@ sub realtime_tokenize { } + +=item tokenized PAYINFO + +Convenience wrapper for L + +PAYINFO is required + +=cut + sub tokenized { my $this = shift; my $payinfo = shift; - $payinfo =~ /^99\d{14}$/; + FS::cust_pay->tokenized($payinfo); } =back diff --git a/FS/FS/payinfo_Mixin.pm b/FS/FS/payinfo_Mixin.pm index a0a2cbcc9..69828349e 100644 --- a/FS/FS/payinfo_Mixin.pm +++ b/FS/FS/payinfo_Mixin.pm @@ -67,7 +67,7 @@ sub payinfo { my($self,$payinfo) = @_; if ( defined($payinfo) ) { - $self->paymask($self->mask_payinfo) unless $self->tokenized; #make sure old mask is set + $self->paymask($self->mask_payinfo) unless $self->paymask || $self->tokenized; #make sure old mask is set $self->setfield('payinfo', $payinfo); $self->paymask($self->mask_payinfo) unless $self->tokenized($payinfo); #remask unless tokenizing } else { @@ -454,12 +454,17 @@ sub process_set_cardtype { } } +=item tokenized [ PAYINFO ] + +Returns true if object payinfo is tokenized + +Optionally, an arbitrary payby and payinfo can be passed. + +=cut + sub tokenized { my $self = shift; my $payinfo = scalar(@_) ? shift : $self->payinfo; - ## or just $self->cust_main->tokenized($payinfo) ?? - ## everything that currently uses this mixin is linked to cust_main, - ## but just in case, false laziness w/ FS::cust_main::Billing_Realtime $payinfo =~ /^99\d{14}$/; } diff --git a/httemplate/misc/process/payment.cgi b/httemplate/misc/process/payment.cgi index 84687f019..1532605d4 100644 --- a/httemplate/misc/process/payment.cgi +++ b/httemplate/misc/process/payment.cgi @@ -193,6 +193,11 @@ if ( (my $custpaybynum = scalar($cgi->param('custpaybynum'))) > 0 ) { errorpage("error saving info, payment not processed: $error") if $error; + + } elsif ( $payby eq 'CARD' ) { # not saving + + $paymask = FS::payinfo_Mixin->mask_payinfo('CARD',$payinfo); # for untokenized but tokenizable payinfo + } } -- cgit v1.2.1 From 9026d337602fd1f9c463de2a46db4f27c0d429f9 Mon Sep 17 00:00:00 2001 From: Jonathan Prykop Date: Tue, 8 Nov 2016 02:48:09 -0600 Subject: 71513: Card tokenization [tokenize for unsaved cards, bug fix to saving from cust_main] --- FS/FS/cust_main/Billing_Realtime.pm | 57 +++++++++++++++++++++++++++++-------- FS/FS/payinfo_Mixin.pm | 2 +- 2 files changed, 46 insertions(+), 13 deletions(-) diff --git a/FS/FS/cust_main/Billing_Realtime.pm b/FS/FS/cust_main/Billing_Realtime.pm index 407b9ca14..e7226fe27 100644 --- a/FS/FS/cust_main/Billing_Realtime.pm +++ b/FS/FS/cust_main/Billing_Realtime.pm @@ -375,13 +375,14 @@ sub _bop_content { \%content; } +# updates payinfo and cust_payby options with token from transaction sub _tokenize_card { my ($self,$transaction,$options) = @_; if ( $transaction->can('card_token') and $transaction->card_token and !$self->tokenized($options->{'payinfo'}) ) { - $options->{'payinfo'} = $transaction->card_token; #for creating cust_pay + $options->{'payinfo'} = $transaction->card_token; $options->{'cust_payby'}->payinfo($transaction->card_token) if $options->{'cust_payby'}; return $transaction->card_token; } @@ -418,6 +419,19 @@ sub realtime_bop { # set fields from passed cust_payby $self->_bop_cust_payby_options(\%options); + # possibly run a separate transaction to tokenize card number, + # so that we never store tokenized card info in cust_pay_pending + if (!$self->tokenized($options{'payinfo'})) { + my $token_error = $self->realtime_tokenize(\%options); + return $token_error if $token_error; + # in theory, all cust_payby will be tokenized during original save, + # so we shouldn't get here with opt cust_payby...but just in case... + if ($options{'cust_payby'}) { + $token_error = $options{'cust_payby'}->replace; + return $token_error if $token_error; + } + } + ### # optional credit card surcharge ### @@ -801,6 +815,8 @@ sub realtime_bop { # Tokenize ### + # This block will only run if the B::OP module supports card_token but not the Tokenize transaction; + # if that never happens, we should get rid of it (as it has the potential to store real card numbers on error) if (my $card_token = $self->_tokenize_card($transaction,\%options)) { # cpp will be replaced in _realtime_bop_result $cust_pay_pending->payinfo($card_token); @@ -906,7 +922,7 @@ sub _realtime_bop_result { or return "no payment gateway in arguments to _realtime_bop_result"; $cust_pay_pending->status($transaction->is_success() ? 'captured' : 'declined'); - my $cpp_captured_err = $cust_pay_pending->replace; #also saves tokenization + my $cpp_captured_err = $cust_pay_pending->replace; #also saves post-transaction tokenization, if that happens return $cpp_captured_err if $cpp_captured_err; if ( $transaction->is_success() ) { @@ -1755,6 +1771,15 @@ sub realtime_verify_bop { return "No cust_payby" unless $options{'cust_payby'}; $self->_bop_cust_payby_options(\%options); + # possibly run a separate transaction to tokenize card number, + # so that we never store tokenized card info in cust_pay_pending + if (!$self->tokenized($options{'payinfo'})) { + my $token_error = $self->realtime_tokenize(\%options); + return $token_error if $token_error; + #important that we not replace cust_payby here, + #because cust_payby->replace uses realtime_verify_bop! + } + ### # select a gateway ### @@ -2113,13 +2138,15 @@ sub realtime_verify_bop { # Tokenize ### - #important that we not replace cust_payby here, - #because cust_payby->replace uses realtime_verify_bop! + # This block will only run if the B::OP module supports card_token but not the Tokenize transaction; + # if that never happens, we should get rid of it (as it has the potential to store real card numbers on error) if (my $card_token = $self->_tokenize_card($transaction,\%options)) { $cust_pay_pending->payinfo($card_token); my $cpp_token_err = $cust_pay_pending->replace; - #this leaves real card number in cust_payby, but can't do much else if cust_payby won't replace + #this leaves real card number in cust_pay_pending, but can't do much else if cpp won't replace return $cpp_token_err if $cpp_token_err; + #important that we not replace cust_payby here, + #because cust_payby->replace uses realtime_verify_bop! } ### @@ -2134,20 +2161,25 @@ sub realtime_verify_bop { =item realtime_tokenize [ OPTION => VALUE ... ] -If possible, runs a tokenize transaction. +If possible and necessary, runs a tokenize transaction. In order to be possible, a credit card cust_payby record must be passed and a Business::OnlinePayment gateway capable of Tokenize transactions must be configured for this user. +Is only necessary if payinfo is not yet tokenized. Returns the empty string if the authorization was sucessful -or was not possible (thus allowing this to be safely called with +or was not possible/necessary (thus allowing this to be safely called with non-tokenizable records/gateways, without having to perform separate tests), or an error message otherwise. -Option I should be passed, even if it's not yet been inserted. +Option I may be passed, even if it's not yet been inserted. Object will be tokenized if possible, but that change will not be updated in database (must be inserted/replaced afterwards.) +Otherwise, options I, I and other cust_payby fields +may be passed. If options are passed as a hashref, I +will be updated as appropriate in the passed hashref. + =cut sub realtime_tokenize { @@ -2157,14 +2189,16 @@ sub realtime_tokenize { my $log = FS::Log->new('FS::cust_main::Billing_Realtime::realtime_tokenize'); my %options = (); + my $outoptions; #for returning cust_payby/payinfo if (ref($_[0]) eq 'HASH') { %options = %{$_[0]}; + $outoptions = $_[0]; } else { %options = @_; + $outoptions = \%options; } # set fields from passed cust_payby - return "No cust_payby" unless $options{'cust_payby'}; $self->_bop_cust_payby_options(\%options); return '' unless $options{method} eq 'CC'; return '' if $self->tokenized($options{payinfo}); #already tokenized @@ -2186,7 +2220,6 @@ sub realtime_tokenize { # check for tokenize ability ### - # just create transaction now, so it loads gateway_module my $transaction = new $namespace( $payment_gateway->gateway_module, $self->_bop_options(\%options), ); @@ -2265,11 +2298,11 @@ sub realtime_tokenize { #important that we not replace cust_payby here, #because cust_payby->replace uses realtime_tokenize! - $self->_tokenize_card($transaction,\%options); + $self->_tokenize_card($transaction,$outoptions); } else { - $error = $transaction->error_message || 'Unknown error'; + $error = $transaction->error_message || 'Unknown error when tokenizing card'; } diff --git a/FS/FS/payinfo_Mixin.pm b/FS/FS/payinfo_Mixin.pm index 69828349e..dfcce2ffc 100644 --- a/FS/FS/payinfo_Mixin.pm +++ b/FS/FS/payinfo_Mixin.pm @@ -67,7 +67,7 @@ sub payinfo { my($self,$payinfo) = @_; if ( defined($payinfo) ) { - $self->paymask($self->mask_payinfo) unless $self->paymask || $self->tokenized; #make sure old mask is set + $self->paymask($self->mask_payinfo) unless $self->getfield('paymask') || $self->tokenized; #make sure old mask is set $self->setfield('payinfo', $payinfo); $self->paymask($self->mask_payinfo) unless $self->tokenized($payinfo); #remask unless tokenizing } else { -- cgit v1.2.1 From 5f2c60fc31d06443fb43b30f62a4d2789aad55cd Mon Sep 17 00:00:00 2001 From: Jonathan Prykop Date: Fri, 11 Nov 2016 21:02:01 -0600 Subject: 71513: Card tokenization [removed selfservice-payment_gateway] --- FS/FS/ClientAPI/MyAccount.pm | 20 ++++------- FS/FS/ClientAPI/Signup.pm | 71 ++++++++++++------------------------- FS/FS/Conf.pm | 4 +-- FS/FS/agent.pm | 16 ++------- FS/FS/cust_main/Billing_Realtime.pm | 8 ----- 5 files changed, 34 insertions(+), 85 deletions(-) diff --git a/FS/FS/ClientAPI/MyAccount.pm b/FS/FS/ClientAPI/MyAccount.pm index 091d6ac68..4a878f8d2 100644 --- a/FS/FS/ClientAPI/MyAccount.pm +++ b/FS/FS/ClientAPI/MyAccount.pm @@ -401,20 +401,12 @@ sub payment_gateway { my $conf = new FS::Conf; my $cust_main = shift; my $cust_payby = shift; - my $gatewaynum = $conf->config('selfservice-payment_gateway'); - if ( $gatewaynum ) { - my $pg = qsearchs('payment_gateway', { gatewaynum => $gatewaynum }); - die "configured gatewaynum $gatewaynum not found!" if !$pg; - return $pg; - } - else { - return '' if ! FS::payby->realtime($cust_payby); - my $pg = $cust_main->agent->payment_gateway( - 'method' => FS::payby->payby2bop($cust_payby), - 'nofatal' => 1 - ); - return $pg; - } + return '' if ! FS::payby->realtime($cust_payby); + my $pg = $cust_main->agent->payment_gateway( + 'method' => FS::payby->payby2bop($cust_payby), + 'nofatal' => 1 + ); + return $pg; } sub access_info { diff --git a/FS/FS/ClientAPI/Signup.pm b/FS/FS/ClientAPI/Signup.pm index e11a47a06..7fad7b308 100644 --- a/FS/FS/ClientAPI/Signup.pm +++ b/FS/FS/ClientAPI/Signup.pm @@ -344,20 +344,11 @@ sub signup_info { my @paybys = @{ $signup_info->{'payby'} }; $signup_info->{'hide_payment_fields'} = []; - my $gatewaynum = $conf->config('selfservice-payment_gateway'); - my $force_gateway; - if ( $gatewaynum ) { - $force_gateway = qsearchs('payment_gateway', { gatewaynum => $gatewaynum }); - warn "using forced gateway #$gatewaynum - " . - $force_gateway->gateway_username . '@' . $force_gateway->gateway_module - if $DEBUG > 1; - die "configured gatewaynum $gatewaynum not found!" if !$force_gateway; - } foreach my $payby (@paybys) { warn "$me checking $payby payment fields\n" if $DEBUG > 1; my $hide = 0; if ( FS::payby->realtime($payby) ) { - my $gateway = $force_gateway || + my $gateway = $agent->payment_gateway( 'method' => FS::payby->payby2bop($payby), 'nofatal' => 1, ); @@ -627,17 +618,9 @@ sub new_customer { return { 'error' => "Unknown reseller" } unless $agent; - my $gw; - my $gatewaynum = $conf->config('selfservice-payment_gateway'); - if ( $gatewaynum ) { - $gw = qsearchs('payment_gateway', { gatewaynum => $gatewaynum }); - die "configured gatewaynum $gatewaynum not found!" if !$gw; - } - else { - $gw = $agent->payment_gateway( 'method' => FS::payby->payby2bop($payby), - 'nofatal' => 1, + my $gw = $agent->payment_gateway( 'method' => FS::payby->payby2bop($payby), + 'nofatal' => 1, ); - } $cust_main->payby('BILL') # MCRD better? no, that's for something else if $gw && $gw->gateway_namespace eq 'Business::OnlineThirdPartyPayment'; @@ -1120,36 +1103,28 @@ sub capture_payment { my $conf = new FS::Conf; - my $payment_gateway; - if ( my $gwnum = $conf->config('selfservice-payment_gateway') ) { - $payment_gateway = qsearchs('payment_gateway', { 'gatewaynum' => $gwnum }) - or die "configured gatewaynum $gwnum not found!"; - } - else { - my $url = $packet->{url}; - - $payment_gateway = qsearchs('payment_gateway', + my $url = $packet->{url}; + my $payment_gateway = $payment_gateway = qsearchs('payment_gateway', { 'gateway_callback_url' => popurl(0, $url) } ); - if (!$payment_gateway) { - - my ( $processor, $login, $password, $action, @bop_options ) = - $conf->config('business-onlinepayment'); - $action ||= 'normal authorization'; - pop @bop_options if scalar(@bop_options) % 2 && $bop_options[-1] =~ /^\s*$/; - die "No real-time processor is enabled - ". - "did you set the business-onlinepayment configuration value?\n" - unless $processor; - - $payment_gateway = new FS::payment_gateway( { - gateway_namespace => $conf->config('business-onlinepayment-namespace'), - gateway_module => $processor, - gateway_username => $login, - gateway_password => $password, - gateway_action => $action, - options => [ ( @bop_options ) ], - }); - } + if (!$payment_gateway) { + + my ( $processor, $login, $password, $action, @bop_options ) = + $conf->config('business-onlinepayment'); + $action ||= 'normal authorization'; + pop @bop_options if scalar(@bop_options) % 2 && $bop_options[-1] =~ /^\s*$/; + die "No real-time processor is enabled - ". + "did you set the business-onlinepayment configuration value?\n" + unless $processor; + + $payment_gateway = new FS::payment_gateway( { + gateway_namespace => $conf->config('business-onlinepayment-namespace'), + gateway_module => $processor, + gateway_username => $login, + gateway_password => $password, + gateway_action => $action, + options => [ ( @bop_options ) ], + }); } die "No real-time third party processor is enabled - ". diff --git a/FS/FS/Conf.pm b/FS/FS/Conf.pm index 4c87f9396..ec317ba91 100644 --- a/FS/FS/Conf.pm +++ b/FS/FS/Conf.pm @@ -2208,8 +2208,8 @@ and customer address. Include units.', { 'key' => 'selfservice-payment_gateway', - 'section' => 'self-service', - 'description' => 'Force the use of this payment gateway for self-service.', + 'section' => 'deprecated', + 'description' => '(no longer supported) Force the use of this payment gateway for self-service.', %payment_gateway_options, }, diff --git a/FS/FS/agent.pm b/FS/FS/agent.pm index fc234334d..c102e7be8 100644 --- a/FS/FS/agent.pm +++ b/FS/FS/agent.pm @@ -265,24 +265,14 @@ sub payment_gateway { my $conf = new FS::Conf; if ( $options{thirdparty} ) { - # still a kludge, but it gets the job done - # and the 'cardtype' semantics don't really apply to thirdparty - # gateways because we have to choose a gateway without ever - # seeing the card number - my $gatewaynum = - $conf->config('selfservice-payment_gateway', $self->agentnum); - my $gateway; - $gateway = FS::payment_gateway->by_key($gatewaynum) if $gatewaynum; - return $gateway if $gateway; - - # a little less kludgey than the above, and allows PayPal to coexist - # with credit card gateways + + # allows PayPal to coexist with credit card gateways my $is_paypal = { op => '!=', value => 'PayPal' }; if ( uc($options{method}) eq 'PAYPAL' ) { $is_paypal = 'PayPal'; } - $gateway = qsearchs({ + my $gateway = qsearchs({ table => 'payment_gateway', addl_from => ' JOIN agent_payment_gateway USING (gatewaynum) ', hashref => { diff --git a/FS/FS/cust_main/Billing_Realtime.pm b/FS/FS/cust_main/Billing_Realtime.pm index e7226fe27..fcc573ed0 100644 --- a/FS/FS/cust_main/Billing_Realtime.pm +++ b/FS/FS/cust_main/Billing_Realtime.pm @@ -226,14 +226,6 @@ sub _bop_recurring_billing { sub _payment_gateway { my ($self, $options) = @_; - if ( $options->{'selfservice'} ) { - my $gatewaynum = FS::Conf->new->config('selfservice-payment_gateway'); - if ( $gatewaynum ) { - return $options->{payment_gateway} ||= - qsearchs('payment_gateway', { gatewaynum => $gatewaynum }); - } - } - if ( $options->{'fake_gatewaynum'} ) { $options->{payment_gateway} = qsearchs('payment_gateway', -- cgit v1.2.1 From 995b2edc47e2285db5c2cd64ef0783f2cbc37ee8 Mon Sep 17 00:00:00 2001 From: Jonathan Prykop Date: Tue, 15 Nov 2016 02:49:35 -0600 Subject: 71513: Card tokenization [bug fix to selfservice-payment_gateway removal] --- FS/FS/ClientAPI/Signup.pm | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FS/FS/ClientAPI/Signup.pm b/FS/FS/ClientAPI/Signup.pm index 7fad7b308..5ced42b2a 100644 --- a/FS/FS/ClientAPI/Signup.pm +++ b/FS/FS/ClientAPI/Signup.pm @@ -1104,7 +1104,7 @@ sub capture_payment { my $conf = new FS::Conf; my $url = $packet->{url}; - my $payment_gateway = $payment_gateway = qsearchs('payment_gateway', + my $payment_gateway = qsearchs('payment_gateway', { 'gateway_callback_url' => popurl(0, $url) } ); if (!$payment_gateway) { -- cgit v1.2.1 From 868e9dd529dc43fd523a6883ee72ec9b22e11b90 Mon Sep 17 00:00:00 2001 From: Jonathan Prykop Date: Tue, 15 Nov 2016 03:08:29 -0600 Subject: 71513: Card tokenization [remove_card_numbers subroutine] --- FS/FS/Conf.pm | 7 ++ FS/FS/cust_main/Billing_Realtime.pm | 209 +++++++++++++++++++++++++++++++++++- FS/FS/payinfo_Mixin.pm | 6 +- FS/FS/payinfo_transaction_Mixin.pm | 6 +- 4 files changed, 221 insertions(+), 7 deletions(-) diff --git a/FS/FS/Conf.pm b/FS/FS/Conf.pm index ec317ba91..a2b165378 100644 --- a/FS/FS/Conf.pm +++ b/FS/FS/Conf.pm @@ -792,6 +792,13 @@ my $validate_email = sub { $_[0] =~ 'type' => 'checkbox', }, + { + 'key' => 'no_saved_cardnumbers', + 'section' => 'credit_cards', + 'description' => 'Do not allow credit card numbers to be written to the database. Prevents realtime processing unless payment gateway supports tokenization.', + 'type' => 'checkbox', + }, + { 'key' => 'credit-card-surcharge-percentage', 'section' => 'credit_cards', diff --git a/FS/FS/cust_main/Billing_Realtime.pm b/FS/FS/cust_main/Billing_Realtime.pm index fcc573ed0..34966ce94 100644 --- a/FS/FS/cust_main/Billing_Realtime.pm +++ b/FS/FS/cust_main/Billing_Realtime.pm @@ -413,15 +413,17 @@ sub realtime_bop { # possibly run a separate transaction to tokenize card number, # so that we never store tokenized card info in cust_pay_pending - if (!$self->tokenized($options{'payinfo'})) { + if (($options{method} eq 'CC') && !$self->tokenized($options{'payinfo'})) { my $token_error = $self->realtime_tokenize(\%options); return $token_error if $token_error; # in theory, all cust_payby will be tokenized during original save, # so we shouldn't get here with opt cust_payby...but just in case... - if ($options{'cust_payby'}) { + if ($options{'cust_payby'} && $self->tokenized($options{'payinfo'})) { $token_error = $options{'cust_payby'}->replace; return $token_error if $token_error; } + return "Cannot tokenize card info" + if $conf->exists('no_saved_cardnumbers') && !$self->tokenized($options{'payinfo'}); } ### @@ -1765,11 +1767,13 @@ sub realtime_verify_bop { # possibly run a separate transaction to tokenize card number, # so that we never store tokenized card info in cust_pay_pending - if (!$self->tokenized($options{'payinfo'})) { + if (($options{method} eq 'CC') && !$self->tokenized($options{'payinfo'})) { my $token_error = $self->realtime_tokenize(\%options); return $token_error if $token_error; #important that we not replace cust_payby here, #because cust_payby->replace uses realtime_verify_bop! + return "Cannot tokenize card info" + if $conf->exists('no_saved_cardnumbers') && !$self->tokenized($options{'payinfo'}); } ### @@ -2216,6 +2220,8 @@ sub realtime_tokenize { $self->_bop_options(\%options), ); + return '' unless $transaction->can('info'); + my %supported_actions = $transaction->info('supported_actions'); return '' unless $supported_actions{'CC'} and grep(/^Tokenize$/,@{$supported_actions{'CC'}}); @@ -2317,12 +2323,205 @@ sub tokenized { FS::cust_pay->tokenized($payinfo); } +=item remove_card_numbers + +NOT AN OBJECT METHOD. Acts on all customers. Placed here because it makes +use of module-internal methods, and to keep everything that uses +Billing::OnlinePayment all in one place. + +Removes all stored card numbers from payinfo in cust_payby and +CARD transactions in cust_pay_pending, cust_pay, cust_pay_void and cust_refund. +Will fail if cust_payby records can't be tokenized. Transaction records that +cannot be tokenized will have their payinfo replaced with their paymask. + +THIS WILL OVERWRITE STORED PAYINFO ON OLD TRANSACTIONS. + +If the gateway originally used for the transaction can't tokenize, this may +prevent the transaction from being voided or refunded. Hence, it should +not (yet) be run as part of a regular upgrade. This is only intended to be +run on systems with current gateways that tokenize, after the window has +passed for voiding/refunding transactions from previous gateways, in order +to remove all real card numbers from the system. + +Also sets the no_saved_cardnumbers conf, to keep things this way. + +=cut + +# ??? probably should add MCRD handling to this + +sub remove_card_numbers { + # no input, always does the same thing + + my $cache = {}; #cache for module info + + eval "use FS::Cursor"; + return "Error initializing FS::Cursor: ".$@ if $@; + + my $oldAutoCommit = $FS::UID::AutoCommit; + local $FS::UID::AutoCommit = 0; + my $dbh = dbh; + + # turn this on + $conf->touch('no_saved_cardnumbers'); + + ### Tokenize cust_payby + + my $cust_search = FS::Cursor->new({ table => 'cust_main' },$dbh); + while (my $cust_main = $cust_search->fetch) { + foreach my $cust_payby ($cust_main->cust_payby('CARD','DCRD')) { + next if $cust_payby->tokenized; + # load gateway first, just so we can cache it + my $payment_gateway = $cust_main->_payment_gateway({ + 'payinfo' => $cust_payby->payinfo, # for cardtype agent overrides + 'nofatal' => 1, # handle error smoothly below + # invnum -- XXX need to figure out how to handle taxclass overrides + }); + unless ($payment_gateway) { + $cust_search->DESTROY; + $dbh->rollback if $oldAutoCommit; + return "No gateway found for custnum ".$cust_main->custnum; + } + my $info = $cust_main->_remove_card_numbers_gateway_info($cache,$payment_gateway); + unless (ref($info) && $info->{'can_tokenize'}) { + $cust_search->DESTROY; + $dbh->rollback if $oldAutoCommit; + my $error = ref($info) + ? "Gateway ".$payment_gateway->gatewaynum." cannot tokenize, for custnum ".$cust_main->custnum + : $info; + return $error; + } + my %tokenopts = ( + 'payment_gateway' => $payment_gateway, + 'cust_payby' => $cust_payby, + ); + my $error = $cust_main->realtime_tokenize(\%tokenopts); + if ($cust_payby->tokenized) { # implies no error + $error = $cust_payby->replace; + } else { + $error = 'Unknown error'; + } + if ($error) { + $cust_search->DESTROY; + $dbh->rollback if $oldAutoCommit; + return "Error tokenizing cust_payby ".$cust_payby->custpaybynum.": ".$error; + } + } + } + + ### Tokenize/mask transaction tables + + # grep assistance: + # $cust_pay_pending->replace, $cust_pay->replace, $cust_pay_void->replace, $cust_refund->replace all run here + foreach my $table ( qw(cust_pay_pending cust_pay cust_pay_void cust_refund) ) { + my $search = FS::Cursor->new({ + table => $table, + hashref => { 'payby' => 'CARD' }, + },$dbh); + while (my $record = $search->fetch) { + next if $record->tokenized; + next if !$record->payinfo; #shouldn't happen, but just in case, no need to mask + next if $record->payinfo =~ /N\/A/; # ??? Not sure what's up with these, but no need to mask + next if $record->payinfo eq $record->paymask; #already masked + my $old_gateway; + if (my $old_gatewaynum = $record->gatewaynum) { + $old_gateway = + qsearchs('payment_gateway',{ 'gatewaynum' => $old_gatewaynum, }); + # not erring out if gateway can't be found, just use paymask + } + # first try to tokenize + my $cust_main = $record->cust_main; + if ($cust_main && $old_gateway) { + my $info = $cust_main->_remove_card_numbers_gateway_info($cache,$old_gateway); + unless (ref($info)) { + # only throws error if Business::OnlinePayment won't load, + # which is just cause to abort this whole process + $search->DESTROY; + $dbh->rollback if $oldAutoCommit; + return $info; + } + if ($info->{'can_tokenize'}) { + my %tokenopts = ( + 'payment_gateway' => $old_gateway, + 'method' => 'CC', + 'payinfo' => $record->payinfo, + 'paydate' => $record->paydate, + ); + my $error = $cust_main->realtime_tokenize(\%tokenopts); + if ($cust_main->tokenized($tokenopts{'payinfo'})) { # implies no error + $record->payinfo($tokenopts{'payinfo'}); + $error = $record->replace; + } else { + $error = 'Unknown error'; + } + if ($error) { + $search->DESTROY; + $dbh->rollback if $oldAutoCommit; + return "Error tokenizing $table ".$record->get($record->primary_key).": ".$error; + } + next; + } + } + # can't tokenize, so just replace with paymask + $record->set('payinfo',$record->paymask); #deliberately evade ->payinfo() remasking effects + my $error = $record->replace; + if ($error) { + $search->DESTROY; + $dbh->rollback if $oldAutoCommit; + return "Error masking payinfo for $table ".$record->get($record->primary_key).": ".$error; + } + } + } + + $dbh->commit if $oldAutoCommit; + + return ''; +} + +sub _remove_card_numbers_gateway_info { + my ($self,$cache,$payment_gateway) = @_; + + return $cache->{$payment_gateway->gateway_module} + if $cache->{$payment_gateway->gateway_module}; + + my $info = {}; + $cache->{$payment_gateway->gateway_module} = $info; + + my $namespace = $payment_gateway->gateway_namespace; + return $info unless $namespace eq 'Business::OnlinePayment'; + $info->{'is_bop'} = 1; + + # only need to load this once, + # don't want to load if nothing is_bop + unless ($cache->{'Business::OnlinePayment'}) { + eval "use $namespace"; + return "Error initializing Business:OnlinePayment: ".$@ if $@; + $cache->{'Business::OnlinePayment'} = 1; + } + + my $transaction = new $namespace( $payment_gateway->gateway_module, + $self->_bop_options({ 'payment_gateway' => $payment_gateway }), + ); + + return $info unless $transaction->can('info'); + $info->{'can_info'} = 1; + + my %supported_actions = $transaction->info('supported_actions'); + $info->{'can_tokenize'} = 1 + if $supported_actions{'CC'} + && grep /^Tokenize$/, @{$supported_actions{'CC'}}; + + $info->{'void_requires_card'} = 1 + if $transaction->info('CC_void_requires_card'); + + $cache->{$payment_gateway->gateway_module} = $info; + + return $info; +} + =back =head1 BUGS -Not autoloaded. - =head1 SEE ALSO L, L diff --git a/FS/FS/payinfo_Mixin.pm b/FS/FS/payinfo_Mixin.pm index dfcce2ffc..7a3dcf0e7 100644 --- a/FS/FS/payinfo_Mixin.pm +++ b/FS/FS/payinfo_Mixin.pm @@ -194,6 +194,8 @@ sub payinfo_check { FS::payby->can_payby($self->table, $self->payby) or return "Illegal payby: ". $self->payby; + my $conf = new FS::Conf; + if ( $self->payby eq 'CARD' && ! $self->is_encrypted($self->payinfo) ) { my $payinfo = $self->payinfo; @@ -212,8 +214,10 @@ sub payinfo_check { $self->payinfo($1); validate($self->payinfo) or return "Illegal credit card number"; return "Unknown card type" if $cardtype eq "Unknown"; + return "Card number not tokenized" + if $conf->exists('no_saved_cardnumbers') && !$self->tokenized; } else { - $self->payinfo('N/A'); #??? + $self->payinfo('N/A'); #??? re-masks card } } } else { diff --git a/FS/FS/payinfo_transaction_Mixin.pm b/FS/FS/payinfo_transaction_Mixin.pm index 50659ac1e..6e4b511d2 100644 --- a/FS/FS/payinfo_transaction_Mixin.pm +++ b/FS/FS/payinfo_transaction_Mixin.pm @@ -102,7 +102,11 @@ auth, and order_number) as well as payby and payinfo sub payinfo_check { my $self = shift; - # All of these can be null, so in principle this could go in payinfo_Mixin. + my $conf = new FS::Conf; + + # allow masked payinfo if we never save card numbers + local $FS::payinfo_Mixin::ignore_masked_payinfo = + $conf->exists('no_saved_cardnumbers') ? 1 : $FS::payinfo_Mixin::ignore_masked_payinfo; $self->SUPER::payinfo_check() || $self->ut_numbern('gatewaynum') -- cgit v1.2.1 From 6f2add8c2496952f0953ae066cfde3570610c98e Mon Sep 17 00:00:00 2001 From: Jonathan Prykop Date: Fri, 18 Nov 2016 05:14:22 -0600 Subject: 71513: Card tokenization [token_check] --- FS/FS/Conf.pm | 7 -- FS/FS/Upgrade.pm | 8 ++ FS/FS/agent.pm | 88 ++++++--------- FS/FS/agent_payment_gateway.pm | 15 +++ FS/FS/cust_main/Billing_Realtime.pm | 207 ++++++++++++++++++++---------------- FS/FS/cust_pay.pm | 3 +- FS/FS/cust_refund.pm | 3 +- FS/FS/payinfo_Mixin.pm | 5 +- FS/FS/payinfo_transaction_Mixin.pm | 4 - 9 files changed, 175 insertions(+), 165 deletions(-) diff --git a/FS/FS/Conf.pm b/FS/FS/Conf.pm index a2b165378..ec317ba91 100644 --- a/FS/FS/Conf.pm +++ b/FS/FS/Conf.pm @@ -792,13 +792,6 @@ my $validate_email = sub { $_[0] =~ 'type' => 'checkbox', }, - { - 'key' => 'no_saved_cardnumbers', - 'section' => 'credit_cards', - 'description' => 'Do not allow credit card numbers to be written to the database. Prevents realtime processing unless payment gateway supports tokenization.', - 'type' => 'checkbox', - }, - { 'key' => 'credit-card-surcharge-percentage', 'section' => 'credit_cards', diff --git a/FS/FS/Upgrade.pm b/FS/FS/Upgrade.pm index 5a1ac2bce..9c0a23036 100644 --- a/FS/FS/Upgrade.pm +++ b/FS/FS/Upgrade.pm @@ -47,6 +47,10 @@ sub upgrade_config { my $conf = new FS::Conf; + # to simplify tokenization upgrades + die "Conf selfservice-payment_gateway no longer supported" + if conf->config('selfservice-payment_gateway'); + $conf->touch('payment_receipt') if $conf->exists('payment_receipt_email') || $conf->config('payment_receipt_msgnum'); @@ -392,6 +396,10 @@ sub upgrade_data { #duplicate history records 'h_cust_svc' => [], + # need before transaction tables, + # blocks tokenization upgrade if deprecated features still in use + 'agent_payment_gateway' => [], + #populate cust_pay.otaker 'cust_pay' => [], diff --git a/FS/FS/agent.pm b/FS/FS/agent.pm index c102e7be8..8aa78c2b7 100644 --- a/FS/FS/agent.pm +++ b/FS/FS/agent.pm @@ -238,31 +238,38 @@ sub ticketing_queue { Returns a payment gateway object (see L) for this agent. -Currently available options are I, I, I, -I, and I. +Currently available options are I, I, I, + and I. If I is set, and no gateway is available, then the empty string will be returned instead of throwing a fatal exception. -If I is set to the number of an invoice (see L) then -an attempt will be made to select a gateway suited for the taxes paid on -the invoice. +The I option can be used to influence the choice +as well. Presently only CHEK/ECHECK and PAYPAL methods are meaningful. -The I and I options can be used to influence the choice -as well. Presently only 'CC', 'ECHECK', and 'PAYPAL' methods are meaningful. +If I is CHEK/ECHECK and the default gateway is being returned, +the business-onlinepayment-ach gateway will be returned if available. -When the I is 'CC' then the card number in I can direct -this routine to route to a gateway suited for that type of card. +If I is set and the I is PAYPAL, the defined paypal +gateway will be returned. -If I is set, the defined self-service payment gateway will -be returned. +If I exists, then either the specified gateway or the +default gateway will be returned. Agent overrides are ignored, and this can +safely be called as a class method if this option is specified. Not +compatible with I. + +Exsisting I<$conf> may be passed for efficiency. =cut +# opts invnum/payinfo for cardtype/taxclass overrides no longer supported +# any future overrides added here need to be reconciled with the tokenization process + sub payment_gateway { my ( $self, %options ) = @_; - my $conf = new FS::Conf; + my $conf = $options{'conf'}; + $conf ||= new FS::Conf; if ( $options{thirdparty} ) { @@ -292,52 +299,17 @@ sub payment_gateway { } } - my $taxclass = ''; - if ( $options{invnum} ) { - - my $cust_bill = qsearchs('cust_bill', { 'invnum' => $options{invnum} } ); - die "invnum ". $options{'invnum'}. " not found" unless $cust_bill; - - my @part_pkg = - map { $_->part_pkg } - grep { $_ } - map { $_->cust_pkg } - $cust_bill->cust_bill_pkg; - - my @taxclasses = map $_->taxclass, @part_pkg; - - $taxclass = $taxclasses[0] - unless grep { $taxclasses[0] ne $_ } @taxclasses; #unless there are - #different taxclasses + my ($override, $payment_gateway); + if (exists $options{'load_gatewaynum'}) { # no agent overrides if this opt is in use + if ($options{'load_gatewaynum'}) { + $payment_gateway = qsearchs('payment_gateway', { gatewaynumnum => $options{'load_gatewaynum'} } ); + # always fatal + die "Could not load payment gateway ".$options{'load_gatewaynum'} unless $payment_gateway; + } # else use default, loaded below + } else { + $override = qsearchs('agent_payment_gateway', { agentnum => $self->agentnum } ); } - #look for an agent gateway override first - my $cardtype = ''; - if ( $options{method} ) { - if ( $options{method} eq 'CC' && $options{payinfo} ) { - $cardtype = cardtype($options{payinfo}); - } elsif ( $options{method} eq 'ECHECK' ) { - $cardtype = 'ACH'; - } else { - $cardtype = $options{method} - } - } - - my $override = - qsearchs('agent_payment_gateway', { agentnum => $self->agentnum, - cardtype => $cardtype, - taxclass => $taxclass, } ) - || qsearchs('agent_payment_gateway', { agentnum => $self->agentnum, - cardtype => '', - taxclass => $taxclass, } ) - || qsearchs('agent_payment_gateway', { agentnum => $self->agentnum, - cardtype => $cardtype, - taxclass => '', } ) - || qsearchs('agent_payment_gateway', { agentnum => $self->agentnum, - cardtype => '', - taxclass => '', } ); - - my $payment_gateway; if ( $override ) { #use a payment gateway override $payment_gateway = $override->payment_gateway; @@ -345,11 +317,13 @@ sub payment_gateway { $payment_gateway->gateway_namespace('Business::OnlinePayment') unless $payment_gateway->gateway_namespace; - } else { #use the standard settings from the config + } elsif (!$payment_gateway) { #use the standard settings from the config # the standard settings from the config could be moved to a null agent # agent_payment_gateway referenced payment_gateway + # remember, this block might be run as a class method if false load_gatewaynum exists + unless ( $conf->exists('business-onlinepayment') ) { if ( $options{'nofatal'} ) { return ''; diff --git a/FS/FS/agent_payment_gateway.pm b/FS/FS/agent_payment_gateway.pm index e71ed2118..4991c1912 100644 --- a/FS/FS/agent_payment_gateway.pm +++ b/FS/FS/agent_payment_gateway.pm @@ -111,6 +111,21 @@ sub check { $self->SUPER::check; } +sub _upgrade_data { + # to simplify tokenization upgrades + die "Agent taxclass override no longer supported" + if qsearch({ + 'table' => 'agent_payment_gateway', + 'extra_sql' => ' WHERE taxclass IS NOT NULL AND taxclass != \'\'', + }); + die "Agent cardtype override no longer supported" + if qsearch({ + 'table' => 'agent_payment_gateway', + 'extra_sql' => ' WHERE cardtype IS NOT NULL AND cardtype != \'\'', + }); + return ''; +} + =item payment_gateway =back diff --git a/FS/FS/cust_main/Billing_Realtime.pm b/FS/FS/cust_main/Billing_Realtime.pm index 34966ce94..48b6ee640 100644 --- a/FS/FS/cust_main/Billing_Realtime.pm +++ b/FS/FS/cust_main/Billing_Realtime.pm @@ -248,8 +248,9 @@ sub _bop_auth { ); } +### not a method! sub _bop_options { - my ($self, $options) = @_; + my ($options) = @_; $options->{payment_gateway}->gatewaynum ? $options->{payment_gateway}->options @@ -692,7 +693,7 @@ sub realtime_bop { split( /\s*\,\s*/, $payment_gateway->gateway_action ); my $transaction = new $namespace( $payment_gateway->gateway_module, - $self->_bop_options(\%options), + _bop_options(\%options), ); $transaction->content( @@ -752,7 +753,7 @@ sub realtime_bop { my $capture = new Business::OnlinePayment( $payment_gateway->gateway_module, - $self->_bop_options(\%options), + _bop_options(\%options), ); my %capture = ( @@ -1283,7 +1284,7 @@ sub realtime_botpp_capture { my $transaction = new Business::OnlineThirdPartyPayment( $payment_gateway->gateway_module, - $self->_bop_options(\%options), + _bop_options(\%options), ); $transaction->reference({ %options }); @@ -1905,7 +1906,7 @@ sub realtime_verify_bop { warn Dumper($cust_pay_pending) if $DEBUG > 2; $transaction = new $namespace( $payment_gateway->gateway_module, - $self->_bop_options(\%options), + _bop_options(\%options), ); $transaction->content( @@ -1953,7 +1954,7 @@ sub realtime_verify_bop { : ''; my $reverse = new $namespace( $payment_gateway->gateway_module, - $self->_bop_options(\%options), + _bop_options(\%options), ); $reverse->content( 'action' => 'Reverse Authorization', @@ -2217,7 +2218,7 @@ sub realtime_tokenize { ### my $transaction = new $namespace( $payment_gateway->gateway_module, - $self->_bop_options(\%options), + _bop_options(\%options), ); return '' unless $transaction->can('info'); @@ -2323,46 +2324,61 @@ sub tokenized { FS::cust_pay->tokenized($payinfo); } -=item remove_card_numbers +=item token_check -NOT AN OBJECT METHOD. Acts on all customers. Placed here because it makes +NOT A METHOD. Acts on all customers. Placed here because it makes use of module-internal methods, and to keep everything that uses Billing::OnlinePayment all in one place. -Removes all stored card numbers from payinfo in cust_payby and +Tokenizes all tokenizable card numbers from payinfo in cust_payby and CARD transactions in cust_pay_pending, cust_pay, cust_pay_void and cust_refund. -Will fail if cust_payby records can't be tokenized. Transaction records that -cannot be tokenized will have their payinfo replaced with their paymask. - -THIS WILL OVERWRITE STORED PAYINFO ON OLD TRANSACTIONS. - -If the gateway originally used for the transaction can't tokenize, this may -prevent the transaction from being voided or refunded. Hence, it should -not (yet) be run as part of a regular upgrade. This is only intended to be -run on systems with current gateways that tokenize, after the window has -passed for voiding/refunding transactions from previous gateways, in order -to remove all real card numbers from the system. -Also sets the no_saved_cardnumbers conf, to keep things this way. +If all configured gateways have the ability to tokenize, then detection of +an untokenizable record will cause a fatal error. =cut -# ??? probably should add MCRD handling to this - -sub remove_card_numbers { - # no input, always does the same thing - - my $cache = {}; #cache for module info +sub token_check { + # no input, acts on all customers eval "use FS::Cursor"; return "Error initializing FS::Cursor: ".$@ if $@; - my $oldAutoCommit = $FS::UID::AutoCommit; - local $FS::UID::AutoCommit = 0; my $dbh = dbh; - # turn this on - $conf->touch('no_saved_cardnumbers'); + # get list of all gateways in table (not counting default gateway) + my $cache = {}; #cache for module info + my $sth = $dbh->prepare('SELECT DISTINCT gatewaynum FROM payment_gateway') + or die $dbh->errstr; + $sth->execute or die $sth->errstr; + my @gatewaynums; + while (my $row = $sth->fetchrow_hashref) { + push(@gatewaynums,$row->{'gatewaynum'}); + } + $sth->finish; + + # look for a gateway that can't tokenize + my $disallow_untokenized = 1; + foreach my $gatewaynum ('',@gatewaynums) { + my $gateway = FS::agent->payment_gateway( load_gatewaynum => $gatewaynum, nofatal => 1 ); + if (!$gateway) { # already died if $gatewaynum + # no default gateway, no promise to tokenize + # can just load other gateways as-needeed below + $disallow_untokenized = 0; + last; + } + my $info = _token_check_gateway_info($cache,$gateway); + return $info unless ref($info); # means it's an error message + unless ($info->{'can_tokenize'}) { + # a configured gateway can't tokenize, that's all we need to know right now + # can just load other gateways as-needeed below + $disallow_untokenized = 0; + last; + } + } + + my $oldAutoCommit = $FS::UID::AutoCommit; + local $FS::UID::AutoCommit = 0; ### Tokenize cust_payby @@ -2372,24 +2388,19 @@ sub remove_card_numbers { next if $cust_payby->tokenized; # load gateway first, just so we can cache it my $payment_gateway = $cust_main->_payment_gateway({ - 'payinfo' => $cust_payby->payinfo, # for cardtype agent overrides 'nofatal' => 1, # handle error smoothly below - # invnum -- XXX need to figure out how to handle taxclass overrides }); unless ($payment_gateway) { + # no reason to have untokenized card numbers saved if no gateway, + # but only fatal if we expected everyone to tokenize card numbers + next unless $disallow_untokenized; $cust_search->DESTROY; $dbh->rollback if $oldAutoCommit; return "No gateway found for custnum ".$cust_main->custnum; } - my $info = $cust_main->_remove_card_numbers_gateway_info($cache,$payment_gateway); - unless (ref($info) && $info->{'can_tokenize'}) { - $cust_search->DESTROY; - $dbh->rollback if $oldAutoCommit; - my $error = ref($info) - ? "Gateway ".$payment_gateway->gatewaynum." cannot tokenize, for custnum ".$cust_main->custnum - : $info; - return $error; - } + my $info = _token_check_gateway_info($cache,$payment_gateway); + # no fail here--a configured gateway can't tokenize, so be it + next unless ref($info) && $info->{'can_tokenize'}; my %tokenopts = ( 'payment_gateway' => $payment_gateway, 'cust_payby' => $cust_payby, @@ -2398,7 +2409,7 @@ sub remove_card_numbers { if ($cust_payby->tokenized) { # implies no error $error = $cust_payby->replace; } else { - $error = 'Unknown error'; + $error ||= 'Unknown error'; } if ($error) { $cust_search->DESTROY; @@ -2419,66 +2430,77 @@ sub remove_card_numbers { },$dbh); while (my $record = $search->fetch) { next if $record->tokenized; - next if !$record->payinfo; #shouldn't happen, but just in case, no need to mask - next if $record->payinfo =~ /N\/A/; # ??? Not sure what's up with these, but no need to mask - next if $record->payinfo eq $record->paymask; #already masked - my $old_gateway; - if (my $old_gatewaynum = $record->gatewaynum) { - $old_gateway = - qsearchs('payment_gateway',{ 'gatewaynum' => $old_gatewaynum, }); - # not erring out if gateway can't be found, just use paymask + next if !$record->payinfo; #shouldn't happen, but at least it's not a card number + next if $record->payinfo =~ /N\/A/; # ??? Not sure why we do this, but it's not a card number + + # don't use customer agent gateway here, use the gatewaynum specified by the record + my $gatewaynum = $record->gatewaynum || ''; + my $gateway = FS::agent->payment_gateway( load_gatewaynum => $gatewaynum ); + unless ($gateway) { # already died if $gatewaynum + # only fatal if we expected everyone to tokenize + next unless $disallow_untokenized; + $search->DESTROY; + $dbh->rollback if $oldAutoCommit; + return "No gateway found for $table ".$record->get($record->primary_key); } - # first try to tokenize + my $info = _token_check_gateway_info($cache,$gateway); + unless (ref($info)) { + # only throws error if Business::OnlinePayment won't load, + # which is just cause to abort this whole process + $search->DESTROY; + $dbh->rollback if $oldAutoCommit; + return $info; # error message + } + + # a configured gateway can't tokenize, move along + next unless $info->{'can_tokenize'}; + my $cust_main = $record->cust_main; - if ($cust_main && $old_gateway) { - my $info = $cust_main->_remove_card_numbers_gateway_info($cache,$old_gateway); - unless (ref($info)) { - # only throws error if Business::OnlinePayment won't load, - # which is just cause to abort this whole process - $search->DESTROY; - $dbh->rollback if $oldAutoCommit; - return $info; - } - if ($info->{'can_tokenize'}) { - my %tokenopts = ( - 'payment_gateway' => $old_gateway, - 'method' => 'CC', - 'payinfo' => $record->payinfo, - 'paydate' => $record->paydate, - ); - my $error = $cust_main->realtime_tokenize(\%tokenopts); - if ($cust_main->tokenized($tokenopts{'payinfo'})) { # implies no error - $record->payinfo($tokenopts{'payinfo'}); - $error = $record->replace; - } else { - $error = 'Unknown error'; - } - if ($error) { - $search->DESTROY; - $dbh->rollback if $oldAutoCommit; - return "Error tokenizing $table ".$record->get($record->primary_key).": ".$error; - } - next; - } + unless ($cust_main) { + # might happen for cust_pay_pending for failed verify records, + # in which case it *should* already be tokenized if possible + # but only get strict about it if we're expecting full tokenization + next if + $table eq 'cust_pay_pending' + && $record->{'custnum_pending'} + && !$disallow_untokenized; + # XXX we currently need a $cust_main to run realtime_tokenize + # even if we made it a class method, wouldn't have access to payname/etc. + # fail for now, but probably could handle this better... + # everything else should absolutely have a cust_main + $search->DESTROY; + $dbh->rollback if $oldAutoCommit; + return "Could not load cust_main for $table ".$record->get($record->primary_key); + } + my %tokenopts = ( + 'payment_gateway' => $gateway, + 'method' => 'CC', + 'payinfo' => $record->payinfo, + 'paydate' => $record->paydate, + ); + my $error = $cust_main->realtime_tokenize(\%tokenopts); + if ($cust_main->tokenized($tokenopts{'payinfo'})) { # implies no error + $record->payinfo($tokenopts{'payinfo'}); + $error = $record->replace; + } else { + $error = 'Unknown error'; } - # can't tokenize, so just replace with paymask - $record->set('payinfo',$record->paymask); #deliberately evade ->payinfo() remasking effects - my $error = $record->replace; if ($error) { $search->DESTROY; $dbh->rollback if $oldAutoCommit; - return "Error masking payinfo for $table ".$record->get($record->primary_key).": ".$error; + return "Error tokenizing $table ".$record->get($record->primary_key).": ".$error; } - } - } + } # end record loop + } # end table loop $dbh->commit if $oldAutoCommit; return ''; } -sub _remove_card_numbers_gateway_info { - my ($self,$cache,$payment_gateway) = @_; +# not a method! +sub _token_check_gateway_info { + my ($cache,$payment_gateway) = @_; return $cache->{$payment_gateway->gateway_module} if $cache->{$payment_gateway->gateway_module}; @@ -2499,7 +2521,7 @@ sub _remove_card_numbers_gateway_info { } my $transaction = new $namespace( $payment_gateway->gateway_module, - $self->_bop_options({ 'payment_gateway' => $payment_gateway }), + _bop_options({ 'payment_gateway' => $payment_gateway }), ); return $info unless $transaction->can('info'); @@ -2510,6 +2532,7 @@ sub _remove_card_numbers_gateway_info { if $supported_actions{'CC'} && grep /^Tokenize$/, @{$supported_actions{'CC'}}; + # not using this any more, but for future reference... $info->{'void_requires_card'} = 1 if $transaction->info('CC_void_requires_card'); diff --git a/FS/FS/cust_pay.pm b/FS/FS/cust_pay.pm index e0a7143c4..b15920b38 100644 --- a/FS/FS/cust_pay.pm +++ b/FS/FS/cust_pay.pm @@ -540,7 +540,8 @@ otherwise returns false. sub replace { my $self = shift; - return "Can't modify closed payment" if $self->closed =~ /^Y/i; + return "Can't modify closed payment" + if $self->closed =~ /^Y/i && !$FS::payinfo_Mixin::allow_closed_replace; $self->SUPER::replace(@_); } diff --git a/FS/FS/cust_refund.pm b/FS/FS/cust_refund.pm index 4d2baa514..12ab0d693 100644 --- a/FS/FS/cust_refund.pm +++ b/FS/FS/cust_refund.pm @@ -289,7 +289,8 @@ otherwise returns false. sub replace { my $self = shift; - return "Can't modify closed refund" if $self->closed =~ /^Y/i; + return "Can't modify closed refund" + if $self->closed =~ /^Y/i && !$FS::payinfo_Mixin::allow_closed_replace; $self->SUPER::replace(@_); } diff --git a/FS/FS/payinfo_Mixin.pm b/FS/FS/payinfo_Mixin.pm index 7a3dcf0e7..2f503129d 100644 --- a/FS/FS/payinfo_Mixin.pm +++ b/FS/FS/payinfo_Mixin.pm @@ -8,7 +8,8 @@ use FS::UID qw(driver_name); use FS::Cursor; use Time::Local qw(timelocal); -use vars qw($ignore_masked_payinfo); +# allow_closed_replace only relevant to cust_pay/cust_refund, for upgrade tokenizing +use vars qw( $ignore_masked_payinfo $allow_closed_replace ); =head1 NAME @@ -214,8 +215,6 @@ sub payinfo_check { $self->payinfo($1); validate($self->payinfo) or return "Illegal credit card number"; return "Unknown card type" if $cardtype eq "Unknown"; - return "Card number not tokenized" - if $conf->exists('no_saved_cardnumbers') && !$self->tokenized; } else { $self->payinfo('N/A'); #??? re-masks card } diff --git a/FS/FS/payinfo_transaction_Mixin.pm b/FS/FS/payinfo_transaction_Mixin.pm index 6e4b511d2..c27d0494b 100644 --- a/FS/FS/payinfo_transaction_Mixin.pm +++ b/FS/FS/payinfo_transaction_Mixin.pm @@ -104,10 +104,6 @@ sub payinfo_check { my $conf = new FS::Conf; - # allow masked payinfo if we never save card numbers - local $FS::payinfo_Mixin::ignore_masked_payinfo = - $conf->exists('no_saved_cardnumbers') ? 1 : $FS::payinfo_Mixin::ignore_masked_payinfo; - $self->SUPER::payinfo_check() || $self->ut_numbern('gatewaynum') # not ut_foreign_keyn, it causes upgrades to fail -- cgit v1.2.1 From e328428724f2274144fb3e33704131ba70d20016 Mon Sep 17 00:00:00 2001 From: Jonathan Prykop Date: Tue, 22 Nov 2016 18:40:39 -0600 Subject: 71513: Card tokenization [cust_pay_pending handling, bug fixes] --- FS/FS/Upgrade.pm | 2 +- FS/FS/agent_payment_gateway.pm | 1 + FS/FS/cust_main/Billing_Realtime.pm | 92 ++++++++++++++++++++++++------------- 3 files changed, 61 insertions(+), 34 deletions(-) diff --git a/FS/FS/Upgrade.pm b/FS/FS/Upgrade.pm index 9c0a23036..7fbbbaaec 100644 --- a/FS/FS/Upgrade.pm +++ b/FS/FS/Upgrade.pm @@ -49,7 +49,7 @@ sub upgrade_config { # to simplify tokenization upgrades die "Conf selfservice-payment_gateway no longer supported" - if conf->config('selfservice-payment_gateway'); + if $conf->config('selfservice-payment_gateway'); $conf->touch('payment_receipt') if $conf->exists('payment_receipt_email') diff --git a/FS/FS/agent_payment_gateway.pm b/FS/FS/agent_payment_gateway.pm index 4991c1912..6a7cc06d1 100644 --- a/FS/FS/agent_payment_gateway.pm +++ b/FS/FS/agent_payment_gateway.pm @@ -1,5 +1,6 @@ package FS::agent_payment_gateway; use base qw(FS::Record); +use FS::Record qw( qsearch ); use strict; diff --git a/FS/FS/cust_main/Billing_Realtime.pm b/FS/FS/cust_main/Billing_Realtime.pm index 48b6ee640..183a7e643 100644 --- a/FS/FS/cust_main/Billing_Realtime.pm +++ b/FS/FS/cust_main/Billing_Realtime.pm @@ -223,6 +223,7 @@ sub _bop_recurring_billing { } +#can run safely as class method if opt payment_gateway already exists sub _payment_gateway { my ($self, $options) = @_; @@ -239,8 +240,9 @@ sub _payment_gateway { $options->{payment_gateway}; } +# not a method!!! sub _bop_auth { - my ($self, $options) = @_; + my ($options) = @_; ( 'login' => $options->{payment_gateway}->gateway_username, @@ -282,8 +284,9 @@ sub _bop_defaults { } +# not a method! sub _bop_cust_payby_options { - my ($self,$options) = @_; + my ($options) = @_; my $cust_payby = $options->{'cust_payby'}; if ($cust_payby) { @@ -319,6 +322,8 @@ sub _bop_cust_payby_options { } } +# can be called as class method, +# but can't load default name/phone fields as class method sub _bop_content { my ($self, $options) = @_; my %content = (); @@ -339,16 +344,16 @@ sub _bop_content { /^\s*([\w \,\.\-\']*)?\s+([\w\,\.\-\']+)\s*$/ or return "Illegal payname $payname"; ($payfirst, $paylast) = ($1, $2); - } else { + } elsif (ref($self)) { # can't set payname if called as class method $payfirst = $self->getfield('first'); $paylast = $self->getfield('last'); $payname = "$payfirst $paylast"; } - $content{last_name} = $paylast; - $content{first_name} = $payfirst; + $content{last_name} = $paylast if $paylast; + $content{first_name} = $payfirst if $payfirst; - $content{name} = $payname; + $content{name} = $payname if $payname; $content{address} = $options->{'address1'}; my $address2 = $options->{'address2'}; @@ -359,7 +364,9 @@ sub _bop_content { $content{zip} = $options->{'zip'}; $content{country} = $options->{'country'}; - $content{phone} = $self->daytime || $self->night; + # can't set phone if called as class method + $content{phone} = $self->daytime || $self->night + if ref($self); my $currency = $conf->exists('business-onlinepayment-currency') && $conf->config('business-onlinepayment-currency'); @@ -369,6 +376,7 @@ sub _bop_content { } # updates payinfo and cust_payby options with token from transaction +# can be called as a class method sub _tokenize_card { my ($self,$transaction,$options) = @_; if ( $transaction->can('card_token') @@ -410,7 +418,7 @@ sub realtime_bop { } # set fields from passed cust_payby - $self->_bop_cust_payby_options(\%options); + _bop_cust_payby_options(\%options); # possibly run a separate transaction to tokenize card number, # so that we never store tokenized card info in cust_pay_pending @@ -698,7 +706,7 @@ sub realtime_bop { $transaction->content( 'type' => $options{method}, - $self->_bop_auth(\%options), + _bop_auth(\%options), 'action' => $action1, 'description' => $options{'description'}, 'amount' => $options{amount}, @@ -760,7 +768,7 @@ sub realtime_bop { %content, type => $options{method}, action => $action2, - $self->_bop_auth(\%options), + _bop_auth(\%options), order_number => $ordernum, amount => $options{amount}, authorization => $auth, @@ -1291,7 +1299,7 @@ sub realtime_botpp_capture { $transaction->content( 'type' => $method, - $self->_bop_auth(\%options), + _bop_auth(\%options), 'action' => 'Post Authorization', 'description' => $options{'description'}, 'amount' => $cust_pay_pending->paid, @@ -1764,7 +1772,7 @@ sub realtime_verify_bop { # set fields from passed cust_payby return "No cust_payby" unless $options{'cust_payby'}; - $self->_bop_cust_payby_options(\%options); + _bop_cust_payby_options(\%options); # possibly run a separate transaction to tokenize card number, # so that we never store tokenized card info in cust_pay_pending @@ -1911,7 +1919,7 @@ sub realtime_verify_bop { $transaction->content( 'type' => 'CC', - $self->_bop_auth(\%options), + _bop_auth(\%options), 'action' => 'Authorization Only', 'description' => $options{'description'}, 'amount' => '1.00', @@ -1958,7 +1966,7 @@ sub realtime_verify_bop { ); $reverse->content( 'action' => 'Reverse Authorization', - $self->_bop_auth(\%options), + _bop_auth(\%options), # B:OP 'amount' => '1.00', @@ -2177,8 +2185,13 @@ Otherwise, options I, I and other cust_payby fields may be passed. If options are passed as a hashref, I will be updated as appropriate in the passed hashref. +Can be run as a class method if option I is passed, +but default customer id/name/phone can't be set in that case. This +is really only intended for tokenizing old records on upgrade. + =cut +# careful--might be run as a class method sub realtime_tokenize { my $self = shift; @@ -2196,7 +2209,7 @@ sub realtime_tokenize { } # set fields from passed cust_payby - $self->_bop_cust_payby_options(\%options); + _bop_cust_payby_options(\%options); return '' unless $options{method} eq 'CC'; return '' if $self->tokenized($options{payinfo}); #already tokenized @@ -2240,6 +2253,11 @@ sub realtime_tokenize { # massage data ### + ### Currently, cardfortress only keys in on card number and exp date. + ### We pass everything we'd pass to a normal transaction, + ### for ease of current and future development, + ### but note, when tokenizing old records, we may only have access to payinfo/paydate + my $bop_content = $self->_bop_content(\%options); return $bop_content unless ref($bop_content); @@ -2263,6 +2281,9 @@ sub realtime_tokenize { my $payissue = $options{'payissue'}; $content{issue_number} = $payissue if $payissue; + $content{customer_id} = $self->custnum + if ref($self); + ### # run transaction ### @@ -2273,10 +2294,9 @@ sub realtime_tokenize { $transaction->content( 'type' => 'CC', - $self->_bop_auth(\%options), + _bop_auth(\%options), 'action' => 'Tokenize', - 'description' => $options{'description'}, - 'customer_id' => $self->custnum, + 'description' => $options{'description'} %$bop_content, %content, #after ); @@ -2314,7 +2334,9 @@ sub realtime_tokenize { Convenience wrapper for L -PAYINFO is required +PAYINFO is required. + +Can be run as class or object method, never loads from object. =cut @@ -2421,6 +2443,9 @@ sub token_check { ### Tokenize/mask transaction tables + # allow tokenization of closed cust_pay/cust_refund records + local $FS::payinfo_Mixin::allow_closed_replace = 1; + # grep assistance: # $cust_pay_pending->replace, $cust_pay->replace, $cust_pay_void->replace, $cust_refund->replace all run here foreach my $table ( qw(cust_pay_pending cust_pay cust_pay_void cust_refund) ) { @@ -2456,34 +2481,35 @@ sub token_check { next unless $info->{'can_tokenize'}; my $cust_main = $record->cust_main; - unless ($cust_main) { - # might happen for cust_pay_pending for failed verify records, - # in which case it *should* already be tokenized if possible - # but only get strict about it if we're expecting full tokenization - next if - $table eq 'cust_pay_pending' - && $record->{'custnum_pending'} - && !$disallow_untokenized; - # XXX we currently need a $cust_main to run realtime_tokenize - # even if we made it a class method, wouldn't have access to payname/etc. - # fail for now, but probably could handle this better... + unless ($cust_main || ( + # might happen for cust_pay_pending from failed verify records, + # in which case we attempt tokenization without cust_main # everything else should absolutely have a cust_main + $table eq 'cust_pay_pending' + && $record->{'custnum_pending'} + && !$disallow_untokenized + )) { $search->DESTROY; $dbh->rollback if $oldAutoCommit; return "Could not load cust_main for $table ".$record->get($record->primary_key); } + # no clear record of name/address/etc used for transaction, + # but will load name/phone/id from customer if run as an object method, + # so we try that if we can my %tokenopts = ( 'payment_gateway' => $gateway, 'method' => 'CC', 'payinfo' => $record->payinfo, 'paydate' => $record->paydate, ); - my $error = $cust_main->realtime_tokenize(\%tokenopts); - if ($cust_main->tokenized($tokenopts{'payinfo'})) { # implies no error + my $error = $cust_main + ? $cust_main->realtime_tokenize(\%tokenopts) + : FS::cust_main::Billing_Realtime->realtime_tokenize(\%tokenopts); + if (FS::cust_main::Billing_Realtime->tokenized($tokenopts{'payinfo'})) { # implies no error $record->payinfo($tokenopts{'payinfo'}); $error = $record->replace; } else { - $error = 'Unknown error'; + $error ||= 'Unknown error'; } if ($error) { $search->DESTROY; -- cgit v1.2.1 From 9605850e1b105d527961a0766ec05840b3d6962e Mon Sep 17 00:00:00 2001 From: Jonathan Prykop Date: Tue, 29 Nov 2016 02:46:10 -0600 Subject: Bug fix to #73185, discovered via #71513 --- FS/FS/Cron/tax_rate_update.pm | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FS/FS/Cron/tax_rate_update.pm b/FS/FS/Cron/tax_rate_update.pm index b6ac63c2e..fec696fbb 100755 --- a/FS/FS/Cron/tax_rate_update.pm +++ b/FS/FS/Cron/tax_rate_update.pm @@ -31,7 +31,7 @@ sub tax_rate_update { my %opt = @_; my $oldAutoCommit = $FS::UID::AutoCommit; - $FS::UID::AutoCommit = 0; + local $FS::UID::AutoCommit = 0; my $dbh = dbh; my $conf = FS::Conf->new; -- cgit v1.2.1 From 3d8958a36f22a88738b637b4d5583e989e91bc8e Mon Sep 17 00:00:00 2001 From: Jonathan Prykop Date: Tue, 29 Nov 2016 04:21:46 -0600 Subject: 71513: Card tokenization [upgrade implemented] --- FS/FS/Cron/cleanup.pm | 16 ++- FS/FS/Upgrade.pm | 10 +- FS/FS/agent.pm | 80 ++--------- FS/FS/cust_main.pm | 5 + FS/FS/cust_main/Billing_Realtime.pm | 270 ++++++++++++++++++++++++++--------- FS/FS/log_context.pm | 1 + FS/FS/payinfo_Mixin.pm | 1 + FS/FS/payment_gateway.pm | 102 +++++++++++++ FS/bin/freeside-daily | 2 +- FS/t/suite/13-tokenization.t | 82 +++++++++++ httemplate/edit/elements/edit.html | 2 +- httemplate/edit/payment_gateway.html | 11 +- 12 files changed, 431 insertions(+), 151 deletions(-) create mode 100755 FS/t/suite/13-tokenization.t diff --git a/FS/FS/Cron/cleanup.pm b/FS/FS/Cron/cleanup.pm index 6ec401398..9d0c06740 100644 --- a/FS/FS/Cron/cleanup.pm +++ b/FS/FS/Cron/cleanup.pm @@ -8,12 +8,26 @@ use FS::Record qw( qsearch ); # start janitor jobs sub cleanup { -# fix locations that are missing coordinates + my %opt = @_; + + # fix locations that are missing coordinates my $job = FS::queue->new({ 'job' => 'FS::cust_location::process_set_coord', 'status' => 'new' }); $job->insert('_JOB'); + + # check card number tokenization + $job = FS::queue->new({ + 'job' => 'FS::cust_main::Billing_Realtime::token_check', + 'status' => 'new' + }); + $job->insert( + %opt, + 'queue' => 1, + 'daily' => 1, + ); + } sub cleanup_before_backup { diff --git a/FS/FS/Upgrade.pm b/FS/FS/Upgrade.pm index 7fbbbaaec..31311e9a0 100644 --- a/FS/FS/Upgrade.pm +++ b/FS/FS/Upgrade.pm @@ -368,7 +368,11 @@ sub upgrade_data { #fix whitespace - before cust_main 'cust_location' => [], - #cust_main (remove paycvv from history, locations, cust_payby, etc) + # need before cust_main tokenization upgrade, + # blocks tokenization upgrade if deprecated features still in use + 'agent_payment_gateway' => [], + + #cust_main (tokenizes cards, remove paycvv from history, locations, cust_payby, etc) 'cust_main' => [], #contact -> cust_contact / prospect_contact @@ -396,10 +400,6 @@ sub upgrade_data { #duplicate history records 'h_cust_svc' => [], - # need before transaction tables, - # blocks tokenization upgrade if deprecated features still in use - 'agent_payment_gateway' => [], - #populate cust_pay.otaker 'cust_pay' => [], diff --git a/FS/FS/agent.pm b/FS/FS/agent.pm index 8aa78c2b7..b97e9b9b4 100644 --- a/FS/FS/agent.pm +++ b/FS/FS/agent.pm @@ -9,6 +9,7 @@ use FS::cust_main; use FS::cust_pkg; use FS::reg_code; use FS::agent_payment_gateway; +use FS::payment_gateway; use FS::TicketSystem; use FS::Conf; @@ -253,12 +254,7 @@ the business-onlinepayment-ach gateway will be returned if available. If I is set and the I is PAYPAL, the defined paypal gateway will be returned. -If I exists, then either the specified gateway or the -default gateway will be returned. Agent overrides are ignored, and this can -safely be called as a class method if this option is specified. Not -compatible with I. - -Exsisting I<$conf> may be passed for efficiency. +Exisisting I<$conf> may be passed for efficiency. =cut @@ -268,8 +264,8 @@ Exsisting I<$conf> may be passed for efficiency. sub payment_gateway { my ( $self, %options ) = @_; + $options{'conf'} ||= new FS::Conf; my $conf = $options{'conf'}; - $conf ||= new FS::Conf; if ( $options{thirdparty} ) { @@ -299,72 +295,12 @@ sub payment_gateway { } } - my ($override, $payment_gateway); - if (exists $options{'load_gatewaynum'}) { # no agent overrides if this opt is in use - if ($options{'load_gatewaynum'}) { - $payment_gateway = qsearchs('payment_gateway', { gatewaynumnum => $options{'load_gatewaynum'} } ); - # always fatal - die "Could not load payment gateway ".$options{'load_gatewaynum'} unless $payment_gateway; - } # else use default, loaded below - } else { - $override = qsearchs('agent_payment_gateway', { agentnum => $self->agentnum } ); - } - - if ( $override ) { #use a payment gateway override - - $payment_gateway = $override->payment_gateway; - - $payment_gateway->gateway_namespace('Business::OnlinePayment') - unless $payment_gateway->gateway_namespace; - - } elsif (!$payment_gateway) { #use the standard settings from the config - - # the standard settings from the config could be moved to a null agent - # agent_payment_gateway referenced payment_gateway - - # remember, this block might be run as a class method if false load_gatewaynum exists + my $override = qsearchs('agent_payment_gateway', { agentnum => $self->agentnum } ); - unless ( $conf->exists('business-onlinepayment') ) { - if ( $options{'nofatal'} ) { - return ''; - } else { - die "Real-time processing not enabled\n"; - } - } - - #load up config - my $bop_config = 'business-onlinepayment'; - $bop_config .= '-ach' - if ( $options{method} - && $options{method} =~ /^(ECHECK|CHEK)$/ - && $conf->exists($bop_config. '-ach') - ); - my ( $processor, $login, $password, $action, @bop_options ) = - $conf->config($bop_config); - $action ||= 'normal authorization'; - pop @bop_options if scalar(@bop_options) % 2 && $bop_options[-1] =~ /^\s*$/; - die "No real-time processor is enabled - ". - "did you set the business-onlinepayment configuration value?\n" - unless $processor; - - $payment_gateway = new FS::payment_gateway; - - $payment_gateway->gateway_namespace( $conf->config('business-onlinepayment-namespace') || - 'Business::OnlinePayment'); - $payment_gateway->gateway_module($processor); - $payment_gateway->gateway_username($login); - $payment_gateway->gateway_password($password); - $payment_gateway->gateway_action($action); - $payment_gateway->set('options', [ @bop_options ]); - - } - - unless ( $payment_gateway->gateway_namespace ) { - $payment_gateway->gateway_namespace( - scalar($conf->config('business-onlinepayment-namespace')) - || 'Business::OnlinePayment' - ); - } + my $payment_gateway = FS::payment_gateway->by_key_or_default( + gatewaynum => $override ? $override->gatewaynum : '', + %options, + ); $payment_gateway; } diff --git a/FS/FS/cust_main.pm b/FS/FS/cust_main.pm index a2c0ee8d4..71552b0d3 100644 --- a/FS/FS/cust_main.pm +++ b/FS/FS/cust_main.pm @@ -5786,6 +5786,11 @@ sub _upgrade_data { #class method } +sub queueable_upgrade { + my $class = shift; + FS::cust_main::Billing_Realtime::token_check(@_); +} + =back =head1 BUGS diff --git a/FS/FS/cust_main/Billing_Realtime.pm b/FS/FS/cust_main/Billing_Realtime.pm index 183a7e643..fb0c01022 100644 --- a/FS/FS/cust_main/Billing_Realtime.pm +++ b/FS/FS/cust_main/Billing_Realtime.pm @@ -14,6 +14,7 @@ use FS::cust_pay_pending; use FS::cust_bill_pay; use FS::cust_refund; use FS::banned_pay; +use FS::payment_gateway; $realtime_bop_decline_quiet = 0; @@ -2296,7 +2297,7 @@ sub realtime_tokenize { 'type' => 'CC', _bop_auth(\%options), 'action' => 'Tokenize', - 'description' => $options{'description'} + 'description' => $options{'description'}, %$bop_content, %content, #after ); @@ -2346,7 +2347,7 @@ sub tokenized { FS::cust_pay->tokenized($payinfo); } -=item token_check +=item token_check [ quiet => 1, queue => 1, daily => 1 ] NOT A METHOD. Acts on all customers. Placed here because it makes use of module-internal methods, and to keep everything that uses @@ -2355,74 +2356,138 @@ Billing::OnlinePayment all in one place. Tokenizes all tokenizable card numbers from payinfo in cust_payby and CARD transactions in cust_pay_pending, cust_pay, cust_pay_void and cust_refund. -If all configured gateways have the ability to tokenize, then detection of -an untokenizable record will cause a fatal error. +If the I flag is set, newly tokenized records will be immediately +committed, regardless of AutoCommit, so as to release the mutex on the record. + +If all configured gateways have the ability to tokenize, detection of an +untokenizable record will cause a fatal error. However, if the I flag +is set, this will instead cause a critical error to be recorded in the log, +and any other tokenizable records will still be committed. + +If the I flag is also set, detection of existing untokenized records will +record a critical error in the system log (because they should have never appeared +in the first place.) Tokenization will still be attempted. + +If any configured gateways do NOT have the ability to tokenize, or if a +default gateway is not configured, then untokenized records are not considered +a threat, and no critical errors will be generated in the log. =cut sub token_check { - # no input, acts on all customers + #acts on all customers + my %opt = @_; + my $debug = !$opt{'quiet'} || $DEBUG; - eval "use FS::Cursor"; - return "Error initializing FS::Cursor: ".$@ if $@; + warn "token_check called with opts\n".Dumper(\%opt) if $debug; - my $dbh = dbh; + # force some explicitness when invoking this method + die "token_check must run with queue flag if run with daily flag" + if $opt{'daily'} && !$opt{'queue'}; + + my $conf = FS::Conf->new; + + my $log = FS::Log->new('FS::cust_main::Billing_Realtime::token_check'); - # get list of all gateways in table (not counting default gateway) my $cache = {}; #cache for module info - my $sth = $dbh->prepare('SELECT DISTINCT gatewaynum FROM payment_gateway') - or die $dbh->errstr; - $sth->execute or die $sth->errstr; - my @gatewaynums; - while (my $row = $sth->fetchrow_hashref) { - push(@gatewaynums,$row->{'gatewaynum'}); - } - $sth->finish; # look for a gateway that can't tokenize - my $disallow_untokenized = 1; - foreach my $gatewaynum ('',@gatewaynums) { - my $gateway = FS::agent->payment_gateway( load_gatewaynum => $gatewaynum, nofatal => 1 ); - if (!$gateway) { # already died if $gatewaynum + my $require_tokenized = 1; + foreach my $gateway ( + FS::payment_gateway->all_gateways( + 'method' => 'CC', + 'conf' => $conf, + 'nofatal' => 1, + ) + ) { + if (!$gateway) { # no default gateway, no promise to tokenize # can just load other gateways as-needeed below - $disallow_untokenized = 0; + $require_tokenized = 0; last; } my $info = _token_check_gateway_info($cache,$gateway); - return $info unless ref($info); # means it's an error message + die $info unless ref($info); # means it's an error message unless ($info->{'can_tokenize'}) { # a configured gateway can't tokenize, that's all we need to know right now # can just load other gateways as-needeed below - $disallow_untokenized = 0; + $require_tokenized = 0; last; } } + warn "REQUIRE TOKENIZED" if $require_tokenized && $debug; + + # upgrade does not call this with autocommit turned on, + # and autocommit will be ignored if opt queue is set, + # but might as well be thorough... my $oldAutoCommit = $FS::UID::AutoCommit; local $FS::UID::AutoCommit = 0; + my $dbh = dbh; + + # for retrieving data in chunks + my $step = 500; + my $offset = 0; ### Tokenize cust_payby - my $cust_search = FS::Cursor->new({ table => 'cust_main' },$dbh); - while (my $cust_main = $cust_search->fetch) { + my @recnums; + +CUSTLOOP: + while (my $custnum = _token_check_next_recnum($dbh,'cust_main',$step,\$offset,\@recnums)) { + my $cust_main = FS::cust_main->by_key($custnum); + my $payment_gateway; foreach my $cust_payby ($cust_main->cust_payby('CARD','DCRD')) { - next if $cust_payby->tokenized; - # load gateway first, just so we can cache it - my $payment_gateway = $cust_main->_payment_gateway({ - 'nofatal' => 1, # handle error smoothly below + + # see if it's already tokenized + if ($cust_payby->tokenized) { + warn "cust_payby ".$cust_payby->get($cust_payby->primary_key)." already tokenized" if $debug; + next; + } + + if ($require_tokenized && $opt{'daily'}) { + $log->critical("Untokenized card number detected in cust_payby ".$cust_payby->custpaybynum); + $dbh->commit or die $dbh->errstr; # commit log message + } + + # only load gateway if we need to, and only need to load it once + my $payment_gateway ||= $cust_main->_payment_gateway({ + 'method' => 'CC', + 'conf' => $conf, + 'nofatal' => 1, # handle lack of gateway smoothly below }); unless ($payment_gateway) { # no reason to have untokenized card numbers saved if no gateway, - # but only fatal if we expected everyone to tokenize card numbers - next unless $disallow_untokenized; - $cust_search->DESTROY; + # but only a problem if we expected everyone to tokenize card numbers + unless ($require_tokenized) { + warn "Skipping cust_payby for cust_main ".$cust_main->custnum.", no payment gateway" if $debug; + next CUSTLOOP; # can skip rest of customer + } + my $error = "No gateway found for custnum ".$cust_main->custnum; + if ($opt{'queue'}) { + $log->critical($error); + $dbh->commit or die $dbh->errstr; # commit error message + next; # not next CUSTLOOP, want to record error for every cust_payby + } $dbh->rollback if $oldAutoCommit; - return "No gateway found for custnum ".$cust_main->custnum; + die $error; } + my $info = _token_check_gateway_info($cache,$payment_gateway); + unless (ref($info)) { + # only throws error if Business::OnlinePayment won't load, + # which is just cause to abort this whole process, even if queue + $dbh->rollback if $oldAutoCommit; + die $info; # error message + } # no fail here--a configured gateway can't tokenize, so be it - next unless ref($info) && $info->{'can_tokenize'}; + unless ($info->{'can_tokenize'}) { + warn "Skipping ".$cust_main->custnum." cannot tokenize" if $debug; + next; + } + + # time to tokenize + $cust_payby = $cust_payby->select_for_update; my %tokenopts = ( 'payment_gateway' => $payment_gateway, 'cust_payby' => $cust_payby, @@ -2434,11 +2499,20 @@ sub token_check { $error ||= 'Unknown error'; } if ($error) { - $cust_search->DESTROY; + $error = "Error tokenizing cust_payby ".$cust_payby->custpaybynum.": ".$error; + if ($opt{'queue'}) { + $log->critical($error); + $dbh->commit or die $dbh->errstr; # commit log message, release mutex + next; # not next CUSTLOOP, want to record error for every cust_payby + } $dbh->rollback if $oldAutoCommit; - return "Error tokenizing cust_payby ".$cust_payby->custpaybynum.": ".$error; + die $error; } + $dbh->commit or die $dbh->errstr if $opt{'queue'}; # release mutex + warn "TOKENIZED cust_payby ".$cust_payby->get($cust_payby->primary_key) if $debug; } + warn "cust_payby upgraded for custnum ".$cust_main->custnum if $debug; + } ### Tokenize/mask transaction tables @@ -2449,50 +2523,83 @@ sub token_check { # grep assistance: # $cust_pay_pending->replace, $cust_pay->replace, $cust_pay_void->replace, $cust_refund->replace all run here foreach my $table ( qw(cust_pay_pending cust_pay cust_pay_void cust_refund) ) { - my $search = FS::Cursor->new({ - table => $table, - hashref => { 'payby' => 'CARD' }, - },$dbh); - while (my $record = $search->fetch) { - next if $record->tokenized; - next if !$record->payinfo; #shouldn't happen, but at least it's not a card number - next if $record->payinfo =~ /N\/A/; # ??? Not sure why we do this, but it's not a card number + warn "Checking $table" if $debug; + + # FS::Cursor does not seem to work over multiple commits (gives cursor not found errors) + # loading only record ids, then loading individual records one at a time + my $tclass = 'FS::'.$table; + $offset = 0; + @recnums = (); + + while (my $recnum = _token_check_next_recnum($dbh,$table,$step,\$offset,\@recnums)) { + my $record = $tclass->by_key($recnum); + if (FS::cust_main::Billing_Realtime->tokenized($record->payinfo)) { + warn "Skipping tokenized record for $table ".$record->get($record->primary_key) if $debug; + next; + } + if (!$record->payinfo) { #shouldn't happen, but at least it's not a card number + warn "Skipping blank payinfo for $table ".$record->get($record->primary_key) if $debug; + next; + } + if ($record->payinfo =~ /N\/A/) { # ??? Not sure why we do this, but it's not a card number + warn "Skipping NA payinfo for $table ".$record->get($record->primary_key) if $debug; + next; + } + + if ($require_tokenized && $opt{'daily'}) { + $log->critical("Untokenized card number detected in $table ".$record->get($record->primary_key)); + $dbh->commit or die $dbh->errstr; # commit log message + } # don't use customer agent gateway here, use the gatewaynum specified by the record - my $gatewaynum = $record->gatewaynum || ''; - my $gateway = FS::agent->payment_gateway( load_gatewaynum => $gatewaynum ); - unless ($gateway) { # already died if $gatewaynum - # only fatal if we expected everyone to tokenize - next unless $disallow_untokenized; - $search->DESTROY; - $dbh->rollback if $oldAutoCommit; - return "No gateway found for $table ".$record->get($record->primary_key); + my $gateway = FS::payment_gateway->by_key_or_default( + 'method' => 'CC', + 'conf' => $conf, + 'nofatal' => 1, + 'gatewaynum' => $record->gatewaynum || '', + ); + unless ($gateway) { + # means no default gateway, no promise to tokenize, can skip + warn "Skipping missing gateway for $table ".$record->get($record->primary_key) if $debug; + next; } + my $info = _token_check_gateway_info($cache,$gateway); unless (ref($info)) { # only throws error if Business::OnlinePayment won't load, - # which is just cause to abort this whole process - $search->DESTROY; + # which is just cause to abort this whole process, even if queue $dbh->rollback if $oldAutoCommit; - return $info; # error message + die $info; # error message } # a configured gateway can't tokenize, move along - next unless $info->{'can_tokenize'}; + unless ($info->{'can_tokenize'}) { + warn "Skipping, cannot tokenize $table ".$record->get($record->primary_key) if $debug; + next; + } my $cust_main = $record->cust_main; - unless ($cust_main || ( + if (!$cust_main) { # might happen for cust_pay_pending from failed verify records, # in which case we attempt tokenization without cust_main # everything else should absolutely have a cust_main - $table eq 'cust_pay_pending' - && $record->{'custnum_pending'} - && !$disallow_untokenized - )) { - $search->DESTROY; - $dbh->rollback if $oldAutoCommit; - return "Could not load cust_main for $table ".$record->get($record->primary_key); + if ($table eq 'cust_pay_pending' && $record->{'custnum_pending'}) { + warn "ATTEMPTING GATEWAY-ONLY TOKENIZE" if $debug; + } else { + my $error = "Could not load cust_main for $table ".$record->get($record->primary_key); + if ($opt{'queue'}) { + $log->critical($error); + $dbh->commit or die $dbh->errstr; # commit log message + next; + } + $dbh->rollback if $oldAutoCommit; + die $error; + } } + + # if we got this far, time to mutex + $record = $record->select_for_update; + # no clear record of name/address/etc used for transaction, # but will load name/phone/id from customer if run as an object method, # so we try that if we can @@ -2512,18 +2619,43 @@ sub token_check { $error ||= 'Unknown error'; } if ($error) { - $search->DESTROY; + $error = "Error tokenizing $table ".$record->get($record->primary_key).": ".$error; + if ($opt{'queue'}) { + $log->critical($error); + $dbh->commit or die $dbh->errstr; # commit log message, release mutex + next; + } $dbh->rollback if $oldAutoCommit; - return "Error tokenizing $table ".$record->get($record->primary_key).": ".$error; + die $error; } + $dbh->commit or die $dbh->errstr if $opt{'queue'}; # release mutex + warn "TOKENIZED $table ".$record->get($record->primary_key) if $debug; + } # end record loop } # end table loop - $dbh->commit if $oldAutoCommit; + $dbh->commit or die $dbh->errstr if $oldAutoCommit; return ''; } +# not a method! +sub _token_check_next_recnum { + my ($dbh,$table,$step,$offset,$recnums) = @_; + my $recnum = shift @$recnums; + return $recnum if $recnum; + my $tclass = 'FS::'.$table; + my $sth = $dbh->prepare('SELECT '.$tclass->primary_key.' FROM '.$table.' ORDER BY '.$tclass->primary_key.' LIMIT '.$step.' OFFSET '.$$offset) or die $dbh->errstr; + $sth->execute() or die $sth->errstr; + my @recnums; + while (my $rec = $sth->fetchrow_hashref) { + push @$recnums, $rec->{$tclass->primary_key}; + } + $sth->finish(); + $$offset += $step; + return shift @$recnums; +} + # not a method! sub _token_check_gateway_info { my ($cache,$payment_gateway) = @_; @@ -2562,8 +2694,6 @@ sub _token_check_gateway_info { $info->{'void_requires_card'} = 1 if $transaction->info('CC_void_requires_card'); - $cache->{$payment_gateway->gateway_module} = $info; - return $info; } diff --git a/FS/FS/log_context.pm b/FS/FS/log_context.pm index 51aa79de5..a41d3c837 100644 --- a/FS/FS/log_context.pm +++ b/FS/FS/log_context.pm @@ -11,6 +11,7 @@ my @contexts = ( qw( FS::cust_main::Billing_Realtime::realtime_bop FS::cust_main::Billing_Realtime::realtime_tokenize FS::cust_main::Billing_Realtime::realtime_verify_bop + FS::cust_main::Billing_Realtime::token_check FS::pay_batch::import_from_gateway FS::part_pkg FS::Misc::Geo::standardize_uscensus diff --git a/FS/FS/payinfo_Mixin.pm b/FS/FS/payinfo_Mixin.pm index 2f503129d..be37568ad 100644 --- a/FS/FS/payinfo_Mixin.pm +++ b/FS/FS/payinfo_Mixin.pm @@ -468,6 +468,7 @@ Optionally, an arbitrary payby and payinfo can be passed. sub tokenized { my $self = shift; my $payinfo = scalar(@_) ? shift : $self->payinfo; + return 0 unless $payinfo; #avoid uninitialized value error $payinfo =~ /^99\d{14}$/; } diff --git a/FS/FS/payment_gateway.pm b/FS/FS/payment_gateway.pm index afae2667e..170d37af9 100644 --- a/FS/FS/payment_gateway.pm +++ b/FS/FS/payment_gateway.pm @@ -323,6 +323,108 @@ sub processor { } } +=item default_gateway OPTIONS + +Class method. + +Returns default gateway (from business-onlinepayment conf) as a payment_gateway object. + +Accepts options + +conf - existing conf object + +nofatal - return blank instead of dying if no default gateway is configured + +method - if set to CHEK or ECHECK, returns object for business-onlinepayment-ach if available + +Before using this, be sure you wouldn't rather be using L or, +more likely, L. + +=cut + +# the standard settings from the config could be moved to a null agent +# agent_payment_gateway referenced payment_gateway + +sub default_gateway { + my ($self,%options) = @_; + + $options{'conf'} ||= new FS::Conf; + my $conf = $options{'conf'}; + + unless ( $conf->exists('business-onlinepayment') ) { + if ( $options{'nofatal'} ) { + return ''; + } else { + die "Real-time processing not enabled\n"; + } + } + + #load up config + my $bop_config = 'business-onlinepayment'; + $bop_config .= '-ach' + if ( $options{method} + && $options{method} =~ /^(ECHECK|CHEK)$/ + && $conf->exists($bop_config. '-ach') + ); + my ( $processor, $login, $password, $action, @bop_options ) = + $conf->config($bop_config); + $action ||= 'normal authorization'; + pop @bop_options if scalar(@bop_options) % 2 && $bop_options[-1] =~ /^\s*$/; + die "No real-time processor is enabled - ". + "did you set the business-onlinepayment configuration value?\n" + unless $processor; + + my $payment_gateway = new FS::payment_gateway; + $payment_gateway->gateway_namespace( $conf->config('business-onlinepayment-namespace') || + 'Business::OnlinePayment'); + $payment_gateway->gateway_module($processor); + $payment_gateway->gateway_username($login); + $payment_gateway->gateway_password($password); + $payment_gateway->gateway_action($action); + $payment_gateway->set('options', [ @bop_options ]); + return $payment_gateway; +} + +=item by_key_or_default OPTIONS + +Either returns the gateway specified by option gatewaynum, or the default gateway. + +Accepts the same options as L. + +Also ensures that the gateway_namespace has been set. + +=cut + +sub by_key_or_default { + my ($self,%options) = @_; + + if ($options{'gatewaynum'}) { + my $payment_gateway = $self->by_key($options{'gatewaynum'}); + # regardless of nofatal, which is only meant for handling lack of default gateway + die "payment_gateway ".$options{'gatewaynum'}." not found" + unless $payment_gateway; + $payment_gateway->gateway_namespace('Business::OnlinePayment') + unless $payment_gateway->gateway_namespace; + return $payment_gateway; + } else { + return $self->default_gateway(%options); + } +} + +# if it weren't for the way gateway_namespace default is set, this method would not be necessary +# that should really go in check() with an accompanying upgrade, so we could just use qsearch safely, +# but currently short on time to test deeper changes... +# +# if no default gateway is set and nofatal is passed, first value returned is blank string +sub all_gateways { + my ($self,%options) = @_; + my @out; + foreach my $gatewaynum ('',( map {$_->gatewaynum} qsearch('payment_gateway') )) { + push @out, $self->by_key_or_default( %options, gatewaynum => $gatewaynum ); + } + return @out; +} + # _upgrade_data # # Used by FS::Upgrade to migrate to a new database. diff --git a/FS/bin/freeside-daily b/FS/bin/freeside-daily index ee95c14db..e1463f5da 100755 --- a/FS/bin/freeside-daily +++ b/FS/bin/freeside-daily @@ -97,7 +97,7 @@ use FS::Cron::backup qw(backup); backup(); #except we'd rather not start cleanup jobs until the backup is done -cleanup(); +cleanup( quiet => !$opt{'v'} ); $log->info('finish'); diff --git a/FS/t/suite/13-tokenization.t b/FS/t/suite/13-tokenization.t new file mode 100755 index 000000000..1b654add5 --- /dev/null +++ b/FS/t/suite/13-tokenization.t @@ -0,0 +1,82 @@ +#!/usr/bin/perl + +use FS::Test; +use Test::More tests => 8; +use FS::Conf; + +### can only run on test database (company name "Freeside Test") +### will run upgrade, which uses lots of prints & warns beyond regular test output + +my $fs = FS::Test->new( user => 'admin' ); +my $conf = new_ok('FS::Conf'); +my $err; +my $bopconf; + +like( $conf->config('company_name'), qr/^Freeside Test/, 'using test database' ) or BAIL_OUT(''); + +# some pre-upgrade cleanup, upgrade will fail if these are still configured +foreach my $cust_main ( $fs->qsearch('cust_main') ) { + my @count = $fs->qsearch('agent_payment_gateway', { agentnum => $cust_main->agentnum } ); + if (@count > 1) { + note("DELETING CARDTYPE GATEWAYS"); + foreach my $apg (@count) { + $err = $apg->delete if $apg->cardtype; + last if $err; + } + @count = $fs->qsearch('agent_payment_gateway', { agentnum => $cust_main->agentnum } ); + if (@count > 1) { + $err = "Still found ".@count." gateways for custnum ".$cust_main->custnum; + last; + } + } +} +ok( !$err, "remove obsolete payment gateways" ) or BAIL_OUT($err); + +$bopconf = +'IPPay +TESTTERMINAL'; +$conf->set('business-onlinepayment' => $bopconf); +is( join("\n",$conf->config('business-onlinepayment')), $bopconf, "setting first default gateway" ) or BAIL_OUT(''); + +$err = system('freeside-upgrade','admin'); +ok( !$err, 'initial upgrade' ) or BAIL_OUT('Error string: '.$!); + +$bopconf = +'CardFortress +cardfortresstest +(TEST54) +Normal Authorization +gateway +IPPay +gateway_login +TESTTERMINAL +gateway_password + +private_key +/usr/local/etc/freeside/cardfortresstest.txt'; +$conf->set('business-onlinepayment' => $bopconf); +is( join("\n",$conf->config('business-onlinepayment')), $bopconf, "setting tokenizable default gateway" ) or BAIL_OUT(''); + +foreach my $pg ($fs->qsearch('payment_gateway')) { + unless ($pg->gateway_module eq 'CardFortress') { + note('UPGRADING NON-CF PAYMENT GATEWAY'); + my %pgopts = ( + gateway => $pg->gateway_module, + gateway_login => $pg->gateway_username, + gateway_password => $pg->gateway_password, + private_key => '/usr/local/etc/freeside/cardfortresstest.txt', + ); + $pg->gateway_module('CardFortress'); + $pg->gateway_username('cardfortresstest'); + $pg->gateway_password('(TEST54)'); + $err = $pg->replace(\%pgopts); + last if $err; + } +} +ok( !$err, "remove non-CF payment gateways" ) or BAIL_OUT($err); + +$err = system('freeside-upgrade','admin'); +ok( !$err, 'tokenizable upgrade' ) or BAIL_OUT('Error string: '.$!); + +1; + diff --git a/httemplate/edit/elements/edit.html b/httemplate/edit/elements/edit.html index 35818dda5..7e0eee467 100644 --- a/httemplate/edit/elements/edit.html +++ b/httemplate/edit/elements/edit.html @@ -247,7 +247,7 @@ Example: > - + <% defined($opt{'form_init'}) ? ( ref($opt{'form_init'}) diff --git a/httemplate/edit/payment_gateway.html b/httemplate/edit/payment_gateway.html index b44b31513..f9b8f2415 100644 --- a/httemplate/edit/payment_gateway.html +++ b/httemplate/edit/payment_gateway.html @@ -22,6 +22,9 @@