paypal_ec_gateway/paypal_express.adapter.php
<?php
use SmashPig\PaymentData\ErrorCode;
use SmashPig\PaymentData\FinalStatus;
use SmashPig\PaymentData\ValidationAction;
use SmashPig\PaymentProviders\IPaymentProvider;
use SmashPig\PaymentProviders\IRecurringPaymentProfileProvider;
use SmashPig\PaymentProviders\PaymentProviderFactory;
use SmashPig\PaymentProviders\PayPal\PaymentProvider;
use SmashPig\PaymentProviders\Responses\PaymentDetailResponse;
/**
* PayPal Express Checkout name value pair integration
*
* 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';
const IDENTIFIER = 'paypal_ec';
const GLOBAL_PREFIX = 'wgPaypalExpressGateway';
protected function defineAccountInfo() {
$this->accountInfo = [];
}
/**
* Use our own Order ID sequence.
*/
protected function defineOrderIDMeta() {
$this->order_id_meta = [
'generate' => true,
'ct_id' => true,
];
}
protected function setGatewayDefaults( $options = [] ) {
if ( $this->getData_Unstaged_Escaped( 'payment_method' ) == null ) {
$this->addRequestData(
[ 'payment_method' => 'paypal' ]
);
}
}
protected function defineTransactions() {
$this->transactions = [];
// https://developer.paypal.com/docs/classic/api/merchant/SetExpressCheckout_API_Operation_NVP/
$this->transactions['SetExpressCheckout'] = [
'request' => [
'return_url',
'cancel_url',
'language',
'amount',
'currency',
'description',
'order_id',
'recurring',
],
'values' => [
'cancel_url' => ResultPages::getCancelPage( $this ),
],
];
// https://developer.paypal.com/docs/classic/api/merchant/DoExpressCheckoutPayment_API_Operation_NVP/
$this->transactions['DoExpressCheckoutPayment'] = [
'request' => [
'amount',
'currency',
'gateway_session_id',
'description',
'order_id',
'processor_contact_id',
],
'values' => [
'description' => WmfFramework::formatMessage( 'donate_interface-donation-description' ),
],
];
// https://developer.paypal.com/docs/classic/api/merchant/CreateRecurringPaymentsProfile_API_Operation_NVP/
$this->transactions['CreateRecurringPaymentsProfile'] = [
'request' => [
'amount',
'currency',
'date',
'description',
'email',
'frequency_unit',
'gateway_session_id',
'order_id',
],
'values' => [
'date' => time(),
'description' => WmfFramework::formatMessage( 'donate_interface-monthly-donation-description' ),
],
];
}
public function doPayment() {
$this->setValidationAction( ValidationAction::PROCESS, true );
$this->logger->debug( 'Running onGatewayReady filters' );
Gateway_Extras_CustomFilters::onGatewayReady( $this );
if ( $this->getValidationAction() != ValidationAction::PROCESS ) {
return PaymentResult::fromResults(
$this->setFailedValidationTransactionResponse( 'SetExpressCheckout' ),
FinalStatus::FAILED
);
}
$this->config['transformers'][] = 'PaypalExpressReturnUrl';
$this->data_transformers[] = new PaypalExpressReturnUrl();
$this->stageData();
$provider = PaymentProviderFactory::getProviderForMethod(
$this->getPaymentMethod()
);
'@phan-var PaymentProvider $provider';
$this->setCurrentTransaction( 'SetExpressCheckout' );
$descriptionKey = $this->getData_Unstaged_Escaped( 'recurring' ) ?
'donate_interface-monthly-donation-description' :
'donate_interface-donation-description';
$this->transactions['SetExpressCheckout']['values']['description'] =
WmfFramework::formatMessage( $descriptionKey );
// Returns a token which and a redirect URL to send the donor to PayPal
$paymentSessionResult = $provider->createPaymentSession( $this->buildRequestArray() );
if ( $paymentSessionResult->isSuccessful() ) {
$this->addResponseData( [ 'gateway_session_id' => $paymentSessionResult->getPaymentSession() ] );
$this->session_addDonorData();
return PaymentResult::newRedirect( $paymentSessionResult->getRedirectUrl() );
}
return PaymentResult::newFailure( $paymentSessionResult->getErrors() );
}
/**
* @return bool false, but we're kinda lying.
* We do need to DoExpressCheckoutPayment when donors return, but it's
* better to lose a few donations and show the thank you page than to
* risk duplicate donations and problems for donor services. We handle
* donors who return with no cookies by running a pending transaction
* resolver like we do with Ingenico.
*/
public function isReturnProcessingRequired() {
return false;
}
public function getRequestProcessId( $requestValues ) {
return $requestValues['token'];
}
public function processDonorReturn( $requestValues ) {
if (
empty( $requestValues['token'] )
) {
throw new ResponseProcessingException(
'Missing required parameters in request',
ErrorCode::MISSING_REQUIRED_DATA
);
}
$this->setValidationAction( ValidationAction::PROCESS, true );
$this->logger->debug( 'Running onGatewayReady filters' );
Gateway_Extras_CustomFilters::onGatewayReady( $this );
if ( $this->getValidationAction() != ValidationAction::PROCESS ) {
return PaymentResult::fromResults(
$this->setFailedValidationTransactionResponse( 'ProcessDonorReturn' ),
FinalStatus::FAILED
);
}
$requestData = [
'gateway_session_id' => urldecode( $requestValues['token'] )
];
if (
empty( $requestValues['PayerID'] )
) {
$this->logger->info( 'Notice missing PayerID in PaypalExpressAdapater::ProcessDonorReturn' );
} else {
$requestData['payer_id'] = $requestValues['PayerID'];
}
$this->addRequestData( $requestData );
$provider = PaymentProviderFactory::getProviderForMethod(
$this->getPaymentMethod()
);
'@phan-var PaymentProvider $provider';
$detailsResult = $provider->getLatestPaymentStatus( [
'gateway_session_id' => $requestData['gateway_session_id']
] );
if ( !$detailsResult->isSuccessful() ) {
if ( $detailsResult->requiresRedirect() ) {
return PaymentResult::newRedirect( $detailsResult->getRedirectUrl() );
}
$this->finalizeInternalStatus( $detailsResult->getStatus() );
return PaymentResult::newFailure( $detailsResult->getErrors() );
}
$this->addDonorDetailsToSession( $detailsResult );
if ( $detailsResult->getStatus() === FinalStatus::PENDING_POKE ) {
$this->runAntifraudFilters();
if ( $this->getValidationAction() !== ValidationAction::PROCESS ) {
$this->finalizeInternalStatus( FinalStatus::FAILED );
return PaymentResult::fromResults(
$this->setFailedValidationTransactionResponse( 'SetExpressCheckout' ),
FinalStatus::FAILED
);
}
if ( $this->getData_Unstaged_Escaped( 'recurring' ) ) {
return $this->createRecurringProfile( $provider );
} else {
return $this->captureOneTimePayment( $provider );
}
} else {
$this->finalizeInternalStatus( $detailsResult->getStatus() );
}
return PaymentResult::fromResults(
new PaymentTransactionResponse(),
$this->getFinalStatus()
);
}
protected function addDonorDetailsToSession( PaymentDetailResponse $detailResponse ): void {
$donorDetails = $detailResponse->getDonorDetails();
if ( $donorDetails !== null ) {
$responseData = [
'first_name' => $donorDetails->getFirstName(),
'last_name' => $donorDetails->getLastName(),
'email' => $donorDetails->getEmail(),
'processor_contact_id' => $detailResponse->getProcessorContactID(),
];
$address = $donorDetails->getBillingAddress();
if ( $address !== null ) {
$responseData += [
'city' => $address->getCity(),
'street_address' => $address->getStreetAddress(),
'postal_code' => $address->getPostalCode(),
'state_province' => $address->getPostalCode()
];
if ( $address->getCountryCode() !== null ) {
$responseData[ 'country' ] = $address->getCountryCode();
}
}
$this->addResponseData( $responseData );
$this->session_addDonorData();
}
}
/**
* @param IRecurringPaymentProfileProvider $provider
* @return PaymentResult
*/
protected function createRecurringProfile( IRecurringPaymentProfileProvider $provider ): PaymentResult {
$this->setCurrentTransaction( 'CreateRecurringPaymentsProfile' );
$profileParams = $this->buildRequestArray();
$createProfileResponse = $provider->createRecurringPaymentsProfile( $profileParams );
if ( $createProfileResponse->isSuccessful() ) {
$this->addResponseData( [
'subscr_id' => $createProfileResponse->getProfileId()
] );
// We've created a subscription, but we haven't got an initial
// payment yet, so we leave the details in the pending queue.
// The IPN listener will push the donation through to Civi when
// it gets notifications from PayPal.
// TODO: it would be nice to send the subscr_start message to
// the recurring queue here.
$this->finalizeInternalStatus( FinalStatus::PENDING );
$this->postProcessDonation();
return PaymentResult::newSuccess();
} else {
if ( $createProfileResponse->requiresRedirect() ) {
return PaymentResult::newRedirect( $createProfileResponse->getRedirectUrl() );
}
$this->finalizeInternalStatus( FinalStatus::FAILED );
return PaymentResult::newFailure( $createProfileResponse->getErrors() );
}
}
/**
* @param IPaymentProvider $provider
* @return PaymentResult
*/
protected function captureOneTimePayment( IPaymentProvider $provider ): PaymentResult {
// One-time payment, or initial payment in a subscription.
$this->setCurrentTransaction( 'DoExpressCheckoutPayment' );
$approvePaymentParams = $this->buildRequestArray();
$approveResult = $provider->approvePayment( $approvePaymentParams );
if ( $approveResult->isSuccessful() ) {
$this->addResponseData(
[ 'gateway_txn_id' => $approveResult->getGatewayTxnId() ]
);
$this->finalizeInternalStatus( FinalStatus::COMPLETE );
$this->postProcessDonation();
return PaymentResult::newSuccess();
} else {
$this->finalizeInternalStatus( FinalStatus::FAILED );
return PaymentResult::newFailure( $approveResult->getErrors() );
}
}
/**
* TODO: add test
* @return array
*/
public function createDonorReturnParams() {
return [ 'token' => $this->getData_Staged( 'gateway_session_id' ) ];
}
}