Skip to content

Commit

Permalink
Sagepay recurring support
Browse files Browse the repository at this point in the history
This works but there are a few outstanding considerations

1) Sagepay uses actions that are inconsistent. I have gone with declaring this as metadata
as the 'most' transparent metadata - ie each piece of metadata should not
have a bunch of interwoved assumptions like 'if it's this
action then it's also this action and card is handled this way' but
I hope we can do some upstream work
to make it less ad hoc  - thephpleague/omnipay-sagepay#157

2) I'm a bit on the fence about the approach of creating a token only when it is recurring
and still using transaction data from the contribution.trxn_id. I think overall I prefer
to always create a token since any contribution could be used for a token and not
to save transaction data (over and above the trxn_id) in the contributon.trxn_id
but given I had written it this way I have not preferred that enough to re-write it. Test
cover should facilitate future changes (more or less)
  • Loading branch information
eileenmcnaughton committed Jan 10, 2021
1 parent bb41ebc commit a6eb746
Show file tree
Hide file tree
Showing 4 changed files with 156 additions and 2 deletions.
52 changes: 50 additions & 2 deletions CRM/Core/Payment/OmnipayMultiProcessor.php
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,9 @@ public function doPayment(&$params, $component = 'contribute') {
if (!empty($params['token'])) {
$response = $this->doTokenPayment($params);
}
elseif (!empty($params['is_recur'])) {
// 'create_card_action' is a bit of a sagePay hack - see https://github.com/thephpleague/omnipay-sagepay/issues/157
// don't rely on it being unchanged - tests & comments are your friend.
elseif (!empty($params['is_recur']) && $this->getProcessorTypeMetadata('create_card_action') !== 'purchase') {
$response = $this->gateway->createCard($this->getCreditCardOptions(array_merge($params, ['action' => 'Purchase']), $this->_component))->send();
}
else {
Expand Down Expand Up @@ -187,6 +189,12 @@ public function doPayment(&$params, $component = 'contribute') {
Contribution::update(FALSE)
->addWhere('id', '=', $params['contributionID'])
->setValues(['trxn_id' => $response->getTransactionReference()])->execute();
// Save the transaction details for recurring if is-recur as a token
// @todo - consider always saving these & not updating the contribution at all.
if (!empty($params['is_recur'])) {
// Ideally this would be getToken - see https://github.com/thephpleague/omnipay-sagepay/issues/157
$this->storePaymentToken($params, (int) $params['contributionRecurID'], $response->getTransactionReference());
}
}
$isTransparentRedirect = ($response->isTransparentRedirect() || !empty($this->gateway->transparentRedirect));
$this->cleanupClassForSerialization(TRUE);
Expand Down Expand Up @@ -812,11 +820,15 @@ public function processPaymentNotification($params) {

if ($this->getLock() && $this->contribution['contribution_status_id:name'] !== 'Completed') {
$this->gatewayConfirmContribution($response);
$trxnReference = $response->getTransactionReference();
civicrm_api3('contribution', 'completetransaction', [
'id' => $this->transaction_id,
'trxn_id' => $response->getTransactionReference(),
'trxn_id' => $trxnReference,
'payment_processor_id' => $params['processor_id'],
]);
if (!empty($this->contribution['contribution_recur_id']) && $trxnReference) {
$this->updatePaymentTokenWithAnyExtraData($trxnReference);
}
}
if (!empty($this->contribution['contribution_recur_id']) && ($tokenReference = $response->getCardReference()) != FALSE) {
$this->storePaymentToken(array_merge($params, ['contact_id' => $contribution['contact_id']]), $this->contribution['contribution_recur_id'], $tokenReference);
Expand Down Expand Up @@ -1494,6 +1506,9 @@ protected function doTokenPayment(&$params) {
if (method_exists($this->gateway, 'completePurchase') && !isset($params['payment_action']) && empty($params['is_recur'])) {
$action = 'completePurchase';
}
elseif ($this->getProcessorTypeMetadata('token_pay_action')) {
$action = $this->getProcessorTypeMetadata('token_pay_action');
}

$params['transactionReference'] = ($params['token']);
$response = $this->gateway->$action($this->getCreditCardOptions(array_merge($params, ['cardTransactionType' => 'continuous'])))
Expand Down Expand Up @@ -1669,5 +1684,38 @@ protected function getContactID() {
->first()['contact_id'];
}

/**
* If the notification contains additional token information store it.
*
* This updates the payment token but only if that token is a json-encoded
* array, in which case it is potentially added to.
*
* In practice this means sagepay can add the 'txAuthNo' to the token.
*
* @param string $trxnReference
*/
protected function updatePaymentTokenWithAnyExtraData(string $trxnReference) {
try {
$paymentToken = civicrm_api3('PaymentToken', 'get', [
'contribution_recur_id' => $this->contribution['contribution_recur_id'],
'options' => ['limit' => 1, 'sort' => 'id DESC'],
'sequential' => TRUE,
]);
if (!empty($paymentToken['values'])) {
// Hmm this check is a bit unclear - sagepay is a json array
// but it'a also probably the only other with a reference at this point...
// comments & tests are your friends.
if (is_array(json_decode($trxnReference, TRUE))) {
civicrm_api3('PaymentToken', 'create', [
'id' => $paymentToken['id'],
'token' => $trxnReference
]);
}
}
} catch (CiviCRM_API3_Exception $e) {
$this->log('possible error saving token', ['error' => $e->getMessage()]);
}
}

}

3 changes: 3 additions & 0 deletions Metadata/omnipay_Sagepay_Server.mgd.php
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@
// Hopefully temporary fix.
// https://github.com/thephpleague/omnipay-sagepay/pull/158
'is_pass_null_for_empty_card' => TRUE,
'create_card_action' => 'purchase',
'token_pay_action' => 'repeatPurchase',
],
'params' =>
[
Expand All @@ -89,6 +91,7 @@
'class_name' => 'Payment_OmnipayMultiProcessor',
'billing_mode' => 4,
'payment_type' => 3,
'is_recur' => TRUE,
],
],
];
12 changes: 12 additions & 0 deletions tests/phpunit/Mock/SagepayRepeatAuthorize.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
HTTP/1.1 200 OK
Date: Thu, 20 Feb 2020 10:25:00 GMT

VPSProtocol=3.00
Status=OK
StatusDetail=0000 : The Authorisation was Successful.
VPSTxId={
B4453DF4-E7D1-1CF3-ED60-6DA4AEA78D08
}
SecurityKey=BEY5QUAYGL
TxAuthNo=8365828
BankAuthCode=999777"
91 changes: 91 additions & 0 deletions tests/phpunit/SagepayTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
use GuzzleHttp\Psr7\Response;
use PHPUnit\Framework\TestCase;
use Civi\Test\Api3TestTrait;
use Civi\Api4\ContributionRecur;
use Civi\Api4\Contribution;

/**
* Sage pay tests for one-off payment.
Expand Down Expand Up @@ -96,6 +98,71 @@ public function testDoSinglePayment(): void {
'contribution_id' => $this->_contribution['id'],
]);

$contribution = $this->callAPISuccess('Contribution', 'get', [
'return' => ['trxn_id'],
'contact_id' => $this->ids['Contact']['id'],
'sequential' => 1,
]);

// Reset session as this would come in from the sage server.
CRM_Core_Session::singleton()->reset();
$ipnParams = $this->getSagepayPaymentConfirmation($this->paymentProcessorID, $contribution['id']);
$this->signRequest($ipnParams);
try {
CRM_Core_Payment_OmnipayMultiProcessor::processPaymentResponse(['processor_id' => $this->paymentProcessorID]);
}
catch (CRM_Core_Exception_PrematureExitException $e) {
// Check we didn't try to redirect the server.
$this->assertArrayNotHasKey('url', $e->errorData);
$contribution = \Civi\Api4\Contribution::get(FALSE)
->addWhere('id', '=', $contribution['id'])
->addSelect('contribution_status_id:name', 'trxn_id')->execute()->first();
$this->assertEquals('Completed', $contribution['contribution_status_id:name']);
}
}


/**
* When a payment is made, the Sagepay transaction identifier `VPSTxId`,
* a secret security key `SecurityKey` and the corresponding `qfKey`
* must be saved as part of the `trxn_id` JSON.
*
* @throws \CRM_Core_Exception
* @throws \CiviCRM_API3_Exception
*/
public function testDoRecurPayment(): void {
$this->setMockHttpResponse([
'SagepayOneOffPaymentSecret.txt',
'SagepayRepeatAuthorize.txt',
]);
Civi::$statics['Omnipay_Test_Config'] = [ 'client' => $this->getHttpClient() ];

$contributionRecur = ContributionRecur::create(FALSE)->setValues([
'contact_id' => $this->ids['Contact'],
'amount' => 5,
'currency' => 'GBP',
'frequency_interval' => 1,
'start_date' => 'now',
'payment_processor_id' => $this->paymentProcessorID,
])->execute()->first();

Contribution::update(FALSE)->addWhere('id', '=', $this->_contribution['id'])->setValues(['contribution_recur_id' => $contributionRecur['id']])->execute();
$transactionSecret = $this->getSagepayTransactionSecret();

$payment = $this->callAPISuccess('PaymentProcessor', 'pay', [
'payment_processor_id' => $this->paymentProcessorID,
'amount' => $this->_new['amount'],
'qfKey' => $this->getQfKey(),
'currency' => $this->_new['currency'],
'component' => 'contribute',
'email' => $this->_new['card']['email'],
'contactID' => $this->ids['Contact']['id'],
'contributionID' => $this->_contribution['id'],
'contribution_id' => $this->_contribution['id'],
'contributionRecurID' => $contributionRecur['id'],
'is_recur' => TRUE,
]);

$contribution = $this->callAPISuccess('Contribution', 'get', [
'return' => ['trxn_id'],
'contact_id' => $this->ids['Contact'],
Expand All @@ -117,6 +184,30 @@ public function testDoSinglePayment(): void {
->addSelect('contribution_status_id:name', 'trxn_id')->execute()->first();
$this->assertEquals('Completed', $contribution['contribution_status_id:name']);
}
$recur = ContributionRecur::get(FALSE)
->addSelect('payment_token_id')
->addSelect('payment_processor_id')
->addWhere('id', '=', $contributionRecur['id'])
->execute()->first();
$this->assertNotEmpty($recur['payment_token_id']);
$contribution = $this->callAPISuccess('Contribution', 'repeattransaction', [
'contribution_recur_id' => $contributionRecur['id'],
'payment_processor_id' => $this->paymentProcessorID,
]);
$this->callAPISuccess('PaymentProcessor', 'pay', [
'amount' => $this->_new['amount'],
'currency' => $this->_new['currency'],
'payment_processor_id' => $this->paymentProcessorID,
'contribution_id' => $contribution['id'],
'token' => civicrm_api3('PaymentToken', 'getvalue', [
'id' => $recur['payment_token_id'],
'return' => 'token',
]),
'payment_action' => 'purchase',
]);

$sent = $this->getRequestBodies();
$this->assertContains('RelatedTxAuthNo=4898041', $sent[1]);
}

}

0 comments on commit a6eb746

Please sign in to comment.