Skip to content

How to implement refunds

adumont-payplug edited this page Sep 16, 2026 · 1 revision

How to implement refunds

Full or partial refunds, issued from your back office. Unlike payments, there is no shopper-facing flow and no JavaScript — one call, one response.

Prerequisite: How to wire the library.

The call

use PayplugUnifiedCore\Exceptions\ApiException;
use PayplugUnifiedCore\Exceptions\InvalidRefundRequestException;
use PayplugUnifiedCore\Exceptions\PaymentNotFoundException;
use PayplugUnifiedCore\Exceptions\RefundAmountException;
use PayplugUnifiedCore\Utilities\Helpers\AmountHelper;

try {
    $response = $service->createRefund(
        $operationId,             // the payment's own id
        $accountId,
        (string) $order->getId(), // required
        'Refund for order ' . $order->getId(),  // required
        $submerchantExternalId,   // optional — must mirror the payment
        AmountHelper::toCents($amount),         // optional — null refunds everything remaining
        'EUR'                     // optional
    );
} catch (InvalidRefundRequestException $e) {
    // empty orderId or description — nothing was sent
} catch (RefundAmountException $e) {
    // amount was zero or negative — nothing was sent
} catch (PaymentNotFoundException $e) {
    // 404 — unknown operation id
} catch (ApiException $e) {
    // any other non-2xx; $e->getCode() is the HTTP status
}

$response['status'];  // 200
$response['body'];    // raw JSON string

$operationId is the payment's id

The first argument is the id of the payment being refunded, not a refund id and not your order id. It is the same value this library's webhook vocabulary calls operationId — which is why the parameter carries that name rather than inventing a second one for the same thing.

You will typically have it from the payment's webhook, stored via IPaymentRepository:

$operation = $paymentRepository->getByOrderId((string) $order->getId());
$operationId = $operation->operationId;

Full vs partial

Omit $amount, or pass null, to refund the full remaining amount. Pass an integer in minor units for a partial refund.

  • Zero or negative throws RefundAmountException before any network call.
  • An amount exceeding what was captured is not checked locally — the Unified API rejects it itself, and UPC deliberately doesn't duplicate that check. Expect an ApiException.
  • UPC does not track how much has already been refunded. Multiple partial refunds that together exceed the payment are your side's problem to prevent; sum what you've issued before calling.

orderId and description are genuinely required

Both are checked locally and throw InvalidRefundRequestException if empty. The local check exists because ApiException carries only the HTTP status, not the API's own message naming the missing field — so a remote rejection would tell you far less than this does.

Both were confirmed required by probing each field's absence individually against the real staging API (2026-08-27), not merely by reading the documentation.

submerchantExternalId

It must mirror the payment being refunded. It belongs to the MID configuration for the payment's currency, and both directions fail:

  • Refunding a EUR payment whose configuration owns a submerchant, without it → 400 The parameter "subMerchantExternalId" is missing.
  • Refunding a non-EUR payment with it → 400 Invalid parameter., succeeding once omitted

(Staging, 2026-09-04. Note the API validates the lower-case submerchantExternalId key despite its own error text capitalising it.)

Practically: store what you used at payment time and pass exactly that back. Don't re-derive it from current settings — a merchant who changed configuration since the payment would refund into a mismatch.

Sent only when non-null and non-empty.

currency

Only available on this endpoint since 2026-09-04. Before that an amount travelled bare and the platform inferred what the minor units meant — harmless while every payment was EUR, ambiguous for a multi-currency merchant.

Passing null or '' falls back to that inference mode rather than putting "" on the wire. Pass it explicitly if you take more than one currency.

Caveat on the evidence: the staging run that first succeeded changed both submerchantExternalId and currency at once, so which of the two the earlier Invalid parameter. referred to was never isolated.

The refund is asynchronous too

A 2xx here means the refund was accepted. The outcome arrives as its own webhook, carrying its own operation id — which is exactly why idempotency is keyed on operation id rather than order id. A refund on an order that already has a payment would otherwise look like a duplicate of it and be silently dropped.

Expect PaymentOutcome::REFUNDED in IOrderStateMutator::apply().

Checklist

  • $operationId is the payment's id, from IPaymentRepository
  • orderId and description always non-empty
  • Amounts converted with AmountHelper::toCents(), never * 100
  • submerchantExternalId mirrors what the payment used — stored, not re-derived
  • currency passed explicitly if the merchant takes more than one
  • Cumulative partial refunds tracked on your side
  • IOrderStateMutator handles REFUNDED
  • Refund webhooks not treated as duplicates of the payment

Failure modes

Symptom Cause
InvalidRefundRequestException Empty orderId or description — nothing was sent
RefundAmountException Amount zero or negative — nothing was sent
PaymentNotFoundException Unknown operation id; likely passed an order id or a refund id
400 The parameter "subMerchantExternalId" is missing. Payment's configuration owns one; pass it
400 Invalid parameter. Passed a submerchant id for a currency whose configuration owns none
ApiException on an apparently valid amount Exceeds what remains captured — not checked locally
Refund succeeds but the order never updates Refund webhook dropped as a duplicate of the payment

PaymentNotFoundException is a sibling of ApiException, not a subclass — catching ApiException alone will not catch it. See Error handling.

Clone this wiki locally