jenkins-bot has submitted this change and it was merged. Change subject: PayPal Express Checkout: recurring ......................................................................
PayPal Express Checkout: recurring TODO: * Transaction is finalized twice, which is a big no-no. * Put controller code somewhere sane, should help with the above. * Hasn't been tested with the newer express checkout flow, so far I've only gotten the old flow will appear in the sandbox, but PP dev says the same parameters give him the new flow, so perhaps we'll have to test on production. Bug: T134446 Change-Id: I8761b077cdccfc693176f15d3efeca83904cb5ff --- M gateway_common/gateway.adapter.php M paypal_gateway/express_checkout/config/transformers.yaml M paypal_gateway/express_checkout/config/var_map.yaml M paypal_gateway/express_checkout/paypal_express.adapter.php 4 files changed, 287 insertions(+), 87 deletions(-) Approvals: Ejegg: Looks good to me, approved jenkins-bot: Verified diff --git a/gateway_common/gateway.adapter.php b/gateway_common/gateway.adapter.php index 04d729d..67860d6 100644 --- a/gateway_common/gateway.adapter.php +++ b/gateway_common/gateway.adapter.php @@ -1068,7 +1068,7 @@ $formatted = $this->getFormattedResponse( $this->transaction_response->getRawResponse() ); // Process the formatted response. This will then drive the result action - try{ + try { $this->processResponse( $formatted ); } catch ( ResponseProcessingException $ex ) { $errCode = $ex->getErrorCode(); diff --git a/paypal_gateway/express_checkout/config/transformers.yaml b/paypal_gateway/express_checkout/config/transformers.yaml index cdaea4f..e55037c 100644 --- a/paypal_gateway/express_checkout/config/transformers.yaml +++ b/paypal_gateway/express_checkout/config/transformers.yaml @@ -1,3 +1,4 @@ - IsoDate - DonorLocale - PaypalExpressReturnUrl +- IsoDate diff --git a/paypal_gateway/express_checkout/config/var_map.yaml b/paypal_gateway/express_checkout/config/var_map.yaml index 5e5a3bb..9bac3f2 100644 --- a/paypal_gateway/express_checkout/config/var_map.yaml +++ b/paypal_gateway/express_checkout/config/var_map.yaml @@ -1,9 +1,14 @@ +AMT: amount COUNTRYCODE: country +CURRENCYCODE: currency_code EMAIL: email FIRSTNAME: fname +# FIXME: This might already come through the Invoice Number field. +L_BILLINGAGREEMENTCUSTOM0: order_id L_PAYMENTREQUEST_0_AMT0: amount LASTNAME: lname LOCALECODE: language +MAXAMT: amount PAYERID: donor_id # Example: 2016%2d05%2d03T21%3a25%3a22Z& PAYMENTINFO_0_ORDERTIME: date @@ -16,6 +21,9 @@ # FIXME: Update the audit and IPN listener to read from this field. PAYMENTREQUEST_0_INVNUM: order_id PAYMENTREQUEST_0_ITEMAMT: amount +PROFILEID: subscr_id +PROFILEREFERENCE: contribution_tracking_id +PROFILESTARTDATE: date # TODO: discuss whether to capture #PHONENUM: phone RETURNURL: returnto diff --git a/paypal_gateway/express_checkout/paypal_express.adapter.php b/paypal_gateway/express_checkout/paypal_express.adapter.php index 32e54fe..dda2c80 100644 --- a/paypal_gateway/express_checkout/paypal_express.adapter.php +++ b/paypal_gateway/express_checkout/paypal_express.adapter.php @@ -8,7 +8,11 @@ * https://developer.paypal.com/docs/classic/express-checkout/overview-ec/ * https://developer.paypal.com/docs/classic/products/ * https://developer.paypal.com/docs/classic/express-checkout/ht_ec-singleItemPayment-curl-etc/ + * https://developer.paypal.com/docs/classic/express-checkout/ht_ec-recurringPaymentProfile-curl-etc/ + * TODO: We would need reference transactions to do recurring in Germany or China. + * https://developer.paypal.com/docs/classic/express-checkout/integration-guide/ECReferenceTxns/#id094UM0C03Y4 * https://developer.paypal.com/docs/classic/api/gs_PayPalAPIs/ + * https://developer.paypal.com/docs/classic/express-checkout/integration-guide/ECCustomizing/ */ class PaypalExpressAdapter extends GatewayAdapter { const GATEWAY_NAME = 'Paypal Express Checkout'; @@ -45,19 +49,6 @@ } /** - * Shared snippet to parse and the ACK response field and store it as - * communication status. - */ - protected function processAckResponse( $response ) { - if ( isset( $response['ACK'] ) && $response['ACK'] === 'Success' ) { - $this->transaction_response->setCommunicationStatus( true ); - return true; - } else { - return false; - } - } - - /** * Use our own Order ID sequence. */ function defineOrderIDMeta() { @@ -68,6 +59,7 @@ } function setGatewayDefaults() {} + // TODO: Support "response" specification. function defineTransactions() { $this->transactions = array(); @@ -88,6 +80,8 @@ 'EMAIL', 'L_PAYMENTREQUEST_0_AMT0', 'L_PAYMENTREQUEST_0_DESC0', + // FIXME: Investigate rate discount for Digital + //'L_PAYMENTREQUEST_0_ITEMCATEGORY0', 'PAYMENTREQUEST_0_AMT', 'PAYMENTREQUEST_0_CURRENCYCODE', // FIXME: This should be deprecated, and is only for back-compat. @@ -99,6 +93,7 @@ 'PAYMENTREQUEST_0_PAYMENTREASON', // TODO: Investigate why can we give this as an input: // PAYMENTREQUEST_n_TRANSACTIONID + // TODO: BUYEREMAILOPTINENABLE=1 ), 'values' => array( 'USER' => $this->account_config['User'], @@ -110,9 +105,79 @@ 'REQCONFIRMSHIPPING' => 0, 'NOSHIPPING' => 1, 'L_PAYMENTREQUEST_0_DESC0' => WmfFramework::formatMessage( 'donate_interface-donation-description' ), + 'L_PAYMENTREQUEST_0_ITEMCATEGORY0' => 'Digital', 'PAYMENTREQUEST_0_DESC' => WmfFramework::formatMessage( 'donate_interface-donation-description' ), 'PAYMENTREQUEST_0_PAYMENTACTION' => 'Sale', 'PAYMENTREQUEST_0_PAYMENTREASON' => 'None', + ), + 'response' => array( + 'TOKEN', + ), + ); + + // https://developer.paypal.com/docs/classic/api/merchant/SetExpressCheckout_API_Operation_NVP/ + $this->transactions['SetExpressCheckout_recurring'] = array( + 'request' => array( + 'USER', + 'PWD', + 'SIGNATURE', + 'VERSION', + 'METHOD', + 'RETURNURL', + 'CANCELURL', + 'REQCONFIRMSHIPPING', + 'NOSHIPPING', + 'LOCALECODE', + // TODO: PAGESTYLE, HDRIMG, LOGOIMG + 'EMAIL', + 'L_BILLINGTYPE0', + 'L_BILLINGAGREEMENTDESCRIPTION0', + 'L_BILLINGAGREEMENTCUSTOM0', + 'L_PAYMENTREQUEST_0_AMT0', + // // Note that the DESC fields can be tweaked to get different + // // effects in the PayPal layout. + //'L_PAYMENTREQUEST_0_DESC0', + 'L_PAYMENTREQUEST_0_ITEMCATEGORY0', + 'L_PAYMENTREQUEST_0_NAME0', + 'L_PAYMENTREQUEST_0_QTY0', + 'MAXAMT', + 'PAYMENTREQUEST_0_AMT', + 'PAYMENTREQUEST_0_CURRENCYCODE', + // // FIXME: This should be deprecated, and is only for back-compat. + // 'PAYMENTREQUEST_0_CUSTOM', + //'PAYMENTREQUEST_0_DESC', + //'PAYMENTREQUEST_0_INVNUM', + 'PAYMENTREQUEST_0_ITEMAMT', + //'PAYMENTREQUEST_0_PAYMENTACTION', + //'PAYMENTREQUEST_0_PAYMENTREASON', + // // TODO: Investigate why would give this as an input: + // // PAYMENTREQUEST_n_TRANSACTIONID + ), + 'values' => array( + 'USER' => $this->account_config['User'], + 'PWD' => $this->account_config['Password'], + 'SIGNATURE' => $this->account_config['Signature'], + 'VERSION' => self::API_VERSION, + 'METHOD' => 'SetExpressCheckout', + 'CANCELURL' => ResultPages::getCancelPage( $this ), + 'REQCONFIRMSHIPPING' => 0, + 'NOSHIPPING' => 1, + 'L_BILLINGTYPE0' => 'RecurringPayments', + // FIXME: Sad! The thank-you message would be perfect here, + // but it seems the exlamation mark is not supported, even when + // urlencoded properly. + //'L_BILLINGAGREEMENTDESCRIPTION0' => WmfFramework::formatMessage( 'donate_interface-donate-error-thank-you-for-your-support' ), + 'L_BILLINGAGREEMENTDESCRIPTION0' => WmfFramework::formatMessage( 'donate_interface-monthly-donation-description' ), + 'L_PAYMENTREQUEST_0_DESC0' => WmfFramework::formatMessage( 'donate_interface-monthly-donation-description' ), + 'L_PAYMENTREQUEST_0_ITEMCATEGORY0' => 'Digital', + 'L_PAYMENTREQUEST_0_NAME0' => WmfFramework::formatMessage( 'donate_interface-monthly-donation-description' ), + 'L_PAYMENTREQUEST_0_QTY0' => 1, + 'PAYMENTREQUEST_0_DESC' => WmfFramework::formatMessage( 'donate_interface-monthly-donation-description' ), + 'PAYMENTREQUEST_0_PAYMENTACTION' => 'Sale', + 'PAYMENTREQUEST_0_PAYMENTREASON' => 'None', + ), + 'response' => array( + 'TOKEN', ), ); @@ -140,6 +205,32 @@ 'SIGNATURE' => $this->account_config['Signature'], 'VERSION' => self::API_VERSION, 'METHOD' => 'GetExpressCheckoutDetails', + ), + 'response' => array( + 'ACK', + 'TOKEN', + 'CORRELATIONID', + 'TIMESTAMP', + 'CUSTOM', + 'INVNUM', + 'BILLINGAGREEMENTACCEPTEDSTATUS', + 'REDIRECTREQUIRED', + 'CHECKOUTSTATUS', + 'EMAIL', + 'PAYERID', + 'COUNTRYCODE', + 'FIRSTNAME', + 'MIDDLENAME', + 'LASTNAME', + 'SUFFIX', + // TODO: Don't know if this is the one? 'PAYMENTINFO_0_CURRENCYCODE', + 'PAYMENTREQUEST_0_AMT', + 'PAYMENTREQUEST_0_CURRENCYCODE', + // Or this one? 'PAYMENTREQUEST_n_ITEMAMT' + // FIXME: Are we able to override contribution_tracking_id like this? + 'PAYMENTREQUEST_0_INVNUM', + 'PAYMENTREQUEST_0_TRANSACTIONID', + // Or, the L_ item? ), ); @@ -176,6 +267,56 @@ 'PAYMENTREQUEST_0_PAYMENTREASON' => 'None', ), ); + + // https://developer.paypal.com/docs/classic/api/merchant/CreateRecurringPaymentsProfile_API_Operation_NVP/ + $this->transactions['CreateRecurringPaymentsProfile'] = array( + 'request' => array( + 'USER', + 'PWD', + 'SIGNATURE', + 'VERSION', + 'METHOD', + 'TOKEN', + 'DESC', + //'L_PAYMENTREQUEST_0_AMT0', + //'L_PAYMENTREQUEST_0_DESC0', + //'L_PAYMENTREQUEST_n_NAME0', + //'L_PAYMENTREQUEST_0_ITEMCATEGORY0', + 'PROFILESTARTDATE', + 'PROFILEREFERENCE', + 'AUTOBILLOUTAMT', + 'BILLINGPERIOD', + 'BILLINGFREQUENCY', + 'TOTALBILLINGCYCLES', + 'MAXFAILEDPAYMENTS', + 'AMT', + 'CURRENCYCODE', + 'EMAIL', + ), + 'values' => array( + 'USER' => $this->account_config['User'], + 'PWD' => $this->account_config['Password'], + 'SIGNATURE' => $this->account_config['Signature'], + 'VERSION' => self::API_VERSION, + 'METHOD' => 'CreateRecurringPaymentsProfile', + 'DESC' => WmfFramework::formatMessage( 'donate_interface-monthly-donation-description' ), + //'L_PAYMENTREQUEST_0_DESC0' => WmfFramework::formatMessage( 'donate_interface-monthly-donation-description' ), + //'L_PAYMENTREQUEST_0_ITEMCATEGORY0' => 'Digital', + //'L_PAYMENTREQUEST_n_NAME0' => WmfFramework::formatMessage( 'donate_interface-monthly-donation-description' ), + // Do not charge for the balance if payments fail. + 'AUTOBILLOUTAMT' => 'NoAutoBill', + 'BILLINGPERIOD' => 'Month', + 'BILLINGFREQUENCY' => 1, + 'TOTALBILLINGCYCLES' => 0, // Forever. + 'MAXFAILEDPAYMENTS' => 3, + ), + 'response' => array( + # FIXME: Make sure this gets passed as subscription_id in the message + 'PROFILEID', + 'PROFILESTATUS', + 'TRANSACTIONID', + ), + ); } function getBasedir() { @@ -184,8 +325,8 @@ public function doPayment() { if ( $this->getData_Unstaged_Escaped( 'recurring' ) ) { - // TODO: implement - throw new Exception( "Recurring not implemented yet." ); + // Build the billing agreement and get a token to redirect. + $resultData = $this->do_transaction( 'SetExpressCheckout_recurring' ); } else { // Returns a token which we use to build a redirect URL into the // PayPal flow. @@ -200,7 +341,7 @@ if ( $resultAction->getRedirect() ) { // FIXME: This stuff should be base behavior for handling redirect responses. $this->logPaymentDetails(); - $this->setLimboMessage( 'pending' ); + // Don't worry about saving a limbo message, we know next to nothing yet. // TODO: need ffname hack? } @@ -217,83 +358,133 @@ $this->transaction_response = new PaymentTransactionResponse(); } $this->transaction_response->setData( $response ); - if ( !$response ) { - throw new ResponseProcessingException( - 'Missing or badly formatted response', - ResponseCodes::NO_RESPONSE - ); - } + // FIXME: I'm not sure why we're responsible for failing the + // transaction. If not, we can omit the try/catch here. + try { + if ( !$response ) { + throw new ResponseProcessingException( + 'Missing or badly formatted response', + ResponseCodes::NO_RESPONSE + ); + } - switch ( $this->getCurrentTransaction() ) { - case 'SetExpressCheckout': - if ( !$this->processAckResponse( $response ) ) { - // TODO: Here and below, parse the API error fields and log. - $this->logger->error( "Failed to set up payment, " . json_encode( $response ) ); - $this->finalizeInternalStatus( FinalStatus::FAILED ); + switch ( $this->getCurrentTransaction() ) { + case 'CreateRecurringPaymentsProfile': + $this->checkResponseAck( $response ); + + // Grab the subscription ID. + $this->addResponseData( $this->unstageKeys( $response ) ); + + // FIXME: Not a satisfying ending. Parse the PROFILESTATUS + // response and sort it into complete or pending. + $this->finalizeInternalStatus( FinalStatus::COMPLETE ); + $this->runPostProcessHooks(); + // FIXME: deprecated + $this->deleteLimboMessage( 'pending' ); break; - } - $this->transaction_response->setRedirect( $this->account_config['RedirectURL'] . $response['TOKEN'] ); - break; - case 'ProcessReturn': - // FIXME: Silly that we have to wedge the response controller in here with tail recursion. - $this->addRequestData( array( - 'ec_token' => $response['token'], - 'donor_id' => $response['PayerID'], - ) ); - $resultData = $this->do_transaction( 'GetExpressCheckoutDetails' ); - if ( $resultData->getCommunicationStatus() ) { - $this->do_transaction( 'DoExpressCheckoutPayment' ); - } - break; - case 'GetExpressCheckoutDetails': - if ( !$this->processAckResponse( $response ) ) { - $this->logger->error( "Failed to get details, " . json_encode( $response ) ); - $this->finalizeInternalStatus( FinalStatus::FAILED ); + case 'SetExpressCheckout': + case 'SetExpressCheckout_recurring': + $this->checkResponseAck( $response ); + $this->transaction_response->setRedirect( + $this->account_config['RedirectURL'] . $response['TOKEN'] ); + break; + case 'ProcessReturn': + // FIXME: Silly that we have to wedge the response controller in + // here with tail recursion. And fragile, because we have to + // remember about reentry. + $this->addRequestData( array( + 'ec_token' => $response['token'], + 'payer_id' => $response['PayerID'], + ) ); + $resultData = $this->do_transaction( 'GetExpressCheckoutDetails' ); + if ( !$resultData->getCommunicationStatus() ) { + throw new ResponseProcessingException( 'Failed to get customer details', + ResponseCodes::UNKNOWN ); + } + + // One-time payment, or initial payment in a subscription. + // XXX: This shouldn't finalize the transaction. + $resultData = $this->do_transaction( 'DoExpressCheckoutPayment' ); + if ( !$resultData->getCommunicationStatus() ) { + $this->finalizeInternalStatus( FinalStatus::FAILED ); + break; + } + + if ( $this->getData_Unstaged_Escaped( 'recurring' ) ) { + // Set up recurring billing agreement. + $this->addRequestData( array( + // Start in a month; we're making today's payment as an one-time charge. + 'date' => time() + 30 * 24 * 3600, // FIXME: calendar month + ) ); + $resultData = $this->do_transaction( 'CreateRecurringPaymentsProfile' ); + if ( !$resultData->getCommunicationStatus() ) { + throw new ResponseProcessingException( + 'Failed to create a recurring profile', ResponseCodes::UNKNOWN ); + } + } + break; + case 'GetExpressCheckoutDetails': + $this->checkResponseAck( $response ); + + // Merge response into our transaction data. + // TODO: Use getFormattedData instead. + // FIXME: We don't want to allow overwriting of ctid, need a + // blacklist of protected fields. + $this->addResponseData( $this->unstageKeys( $response ) ); + + $this->runAntifraudHooks(); + if ( $this->getValidationAction() !== 'process' ) { + $this->finalizeInternalStatus( FinalStatus::FAILED ); + } + break; + case 'DoExpressCheckoutPayment': + $this->checkResponseAck( $response ); + + $this->addResponseData( $this->unstageKeys( $response ) ); + // FIXME: Silly. + $this->transaction_response->setGatewayTransactionId( + $this->getData_Unstaged_Escaped( 'gateway_txn_id' ) ); + $status = $this->findCodeAction( 'DoExpressCheckoutPayment', + 'PAYMENTINFO_0_ERRORCODE', $response['PAYMENTINFO_0_ERRORCODE'] ); + // TODO: Can we do this from do_transaction instead, or at least protect with !recurring... + $this->finalizeInternalStatus( $status ); + $this->runPostProcessHooks(); + // FIXME: deprecated + $this->deleteLimboMessage( 'pending' ); break; } - // Merge response into our transaction data. - // TODO: Use getFormattedData instead. - // FIXME: We don't want to allow overwriting of ctid, need a - // blacklist of protected fields. - $this->addResponseData( $this->unstageKeys( $response ) ); - - $this->runAntifraudHooks(); - if ( $this->getValidationAction() !== 'process' ) { - $this->finalizeInternalStatus( FinalStatus::FAILED ); + if ( !$this->transaction_response->getCommunicationStatus() ) { + // TODO: so much boilerplate... Just throw an exception subclass. + $logme = 'Failed response for Order ID ' . $this->getData_Unstaged_Escaped( 'order_id' ); + $this->logger->error( $logme ); + $this->transaction_response->setErrors( array( + 'internal-0000' => array ( + 'message' => $this->getErrorMapByCodeAndTranslate( 'internal-0000' ), + 'debugInfo' => $logme, + 'logLevel' => LogLevel::ERROR + ) + ) ); } - break; - case 'DoExpressCheckoutPayment': - if ( !$this->processAckResponse( $response ) ) { - // FIXME: is response already logged? - $this->logger->error( "Failed to complete payment, " . json_encode( $response ) ); - $this->finalizeInternalStatus( FinalStatus::FAILED ); - break; - } - $this->addResponseData( $this->unstageKeys( $response ) ); - // FIXME: Silly. - $this->transaction_response->setGatewayTransactionId( $this->getData_Unstaged_Escaped( 'gateway_txn_id' ) ); - $status = $this->findCodeAction( 'DoExpressCheckoutPayment', - 'PAYMENTINFO_0_ERRORCODE', $response['PAYMENTINFO_0_ERRORCODE'] ); - $this->finalizeInternalStatus( $status ); - $this->runPostProcessHooks(); - // FIXME: deprecated - $this->deleteLimboMessage( 'pending' ); - break; - } - - if ( !$this->transaction_response->getCommunicationStatus() ) { - // TODO: so much boilerplate... Just throw an exception subclass. - $logme = 'Failed response for Order ID ' . $this->getData_Unstaged_Escaped( 'order_id' ); - $this->logger->error( $logme ); - $this->transaction_response->setErrors( array( - 'internal-0000' => array ( - 'message' => $this->getErrorMapByCodeAndTranslate( 'internal-0000' ), - 'debugInfo' => $logme, - 'logLevel' => LogLevel::ERROR - ) - ) ); + } catch ( Exception $ex ) { + // TODO: Parse the API error fields and log them. + $this->logger->error( "Failure detected in " . json_encode( $response ) ); + $this->finalizeInternalStatus( FinalStatus::FAILED ); + throw $ex; } } + /** + * Shared snippet to parse the ACK response field and store it as + * communication status. + * + * @throws ResponseProcessingException + */ + protected function checkResponseAck( $response ) { + if ( isset( $response['ACK'] ) && $response['ACK'] === 'Success' ) { + $this->transaction_response->setCommunicationStatus( true ); + } else { + throw new ResponseProcessingException( "Failure response", $response['ACK'] ); + } + } } -- To view, visit https://gerrit.wikimedia.org/r/287036 To unsubscribe, visit https://gerrit.wikimedia.org/r/settings Gerrit-MessageType: merged Gerrit-Change-Id: I8761b077cdccfc693176f15d3efeca83904cb5ff Gerrit-PatchSet: 12 Gerrit-Project: mediawiki/extensions/DonationInterface Gerrit-Branch: master Gerrit-Owner: Awight <awi...@wikimedia.org> Gerrit-Reviewer: Awight <awi...@wikimedia.org> Gerrit-Reviewer: Ejegg <eeggles...@wikimedia.org> Gerrit-Reviewer: jenkins-bot <> _______________________________________________ MediaWiki-commits mailing list MediaWiki-commits@lists.wikimedia.org https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits