X-Git-Url: http://git.freeside.biz/gitweb/?a=blobdiff_plain;f=FS%2FFS%2Fcust_main%2FBilling_Realtime.pm;h=c008c2dd3312d6a6c94d751a95b999e4b8d874b1;hb=ca870678fbcc49f24e3ccbba899c974938c77336;hp=ced3b236fe23086dab11c4ef5cb3e78dcf947e47;hpb=c5a2d3613acdc3b9ab6f32eaf5316c2834071417;p=freeside.git diff --git a/FS/FS/cust_main/Billing_Realtime.pm b/FS/FS/cust_main/Billing_Realtime.pm index ced3b236f..c008c2dd3 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', @@ -375,29 +367,18 @@ sub _bop_content { \%content; } +# updates payinfo and cust_payby options with token from transaction sub _tokenize_card { - my ($self,$transaction,$cust_payby,$log,%opt) = @_; - - if ( $cust_payby - and $transaction->can('card_token') + my ($self,$transaction,$options) = @_; + if ( $transaction->can('card_token') and $transaction->card_token - and $cust_payby->payinfo !~ /^99\d{14}$/ #not already tokenized + and !$self->tokenized($options->{'payinfo'}) ) { - - $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; + $options->{'cust_payby'}->payinfo($transaction->card_token) if $options->{'cust_payby'}; + return $transaction->card_token; } - + return ''; } my %bop_method2payby = ( @@ -430,14 +411,29 @@ 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 (($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'} && $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'}); + } + ### # optional credit card surcharge ### my $cc_surcharge = 0; my $cc_surcharge_pct = 0; - $cc_surcharge_pct = $conf->config('credit-card-surcharge-percentage') - if $conf->config('credit-card-surcharge-percentage') + $cc_surcharge_pct = $conf->config('credit-card-surcharge-percentage', $self->agentnum) + if $conf->config('credit-card-surcharge-percentage', $self->agentnum) && $options{method} eq 'CC'; # always add cc surcharge if called from event @@ -803,6 +799,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"; } } @@ -811,8 +809,17 @@ sub realtime_bop { # Tokenize ### - my $error = $self->_tokenize_card($transaction,$options{'cust_payby'},$log,'replace' => 1); - return $error if $error; + # 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); + 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 @@ -849,9 +856,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', @@ -911,7 +916,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 post-transaction tokenization, if that happens return $cpp_captured_err if $cpp_captured_err; if ( $transaction->is_success() ) { @@ -1760,6 +1765,17 @@ 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 (($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'}); + } + ### # select a gateway ### @@ -2110,12 +2126,24 @@ 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, - #because cust_payby->replace uses realtime_verify_bop! - $self->_tokenize_card($transaction,$options{'cust_payby'},$log); + # 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_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! + } ### # result handling @@ -2129,20 +2157,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 { @@ -2152,17 +2185,19 @@ 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 $options{payinfo} =~ /^99\d{14}$/; #already tokenized + return '' if $self->tokenized($options{payinfo}); #already tokenized ### # select a gateway @@ -2181,13 +2216,15 @@ 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), ); + return '' unless $transaction->can('info'); + my %supported_actions = $transaction->info('supported_actions'); - return '' unless $supported_actions{'CC'} and grep(/^Tokenize$/,@{$supported_actions{'CC'}}); + return '' unless $supported_actions{'CC'} + && grep /^Tokenize$/, @{$supported_actions{'CC'}}; ### # check for banned credit card/ACH @@ -2251,13 +2288,20 @@ 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{'cust_payby'},$log); + $self->_tokenize_card($transaction,$outoptions); } else { - $error = $transaction->error_message || 'Unknown error'; + $error = $transaction->error_message || 'Unknown error when tokenizing card'; } @@ -2265,12 +2309,220 @@ sub realtime_tokenize { } + +=item tokenized PAYINFO + +Convenience wrapper for L + +PAYINFO is required + +=cut + +sub tokenized { + my $this = shift; + my $payinfo = shift; + 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