From e41cafbe7616c4f046c6161999d4577f1c918311 Mon Sep 17 00:00:00 2001 From: Jonathan Prykop Date: Fri, 18 Nov 2016 05:14:22 -0600 Subject: [PATCH] 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 ea1d391b1..1b6deec16 100644 --- a/FS/FS/Conf.pm +++ b/FS/FS/Conf.pm @@ -770,13 +770,6 @@ my $validate_email = sub { $_[0] =~ }, { - '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', 'description' => 'Add a credit card surcharge to invoices, as a % of the invoice total. WARNING: Although recently permitted to US merchants in general, specific consumer protection laws may prohibit or restrict this practice in California, Colorado, Connecticut, Florda, Kansas, Maine, Massachusetts, New York, Oklahome, and Texas. Surcharging is also generally prohibited in most countries outside the US, AU and UK. When allowed, typically not permitted to be above 4%.', diff --git a/FS/FS/Upgrade.pm b/FS/FS/Upgrade.pm index fce46335b..0113bf92a 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'); @@ -386,6 +390,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 c008c2dd3..d57be11ab 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'); @@ -2324,46 +2325,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 @@ -2373,24 +2389,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, @@ -2399,7 +2410,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; @@ -2420,66 +2431,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}; @@ -2500,7 +2522,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'); @@ -2511,6 +2533,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 -- 2.11.0