Skip to content

Commit 4fc8707

Browse files
Richard van Oosterhoutclaude
andcommitted
Add supportsRefund/doRefund for gateways whose Omnipay driver implements refund()
Implements CiviCRM's refund contract (PaymentProcessor.refund API / supportsRefund capability) generically: supportsRefund() resolves the Omnipay gateway class without instantiating it and reports TRUE only when that class exposes a refund() method. doRefund() sends the refund via the gateway (transactionReference = original trxn_id) and returns refund_trxn_id / refund_status per the core contract. Failures throw PaymentProcessorException so CiviCRM never records a refund the gateway rejected. Details: - A description is always sent: Mollie's create-refund API requires one and it may be shown to the customer (e.g. on their bank statement). Callers can pass their own via $params['description']. - The existing alterPaymentProcessorParams hook fires before sending, with an 'action' => 'Refund' marker (removed after the hook), so extensions can customise refund parameters the same way they already can for purchases. - The fallback currency is resolved from the original transaction via APIv4 FinancialTrxn::get, defaulting to the processor currency. - refund_trxn_id prefers getTransactionId() (Mollie exposes the new re_... id there) and falls back to getTransactionReference(), which is where most other Omnipay drivers expose the refund reference. Verified live against Mollie (iDeal) on CiviCRM 6.x: refund accepted and the re_... id recorded as refund_trxn_id. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent c717bad commit 4fc8707

1 file changed

Lines changed: 94 additions & 0 deletions

File tree

CRM/Core/Payment/OmnipayMultiProcessor.php

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,100 @@ public function doPayment(&$params, $component = 'contribute'): array {
271271
}
272272
}
273273

274+
/**
275+
* Does this processor support refunds?
276+
*
277+
* Generic for any Omnipay gateway that exposes a refund() method
278+
* (the tested target is Mollie). The gateway class is resolved without
279+
* instantiating it, so this is safe to call in listing contexts.
280+
*
281+
* @return bool
282+
*/
283+
public function supportsRefund() {
284+
try {
285+
$this->ensurePaymentProcessorTypeIsSet();
286+
$shortName = str_replace('omnipay_', '', $this->_paymentProcessor['payment_processor_type']);
287+
$gatewayClass = \Omnipay\Common\Helper::getGatewayClassName($shortName);
288+
return class_exists($gatewayClass) && method_exists($gatewayClass, 'refund');
289+
}
290+
catch (\Exception $e) {
291+
return FALSE;
292+
}
293+
}
294+
295+
/**
296+
* Submit a refund to the payment processor.
297+
*
298+
* Generic for any Omnipay gateway that exposes a refund() method; the
299+
* tested target is Mollie. On failure an exception is thrown (rather than
300+
* returning a failure array) so that CiviCRM never records a refund that
301+
* the gateway rejected.
302+
*
303+
* @param array $params
304+
* Expected keys: trxn_id (the gateway's original transaction reference),
305+
* amount, and optionally currency (resolved from the original financial
306+
* transaction when omitted).
307+
*
308+
* @return array
309+
* refund_trxn_id, refund_status ('Completed'), fee_amount, trxn_date.
310+
*
311+
* @throws \Civi\Payment\Exception\PaymentProcessorException
312+
*/
313+
public function doRefund(&$params) {
314+
if (empty($params['trxn_id']) || empty($params['amount'])) {
315+
throw new \Civi\Payment\Exception\PaymentProcessorException('doRefund requires trxn_id and amount');
316+
}
317+
$currency = $params['currency'] ?? NULL;
318+
if (empty($currency)) {
319+
// Look up the currency of the original transaction so the refund is
320+
// issued in the same currency; fall back to the default currency.
321+
$originalTrxn = \Civi\Api4\FinancialTrxn::get(FALSE)
322+
->addSelect('currency')
323+
->addWhere('trxn_id', '=', $params['trxn_id'])
324+
->execute()
325+
->first();
326+
$currency = $originalTrxn['currency'] ?? $this->getCurrency($params);
327+
}
328+
$this->ensurePaymentProcessorTypeIsSet();
329+
$this->createGatewayObject();
330+
$this->setProcessorFields();
331+
332+
$refundOptions = [
333+
'action' => 'Refund',
334+
'transactionReference' => $params['trxn_id'],
335+
'amount' => \Civi::format()->machineMoney($params['amount'], $currency),
336+
'currency' => $currency,
337+
// Mollie requires a description; it may be shown to the customer
338+
// (e.g. on their bank statement) depending on the payment method.
339+
'description' => $params['description'] ?? ts('Refund of payment %1', [1 => $params['trxn_id']]),
340+
];
341+
CRM_Utils_Hook::alterPaymentProcessorParams($this, $params, $refundOptions);
342+
unset($refundOptions['action']);
343+
344+
try {
345+
$response = $this->gateway->refund($refundOptions)->send();
346+
}
347+
catch (\Exception $e) {
348+
throw new \Civi\Payment\Exception\PaymentProcessorException('Refund failed: ' . $e->getMessage());
349+
}
350+
if (!$response->isSuccessful()) {
351+
throw new \Civi\Payment\Exception\PaymentProcessorException('Refund failed: ' . $response->getMessage());
352+
}
353+
return [
354+
// Prefer the refund's own reference. Per the Omnipay contract only
355+
// getTransactionReference() is guaranteed; getTransactionId() defaults
356+
// to null and echoes a merchant-supplied id, which we never send on a
357+
// refund - so for standard gateways this falls through to the contract
358+
// method. Mollie is the exception: it overrides getTransactionId() to
359+
// expose the new refund id (re_...) and keeps the original payment id
360+
// (tr_...) in getTransactionReference().
361+
'refund_trxn_id' => $response->getTransactionId() ?: $response->getTransactionReference(),
362+
'refund_status' => 'Completed',
363+
'fee_amount' => 0,
364+
'trxn_date' => date('YmdHis'),
365+
];
366+
}
367+
274368
/**
275369
* Initialize class variables.
276370
*

0 commit comments

Comments
 (0)