Skip to content

Commit a6eb746

Browse files
Sagepay recurring support
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)
1 parent bb41ebc commit a6eb746

File tree

4 files changed

+156
-2
lines changed

4 files changed

+156
-2
lines changed

CRM/Core/Payment/OmnipayMultiProcessor.php

+50-2
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,9 @@ public function doPayment(&$params, $component = 'contribute') {
156156
if (!empty($params['token'])) {
157157
$response = $this->doTokenPayment($params);
158158
}
159-
elseif (!empty($params['is_recur'])) {
159+
// 'create_card_action' is a bit of a sagePay hack - see https://github.com/thephpleague/omnipay-sagepay/issues/157
160+
// don't rely on it being unchanged - tests & comments are your friend.
161+
elseif (!empty($params['is_recur']) && $this->getProcessorTypeMetadata('create_card_action') !== 'purchase') {
160162
$response = $this->gateway->createCard($this->getCreditCardOptions(array_merge($params, ['action' => 'Purchase']), $this->_component))->send();
161163
}
162164
else {
@@ -187,6 +189,12 @@ public function doPayment(&$params, $component = 'contribute') {
187189
Contribution::update(FALSE)
188190
->addWhere('id', '=', $params['contributionID'])
189191
->setValues(['trxn_id' => $response->getTransactionReference()])->execute();
192+
// Save the transaction details for recurring if is-recur as a token
193+
// @todo - consider always saving these & not updating the contribution at all.
194+
if (!empty($params['is_recur'])) {
195+
// Ideally this would be getToken - see https://github.com/thephpleague/omnipay-sagepay/issues/157
196+
$this->storePaymentToken($params, (int) $params['contributionRecurID'], $response->getTransactionReference());
197+
}
190198
}
191199
$isTransparentRedirect = ($response->isTransparentRedirect() || !empty($this->gateway->transparentRedirect));
192200
$this->cleanupClassForSerialization(TRUE);
@@ -812,11 +820,15 @@ public function processPaymentNotification($params) {
812820

813821
if ($this->getLock() && $this->contribution['contribution_status_id:name'] !== 'Completed') {
814822
$this->gatewayConfirmContribution($response);
823+
$trxnReference = $response->getTransactionReference();
815824
civicrm_api3('contribution', 'completetransaction', [
816825
'id' => $this->transaction_id,
817-
'trxn_id' => $response->getTransactionReference(),
826+
'trxn_id' => $trxnReference,
818827
'payment_processor_id' => $params['processor_id'],
819828
]);
829+
if (!empty($this->contribution['contribution_recur_id']) && $trxnReference) {
830+
$this->updatePaymentTokenWithAnyExtraData($trxnReference);
831+
}
820832
}
821833
if (!empty($this->contribution['contribution_recur_id']) && ($tokenReference = $response->getCardReference()) != FALSE) {
822834
$this->storePaymentToken(array_merge($params, ['contact_id' => $contribution['contact_id']]), $this->contribution['contribution_recur_id'], $tokenReference);
@@ -1494,6 +1506,9 @@ protected function doTokenPayment(&$params) {
14941506
if (method_exists($this->gateway, 'completePurchase') && !isset($params['payment_action']) && empty($params['is_recur'])) {
14951507
$action = 'completePurchase';
14961508
}
1509+
elseif ($this->getProcessorTypeMetadata('token_pay_action')) {
1510+
$action = $this->getProcessorTypeMetadata('token_pay_action');
1511+
}
14971512

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

1687+
/**
1688+
* If the notification contains additional token information store it.
1689+
*
1690+
* This updates the payment token but only if that token is a json-encoded
1691+
* array, in which case it is potentially added to.
1692+
*
1693+
* In practice this means sagepay can add the 'txAuthNo' to the token.
1694+
*
1695+
* @param string $trxnReference
1696+
*/
1697+
protected function updatePaymentTokenWithAnyExtraData(string $trxnReference) {
1698+
try {
1699+
$paymentToken = civicrm_api3('PaymentToken', 'get', [
1700+
'contribution_recur_id' => $this->contribution['contribution_recur_id'],
1701+
'options' => ['limit' => 1, 'sort' => 'id DESC'],
1702+
'sequential' => TRUE,
1703+
]);
1704+
if (!empty($paymentToken['values'])) {
1705+
// Hmm this check is a bit unclear - sagepay is a json array
1706+
// but it'a also probably the only other with a reference at this point...
1707+
// comments & tests are your friends.
1708+
if (is_array(json_decode($trxnReference, TRUE))) {
1709+
civicrm_api3('PaymentToken', 'create', [
1710+
'id' => $paymentToken['id'],
1711+
'token' => $trxnReference
1712+
]);
1713+
}
1714+
}
1715+
} catch (CiviCRM_API3_Exception $e) {
1716+
$this->log('possible error saving token', ['error' => $e->getMessage()]);
1717+
}
1718+
}
1719+
16721720
}
16731721

Metadata/omnipay_Sagepay_Server.mgd.php

+3
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@
7777
// Hopefully temporary fix.
7878
// https://github.com/thephpleague/omnipay-sagepay/pull/158
7979
'is_pass_null_for_empty_card' => TRUE,
80+
'create_card_action' => 'purchase',
81+
'token_pay_action' => 'repeatPurchase',
8082
],
8183
'params' =>
8284
[
@@ -89,6 +91,7 @@
8991
'class_name' => 'Payment_OmnipayMultiProcessor',
9092
'billing_mode' => 4,
9193
'payment_type' => 3,
94+
'is_recur' => TRUE,
9295
],
9396
],
9497
];
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
HTTP/1.1 200 OK
2+
Date: Thu, 20 Feb 2020 10:25:00 GMT
3+
4+
VPSProtocol=3.00
5+
Status=OK
6+
StatusDetail=0000 : The Authorisation was Successful.
7+
VPSTxId={
8+
B4453DF4-E7D1-1CF3-ED60-6DA4AEA78D08
9+
}
10+
SecurityKey=BEY5QUAYGL
11+
TxAuthNo=8365828
12+
BankAuthCode=999777"

tests/phpunit/SagepayTest.php

+91
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
use GuzzleHttp\Psr7\Response;
88
use PHPUnit\Framework\TestCase;
99
use Civi\Test\Api3TestTrait;
10+
use Civi\Api4\ContributionRecur;
11+
use Civi\Api4\Contribution;
1012

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

101+
$contribution = $this->callAPISuccess('Contribution', 'get', [
102+
'return' => ['trxn_id'],
103+
'contact_id' => $this->ids['Contact']['id'],
104+
'sequential' => 1,
105+
]);
106+
107+
// Reset session as this would come in from the sage server.
108+
CRM_Core_Session::singleton()->reset();
109+
$ipnParams = $this->getSagepayPaymentConfirmation($this->paymentProcessorID, $contribution['id']);
110+
$this->signRequest($ipnParams);
111+
try {
112+
CRM_Core_Payment_OmnipayMultiProcessor::processPaymentResponse(['processor_id' => $this->paymentProcessorID]);
113+
}
114+
catch (CRM_Core_Exception_PrematureExitException $e) {
115+
// Check we didn't try to redirect the server.
116+
$this->assertArrayNotHasKey('url', $e->errorData);
117+
$contribution = \Civi\Api4\Contribution::get(FALSE)
118+
->addWhere('id', '=', $contribution['id'])
119+
->addSelect('contribution_status_id:name', 'trxn_id')->execute()->first();
120+
$this->assertEquals('Completed', $contribution['contribution_status_id:name']);
121+
}
122+
}
123+
124+
125+
/**
126+
* When a payment is made, the Sagepay transaction identifier `VPSTxId`,
127+
* a secret security key `SecurityKey` and the corresponding `qfKey`
128+
* must be saved as part of the `trxn_id` JSON.
129+
*
130+
* @throws \CRM_Core_Exception
131+
* @throws \CiviCRM_API3_Exception
132+
*/
133+
public function testDoRecurPayment(): void {
134+
$this->setMockHttpResponse([
135+
'SagepayOneOffPaymentSecret.txt',
136+
'SagepayRepeatAuthorize.txt',
137+
]);
138+
Civi::$statics['Omnipay_Test_Config'] = [ 'client' => $this->getHttpClient() ];
139+
140+
$contributionRecur = ContributionRecur::create(FALSE)->setValues([
141+
'contact_id' => $this->ids['Contact'],
142+
'amount' => 5,
143+
'currency' => 'GBP',
144+
'frequency_interval' => 1,
145+
'start_date' => 'now',
146+
'payment_processor_id' => $this->paymentProcessorID,
147+
])->execute()->first();
148+
149+
Contribution::update(FALSE)->addWhere('id', '=', $this->_contribution['id'])->setValues(['contribution_recur_id' => $contributionRecur['id']])->execute();
150+
$transactionSecret = $this->getSagepayTransactionSecret();
151+
152+
$payment = $this->callAPISuccess('PaymentProcessor', 'pay', [
153+
'payment_processor_id' => $this->paymentProcessorID,
154+
'amount' => $this->_new['amount'],
155+
'qfKey' => $this->getQfKey(),
156+
'currency' => $this->_new['currency'],
157+
'component' => 'contribute',
158+
'email' => $this->_new['card']['email'],
159+
'contactID' => $this->ids['Contact']['id'],
160+
'contributionID' => $this->_contribution['id'],
161+
'contribution_id' => $this->_contribution['id'],
162+
'contributionRecurID' => $contributionRecur['id'],
163+
'is_recur' => TRUE,
164+
]);
165+
99166
$contribution = $this->callAPISuccess('Contribution', 'get', [
100167
'return' => ['trxn_id'],
101168
'contact_id' => $this->ids['Contact'],
@@ -117,6 +184,30 @@ public function testDoSinglePayment(): void {
117184
->addSelect('contribution_status_id:name', 'trxn_id')->execute()->first();
118185
$this->assertEquals('Completed', $contribution['contribution_status_id:name']);
119186
}
187+
$recur = ContributionRecur::get(FALSE)
188+
->addSelect('payment_token_id')
189+
->addSelect('payment_processor_id')
190+
->addWhere('id', '=', $contributionRecur['id'])
191+
->execute()->first();
192+
$this->assertNotEmpty($recur['payment_token_id']);
193+
$contribution = $this->callAPISuccess('Contribution', 'repeattransaction', [
194+
'contribution_recur_id' => $contributionRecur['id'],
195+
'payment_processor_id' => $this->paymentProcessorID,
196+
]);
197+
$this->callAPISuccess('PaymentProcessor', 'pay', [
198+
'amount' => $this->_new['amount'],
199+
'currency' => $this->_new['currency'],
200+
'payment_processor_id' => $this->paymentProcessorID,
201+
'contribution_id' => $contribution['id'],
202+
'token' => civicrm_api3('PaymentToken', 'getvalue', [
203+
'id' => $recur['payment_token_id'],
204+
'return' => 'token',
205+
]),
206+
'payment_action' => 'purchase',
207+
]);
208+
209+
$sent = $this->getRequestBodies();
210+
$this->assertContains('RelatedTxAuthNo=4898041', $sent[1]);
120211
}
121212

122213
}

0 commit comments

Comments
 (0)