From d860f85d2a4823a040996f9b1055e2d4054b864e Mon Sep 17 00:00:00 2001 From: Ivan Kohler Date: Mon, 2 May 2016 15:15:01 -0700 Subject: [PATCH] verify credit card changes via $1 auth, RT#37632 --- FS/FS/Conf.pm | 7 + FS/FS/cust_main/Billing_Realtime.pm | 321 ++++++++++++++++++++++++++++++++++-- 2 files changed, 313 insertions(+), 15 deletions(-) diff --git a/FS/FS/Conf.pm b/FS/FS/Conf.pm index 9a7558246..433a9a52e 100644 --- a/FS/FS/Conf.pm +++ b/FS/FS/Conf.pm @@ -975,6 +975,13 @@ my $validate_email = sub { $_[0] =~ }, { + 'key' => 'business-onlinepayment-verification', + 'section' => 'billing', + 'description' => 'Run a $1 authorization (followed by a void) to verify new credit card information.', + 'type' => 'checkbox', + }, + + { 'key' => 'currency', 'section' => 'billing', 'description' => 'Main accounting currency', diff --git a/FS/FS/cust_main/Billing_Realtime.pm b/FS/FS/cust_main/Billing_Realtime.pm index 1cd2aba5b..b892e97c2 100644 --- a/FS/FS/cust_main/Billing_Realtime.pm +++ b/FS/FS/cust_main/Billing_Realtime.pm @@ -73,14 +73,14 @@ sub realtime_cust_payby { =item realtime_collect [ OPTION => VALUE ... ] Attempt to collect the customer's current balance with a realtime credit -card, electronic check, or phone bill transaction (see realtime_bop() below). +card or electronic check transaction (see realtime_bop() below). Returns the result of realtime_bop(): nothing, an error message, or a hashref of state information for a third-party transaction. Available options are: I, I, I, I, I, I, I, I, I -I is one of: I, I and I. If none is specified +I is one of: I or I. If none is specified then it is deduced from the customer record. If no I is specified, then the customer balance is used. @@ -133,13 +133,13 @@ sub realtime_collect { =item realtime_bop { [ ARG => VALUE ... ] } -Runs a realtime credit card, ACH (electronic check) or phone bill transaction +Runs a realtime credit card or ACH (electronic check) transaction via a Business::OnlinePayment realtime gateway. See L for supported gateways. Required arguments in the hashref are I, and I -Available methods are: I, I, I, and I +Available methods are: I, I, or I Available optional arguments are: I, I, I, I, I, I, I @@ -358,7 +358,6 @@ sub _bop_content { my %bop_method2payby = ( 'CC' => 'CARD', 'ECHECK' => 'CHEK', - 'LEC' => 'LECB', 'PAYPAL' => 'PPAL', ); @@ -575,8 +574,6 @@ sub realtime_bop { ? $options{'ss'} : $self->ss; - } elsif ( $options{method} eq 'LEC' ) { - $content{phone} = $options{payinfo}; } else { die "unknown method ". $options{method}; } @@ -870,8 +867,8 @@ sub fake_bop { # item _realtime_bop_result CUST_PAY_PENDING, BOP_OBJECT [ OPTION => VALUE ... ] # -# Wraps up processing of a realtime credit card, ACH (electronic check) or -# phone bill transaction. +# Wraps up processing of a realtime credit card or ACH (electronic check) +# transaction. sub _realtime_bop_result { my( $self, $cust_pay_pending, $transaction, %options ) = @_; @@ -1167,8 +1164,8 @@ sub _realtime_bop_result { =item realtime_botpp_capture CUST_PAY_PENDING [ OPTION => VALUE ... ] -Verifies successful third party processing of a realtime credit card, -ACH (electronic check) or phone bill transaction via a +Verifies successful third party processing of a realtime credit card or +ACH (electronic check) transaction via a Business::OnlineThirdPartyPayment realtime gateway. See L for supported gateways. @@ -1325,11 +1322,11 @@ sub default_payment_gateway { =item realtime_refund_bop METHOD [ OPTION => VALUE ... ] -Refunds a realtime credit card, ACH (electronic check) or phone bill transaction +Refunds a realtime credit card or ACH (electronic check) transaction via a Business::OnlinePayment realtime gateway. See L for supported gateways. -Available methods are: I, I and I +Available methods are: I or I Available options are: I, I, I, I @@ -1623,8 +1620,7 @@ sub realtime_refund_bop { $content{account_name} = $payname; $content{customer_org} = $self->company ? 'B' : 'I'; $content{customer_ssn} = $self->ss; - } elsif ( $options{method} eq 'LEC' ) { - $content{phone} = $payinfo = $self->payinfo; + } #then try refund @@ -1698,6 +1694,301 @@ sub realtime_refund_bop { } +=item realtime_verify_bop [ OPTION => VALUE ... ] + +Runs an authorization-only transaction for $1 against this credit card (if +successful, immediatly reverses the authorization). + +Returns the empty string if the authorization was sucessful, or an error +message otherwise. + +I + +I + +I specifies the expiration date for a credit card overriding the +value from the customer record or the payment record. Specified as yyyy-mm-dd + +#The additional options I, I, I, I, +#I are also available. Any of these options, +#if set, will override the value from the customer record. + +=cut + +#Available methods are: I or I + +#some false laziness w/realtime_bop and realtime_refund_bop, not enough to make +#it worth merging but some useful small subs should be pulled out +sub realtime_verify_bop { + my $self = shift; + + local($DEBUG) = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG; + + my %options = (); + if (ref($_[0]) eq 'HASH') { + %options = %{$_[0]}; + } else { + %options = @_; + } + + if ( $DEBUG ) { + warn "$me realtime_verify_bop\n"; + warn " $_ => $options{$_}\n" foreach keys %options; + } + + ### + # select a gateway + ### + + my $payment_gateway = $self->_payment_gateway( \%options ); + my $namespace = $payment_gateway->gateway_namespace; + + eval "use $namespace"; + die $@ if $@; + + ### + # check for banned credit card/ACH + ### + + my $ban = FS::banned_pay->ban_search( + 'payby' => $bop_method2payby{'CC'}, + 'payinfo' => $options{payinfo}, + ); + return "Banned credit card" if $ban && $ban->bantype ne 'warn'; + + ### + # massage data + ### + + my $bop_content = $self->_bop_content(\%options); + return $bop_content unless ref($bop_content); + + my @invoicing_list = $self->invoicing_list_emailonly; + if ( $conf->exists('emailinvoiceautoalways') + || $conf->exists('emailinvoiceauto') && ! @invoicing_list + || ( $conf->exists('emailinvoiceonly') && ! @invoicing_list ) ) { + push @invoicing_list, $self->all_emails; + } + + my $email = ($conf->exists('business-onlinepayment-email-override')) + ? $conf->config('business-onlinepayment-email-override') + : $invoicing_list[0]; + + my $paydate = ''; + my %content = (); + + if ( $namespace eq 'Business::OnlinePayment' ) { + + if ( $options{method} eq 'CC' ) { + + $content{card_number} = $options{payinfo}; + $paydate = exists($options{'paydate'}) + ? $options{'paydate'} + : $self->paydate; + $paydate =~ /^\d{2}(\d{2})[\/\-](\d+)[\/\-]\d+$/; + $content{expiration} = "$2/$1"; + + my $paycvv = exists($options{'paycvv'}) + ? $options{'paycvv'} + : $self->paycvv; + $content{cvv2} = $paycvv + if length($paycvv); + + my $paystart_month = exists($options{'paystart_month'}) + ? $options{'paystart_month'} + : $self->paystart_month; + + my $paystart_year = exists($options{'paystart_year'}) + ? $options{'paystart_year'} + : $self->paystart_year; + + $content{card_start} = "$paystart_month/$paystart_year" + if $paystart_month && $paystart_year; + + my $payissue = exists($options{'payissue'}) + ? $options{'payissue'} + : $self->payissue; + $content{issue_number} = $payissue if $payissue; + + } elsif ( $options{method} eq 'ECHECK' ){ + + #nop for checks (though it shouldn't be called...) + + } else { + die "unknown method ". $options{method}; + } + + } elsif ( $namespace eq 'Business::OnlineThirdPartyPayment' ) { + #move along + } else { + die "unknown namespace $namespace"; + } + + ### + # run transaction(s) + ### + + warn "claiming mutex on customer ". $self->custnum. "\n" if $DEBUG > 1; + $self->select_for_update; #mutex ... just until we get our pending record in + warn "obtained mutex on customer ". $self->custnum. "\n" if $DEBUG > 1; + + #the checks here are intended to catch concurrent payments + #double-form-submission prevention is taken care of in cust_pay_pending::check + + #also check and make sure there aren't *other* pending payments for this cust + + my @pending = qsearch('cust_pay_pending', { + 'custnum' => $self->custnum, + 'status' => { op=>'!=', value=>'done' } + }); + + return "A payment is already being processed for this customer (". + join(', ', map 'paypendingnum '. $_->paypendingnum, @pending ). + "); verification transaction aborted." + if scalar(@pending); + + #okay, good to go, if we're a duplicate, cust_pay_pending will kick us out + + my $cust_pay_pending = new FS::cust_pay_pending { + 'custnum' => $self->custnum, + 'paid' => '1.00', + '_date' => '', + 'payby' => $bop_method2payby{'CC'}, + 'payinfo' => $options{payinfo}, + 'paymask' => $options{paymask}, + 'paydate' => $paydate, + #'recurring_billing' => $content{recurring_billing}, + 'pkgnum' => $options{'pkgnum'}, + 'status' => 'new', + 'gatewaynum' => $payment_gateway->gatewaynum || '', + 'session_id' => $options{session_id} || '', + #'jobnum' => $options{depend_jobnum} || '', + }; + $cust_pay_pending->payunique( $options{payunique} ) + if defined($options{payunique}) && length($options{payunique}); + + warn "inserting cust_pay_pending record for customer ". $self->custnum. "\n" + if $DEBUG > 1; + my $cpp_new_err = $cust_pay_pending->insert; #mutex lost when this is inserted + return $cpp_new_err if $cpp_new_err; + + warn "inserted cust_pay_pending record for customer ". $self->custnum. "\n" + if $DEBUG > 1; + warn Dumper($cust_pay_pending) if $DEBUG > 2; + + my $transaction = new $namespace( $payment_gateway->gateway_module, + $self->_bop_options(\%options), + ); + + $transaction->content( + 'type' => 'CC', + $self->_bop_auth(\%options), + 'action' => 'Authorization Only', + 'description' => $options{'description'}, + 'amount' => '1.00', + #'invoice_number' => $options{'invnum'}, + 'customer_id' => $self->custnum, + %$bop_content, + 'reference' => $cust_pay_pending->paypendingnum, #for now + 'callback_url' => $payment_gateway->gateway_callback_url, + 'cancel_url' => $payment_gateway->gateway_cancel_url, + 'email' => $email, + %content, #after + ); + + $cust_pay_pending->status('pending'); + my $cpp_pending_err = $cust_pay_pending->replace; + return $cpp_pending_err if $cpp_pending_err; + + warn Dumper($transaction) if $DEBUG > 2; + + unless ( $BOP_TESTING ) { + $transaction->test_transaction(1) + if $conf->exists('business-onlinepayment-test_transaction'); + $transaction->submit(); + } else { + if ( $BOP_TESTING_SUCCESS ) { + $transaction->is_success(1); + $transaction->authorization('fake auth'); + } else { + $transaction->is_success(0); + $transaction->error_message('fake failure'); + } + } + + if ( $transaction->is_success() ) { + + $cust_pay_pending->status('authorized'); + my $cpp_authorized_err = $cust_pay_pending->replace; + return $cpp_authorized_err if $cpp_authorized_err; + + my $auth = $transaction->authorization; + my $ordernum = $transaction->can('order_number') + ? $transaction->order_number + : ''; + + my $reverse = new $namespace( $payment_gateway->gateway_module, + $self->_bop_options(\%options), + ); + + $reverse->content( 'action' => 'Reverse Authorization', + + # B:OP + 'authorization' => $transaction->authorization, + 'order_number' => $ordernum, + + # vsecure + 'result_code' => $transaction->result_code, + 'txn_date' => $transaction->txn_date, + + %content, + ); + $reverse->test_transaction(1) + if $conf->exists('business-onlinepayment-test_transaction'); + $reverse->submit(); + + if ( $reverse->is_success ) { + + $cust_pay_pending->status('done'); + my $cpp_authorized_err = $cust_pay_pending->replace; + return $cpp_authorized_err if $cpp_authorized_err; + + } else { + + my $e = "Authorization successful but reversal failed, custnum #". + $self->custnum. ': '. $reverse->result_code. + ": ". $reverse->error_message; + warn $e; + return $e; + + } + + } + + ### + # Tokenize + ### + + if ( $transaction->can('card_token') && $transaction->card_token ) { + + if ( $options{'payinfo'} eq $self->payinfo ) { + $self->payinfo($transaction->card_token); + my $error = $self->replace; + if ( $error ) { + warn "WARNING: error storing token: $error, but proceeding anyway\n"; + } + } + + } + + ### + # result handling + ### + + $transaction->is_success() ? '' : $transaction->error_message(); + +} + =back =head1 BUGS -- 2.11.0