jenkins-bot has submitted this change and it was merged. Change subject: Handle results of Amazon API calls ......................................................................
Handle results of Amazon API calls * Redirect to thank you page on success, failpage on hard fail. * Add call to runPostProcessHooks * Don't repeat calls to setOrderReferenceDetails for the same order reference * Show error messages when donor can try another card or repeat attempt later * Redisplay wallet widget on InvalidPaymentMethod * Display login button on token expiration TODO: make amount non-editable after first submit Bug: T108123 Change-Id: I1eefabfde29aafdf0975ba5da1e17dae90853a76 --- M amazon_gateway/amazon.adapter.php M amazon_gateway/amazon.api.php M amazon_gateway/amazon.js M gateway_common/i18n/interface/en.json M gateway_common/i18n/interface/qqq.json M tests/Adapter/Amazon/AmazonTest.php A tests/includes/Responses/amazon/authorize_AmazonRejected.json A tests/includes/Responses/amazon/authorize_TransactionTimedOut.json 8 files changed, 299 insertions(+), 33 deletions(-) Approvals: Awight: Looks good to me, approved jenkins-bot: Verified diff --git a/amazon_gateway/amazon.adapter.php b/amazon_gateway/amazon.adapter.php index bc4d269..49b2e88 100644 --- a/amazon_gateway/amazon.adapter.php +++ b/amazon_gateway/amazon.adapter.php @@ -26,6 +26,7 @@ * and https://github.com/amzn/login-and-pay-with-amazon-sdk-php */ class AmazonAdapter extends GatewayAdapter { + const GATEWAY_NAME = 'Amazon'; const IDENTIFIER = 'amazon'; const GLOBAL_PREFIX = 'wgAmazonGateway'; @@ -37,10 +38,23 @@ 'Declined' => FinalStatus::FAILED, ); + // When an authorization or capture is declined, we examine the reason code + // to see if we should let the donor try again with a different card. For + // these codes, we should tell the donor to try a different method entirely. + protected $fatal_errors = array( + // These two may show up if we start doing asynchronous authorization + 'AmazonClosed', + 'AmazonRejected', + // For synchronous authorization, timeouts usually indicate that the + // donor's account is under scrutiny, so letting them choose a different + // card would likely just time out again + 'TransactionTimedOut', + ); + function __construct( $options = array() ) { parent::__construct( $options ); - if ($this->getData_Unstaged_Escaped( 'payment_method' ) == null ) { + if ( $this->getData_Unstaged_Escaped( 'payment_method' ) == null ) { $this->addRequestData( array( 'payment_method' => 'amazon' ) ); @@ -49,7 +63,7 @@ } public function getFormClass() { - if ( strpos( $this->dataObj->getVal_Escaped( 'ffname' ), 'error') === 0 ) { + if ( strpos( $this->dataObj->getVal_Escaped( 'ffname' ), 'error' ) === 0 ) { // TODO: make a mustache error form return parent::getFormClass(); } @@ -75,10 +89,13 @@ // We use account_config instead $this->accountInfo = array(); } + function defineReturnValueMap() {} + function defineDataConstraints() {} + function defineOrderIDMeta() { - $this->order_id_meta = array ( + $this->order_id_meta = array( 'generate' => TRUE, 'ct_id' => TRUE, ); @@ -87,7 +104,24 @@ function setGatewayDefaults() {} public function defineErrorMap() { - $this->error_map = array(); + $self = $this; + $differentCard = function() use ( $self ) { + $language = $self->getData_Unstaged_Escaped( 'language' ); + $country = $self->getData_Unstaged_Escaped( 'country' ); + return WmfFramework::formatMessage( + 'donate_interface-donate-error-try-a-different-card-html', 'https://wikimediafoundation.org/wiki/Special:LandingCheck?basic=true&landing_page=Ways_to_Give' + . "&language=$language&uselang=$language&country=$country", 'problemsdonat...@wikimedia.org' + ); + }; + $this->error_map = array( + // These might be transient - tell donor to try again soon + 'InternalServerError' => 'donate_interface-try-again', + 'RequestThrottled' => 'donate_interface-try-again', + 'ServiceUnavailable' => 'donate_interface-try-again', + 'ProcessingFailure' => 'donate_interface-try-again', + // Donor needs to select a different card + 'InvalidPaymentMethod' => $differentCard, + ); } function defineTransactions() { @@ -123,10 +157,7 @@ $this->confirmOrderReference(); $this->authorizeAndCapturePayment(); } catch ( ResponseProcessingException $ex ) { - $resultData->addError( - $ex->getErrorCode(), - $ex->getMessage() - ); + $this->handleErrors( $ex, $resultData ); } $this->incrementSequenceNumber(); @@ -165,20 +196,15 @@ $orderReferenceId = $this->getData_Staged( 'order_reference_id' ); - $setDetailsResult = $client->setOrderReferenceDetails( array( - 'amazon_order_reference_id' => $orderReferenceId, - 'amount' => $this->getData_Staged( 'amount' ), - 'currency_code' => $this->getData_Staged( 'currency_code' ), - 'seller_note' => WmfFramework::formatMessage( 'donate_interface-donation-description' ), - 'seller_order_reference_id' => $this->getData_Staged( 'order_id' ), - ) )->toArray(); - self::checkErrors( $setDetailsResult ); + $this->setOrderReferenceDetailsIfUnset( $client, $orderReferenceId ); $confirmResult = $client->confirmOrderReference( array( 'amazon_order_reference_id' => $orderReferenceId, ) )->toArray(); self::checkErrors( $confirmResult ); + // TODO: either check the status, or skip this call when we already have + // donor details $getDetailsResult = $client->getOrderReferenceDetails( array( 'amazon_order_reference_id' => $orderReferenceId, ) )->toArray(); @@ -195,6 +221,33 @@ 'fname' => $fname, 'lname' => $lname, ) ); + // Stash their info in pending queue and logs to fill in data for + // audit and IPN messages + $details = $this->getStompTransaction(); + $this->logger->info( 'Got info for Amazon donation: ' . json_encode( $details ) ); + $this->setLimboMessage( 'pending' ); + } + + /** + * Set the order reference details if they haven't been set yet. Track + * which ones have been set in session. + * @param PwaClientInterface $client + * @param string $orderReferenceId + */ + protected function setOrderReferenceDetailsIfUnset( $client, $orderReferenceId ) { + if ( $this->session_getData( 'order_refs', $orderReferenceId ) ) { + return; + } + $setDetailsResult = $client->setOrderReferenceDetails( array( + 'amazon_order_reference_id' => $orderReferenceId, + 'amount' => $this->getData_Staged( 'amount' ), + 'currency_code' => $this->getData_Staged( 'currency_code' ), + 'seller_note' => WmfFramework::formatMessage( 'donate_interface-donation-description' ), + 'seller_order_reference_id' => $this->getData_Staged( 'order_id' ), + ) )->toArray(); + self::checkErrors( $setDetailsResult ); + // TODO: session_setData wrapper? + $_SESSION['order_refs'][$orderReferenceId] = true; } /** @@ -217,6 +270,10 @@ 'authorization_reference_id' => $this->getData_Staged( 'order_id' ), 'transaction_timeout' => 0, // authorize synchronously // Could set 'SoftDescriptor' to control what appears on CC statement (16 char max, prepended with AMZ*) + // Use the seller_authorization_note to simulate an error in the sandbox + // See https://payments.amazon.com/documentation/lpwa/201749840#201750790 + // 'seller_authorization_note' => '{"SandboxSimulation": {"State":"Declined", "ReasonCode":"TransactionTimedOut"}}', + // 'seller_authorization_note' => '{"SandboxSimulation": {"State":"Declined", "ReasonCode":"InvalidPaymentMethod"}}', ) )->toArray(); $this->checkErrors( $authResponse ); @@ -245,11 +302,21 @@ $captureDetails = $captureResponse['GetCaptureDetailsResult']['CaptureDetails']; $captureState = $captureDetails['CaptureStatus']['State']; + // TODO: verify that this does not prevent us from refunding. + $this->closeOrderReference(); + + $this->finalizeInternalStatus( $this->capture_status_map[$captureState] ); + $this->runPostProcessHooks(); + $this->deleteLimboMessage( 'pending' ); + } + + protected function closeOrderReference() { + $client = $this->getPwaClient(); // maybe just make this a member variable + $orderReferenceId = $this->getData_Staged( 'order_reference_id' ); + $client->closeOrderReference( array( 'amazon_order_reference_id' => $orderReferenceId, ) ); - - $this->finalizeInternalStatus( $this->capture_status_map[$captureState] ); } /** @@ -305,4 +372,25 @@ $vars['wgAmazonGatewayWidgetScript'] = $this->account_config['WidgetScriptURL']; $vars['wgAmazonGatewayLoginScript'] = $this->getGlobal( 'LoginScript' ); } + + /** + * FIXME: this synthesized 'TransactionResponse' is increasingly silly + * Maybe make this adapter more normal by adding an 'SDK' communication type + * that just creates an array of $data, then overriding curl_transaction + * to use the PwaClient. + * @param ResponseProcessingException $exception + * @param PaymentTransactionResponse $resultData + */ + public function handleErrors( $exception, $resultData ) { + $errorCode = $exception->getErrorCode(); + $resultData->addError( + $errorCode, $this->getErrorMapByCodeAndTranslate( $errorCode ) + ); + if ( array_search( $errorCode, $this->fatal_errors ) !== false ) { + // These seem potentially fraudy - let's pay attention to them + $this->logger->error( 'Heinous status returned from Amazon: ' . $errorCode ); + $this->finalizeInternalStatus( FinalStatus::FAILED ); + } + } + } diff --git a/amazon_gateway/amazon.api.php b/amazon_gateway/amazon.api.php index 65db0be..bacdb92 100644 --- a/amazon_gateway/amazon.api.php +++ b/amazon_gateway/amazon.api.php @@ -9,6 +9,7 @@ ); public function execute() { + $output = $this->getResult(); $orderReferenceId = $this->getParameter( 'orderReferenceId' ); $adapterParams = array( 'api_request' => true, @@ -25,22 +26,28 @@ 'order_reference_id' => $orderReferenceId, ) ); $result = $adapter->doPayment(); - if ( $result->getRefresh() ) { - $this->getResult()->addValue( + if ( $result->isFailed() ) { + $output->addvalue( + null, + 'redirect', + $adapter->getFailPage() + ); + } else if ( $result->getRefresh() ) { + $output->addValue( null, 'errors', $result->getErrors() ); } else { - $this->getResult()->addValue( + $output->addValue( null, - 'success', - !$result->isFailed() + 'redirect', + $adapter->getThankYouPage() ); } } else { // Don't let people continue if they failed a token check! - $this->getResult()->addValue( + $output->addValue( null, 'errors', array( 'token-mismatch' => $this->msg( 'donate_interface-token-mismatch' )->text() ) diff --git a/amazon_gateway/amazon.js b/amazon_gateway/amazon.js index 437d902..94a0617 100644 --- a/amazon_gateway/amazon.js +++ b/amazon_gateway/amazon.js @@ -40,10 +40,16 @@ amazon.Login.authorize( loginOptions, returnUrl ); } - function showErrorAndLoginButton( message ) { + function addErrorMessage( message ) { $( '#topError' ).append( - $( '<div class="error">' + message + '</div>' ) + $( '<p class="error">' + message + '</p>' ) ); + } + + function showErrorAndLoginButton( message ) { + if ( message ) { + addErrorMessage( message ); + } OffAmazonPayments.Button( 'amazonLogin', sellerId, @@ -56,21 +62,29 @@ ); } + function tokenExpired() { + // Re-create widget so it displays timeout error message + createWalletWidget(); + showErrorAndLoginButton(); + } + accessToken = getURLParameter( 'access_token', location.hash ); loginError = getURLParameter( 'error', location.search ); // This will be called as soon as the login script is loaded window.onAmazonLoginReady = function() { + var tokenLifetime; amazon.Login.setClientId( clientId ); amazon.Login.setUseCookie( true ); amazon.Login.setSandboxMode( sandbox ); if ( loggedIn ) { + tokenLifetime = parseInt( getURLParameter( 'expires_in', location.hash ), 10 ); createWalletWidget(); + setTimeout( tokenLifetime * 1000, tokenExpired ); } else { if ( loginError ) { showErrorAndLoginButton( getURLParameter( 'error_description', location.search ) - // TODO: better error message with links to alternative donation methods ); } else { redirectToLogin(); @@ -94,7 +108,7 @@ } function createWalletWidget() { - new OffAmazonPayments.Widgets.Wallet( { + var params = { sellerId: sellerId, onReady: function( billingAgreement ) { // Will come in handy for recurring payments @@ -102,22 +116,63 @@ }, agreementType: 'OrderReference', onOrderReferenceCreate: function( orderReference ) { + if ( orderReferenceId ) { + // Redisplaying for an existing order, no need to continue + return; + } orderReferenceId = orderReference.getAmazonOrderReferenceId(); $( '#paymentContinue' ).show(); + // FIXME: Unbind click handler from forms.js $( '#paymentContinueBtn' ).off( 'click' ); $( '#paymentContinueBtn' ).click( submitPayment ); + }, + onPaymentSelect: function() { + // In case we hid the button because of an invalid payment error + $( '#paymentContinue' ).show(); }, design: { designMode: 'responsive' }, onError: function( error ) { // Error message appears directly in widget - showErrorAndLoginButton( '' ); + showErrorAndLoginButton(); } - } ).bind( 'walletWidget' ); + }; + // If we are refreshing the widget to display a correctable error, + // we need to set the Amazon order reference ID for continuity + if ( orderReferenceId ) { + params.amazonOrderReferenceId = orderReferenceId; + } + new OffAmazonPayments.Widgets.Wallet( params ).bind( 'walletWidget' ); } + function handleErrors( errors ) { + var code, + refreshWallet = false; + + for ( code in errors ) { + if ( !errors.hasOwnProperty( code ) ) { + continue; + } + addErrorMessage( errors[code] ); + if ( code === 'InvalidPaymentMethod' ) { + // Card declined, but they can try another + refreshWallet = true; + } + } + + if ( refreshWallet ) { + // Redisplay the widget to show an error and let the donor pick a different card + $( '#paymentContinue' ).hide(); + createWalletWidget(); + } + } + + // FIXME: if donation amount is edited after we call setOrderReferenceDetails + // once, we need to close the old order reference and get a new one on retry. + // Maybe just make it non-editable here? function submitPayment() { + $( '#topError' ).html(''); $( '#overlay' ).show(); var postdata = { action: 'di_amazon_bill', @@ -135,9 +190,9 @@ success: function ( data ) { $( '#overlay' ).hide(); if ( data.errors ) { - // TODO: correctable error, let 'em correct it - } else if ( data.success ) { - // TODO: send donor to TY page, auth/capture money + handleErrors( data.errors ); + } else if ( data.redirect ) { + location.href = data.redirect; } else { // TODO: send donor to fail page } diff --git a/gateway_common/i18n/interface/en.json b/gateway_common/i18n/interface/en.json index fe5371a..b34b3e0 100644 --- a/gateway_common/i18n/interface/en.json +++ b/gateway_common/i18n/interface/en.json @@ -218,6 +218,7 @@ "donate_interface-error-msg-fiscal_number-pe": "RUC", "donate_interface-error-msg-fiscal_number-uy": "RUT", "donate_interface-donate-error-try-a-different-card": "Please [$1 try a different card] or one of our [$2 other ways to give] or contact us at $3", + "donate_interface-donate-error-try-a-different-card-html": "Please try a different card or one of our <a href=\"$1\">other ways to give</a>, or contact us at <a href=\"mailto:$2\">$2</a>", "donate_interface-donate-error-try-again-html": "Please <a href=\"$1\">try again</a>, try one of our <a href=\"$2\">other ways to give</a>, or contact us at <a href=\"mailto:$3\">$3</a>", "donate_interface-donate-error-thank-you-for-your-support": "Thank you for your support!", "donate_interface-error-no-form": "We were unable to find a donation form matching your parameters. Please contact [mailto:don...@wikimedia.org our help team] for more information.", diff --git a/gateway_common/i18n/interface/qqq.json b/gateway_common/i18n/interface/qqq.json index 0e720f1..31464cd 100644 --- a/gateway_common/i18n/interface/qqq.json +++ b/gateway_common/i18n/interface/qqq.json @@ -242,6 +242,7 @@ "donate_interface-error-msg-fiscal_number-pe": "Peru-specific term for the fiscal number or tax id. This is the RUC (Registro Único de Contribuyentes) assigned by the Peruvian government.", "donate_interface-error-msg-fiscal_number-uy": "Uruguay-specific term for the fiscal number or tax id. This is the RUT (Registro Único Tributario) assigned by the Uruguayan government.", "donate_interface-donate-error-try-a-different-card": "This message will be displayed in the the article /index.php/Donate-error. Parameters:\n* $1 - link back to the form to try another credit card\n* $2 - link to other payment methods\n* $3 - an e-mail address link such as: mailto:some...@example.com", + "donate_interface-donate-error-try-a-different-card-html": "{{Related|donate_interface-donate-error-try-a-different-card}}This message will be displayed in the payments form where the donor can select a different card type. Parameters:\n* $1 - link to other payment methods\n* $2 - an e-mail address link such as: mailto:some...@example.com", "donate_interface-donate-error-try-again-html": "This html-formatted message will be used on dynamic error pages for all payment types.\n\nParameters:\n* $1 - link back to the payments form most recently used by the donor, to try again\n* $2 - link to other payment methods\n* $3 - an email address, where the donor can report problems. Example: problemsdonat...@wikimedia.org", "donate_interface-donate-error-thank-you-for-your-support": "Thank you for your support!", "donate_interface-error-no-form": "Error message given if no form or payment method is available in this language/country/currency.", diff --git a/tests/Adapter/Amazon/AmazonTest.php b/tests/Adapter/Amazon/AmazonTest.php index e7ff302..bb09574 100644 --- a/tests/Adapter/Amazon/AmazonTest.php +++ b/tests/Adapter/Amazon/AmazonTest.php @@ -184,4 +184,50 @@ $errors = $result->getErrors(); $this->assertTrue( isset( $errors['InvalidPaymentMethod'] ), 'InvalidPaymentMethod error should be set' ); } + + /** + * This apparently indicates a shady enough txn that we should turn them away + */ + function testFailOnAmazonRejected() { + $init = $this->getDonorTestData( 'US' ); + $init['amount'] = '10.00'; + $init['order_reference_id'] = mt_rand( 0, 10000000 ); // provided by client-side widget IRL + // We don't get any profile data up front + unset( $init['email'] ); + unset( $init['fname'] ); + unset( $init['lname'] ); + + $mockClient = TestingAmazonAdapter::$client; + $mockClient->returns['authorize'][] = 'AmazonRejected'; + + $gateway = $this->getFreshGatewayObject( $init ); + $result = $gateway->doPayment(); + + $this->assertTrue( $result->isFailed(), 'Result should be failed' ); + // Could assert something about errors after rebasing onto master + // $errors = $result->getErrors(); + // $this->assertTrue( isset( $errors['AmazonRejected'] ), 'AmazonRejected error should be set' ); + } + + /** + * When the transaction times out, just gotta fail it till we work out an + * asynchronous authorization flow + */ + function testTransactionTimedOut() { + $init = $this->getDonorTestData( 'US' ); + $init['amount'] = '10.00'; + $init['order_reference_id'] = mt_rand( 0, 10000000 ); // provided by client-side widget IRL + // We don't get any profile data up front + unset( $init['email'] ); + unset( $init['fname'] ); + unset( $init['lname'] ); + + $mockClient = TestingAmazonAdapter::$client; + $mockClient->returns['authorize'][] = 'TransactionTimedOut'; + + $gateway = $this->getFreshGatewayObject( $init ); + $result = $gateway->doPayment(); + + $this->assertTrue( $result->isFailed(), 'Result should be failed' ); + } } diff --git a/tests/includes/Responses/amazon/authorize_AmazonRejected.json b/tests/includes/Responses/amazon/authorize_AmazonRejected.json new file mode 100644 index 0000000..687ea6d --- /dev/null +++ b/tests/includes/Responses/amazon/authorize_AmazonRejected.json @@ -0,0 +1,34 @@ +{ + "AuthorizeResult": { + "AuthorizationDetails": { + "AuthorizationAmount": { + "CurrencyCode": "USD", + "Amount": "10.00" + }, + "CapturedAmount": { + "CurrencyCode": "USD", + "Amount": "0" + }, + "SoftDescriptor": "AMZ*Wikimedia Founda", + "ExpirationTimestamp": "2015-10-01T22:31:02.551Z", + "AuthorizationStatus": { + "LastUpdateTimestamp": "2015-09-01T22:31:02.551Z", + "State": "Declined", + "ReasonCode": "AmazonRejected" + }, + "AuthorizationFee": { + "CurrencyCode": "USD", + "Amount": "0.00" + }, + "CaptureNow": "true", + "SellerAuthorizationNote": "{\"SandboxSimulation\": {\"State\":\"Declined\", \"ReasonCode\":\"AmazonRejected\"}}", + "CreationTimestamp": "2015-09-01T22:31:02.551Z", + "AmazonAuthorizationId": "S01-7821958-9177140-A084347", + "AuthorizationReferenceId": "36435-0" + } + }, + "ResponseMetadata": { + "RequestId": "4a509982-d8cb-4dec-ad56-81a9dc5f070c" + }, + "ResponseStatus": "200" +} \ No newline at end of file diff --git a/tests/includes/Responses/amazon/authorize_TransactionTimedOut.json b/tests/includes/Responses/amazon/authorize_TransactionTimedOut.json new file mode 100644 index 0000000..c2a1cf2 --- /dev/null +++ b/tests/includes/Responses/amazon/authorize_TransactionTimedOut.json @@ -0,0 +1,34 @@ +{ + "AuthorizeResult": { + "AuthorizationDetails": { + "AuthorizationAmount": { + "CurrencyCode": "USD", + "Amount": "10.00" + }, + "CapturedAmount": { + "CurrencyCode": "USD", + "Amount": "0" + }, + "SoftDescriptor": "AMZ*Wikimedia Founda", + "ExpirationTimestamp": "2015-10-02T17:14:02.214Z", + "AuthorizationStatus": { + "LastUpdateTimestamp": "2015-09-02T17:14:02.214Z", + "State": "Declined", + "ReasonCode": "TransactionTimedOut" + }, + "AuthorizationFee": { + "CurrencyCode": "USD", + "Amount": "0.00" + }, + "CaptureNow": "true", + "SellerAuthorizationNote": "{\"SandboxSimulation\": {\"State\":\"Declined\", \"ReasonCode\":\"TransactionTimedOut\"}}", + "CreationTimestamp": "2015-09-02T17:14:02.214Z", + "AmazonAuthorizationId": "S01-6689996-9664966-A069817", + "AuthorizationReferenceId": "36450-0" + } + }, + "ResponseMetadata": { + "RequestId": "06488d3c-bcee-4448-8f0a-bd6f816e6fec" + }, + "ResponseStatus": "200" +} \ No newline at end of file -- To view, visit https://gerrit.wikimedia.org/r/233993 To unsubscribe, visit https://gerrit.wikimedia.org/r/settings Gerrit-MessageType: merged Gerrit-Change-Id: I1eefabfde29aafdf0975ba5da1e17dae90853a76 Gerrit-PatchSet: 13 Gerrit-Project: mediawiki/extensions/DonationInterface Gerrit-Branch: amazon Gerrit-Owner: Ejegg <eeggles...@wikimedia.org> Gerrit-Reviewer: AndyRussG <andrew.green...@gmail.com> Gerrit-Reviewer: Awight <awi...@wikimedia.org> Gerrit-Reviewer: Cdentinger <cdentin...@wikimedia.org> Gerrit-Reviewer: Ejegg <eeggles...@wikimedia.org> Gerrit-Reviewer: Katie Horn <kh...@wikimedia.org> Gerrit-Reviewer: Ssmith <ssm...@wikimedia.org> Gerrit-Reviewer: XenoRyet <dkozlow...@wikimedia.org> Gerrit-Reviewer: jenkins-bot <> _______________________________________________ MediaWiki-commits mailing list MediaWiki-commits@lists.wikimedia.org https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits