wikimedia/mediawiki-extensions-DonationInterface

View on GitHub
ingenico_gateway/ingenico.adapter.php

Summary

Maintainability
B
6 hrs
Test Coverage
<?php

use Psr\Log\LogLevel;
use SmashPig\Core\PaymentError;
use SmashPig\PaymentData\FinalStatus;
use SmashPig\PaymentData\RecurringModel;
use SmashPig\PaymentData\ValidationAction;
use SmashPig\PaymentProviders\Ingenico\HostedCheckoutProvider;
use SmashPig\PaymentProviders\PaymentProviderFactory;
use SmashPig\PaymentProviders\Responses\CreatePaymentSessionResponse;
use SmashPig\PaymentProviders\Responses\PaymentDetailResponse;
use SmashPig\PaymentProviders\Responses\PaymentProviderResponse;

class IngenicoAdapter extends GatewayAdapter implements RecurringConversion {
    use RecurringConversionTrait;

    const GATEWAY_NAME = 'Ingenico';
    const IDENTIFIER = 'ingenico';
    const GLOBAL_PREFIX = 'wgIngenicoGateway';

    /**
     * Setting some Ingenico-specific defaults.
     * @param array $options These get extracted in the parent.
     */
    protected function setGatewayDefaults( $options = [] ) {
        $returnTo = $options['returnTo'] ??
            Title::newFromText( 'Special:IngenicoGatewayResult' )->getFullURL( [], false, PROTO_CURRENT );

        $defaults = [
            'return_url' => $returnTo,
            'attempt_id' => '1',
            'effort_id' => '1',
            'processor_form' => 'default',
        ];

        $this->addRequestData( $defaults );
    }

    public function defineTransactions() {
        $this->transactions['createPaymentSession'] = [
            'request' => [
                'use_3d_secure',
                'amount',
                'currency',
                'recurring',
                'return_url',
                'processor_form',
                'city',
                'street_address',
                'state_province',
                'postal_code',
                'email',
                'order_id',
                'description',
                'user_ip',
                'country',
                'language',
                'payment_submethod',
            ],
            'values' => [
                'description' => WmfFramework::formatMessage( 'donate_interface-donation-description' ),
            ],
        ];

        $this->transactions['getPaymentStatus'] = [
            'request' => [ 'id' ],
            'response' => [
                'amount',
                'currencyCode',
                'avsResult',
                'cvvResult',
                'statusCode',
                'paymentProductId',
            ]
        ];

        $this->transactions['cancelPayment'] = [
            'request' => [ 'id' ],
            'response' => [ 'statusCode' ]
        ];
    }

    /**
     * Sets up the $order_id_meta array.
     * Should contain the following keys/values:
     * 'alt_locations' => [ $dataset_name, $dataset_key ] //ordered
     * 'type' => numeric, or alphanumeric
     * 'length' => $max_charlen
     */
    public function defineOrderIDMeta() {
        $this->order_id_meta = [
            'alt_locations' => [],
            'ct_id' => true,
            'generate' => true,
        ];
    }

    public function doPayment() {
        $this->ensureUniqueOrderID();
        $this->incrementSequenceNumber();
        $this->session_addDonorData();
        Gateway_Extras_CustomFilters::onGatewayReady( $this );
        $this->runSessionVelocityFilter();
        if ( $this->getValidationAction() !== ValidationAction::PROCESS ) {
            return PaymentResult::newFailure( [ new PaymentError(
                'internal-0000',
                "Failed pre-process checks for payment.",
                LogLevel::INFO
            ) ] );
        }
        /** @var HostedCheckoutProvider $provider */
        $provider = $this->getPaymentProvider();
        '@phan-var HostedCheckoutProvider $provider';
        $email = $this->getData_Unstaged_Escaped( 'email' );
        $this->logger->info( "Calling createPaymentSession for donor $email" );
        $createSessionResponse = $this->createPaymentSession( $provider );
        if ( $createSessionResponse->getRedirectUrl() ) {
            $this->addResponseData( [
                'gateway_session_id' => $createSessionResponse->getPaymentSession()
            ] );
            $this->session_addDonorData();
            return PaymentResult::newIframe( $createSessionResponse->getRedirectUrl() );
        }
        return PaymentResult::newFailure( $createSessionResponse->getErrors() );
    }

    protected function createPaymentSession( HostedCheckoutProvider $provider ): CreatePaymentSessionResponse {
        $this->setCurrentTransaction( 'createPaymentSession' );
        $data = $this->buildRequestArray();
        if ( $this->getData_Unstaged_Escaped( 'recurring' ) ) {
            $data['description'] = WmfFramework::formatMessage( 'donate_interface-monthly-donation-description' );
        }
        // FIXME: need special handling to pass through 'false' because it's erased by buildRequestArray
        if ( $this->getData_Staged( 'use_3d_secure' ) === false ) {
            $data['use_3d_secure'] = false;
        }
        // If we are going to ask for a monthly donation after a one-time donation completes, set the
        // recurring param to 1 to tokenize the payment.
        if ( $this->showMonthlyConvert() ) {
            $data['recurring'] = 1;
            // Since we're not sure if we're going to ever use the token, flag the transaction as
            // 'card on file' rather than 'subscription' (the default for recurring). This may avoid
            // donor complaints of one-time donations appearing as recurring on their card statement.
            $data['recurring_model'] = RecurringModel::CARD_ON_FILE;
        }
        return $provider->createPaymentSession( $data );
    }

    protected function getPaymentProvider() {
        $method = $this->getData_Unstaged_Escaped( 'payment_method' );
        return PaymentProviderFactory::getProviderForMethod( $method );
    }

    public function processDonorReturn( $requestValues ) {
        // FIXME: make sure we're processing the order ID we expect!
        /** @var HostedCheckoutProvider $provider */
        $provider = $this->getPaymentProvider();
        '@phan-var HostedCheckoutProvider $provider';
        $statusResponse = $provider->getLatestPaymentStatus( [
            'gateway_session_id' => $this->getData_Unstaged_Escaped( 'gateway_session_id' )
        ] );
        $transactionStatus = $statusResponse->getStatus();
        $paymentResult = PaymentResult::newSuccess();
        if ( !$statusResponse->isSuccessful() ) {
            if ( $statusResponse->getStatus() === FinalStatus::PENDING ) {
                // Sometimes donors get back to our form with a not-fully-processed payment
                // and our lookup comes back with 'IN_PROGRESS' Weird, but send the donor
                // to the TY page and hope the pending resolver can pick it up later
                $this->logger->warning( 'Donor came back to Ingenico ResultSwitcher with payment still pending' );
            } else {
                $this->logFailedStatusResponse( $statusResponse );
                $paymentResult = PaymentResult::newFailure( $statusResponse->getErrors() );
            }
        } else {
            $this->logSanitizedResponse( $statusResponse );
            $this->addLatestPaymentStatusResponseData( $statusResponse );
            if ( $statusResponse->requiresApproval() ) {
                $this->addFraudDataAndRunFilters( $statusResponse );
                switch ( $this->getValidationAction() ) {
                    case ValidationAction::PROCESS:
                        $this->logger->info(
                            "Calling approvePayment on gateway transaction ID {$statusResponse->getGatewayTxnId()}"
                        );
                        $captureResult = $provider->approvePayment( [
                            'gateway_txn_id' => $statusResponse->getGatewayTxnId(),
                        ] );
                        $this->logSanitizedResponse( $captureResult );
                        $transactionStatus = $captureResult->getStatus();
                        if ( $captureResult->isSuccessful() ) {
                            $this->logger->info( "Capture succeeded for gateway transaction ID {$captureResult->getGatewayTxnId()}" );
                            if ( $this->showMonthlyConvert() ) {
                                $this->logger->info( "Displaying monthly convert modal" );
                            }
                        } else {
                            $this->logger->info( 'Capture call unsuccessful' );
                            $paymentResult = PaymentResult::newFailure();
                        }
                        break;
                    case ValidationAction::REJECT:
                        $paymentResult = PaymentResult::newFailure();
                        $this->logger->info( 'Created payment rejected by our fraud filters' );
                        break;
                    default:
                        $this->logger->info(
                            'Not capturing authorized payment - validation action is ' .
                            $this->getValidationAction()
                        );
                }
            }
        }

        if ( $statusResponse->getRecurringPaymentToken() ) {
            $this->addResponseData( [
                'recurring_payment_token' => $statusResponse->getRecurringPaymentToken()
            ] );
            if ( $this->showMonthlyConvert() ) {
                $this->session_addDonorData();
            }
        }
        // Log and send the payments-init message, and clean out the session
        $this->finalizeInternalStatus( $transactionStatus );

        // Run some post-donation filters and send donation queue message
        $this->postProcessDonation();
        return $paymentResult;
    }

