From da820d8c8837dce295e7cbd61accc22c4c019e14 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(-) (limited to 'FS') 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 51af38bb6..1b6deec16 100644 --- a/FS/FS/Conf.pm +++ b/FS/FS/Conf.pm @@ -2185,8 +2185,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 3f3b222c0..ff1622cb4 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 eb58fee531cc006272224446e5a518085c4ec9be 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(-) (limited to 'FS') 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 ca870678fbcc49f24e3ccbba899c974938c77336 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(-) (limited to 'FS') 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 @@ -769,6 +769,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 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') -- cgit v1.2.1