From: Jonathan Prykop Date: Tue, 15 Nov 2016 09:08:29 +0000 (-0600) Subject: 71513: Card tokenization [remove_card_numbers subroutine] X-Git-Url: http://git.freeside.biz/gitweb/?p=freeside.git;a=commitdiff_plain;h=ca870678fbcc49f24e3ccbba899c974938c77336 71513: Card tokenization [remove_card_numbers subroutine] --- diff --git a/FS/FS/Conf.pm b/FS/FS/Conf.pm index 1b6deec16..ea1d391b1 100644 --- a/FS/FS/Conf.pm +++ b/FS/FS/Conf.pm @@ -770,6 +770,13 @@ 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/cust_main/Billing_Realtime.pm b/FS/FS/cust_main/Billing_Realtime.pm index ff1622cb4..c008c2dd3 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'} && grep /^Tokenize$/, @{$supported_actions{'CC'}}; @@ -2318,12 +2324,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')