Skip to main content

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:

  1. Accept the refund request from the admin area.
  2. Create the refund through the GoCardless API.
  3. Reconcile the refund outcome when GoCardless later emits refunds.paid or refunds.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 confirmed or paid_out status 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.

EventDescription
refunds/createdRefund has been created successfully.
refunds/failedRefund could not be processed.
refunds/paidRefund has been sent to the customer.
refunds/refund_settledRefund has been settled and deducted from the payout.
refunds/funds_returnedFunds 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_refunded value 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:

ScenarioEffect on Pool
A payment reaches confirmed statusPool increases by the payment amount
A payout is sent to your bankConfirmed balance resets; pool may decrease
A refund is issuedPool decreases immediately by the refund amount
A refund fails or is returned (funds_returned)Pool is restored
A payment fails or is charged backPool 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:

  1. Resolves the target order and matching credit note, if one exists.
  2. Finds the original OrderPayment by transaction_id.
  3. Ensures the payment exists and already has a GoCardless payment ID in uid_accounting_system.
  4. 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
  5. Catches any exception, logs it, and returns the user back with a generic refund failure message.
  6. 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

StepBehavior
Payment lookupOrderPayment::where('transaction_id', $request->payment_id)->first()
Required GoCardless IDUses uid_accounting_system as the refund target
Local amountUses the submitted refund_amount, rounded to 2 decimals
API callGoCardlessService::createRefund(...)
Failure handlingcatch (\Throwable $th) then report($th) and redirect back
Local persistencePaymentModule::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:

  • confirmed
  • paid_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:

  • refundAmountCents
  • alreadyRefundedCents
  • originalAmountCents

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:

ConditionSource of truthResult
The local payment existsOrderController::paymentRefund()Otherwise the controller returns an error
The payment has a GoCardless payment IDuid_accounting_systemOtherwise the controller returns an error
The GoCardless payment is refundableGoCardlessService::createRefund()Otherwise the service throws GoCardlessProException
The requested amount does not exceed the remaining balanceGoCardlessService::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_id to locate the refund candidate
  • uid_accounting_system to identify the GoCardless payment ID
  • amount to 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:

  1. Loads the refund by GoCardless refund ID.
  2. Skips processing if the refund is missing.
  3. Skips processing if the refund is not pending.
  4. Loads the original OrderPayment.
  5. Updates the payment totals and refund counters.
  6. Marks related credit note invoices as refunded.
  7. Marks the refund as succeeded.

refunds.failed

When the webhook controller receives a refunds.failed event, the same job:

  1. Loads the refund by GoCardless refund ID.
  2. Skips processing if the refund is missing or not pending.
  3. Marks the refund as cancelled.
  4. Resets related credit note invoices to unpaid.
  5. Restores the order-level payment_refund balance 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:

EventLocal effect
refunds.paidOrderPaymentRefund.status = succeeded
refunds.paidOrderPayment.final_amount is reduced
refunds.paidOrderPayment.amount_refunded increases
refunds.paidOrderPayment.refund_count increases
refunds.paidLinked credit note invoice becomes refunded
refunds.failedOrderPaymentRefund.status = cancelled
refunds.failedLinked credit note invoice becomes unpaid
refunds.failedOrder.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:

  1. The webhook handler only dispatches actionable refund events.
  2. The job ignores refund events when the local refund record is missing.
  3. The job ignores refund events when the refund is no longer pending.
  4. 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:

ConcernImplementation
External API accessGoCardlessService
Incoming webhooksGoCardlessWebhookController
Async reconciliationProcessGoCardlessPayment
Admin-initiated actionOrderController::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.