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