X-Git-Url: http://git.freeside.biz/gitweb/?p=freeside.git;a=blobdiff_plain;f=FS%2FFS%2Fcust_main%2FBilling_Realtime.pm;h=9fea1bb332f588e83e70ee30c0a543af60d06eb3;hp=4a9d2c685204a4bc8dfb49c1fc1af1e4f4f0722c;hb=95502dabd865c34d1483b20c583523b12fe9332d;hpb=5250c44bd7f282c7d782bf0e8349af12376929df diff --git a/FS/FS/cust_main/Billing_Realtime.pm b/FS/FS/cust_main/Billing_Realtime.pm index 4a9d2c685..9fea1bb33 100644 --- a/FS/FS/cust_main/Billing_Realtime.pm +++ b/FS/FS/cust_main/Billing_Realtime.pm @@ -3,15 +3,17 @@ package FS::cust_main::Billing_Realtime; use strict; use vars qw( $conf $DEBUG $me ); use vars qw( $realtime_bop_decline_quiet ); #ugh -use Digest::MD5 qw(md5_base64); +use Carp; +use Data::Dumper; use Business::CreditCard 0.28; use FS::UID qw( dbh ); use FS::Record qw( qsearch qsearchs ); -use FS::Misc qw( send_email ); use FS::payby; use FS::cust_pay; use FS::cust_pay_pending; +use FS::cust_bill_pay; use FS::cust_refund; +use FS::banned_pay; $realtime_bop_decline_quiet = 0; @@ -21,6 +23,9 @@ $realtime_bop_decline_quiet = 0; $DEBUG = 0; $me = '[FS::cust_main::Billing_Realtime]'; +our $BOP_TESTING = 0; +our $BOP_TESTING_SUCCESS = 1; + install_callback FS::UID sub { $conf = new FS::Conf; #yes, need it for stuff below (prolly should be cached) @@ -40,20 +45,42 @@ These methods are available on FS::cust_main objects. =over 4 -=item realtime_collect [ OPTION => VALUE ... ] +=item realtime_cust_payby -Runs a realtime credit card, ACH (electronic check) or phone bill transaction -via a Business::OnlinePayment or Business::OnlineThirdPartyPayment realtime -gateway. See L and -L for supported gateways. +=cut + +sub realtime_cust_payby { + my( $self, %options ) = @_; + + local($DEBUG) = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG; + + $options{amount} = $self->balance unless exists( $options{amount} ); + + my @cust_payby = $self->cust_payby('CARD','CHEK'); + + my $error; + foreach my $cust_payby (@cust_payby) { + $error = $cust_payby->realtime_bop( %options, ); + last unless $error; + } -On failure returns an error message. + #XXX what about the earlier errors? -Returns false or a hashref upon success. The hashref contains keys popup_url reference, and collectitems. The first is a URL to which a browser should be redirected for completion of collection. The second is a reference id for the transaction suitable for the end user. The collectitems is a reference to a list of name value pairs suitable for assigning to a html form and posted to popup_url. + $error; + +} + +=item realtime_collect [ OPTION => VALUE ... ] + +Attempt to collect the customer's current balance with a realtime credit +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. @@ -67,12 +94,11 @@ the value defined by the business-onlinepayment-description configuration option, or "Internet services" if that is unset. If an I is specified, this payment (if successful) is applied to the -specified invoice. If you don't specify an I you might want to -call the B method or set the I option. +specified invoice. -I can be set to true to apply a resulting payment. +I will automatically apply a resulting payment. -I can be set true to surpress email decline notices. +I can be set true to suppress email decline notices. I can be set to a scalar reference. It will be filled in with the resulting paynum, if any. @@ -88,12 +114,16 @@ I allows payment capture to unlock export jobs sub realtime_collect { my( $self, %options ) = @_; + local($DEBUG) = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG; + if ( $DEBUG ) { warn "$me realtime_collect:\n"; warn " $_ => $options{$_}\n" foreach keys %options; } $options{amount} = $self->balance unless exists( $options{amount} ); + return '' unless $options{amount} > 0; + $options{method} = FS::payby->payby2bop($self->payby) unless exists( $options{method} ); @@ -103,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 and I +Available methods are: I, I, or I Available optional arguments are: I, I, I, I, I, I, I @@ -122,10 +152,17 @@ the value defined by the business-onlinepayment-description configuration option, or "Internet services" if that is unset. If an I is specified, this payment (if successful) is applied to the -specified invoice. If you don't specify an I you might want to -call the B method or set the I option. +specified invoice. If the customer has exactly one open invoice, that +invoice number will be assumed. If you don't specify an I you might +want to call the B method or set the I option. -I can be set to true to apply a resulting payment. +I can be set to true to prevent that default invnum from being set. + +I can be set to true to run B on success. + +I can be set to true to set that flag on the resulting payment +(prevents payment from being applied by B or B, +but will still be applied if I exists...use with I for intended effect.) I can be set true to surpress email decline notices. @@ -138,13 +175,33 @@ I is a session identifier associated with this payment. I allows payment capture to unlock export jobs -I attempts to take a discount by prepaying for discount_term +I attempts to take a discount by prepaying for discount_term. +The payment will fail if I is incorrect for this discount term. + +A direct (Business::OnlinePayment) transaction will return nothing on success, +or an error message on failure. + +A third-party transaction will return a hashref containing: + +- popup_url: the URL to which a browser should be redirected to complete + the transaction. +- collectitems: an arrayref of name-value pairs to be posted to popup_url. +- reference: a reference ID for the transaction, to show the customer. (moved from cust_bill) (probably should get realtime_{card,ach,lec} here too) =cut # some helper routines +# +# _bop_recurring_billing: Checks whether this payment should have the +# recurring_billing flag used by some B:OP interfaces (IPPay, PlugnPay, +# vSecure, etc.). This works in two different modes: +# - actual_oncard (default): treat the payment as recurring if the customer +# has made a payment using this card before. +# - transaction_is_recur: treat the payment as recurring if the invoice +# being paid has any recurring package charges. + sub _bop_recurring_billing { my( $self, %opt ) = @_; @@ -156,15 +213,8 @@ sub _bop_recurring_billing { } else { - my %hash = ( 'custnum' => $self->custnum, - 'payby' => 'CARD', - ); - - return 1 - if qsearch('cust_pay', { %hash, 'payinfo' => $opt{'payinfo'} } ) - || qsearch('cust_pay', { %hash, 'paymask' => $self->mask_payinfo('CARD', - $opt{'payinfo'} ) - } ); + # return 1 if the payinfo has been used for another payment + return $self->payinfo_used($opt{'payinfo'}); # in payinfo_Mixin } @@ -175,6 +225,21 @@ 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', + { 'gatewaynum' => $options->{'fake_gatewaynum'}, } + ); + } + $options->{payment_gateway} = $self->agent->payment_gateway( %$options ) unless exists($options->{payment_gateway}); @@ -214,8 +279,18 @@ sub _bop_defaults { } } - $options->{payinfo} = $self->payinfo unless exists( $options->{payinfo} ); - $options->{invnum} ||= ''; + unless ( exists( $options->{'payinfo'} ) ) { + $options->{'payinfo'} = $self->payinfo; + $options->{'paymask'} = $self->paymask; + } + + # Default invoice number if the customer has exactly one open invoice. + unless ( $options->{'invnum'} || $options->{'no_invnum'} ) { + $options->{'invnum'} = ''; + my @open = $self->open_cust_bill; + $options->{'invnum'} = $open[0]->invnum if scalar(@open) == 1; + } + $options->{payname} = $self->payname unless exists( $options->{payname} ); } @@ -271,21 +346,30 @@ sub _bop_content { ? $options->{country} : $self->country; - $content{referer} = 'http://cleanwhisker.420.am/'; #XXX fix referer :/ $content{phone} = $self->daytime || $self->night; + my $currency = $conf->exists('business-onlinepayment-currency') + && $conf->config('business-onlinepayment-currency'); + $content{currency} = $currency if $currency; + \%content; } my %bop_method2payby = ( 'CC' => 'CARD', 'ECHECK' => 'CHEK', - 'LEC' => 'LECB', + 'PAYPAL' => 'PPAL', ); sub realtime_bop { my $self = shift; + confess "Can't call realtime_bop within another transaction ". + '($FS::UID::AutoCommit is false)' + unless $FS::UID::AutoCommit; + + local($DEBUG) = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG; + my %options = (); if (ref($_[0]) eq 'HASH') { %options = %{$_[0]}; @@ -295,13 +379,43 @@ sub realtime_bop { $options{method} = $method; $options{amount} = $amount; } + + + ### + # 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') + && $options{method} eq 'CC'; + + # always add cc surcharge if called from event + if($options{'cc_surcharge_from_event'} && $cc_surcharge_pct > 0) { + $cc_surcharge = $options{'amount'} * $cc_surcharge_pct / 100; + $options{'amount'} += $cc_surcharge; + $options{'amount'} = sprintf("%.2f", $options{'amount'}); # round (again)? + } + elsif($cc_surcharge_pct > 0) { # we're called not from event (i.e. from a + # payment screen), so consider the given + # amount as post-surcharge + $cc_surcharge = $options{'amount'} - ($options{'amount'} / ( 1 + $cc_surcharge_pct/100 )); + } + $cc_surcharge = sprintf("%.2f",$cc_surcharge) if $cc_surcharge > 0; + $options{'cc_surcharge'} = $cc_surcharge; + + if ( $DEBUG ) { warn "$me realtime_bop (new): $options{method} $options{amount}\n"; + warn " cc_surcharge = $cc_surcharge\n"; + } + if ( $DEBUG > 2 ) { warn " $_ => $options{$_}\n" foreach keys %options; } - return $self->fake_bop(%options) if $options{'fake'}; + return $self->fake_bop(\%options) if $options{'fake'}; $self->_bop_defaults(\%options); @@ -340,11 +454,29 @@ sub realtime_bop { # check for banned credit card/ACH ### - my $ban = qsearchs('banned_pay', { + my $ban = FS::banned_pay->ban_search( 'payby' => $bop_method2payby{$options{method}}, - 'payinfo' => md5_base64($options{payinfo}), - } ); - return "Banned credit card" if $ban; + 'payinfo' => $options{payinfo}, + ); + return "Banned credit card" if $ban && $ban->bantype ne 'warn'; + + ### + # check for term discount validity + ### + + my $discount_term = $options{discount_term}; + if ( $discount_term ) { + my $bill = ($self->cust_bill)[-1] + or return "Can't apply a term discount to an unbilled customer"; + my $plan = FS::discount_plan->new( + cust_bill => $bill, + months => $discount_term + ) or return "No discount available for term '$discount_term'"; + + if ( $plan->discounted_total != $options{amount} ) { + return "Incorrect term prepayment amount (term $discount_term, amount $options{amount}, requires ".$plan->discounted_total.")"; + } + } ### # massage data @@ -366,76 +498,89 @@ sub realtime_bop { my $paydate = ''; my %content = (); - if ( $namespace eq 'Business::OnlinePayment' && $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; - - if ( $self->_bop_recurring_billing( 'payinfo' => $options{'payinfo'}, - 'trans_is_recur' => $trans_is_recur, - ) - ) - { - $content{recurring_billing} = 'YES'; - $content{acct_code} = 'rebill' - if $conf->exists('credit_card-recurring_billing_acct_code'); - } - } elsif ( $namespace eq 'Business::OnlinePayment' && $options{method} eq 'ECHECK' ){ - ( $content{account_number}, $content{routing_code} ) = - split('@', $options{payinfo}); - $content{bank_name} = $options{payname}; - $content{bank_state} = exists($options{'paystate'}) - ? $options{'paystate'} - : $self->getfield('paystate'); - $content{account_type} = exists($options{'paytype'}) - ? uc($options{'paytype'}) || 'CHECKING' - : uc($self->getfield('paytype')) || 'CHECKING'; - $content{account_name} = $self->getfield('first'). ' '. - $self->getfield('last'); + 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"; + + $content{cvv2} = $options{'paycvv'} + if length($options{'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; + + if ( $self->_bop_recurring_billing( + 'payinfo' => $options{'payinfo'}, + 'trans_is_recur' => $trans_is_recur, + ) + ) + { + $content{recurring_billing} = 'YES'; + $content{acct_code} = 'rebill' + if $conf->exists('credit_card-recurring_billing_acct_code'); + } + + } elsif ( $options{method} eq 'ECHECK' ){ + + ( $content{account_number}, $content{routing_code} ) = + split('@', $options{payinfo}); + $content{bank_name} = $options{payname}; + $content{bank_state} = exists($options{'paystate'}) + ? $options{'paystate'} + : $self->getfield('paystate'); + $content{account_type}= + (exists($options{'paytype'}) && $options{'paytype'}) + ? uc($options{'paytype'}) + : uc($self->getfield('paytype')) || 'PERSONAL CHECKING'; + + $content{company} = $self->company if $self->company; + + if ( $content{account_type} =~ /BUSINESS/i && $self->company ) { + $content{account_name} = $self->company; + } else { + $content{account_name} = $self->getfield('first'). ' '. + $self->getfield('last'); + } + + $content{customer_org} = $self->company ? 'B' : 'I'; + $content{state_id} = exists($options{'stateid'}) + ? $options{'stateid'} + : $self->getfield('stateid'); + $content{state_id_state} = exists($options{'stateid_state'}) + ? $options{'stateid_state'} + : $self->getfield('stateid_state'); + $content{customer_ssn} = exists($options{'ss'}) + ? $options{'ss'} + : $self->ss; + + } else { + die "unknown method ". $options{method}; + } - $content{customer_org} = $self->company ? 'B' : 'I'; - $content{state_id} = exists($options{'stateid'}) - ? $options{'stateid'} - : $self->getfield('stateid'); - $content{state_id_state} = exists($options{'stateid_state'}) - ? $options{'stateid_state'} - : $self->getfield('stateid_state'); - $content{customer_ssn} = exists($options{'ss'}) - ? $options{'ss'} - : $self->ss; - } elsif ( $namespace eq 'Business::OnlinePayment' && $options{method} eq 'LEC' ) { - $content{phone} = $options{payinfo}; } elsif ( $namespace eq 'Business::OnlineThirdPartyPayment' ) { #move along } else { - #die an evil death + die "unknown namespace $namespace"; } ### @@ -446,7 +591,9 @@ sub realtime_bop { ? $options{'balance'} : $self->balance; + 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 @@ -454,7 +601,6 @@ sub realtime_bop { #check the balance return "The customer's balance has changed; $options{method} transaction aborted." if $self->balance < $balance; - #&& $self->balance < $options{amount}; #might as well anyway? #also check and make sure there aren't *other* pending payments for this cust @@ -462,6 +608,19 @@ sub realtime_bop { 'custnum' => $self->custnum, 'status' => { op=>'!=', value=>'done' } }); + + #for third-party payments only, remove pending payments if they're in the + #'thirdparty' (waiting for customer action) state. + if ( $namespace eq 'Business::OnlineThirdPartyPayment' ) { + foreach ( grep { $_->status eq 'thirdparty' } @pending ) { + my $error = $_->delete; + warn "error deleting unfinished third-party payment ". + $_->paypendingnum . ": $error\n" + if $error; + } + @pending = grep { $_->status ne 'thirdparty' } @pending; + } + return "A payment is already being processed for this customer (". join(', ', map 'paypendingnum '. $_->paypendingnum, @pending ). "); $options{method} transaction aborted." @@ -471,11 +630,11 @@ sub realtime_bop { my $cust_pay_pending = new FS::cust_pay_pending { 'custnum' => $self->custnum, - #'invnum' => $options{'invnum'}, 'paid' => $options{amount}, '_date' => '', 'payby' => $bop_method2payby{$options{method}}, 'payinfo' => $options{payinfo}, + 'paymask' => $options{paymask}, 'paydate' => $paydate, 'recurring_billing' => $content{recurring_billing}, 'pkgnum' => $options{'pkgnum'}, @@ -486,9 +645,16 @@ sub realtime_bop { }; $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( $action1, $action2 ) = split( /\s*\,\s*/, $payment_gateway->gateway_action ); @@ -506,6 +672,8 @@ sub realtime_bop { '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 ); @@ -514,9 +682,7 @@ sub realtime_bop { my $cpp_pending_err = $cust_pay_pending->replace; return $cpp_pending_err if $cpp_pending_err; - #config? - my $BOP_TESTING = 0; - my $BOP_TESTING_SUCCESS = 1; + warn Dumper($transaction) if $DEBUG > 2; unless ( $BOP_TESTING ) { $transaction->test_transaction(1) @@ -534,6 +700,9 @@ sub realtime_bop { if ( $transaction->is_success() && $namespace eq 'Business::OnlineThirdPartyPayment' ) { + $cust_pay_pending->status('thirdparty'); + my $cpp_err = $cust_pay_pending->replace; + return { error => $cpp_err } if $cpp_err; return { reference => $cust_pay_pending->paypendingnum, map { $_ => $transaction->$_ } qw ( popup_url collectitems ) }; @@ -591,12 +760,11 @@ sub realtime_bop { # remove paycvv after initial transaction ### - #false laziness w/misc/process/payment.cgi - check both to make sure working - # correctly - if ( length($self->paycvv) + # compare to FS::cust_main::save_cust_payby - check both to make sure working correctly + if ( length($options{'paycvv'}) && ! grep { $_ eq cardtype($options{payinfo}) } $conf->config('cvv-save') ) { - my $error = $self->remove_cvv; + my $error = $self->remove_cvv_from_cust_payby($options{payinfo}); if ( $error ) { warn "WARNING: error removing cvv: $error\n"; } @@ -609,8 +777,6 @@ sub realtime_bop { if ( $transaction->can('card_token') && $transaction->card_token ) { - $self->card_token($transaction->card_token); - if ( $options{'payinfo'} eq $self->payinfo ) { $self->payinfo($transaction->card_token); my $error = $self->replace; @@ -650,19 +816,6 @@ sub fake_bop { return "Error: No error; test failure requested with fake_failure"; } - #my $paybatch = ''; - #if ( $payment_gateway->gatewaynum ) { # agent override - # $paybatch = $payment_gateway->gatewaynum. '-'; - #} - # - #$paybatch .= "$processor:". $transaction->authorization; - # - #$paybatch .= ':'. $transaction->order_number - # if $transaction->can('order_number') - # && length($transaction->order_number); - - my $paybatch = 'FakeProcessor:54:32'; - my $cust_pay = new FS::cust_pay ( { 'custnum' => $self->custnum, 'invnum' => $options{'invnum'}, @@ -671,12 +824,19 @@ sub fake_bop { 'payby' => $bop_method2payby{$options{method}}, #'payinfo' => $payinfo, 'payinfo' => '4111111111111111', - 'paybatch' => $paybatch, #'paydate' => $paydate, 'paydate' => '2012-05-01', + 'processor' => 'FakeProcessor', + 'auth' => '54', + 'order_number' => '32', } ); $cust_pay->payunique( $options{payunique} ) if length($options{payunique}); + if ( $DEBUG ) { + warn "fake_bop\n cust_pay: ". Dumper($cust_pay) . "\n options: "; + warn " $_ => $options{$_}\n" foreach keys %options; + } + my $error = $cust_pay->insert($options{'manual'} ? ( 'manual' => 1 ) : () ); if ( $error ) { @@ -706,11 +866,14 @@ 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 ) = @_; + + local($DEBUG) = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG; + if ( $DEBUG ) { warn "$me _realtime_bop_result: pending transaction ". $cust_pay_pending->paypendingnum. "\n"; @@ -726,17 +889,8 @@ sub _realtime_bop_result { if ( $transaction->is_success() ) { - my $paybatch = ''; - if ( $payment_gateway->gatewaynum ) { # agent override - $paybatch = $payment_gateway->gatewaynum. '-'; - } - - $paybatch .= $payment_gateway->gateway_module. ":". - $transaction->authorization; - - $paybatch .= ':'. $transaction->order_number - if $transaction->can('order_number') - && length($transaction->order_number); + my $order_number = $transaction->order_number + if $transaction->can('order_number'); my $cust_pay = new FS::cust_pay ( { 'custnum' => $self->custnum, @@ -745,10 +899,15 @@ sub _realtime_bop_result { '_date' => '', 'payby' => $cust_pay_pending->payby, 'payinfo' => $options{'payinfo'}, - 'paybatch' => $paybatch, + 'paymask' => $options{'paymask'} || $cust_pay_pending->paymask, 'paydate' => $cust_pay_pending->paydate, 'pkgnum' => $cust_pay_pending->pkgnum, - 'discount_term' => $options{'discount_term'}, + 'discount_term' => $options{'discount_term'}, + 'gatewaynum' => ($payment_gateway->gatewaynum || ''), + 'processor' => $payment_gateway->gateway_module, + 'auth' => $transaction->authorization, + 'order_number' => $order_number || '', + 'no_auto_apply' => $options{'no_auto_apply'} ? 'Y' : '', } ); #doesn't hurt to know, even though the dup check is in cust_pay_pending now $cust_pay->payunique( $options{payunique} ) @@ -763,13 +922,16 @@ sub _realtime_bop_result { my $error = $cust_pay->insert($options{'manual'} ? ( 'manual' => 1 ) : () ); if ( $error ) { + $dbh->rollback or die $dbh->errstr if $oldAutoCommit; $cust_pay->invnum(''); #try again with no specific invnum + $cust_pay->paynum(''); my $error2 = $cust_pay->insert( $options{'manual'} ? ( 'manual' => 1 ) : () ); if ( $error2 ) { # gah. but at least we have a record of the state we had to abort in # from cust_pay_pending now. + $dbh->rollback or die $dbh->errstr if $oldAutoCommit; my $e = "WARNING: $options{method} captured but payment not recorded -". " error inserting payment (". $payment_gateway->gateway_module. "): $error2". @@ -804,6 +966,8 @@ sub _realtime_bop_result { return $e; } + $cust_pay_pending->set('jobnum',''); + } if ( $options{'paynum_ref'} ) { @@ -837,14 +1001,73 @@ sub _realtime_bop_result { } } + # have a CC surcharge portion --> one-time charge + if ( $options{'cc_surcharge'} > 0 ) { + # XXX: this whole block needs to be in a transaction? + + my $invnum; + $invnum = $options{'invnum'} if $options{'invnum'}; + unless ( $invnum ) { # probably from a payment screen + # do we have any open invoices? pick earliest + # uses the fact that cust_main->cust_bill sorts by date ascending + my @open = $self->open_cust_bill; + $invnum = $open[0]->invnum if scalar(@open); + } + + unless ( $invnum ) { # still nothing? pick last closed invoice + # again uses fact that cust_main->cust_bill sorts by date ascending + my @closed = $self->cust_bill; + $invnum = $closed[$#closed]->invnum if scalar(@closed); + } + + unless ( $invnum ) { + # XXX: unlikely case - pre-paying before any invoices generated + # what it should do is create a new invoice and pick it + warn 'CC SURCHARGE AND NO INVOICES PICKED TO APPLY IT!'; + return ''; + } + + my $cust_pkg; + my $charge_error = $self->charge({ + 'amount' => $options{'cc_surcharge'}, + 'pkg' => 'Credit Card Surcharge', + 'setuptax' => 'Y', + 'cust_pkg_ref' => \$cust_pkg, + }); + if($charge_error) { + warn 'Unable to add CC surcharge cust_pkg'; + return ''; + } + + $cust_pkg->setup(time); + my $cp_error = $cust_pkg->replace; + if($cp_error) { + warn 'Unable to set setup time on cust_pkg for cc surcharge'; + # but keep going... + } + + my $cust_bill = qsearchs('cust_bill', { 'invnum' => $invnum }); + unless ( $cust_bill ) { + warn "race condition + invoice deletion just happened"; + return ''; + } + + my $grand_error = + $cust_bill->add_cc_surcharge($cust_pkg->pkgnum,$options{'cc_surcharge'}); + + warn "cannot add CC surcharge to invoice #$invnum: $grand_error" + if $grand_error; + } + return ''; #no error } } else { - my $perror = $payment_gateway->gateway_module. " error: ". - $transaction->error_message; + my $perror = $transaction->error_message; + #$payment_gateway->gateway_module. " error: ". + # removed for conciseness my $jobnum = $cust_pay_pending->jobnum; if ( $jobnum ) { @@ -853,8 +1076,9 @@ sub _realtime_bop_result { if ( $placeholder ) { my $error = $placeholder->depended_delete; $error ||= $placeholder->delete; + $cust_pay_pending->set('jobnum',''); warn "error removing provisioning jobs after declined paypendingnum ". - $cust_pay_pending->paypendingnum. "\n"; + $cust_pay_pending->paypendingnum. ": $error\n" if $error; } else { my $e = "error finding job $jobnum for declined paypendingnum ". $cust_pay_pending->paypendingnum. "\n"; @@ -894,10 +1118,10 @@ sub _realtime_bop_result { } if ( !$options{'quiet'} && !$realtime_bop_decline_quiet - && $conf->exists('emaildecline') + && $conf->exists('emaildecline', $self->agentnum) && grep { $_ ne 'POST' } $self->invoicing_list && ! grep { $transaction->error_message =~ /$_/ } - $conf->config('emaildecline-exclude') + $conf->config('emaildecline-exclude', $self->agentnum) ) { # Send a decline alert to the customer. @@ -910,31 +1134,7 @@ sub _realtime_bop_result { $error = $msg_template->send( 'cust_main' => $self, 'object' => $cust_pay_pending ); } - else { #!$msgnum - - my @templ = $conf->config('declinetemplate'); - my $template = new Text::Template ( - TYPE => 'ARRAY', - SOURCE => [ map "$_\n", @templ ], - ) or return "($perror) can't create template: $Text::Template::ERROR"; - $template->compile() - or return "($perror) can't compile template: $Text::Template::ERROR"; - - my $templ_hash = { - 'company_name' => - scalar( $conf->config('company_name', $self->agentnum ) ), - 'company_address' => - join("\n", $conf->config('company_address', $self->agentnum ) ), - 'error' => $transaction->error_message, - }; - - my $error = send_email( - 'from' => $conf->config('invoice_from', $self->agentnum ), - 'to' => [ grep { $_ ne 'POST' } $self->invoicing_list ], - 'subject' => 'Your payment could not be processed', - 'body' => [ $template->fill_in(HASH => $templ_hash) ], - ); - } + $perror .= " (also received error sending decline notification: $error)" if $error; @@ -942,7 +1142,11 @@ sub _realtime_bop_result { } $cust_pay_pending->status('done'); - $cust_pay_pending->statustext("declined: $perror"); + $cust_pay_pending->statustext($perror); + #'declined:': no, that's failure_status + if ( $transaction->can('failure_status') ) { + $cust_pay_pending->failure_status( $transaction->failure_status ); + } my $cpp_done_err = $cust_pay_pending->replace; if ( $cpp_done_err ) { my $e = "WARNING: $options{method} declined but pending payment not ". @@ -959,8 +1163,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. @@ -991,6 +1195,9 @@ upon success) and session_id of any associated session. sub realtime_botpp_capture { my( $self, $cust_pay_pending, %options ) = @_; + + local($DEBUG) = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG; + if ( $DEBUG ) { warn "$me realtime_botpp_capture: pending transaction $cust_pay_pending\n"; warn " $_ => $options{$_}\n" foreach keys %options; @@ -1005,9 +1212,10 @@ sub realtime_botpp_capture { my $method = FS::payby->payby2bop($cust_pay_pending->payby); - my $payment_gateway = $cust_pay_pending->gatewaynum - ? qsearchs( 'payment_gateway', - { gatewaynum => $cust_pay_pending->gatewaynum } + my $payment_gateway; + my $gatewaynum = $cust_pay_pending->getfield('gatewaynum'); + $payment_gateway = $gatewaynum ? qsearchs( 'payment_gateway', + { gatewaynum => $gatewaynum } ) : $self->agent->payment_gateway( 'method' => $method, # 'invnum' => $cust_pay_pending->invnum, @@ -1056,7 +1264,6 @@ sub realtime_botpp_capture { 'amount' => $cust_pay_pending->paid, #'invoice_number' => $options{'invnum'}, 'customer_id' => $self->custnum, - 'referer' => 'http://cleanwhisker.420.am/', 'reference' => $cust_pay_pending->paypendingnum, 'email' => $email, 'phone' => $self->daytime || $self->night, @@ -1069,7 +1276,14 @@ sub realtime_botpp_capture { my $error = $self->_realtime_bop_result( $cust_pay_pending, $transaction, %options ); - { + if ( $options{'apply'} ) { + my $apply_error = $self->apply_payments_and_credits; + if ( $apply_error ) { + warn "WARNING: error applying payment: $apply_error\n"; + } + } + + return { bill_error => $error, session_id => $cust_pay_pending->session_id, } @@ -1107,20 +1321,20 @@ 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 +Available options are: I, I, I, I Most gateways require a reference to an original payment transaction to refund, so you probably need to specify a I. I defaults to the original amount of the payment if not specified. -I specifies a reason for the refund. +I specified an existing refund reason for the refund 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 @@ -1129,7 +1343,8 @@ Implementation note: If I is unspecified or equal to the amount of the orignal payment, first an attempt is made to "void" the transaction via the gateway (to cancel a not-yet settled transaction) and then if that fails, the normal attempt is made to "refund" ("credit") the transaction via the -gateway is attempted. +gateway is attempted. No attempt to "void" the transaction is made if the +gateway has introspection data and doesn't support void. #The additional options I, I, I, I, I, #I, I and I are also available. Any of these options, @@ -1146,6 +1361,8 @@ gateway is attempted. sub realtime_refund_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]}; @@ -1160,6 +1377,10 @@ sub realtime_refund_bop { warn " $_ => $options{$_}\n" foreach keys %options; } + return "No reason specified" unless $options{'reasonnum'} =~ /^\d+$/; + + my %content = (); + ### # look up the original payment and optionally a gateway for that payment ### @@ -1169,6 +1390,7 @@ sub realtime_refund_bop { my( $processor, $login, $password, @bop_options, $namespace ) ; my( $auth, $order_number ) = ( '', '', '' ); + my $gatewaynum = ''; if ( $options{'paynum'} ) { @@ -1177,11 +1399,25 @@ sub realtime_refund_bop { or return "Unknown paynum $options{'paynum'}"; $amount ||= $cust_pay->paid; - $cust_pay->paybatch =~ /^((\d+)\-)?(\w+):\s*([\w\-\/ ]*)(:([\w\-]+))?$/ - or return "Can't parse paybatch for paynum $options{'paynum'}: ". - $cust_pay->paybatch; - my $gatewaynum = ''; - ( $gatewaynum, $processor, $auth, $order_number ) = ( $2, $3, $4, $6 ); + my @cust_bill_pay = qsearch('cust_bill_pay', { paynum=>$cust_pay->paynum }); + $content{'invoice_number'} = $cust_bill_pay[0]->invnum if @cust_bill_pay; + + if ( $cust_pay->get('processor') ) { + ($gatewaynum, $processor, $auth, $order_number) = + ( + $cust_pay->gatewaynum, + $cust_pay->processor, + $cust_pay->auth, + $cust_pay->order_number, + ); + } else { + # this payment wasn't upgraded, which probably means this won't work, + # but try it anyway + $cust_pay->paybatch =~ /^((\d+)\-)?(\w+):\s*([\w\-\/ ]*)(:([\w\-]+))?$/ + or return "Can't parse paybatch for paynum $options{'paynum'}: ". + $cust_pay->paybatch; + ( $gatewaynum, $processor, $auth, $order_number ) = ( $2, $3, $4, $6 ); + } if ( $gatewaynum ) { #gateway for the payment to be refunded @@ -1238,18 +1474,22 @@ sub realtime_refund_bop { eval "use $namespace"; die $@ if $@; - my %content = ( + %content = ( + %content, 'type' => $options{method}, 'login' => $login, 'password' => $password, 'order_number' => $order_number, 'amount' => $amount, - 'referer' => 'http://cleanwhisker.420.am/', #XXX fix referer :/ ); $content{authorization} = $auth if length($auth); #echeck/ACH transactions have an order # but no auth #(at least with authorize.net) + my $currency = $conf->exists('business-onlinepayment-currency') + && $conf->config('business-onlinepayment-currency'); + $content{currency} = $currency if $currency; + my $disable_void_after; if ($conf->exists('disable_void_after') && $conf->config('disable_void_after') =~ /^(\d+)$/) { @@ -1257,14 +1497,28 @@ sub realtime_refund_bop { } #first try void if applicable + my $void = new Business::OnlinePayment( $processor, @bop_options ); + + my $tryvoid = 1; + if ($void->can('info')) { + my $paytype = ''; + $paytype = 'ECHECK' if $cust_pay && $cust_pay->payby eq 'CHEK'; + $paytype = 'CC' if $cust_pay && $cust_pay->payby eq 'CARD'; + my %supported_actions = $void->info('supported_actions'); + $tryvoid = 0 + if ( %supported_actions && $paytype + && defined($supported_actions{$paytype}) + && !grep{ $_ eq 'Void' } @{$supported_actions{$paytype}} ); + } + if ( $cust_pay && $cust_pay->paid == $amount && ( ( not defined($disable_void_after) ) || ( time < ($cust_pay->_date + $disable_void_after ) ) ) + && $tryvoid ) { warn " attempting void\n" if $DEBUG > 1; - my $void = new Business::OnlinePayment( $processor, @bop_options ); if ( $void->can('info') ) { if ( $cust_pay->payby eq 'CARD' && $void->info('CC_void_requires_card') ) @@ -1283,7 +1537,12 @@ sub realtime_refund_bop { if $conf->exists('business-onlinepayment-test_transaction'); $void->submit(); if ( $void->is_success ) { - my $error = $cust_pay->void($options{'reason'}); + # specified as a refund reason, but now we want a payment void reason + # extract just the reason text, let cust_pay::void handle new_or_existing + my $reason = qsearchs('reason',{ 'reasonnum' => $options{'reasonnum'} }); + my $error; + $error = 'Reason could not be loaded' unless $reason; + $error = $cust_pay->void($reason->reason) unless $error; if ( $error ) { # gah, even with transactions. my $e = 'WARNING: Card/ACH voided but database not updated - '. @@ -1360,8 +1619,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 @@ -1390,10 +1648,9 @@ sub realtime_refund_bop { return "$processor error: ". $refund->error_message unless $refund->is_success(); - my $paybatch = "$processor:". $refund->authorization; - $paybatch .= ':'. $refund->order_number - if $refund->can('order_number') && $refund->order_number; + $order_number = $refund->order_number if $refund->can('order_number'); + # change this to just use $cust_pay->delete_cust_bill_pay? while ( $cust_pay && $cust_pay->unapplied < $amount ) { my @cust_bill_pay = $cust_pay->cust_bill_pay; last unless @cust_bill_pay; @@ -1405,16 +1662,21 @@ sub realtime_refund_bop { my $cust_refund = new FS::cust_refund ( { 'custnum' => $self->custnum, 'paynum' => $options{'paynum'}, + 'source_paynum' => $options{'paynum'}, 'refund' => $amount, '_date' => '', 'payby' => $bop_method2payby{$options{method}}, 'payinfo' => $payinfo, - 'paybatch' => $paybatch, - 'reason' => $options{'reason'} || 'card or ACH refund', + 'reasonnum' => $options{'reasonnum'}, + 'gatewaynum' => $gatewaynum, # may be null + 'processor' => $processor, + 'auth' => $refund->authorization, + 'order_number' => $order_number, } ); my $error = $cust_refund->insert; if ( $error ) { $cust_refund->paynum(''); #try again with no specific paynum + $cust_refund->source_paynum(''); my $error2 = $cust_refund->insert; if ( $error2 ) { # gah, even with transactions. @@ -1431,6 +1693,418 @@ 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"; + + $content{cvv2} = $options{'paycvv'} + if length($options{'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'); + } + } + + my $log = FS::Log->new('FS::cust_main::Billing_Realtime::realtime_verify_bop'); + + 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', + $self->_bop_auth(\%options), + + # B:OP + 'amount' => '1.00', + '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; + $log->warning($e); + warn $e; + return $e; + + } + + ### Address Verification ### + # + # Single-letter codes vary by cardtype. + # + # Erring on the side of accepting cards if avs is not available, + # only rejecting if avs occurred and there's been an explicit mismatch + # + # Charts below taken from vSecure documentation, + # shows codes for Amex/Dscv/MC/Visa + # + # ACCEPTABLE AVS RESPONSES: + # Both Address and 5-digit postal code match Y A Y Y + # Both address and 9-digit postal code match Y A X Y + # United Kingdom – Address and postal code match _ _ _ F + # International transaction – Address and postal code match _ _ _ D/M + # + # ACCEPTABLE, BUT ISSUE A WARNING: + # Ineligible transaction; or message contains a content error _ _ _ E + # System unavailable; retry R U R R + # Information unavailable U W U U + # Issuer does not support AVS S U S S + # AVS is not applicable _ _ _ S + # Incompatible formats – Not verified _ _ _ C + # Incompatible formats – Address not verified; postal code matches _ _ _ P + # International transaction – address not verified _ G _ G/I + # + # UNACCEPTABLE AVS RESPONSES: + # Only Address matches A Y A A + # Only 5-digit postal code matches Z Z Z Z + # Only 9-digit postal code matches Z Z W W + # Neither address nor postal code matches N N N N + + if (my $avscode = uc($transaction->avs_code)) { + + # map codes to accept/warn/reject + my $avs = { + 'American Express card' => { + 'A' => 'r', + 'N' => 'r', + 'R' => 'w', + 'S' => 'w', + 'U' => 'w', + 'Y' => 'a', + 'Z' => 'r', + }, + 'Discover card' => { + 'A' => 'a', + 'G' => 'w', + 'N' => 'r', + 'U' => 'w', + 'W' => 'w', + 'Y' => 'r', + 'Z' => 'r', + }, + 'MasterCard' => { + 'A' => 'r', + 'N' => 'r', + 'R' => 'w', + 'S' => 'w', + 'U' => 'w', + 'W' => 'r', + 'X' => 'a', + 'Y' => 'a', + 'Z' => 'r', + }, + 'VISA card' => { + 'A' => 'r', + 'C' => 'w', + 'D' => 'a', + 'E' => 'w', + 'F' => 'a', + 'G' => 'w', + 'I' => 'w', + 'M' => 'a', + 'N' => 'r', + 'P' => 'w', + 'R' => 'w', + 'S' => 'w', + 'U' => 'w', + 'W' => 'r', + 'Y' => 'a', + 'Z' => 'r', + }, + }; + my $cardtype = cardtype($content{card_number}); + if ($avs->{$cardtype}) { + my $avsact = $avs->{$cardtype}->{$avscode}; + my $warning = ''; + if ($avsact eq 'r') { + return "AVS code verification failed, cardtype $cardtype, code $avscode"; + } elsif ($avsact eq 'w') { + $warning = "AVS did not occur, cardtype $cardtype, code $avscode"; + } elsif (!$avsact) { + $warning = "AVS code unknown, cardtype $cardtype, code $avscode"; + } # else $avsact eq 'a' + if ($warning) { + $log->warning($warning); + warn $warning; + } + } # else $cardtype avs handling not implemented + } # else !$transaction->avs_code + + } else { # is not success + + # status is 'done' not 'declined', as in _realtime_bop_result + $cust_pay_pending->status('done'); + $cust_pay_pending->statustext( $transaction->error_message || 'Unknown error' ); + # could also record failure_status here, + # but it's not supported by B::OP::vSecureProcessing... + # need a B::OP module with (reverse) auth only to test it with + my $cpp_declined_err = $cust_pay_pending->replace; + return $cpp_declined_err if $cpp_declined_err; + + } + + ### + # 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 ) { + my $warning = "WARNING: error storing token: $error, but proceeding anyway\n"; + $log->warning($warning); + warn $warning; + } + } + + } + + ### + # result handling + ### + + $transaction->is_success() ? '' : $transaction->error_message(); + +} + =back =head1 BUGS