    protected function logSanitizedResponse( PaymentProviderResponse $response ): void {
        $rawResponse = $this->getSanitizedResponse( $response );
        $this->logger->info( 'RETURNED FROM CURL:' . print_r( $rawResponse, true ) );
    }

    protected function getSanitizedResponse( PaymentProviderResponse $response ): string {
        $rawResponse = $response->getRawResponse();
        // do not send card to rawResponse for log, below two was for ingenico getHostedPaymentStatus, approvePayment and cancelPayment
        if ( isset( $rawResponse['createdPaymentOutput']['payment']['paymentOutput']['cardPaymentMethodSpecificOutput']['card'] ) ) {
            unset( $rawResponse['createdPaymentOutput']['payment']['paymentOutput']['cardPaymentMethodSpecificOutput']['card']['cardNumber'] );
            unset( $rawResponse['createdPaymentOutput']['payment']['paymentOutput']['cardPaymentMethodSpecificOutput']['card']['expiryDate'] );
        }
        if ( isset( $rawResponse['payment']['paymentOutput']['cardPaymentMethodSpecificOutput']['card'] ) ) {
            unset( $rawResponse['payment']['paymentOutput']['cardPaymentMethodSpecificOutput']['card']['cardNumber'] );
            unset( $rawResponse['payment']['paymentOutput']['cardPaymentMethodSpecificOutput']['card']['expiryDate'] );
        }
        return json_encode( $rawResponse );
    }

    protected function logFailedStatusResponse( PaymentDetailResponse $statusResponse ) {
        $errorLogMessage = 'Unsuccessful hosted checkout status response from gateway: ';
        $errorLogMessage .= $statusResponse->getStatus() . " : ";
        $errorLogMessage .= $this->getSanitizedResponse( $statusResponse );
        $this->logger->info( $errorLogMessage );
    }

    protected function addLatestPaymentStatusResponseData( PaymentDetailResponse $statusResult ): void {
        $responseData = [
            'amount' => $statusResult->getAmount(),
            'currency' => $statusResult->getCurrency(),
            'gateway_txn_id' => $statusResult->getGatewayTxnId(),
            'initial_scheme_transaction_id' => $statusResult->getInitialSchemeTransactionId(),
            'payment_submethod' => $statusResult->getPaymentSubmethod(),
        ];
        if (
            $statusResult->getDonorDetails() === null ||
            $statusResult->getDonorDetails()->getFullName() === null
        ) {
            $this->logger->warning( 'Cardholder name is missing in Ingenico status response' );
        } else {
            $responseData['full_name'] = $statusResult->getDonorDetails()->getFullName();
        }
        $this->addResponseData( $responseData );
    }

    /**
     * Adds fraud scores to unstaged data and runs filters
     *
     * @param PaymentDetailResponse $statusResponse
     */
    protected function addFraudDataAndRunFilters( PaymentDetailResponse $statusResponse ): void {
        $riskScores = $statusResponse->getRiskScores();
        $this->addResponseData( [
            'avs_result' => $riskScores['avs'] ?? 0,
            'cvv_result' => $riskScores['cvv'] ?? 0
        ] );
        $this->runAntifraudFilters();
    }

    /**
     * getAVSResult is intended to be used by the functions filter, to
     * determine if we want to fail the transaction ourselves or not.
     * @return int
     */
    public function getAVSResult() {
        return $this->getData_Unstaged_Escaped( 'avs_result' );
    }

    /**
     * getCVVResult is intended to be used by the functions filter, to
     * determine if we want to fail the transaction ourselves or not.
     * @return int
     */
    public function getCVVResult() {
        return $this->getData_Unstaged_Escaped( 'cvv_result' );
    }

    public function getRequestProcessId( $requestValues ) {
        return $requestValues['hostedCheckoutId'];
    }

    public function getPaymentMethodsSupportingRecurringConversion(): array {
        return [ 'cc' ];
    }

    /**
     * FIXME drop these last two functions from GatewayAdapter abstract class
     */
    protected function defineAccountInfo() {
        // We use account_config instead
        $this->accountInfo = [];
    }
}