GoCardless Direct Debit — Refund Integration
Module: Payment Gateway Integration
Document Type: Explanation (Refund-Oriented)
Audience: Backend developers and tech leads working on payment reversals
Why the Refund Flow Exists
Refunds are handled separately from the normal GoCardless payment lifecycle because a refund is not just a local database update. It is an external payment reversal that must be validated against the current GoCardless payment state, then confirmed asynchronously through GoCardless webhook events.
In this codebase, refund handling has three distinct responsibilities:
- Accept the refund request from the admin area.
- Create the refund through the GoCardless API.
- Reconcile the refund outcome when GoCardless later emits
refunds.paidorrefunds.failed.
That split keeps the controller thin, keeps the GoCardless API logic in one service class, and keeps database reconciliation in the job that already handles other GoCardless lifecycle events.
Prerequisites
Before a refund can be created, the GoCardless account and the target payment must satisfy the platform rules that govern refunds:
- The refund feature must be enabled on the GoCardless account.
- The payment must be in
confirmedorpaid_outstatus before the refund is attempted. - There must be sufficient confirmed balance to cover the refund amount.
- A 7-day safety window is recommended by GoCardless to avoid refunding funds that have not fully settled yet.
In the current codebase, the service enforces the refundable payment state and prevents over-refunding. The remaining balance and timing guidance should still be treated as operational constraints for the admin workflow.
Refund Lifecycle & Webhook Events
GoCardless uses refund webhook events to tell us how a refund progresses after the API request succeeds.
| Event | Description |
|---|---|
refunds/created | Refund has been created successfully. |
refunds/failed | Refund could not be processed. |
refunds/paid | Refund has been sent to the customer. |
refunds/refund_settled | Refund has been settled and deducted from the payout. |
refunds/funds_returned | Funds were returned, for example if the customer account is closed. |
In this application, the webhook controller only dispatches the events that matter to the refund pipeline, and the job handles the local state updates after those events arrive. That is why the refund is not considered complete at the moment refunds()->create() returns.
Important Business Rules
Partial vs Full Refunds
- Both partial and full refunds are supported.
- Multiple partial refunds can be issued against a single payment.
- The total refunded amount must never exceed the original payment amount.
- The payment record's
amount_refundedvalue tracks the cumulative refunded amount.
Balance and Funds
- Refunds are deducted from the confirmed balance immediately.
- If the balance is insufficient, GoCardless can reject the refund.
- GoCardless exposes the available refund amount in the account settings and, on failure, in error metadata.
Chargeback Risk
- Refunds are sent through Faster Payments, not the Direct Debit scheme.
- A customer may still raise a Direct Debit indemnity claim after receiving a refund.
Timing
- Customers typically receive the refund within 1 to 2 working days.
- GoCardless recommends waiting 7 days after the charge date before issuing a refund.
Refund Pool
The Refund Pool is the amount of money that GoCardless currently considers available for refunds in a given currency.
It is not a static wallet. It changes as payments are confirmed, paid out, refunded, or returned.
What It Means
- Confirmed payments increase the pool.
- Pending or in-flight refunds reduce the pool.
- Paid-out funds reduce the balance available for refunds.
- Returned refund funds can restore the pool.
Why It Matters
The service-side validation in this codebase checks the payment state and the remaining refundable balance on the payment itself. The Refund Pool concept explains the external platform constraint behind that rule: GoCardless will reject a refund if the account does not currently have enough refundable balance.
Operational Rule
Before creating a refund, administrators should assume that:
- the pool is per currency
- the pool can change between checking and submitting the refund
- the UI should be prepared to display a refund failure if GoCardless rejects the request
How the Pool Replenishes
The pool is self-replenishing as new payments flow through your account. You do not need to manually top it up. A few patterns to understand:
| Scenario | Effect on Pool |
|---|---|
A payment reaches confirmed status | Pool increases by the payment amount |
| A payout is sent to your bank | Confirmed balance resets; pool may decrease |
| A refund is issued | Pool decreases immediately by the refund amount |
A refund fails or is returned (funds_returned) | Pool is restored |
| A payment fails or is charged back | Pool decreases by that payment amount |
Cross-payment refunding example: You collect Payment A (£50) but it has already been paid out, leaving no pool balance. The following week, Payment B (£100) reaches confirmed status — your pool is now £100, and you can use part of it to issue the refund for Payment A.
The Refund Flow — Conceptual Explanation
The refund flow starts in the admin panel and ends only after GoCardless confirms the refund state back to us.
The important detail is that the admin action only creates the refund request. The refund is not considered fully complete until the webhook job updates the local state.
What paymentRefund Does
The paymentRefund() method in app/Http/Controllers/Admin/OrderController.php is the entry point for refunding an order payment from the admin UI.
For the GoCardless branch, it does the following:
- Resolves the target order and matching credit note, if one exists.
- Finds the original
OrderPaymentbytransaction_id. - Ensures the payment exists and already has a GoCardless payment ID in
uid_accounting_system. - Calls
GoCardlessService::createRefund()with:- the GoCardless payment ID
- the requested refund amount
- the GoCardless payment amount
- metadata containing the refund reason and credit note invoice
- Catches any exception, logs it, and returns the user back with a generic refund failure message.
- Stores the local refund record using
PaymentModule::paymentRefund().
The controller itself does not decide whether the refund is allowed. That validation belongs to the service layer, where the live GoCardless payment state can be checked immediately before the API call.
Controller Branch Summary
| Step | Behavior |
|---|---|
| Payment lookup | OrderPayment::where('transaction_id', $request->payment_id)->first() |
| Required GoCardless ID | Uses uid_accounting_system as the refund target |
| Local amount | Uses the submitted refund_amount, rounded to 2 decimals |
| API call | GoCardlessService::createRefund(...) |
| Failure handling | catch (\Throwable $th) then report($th) and redirect back |
| Local persistence | PaymentModule::paymentRefund($data) |
What GoCardlessService::createRefund() Does
The refund service now acts as the source of truth for GoCardless refund eligibility.
1. Fetch the live payment
Before creating the refund, the service calls:
$payment = $this->client->payments()->get($gcPaymentId);
This is important because the stored local payment record may not reflect the latest GoCardless state.
2. Reject non-refundable states
The service only allows refunds when the payment is in a refundable state:
confirmedpaid_out
If the payment is in any other state, the service throws GoCardlessProException and stops before making the refund request.
3. Prevent over-refunding
The service calculates:
refundAmountCentsalreadyRefundedCentsoriginalAmountCents
It then blocks the request if:
$alreadyRefundedCents + $refundAmountCents > $originalAmountCents
That prevents refunding more than the original collected amount.
4. Set the confirmation amount correctly
The total_amount_confirmation value is not simply the original payment amount. In the current implementation it is the amount already refunded plus the new refund amount.
That makes the payload explicit about the running refundable balance at the point the request is created.
5. Create the refund
If the validations pass, the service sends this payload to GoCardless:
[
'params' => [
'amount' => $refundAmountCents,
'total_amount_confirmation' => $totalAmountConfirmationCents,
'metadata' => $metadata,
'links' => [
'payment' => $gcPaymentId,
],
],
]
Refund Preconditions
Refunds are only valid when all of the following are true:
| Condition | Source of truth | Result |
|---|---|---|
| The local payment exists | OrderController::paymentRefund() | Otherwise the controller returns an error |
| The payment has a GoCardless payment ID | uid_accounting_system | Otherwise the controller returns an error |
| The GoCardless payment is refundable | GoCardlessService::createRefund() | Otherwise the service throws GoCardlessProException |
| The requested amount does not exceed the remaining balance | GoCardlessService::createRefund() | Otherwise the service throws GoCardlessProException |
This division is intentional:
- Controller: local request validation and UI flow
- Service: remote payment eligibility and API payload construction
Relationship to Existing Payment Records
The refund flow depends on the original payment record and the optional credit note invoice.
Required payment fields
The controller expects:
transaction_idto locate the refund candidateuid_accounting_systemto identify the GoCardless payment IDamountto pass the original payment amount to the refund service
Optional credit note
The controller searches for a matching unpaid credit note:
OrderInvoice::where('order_id', $id)
->where('status_invoice', 'Credit Note')
->where('status', 'unpaid')
->where('grandtotal', $request->refund_amount)
->first();
If a matching credit note exists, its invoice number is added to the refund metadata. If not, the refund still proceeds without it.
What Happens After GoCardless Accepts the Refund
GoCardless does not finalize the local refund state immediately. Instead, it sends webhook events that are handled asynchronously by the existing webhook pipeline.
refunds.paid
When the webhook controller receives a refunds.paid event, it dispatches ProcessGoCardlessPayment.
The job then:
- Loads the refund by GoCardless refund ID.
- Skips processing if the refund is missing.
- Skips processing if the refund is not pending.
- Loads the original
OrderPayment. - Updates the payment totals and refund counters.
- Marks related credit note invoices as
refunded. - Marks the refund as
succeeded.
refunds.failed
When the webhook controller receives a refunds.failed event, the same job:
- Loads the refund by GoCardless refund ID.
- Skips processing if the refund is missing or not pending.
- Marks the refund as
cancelled. - Resets related credit note invoices to
unpaid. - Restores the order-level
payment_refundbalance if the refund was tied to an order.
Refund State Model
The refund lifecycle in this implementation has two layers of state:
GoCardless state
The service checks whether the remote payment can be refunded.
Local application state
The job updates local records after the webhook arrives:
| Event | Local effect |
|---|---|
refunds.paid | OrderPaymentRefund.status = succeeded |
refunds.paid | OrderPayment.final_amount is reduced |
refunds.paid | OrderPayment.amount_refunded increases |
refunds.paid | OrderPayment.refund_count increases |
refunds.paid | Linked credit note invoice becomes refunded |
refunds.failed | OrderPaymentRefund.status = cancelled |
refunds.failed | Linked credit note invoice becomes unpaid |
refunds.failed | Order.payment_refund increases by the failed amount |
This is the core design pattern: GoCardless provides the external truth, and the job reconciles that truth into the local domain model.
Error Handling
The current implementation has two different error surfaces:
Controller-level errors
The admin action handles operational failures with a generic catch-all:
- missing payment
- missing GoCardless payment ID
- SDK failure
- network failure
- unexpected runtime errors
Those are reported and then shown to the user as a generic refund failure.
Service-level errors
The service throws GoCardlessProException when the refund should not be attempted:
- payment is not in a refundable state
- refund amount exceeds the remaining refundable balance
This keeps the service responsible for domain-specific refund rules.
Idempotency and Duplicate Prevention
Refund handling is designed so the same event can be received more than once without corrupting the local state.
The main safeguards are:
- The webhook handler only dispatches actionable refund events.
- The job ignores refund events when the local refund record is missing.
- The job ignores refund events when the refund is no longer
pending. - Database updates are wrapped in transactions.
That means a duplicate refunds.paid webhook should not keep incrementing the same refund record.
How This Fits the Existing GoCardless Design
The refund integration follows the same pattern as the rest of the GoCardless module:
| Concern | Implementation |
|---|---|
| External API access | GoCardlessService |
| Incoming webhooks | GoCardlessWebhookController |
| Async reconciliation | ProcessGoCardlessPayment |
| Admin-initiated action | OrderController::paymentRefund() |
That keeps the module consistent:
- controller for request routing
- service for API interaction and business rules
- job for asynchronous reconciliation
Complete Refund Flow Diagram
Relationship to the Rest of the Module
The refund integration depends on the same webhook and payment-processing infrastructure used by the rest of GoCardless. That matters because it means refund behavior is not isolated:
- the webhook controller decides which events are actionable
- the job owns local reconciliation
- the service owns refund eligibility and API payload construction
- the admin controller owns user-facing workflow and local persistence
The result is a refund flow that is consistent with the rest of the integration instead of a one-off implementation.