Skip to main content

GoCardless Direct Debit — Technical Reference

Module: Payment Gateway Integration
Version: 1.1.0
Last Updated: 2026-07-07
Status: Active


Overview

This document is the authoritative technical reference for the GoCardless Direct Debit integration in Waste Vantage. It covers every component, class, method signature, route, event type, database column mapping, and configuration key. Use this document when you need to understand what the system does and how it is structured.


Architecture Overview


Component Reference

1. GoCardlessService

File: app/Services/GoCardlessService.php
Namespace: App\Services
Purpose: Single entry point for all GoCardless SDK operations. Wraps GoCardlessPro\Client and exposes typed, domain-specific methods.

Constructor

public function __construct()

Builds a GoCardlessPro\Client using config('services.gocardless.access_token') and config('services.gocardless.environment'). Also resolves Supplier::first() to attach supplier_id to payment records.

Methods

MethodReturnDescription
createBillingRequest(OrderInvoice $invoice)BillingRequestCreates a GoCardless BillingRequest with mandate_request (BECS/AUD) + payment_request for the invoice amount
createBillingRequestFlow(string $billingRequestId, string $successUrl, string $cancelUrl)stringCreates a BillingRequestFlow; returns the hosted authorisation_url
saveGoCardlessPendingPayment(OrderInvoice $invoice, string $billingRequestId, PaymentModule $paymentModule)OrderPaymentPersists a Not Processed OrderPayment row via PaymentModule::payment()
fetchBillingRequest(string $billingRequestId)BillingRequestDirect API call — retrieves a BillingRequest by ID (used by poller)
fetchPayment(string $gcPaymentId)PaymentDirect API call — retrieves a GoCardless Payment by ID (used by poller)
client()ClientReturns the raw GoCardlessPro\Client instance for advanced use

2. GoCardlessController

File: app/Http/Controllers/Frontend/GoCardlessController.php
Namespace: App\Http\Controllers\Frontend
Purpose: Handles the customer-facing Billing Request Flow (redirect-based) and the AJAX status-check endpoint.

Methods

MethodHTTPRouteDescription
checkout(Request $r)POSTpayment/choose/gocardlessCreates BillingRequest + Flow, saves pending payment, redirects 303 to GoCardless
confirmUrl(Request $r)GETpayment/choose/gocardless/confirmReceives redirect after mandate authorisation; redirects to payment.order.loading-payment
cancelUrl(Request $r)GETpayment/choose/gocardless/cancelReceives redirect after customer cancels; redirects back to payment page
checkPaymentStatus(Request $r, int $invoiceId)GETpayment/gocardless/status/{invoiceId}AJAX polling endpoint — checks DB then GoCardless API, dispatches job on change

checkPaymentStatus — JSON Response Schema

// Payment not yet confirmed
{
"success": true,
"status": "pending",
"message": "Status checked.",
"change_detected": false,
"data": null
}

// Payment confirmed (redirect the browser)
{
"success": true,
"status": "succeeded",
"message": "Payment confirmed.",
"redirect": "https://app.example.com/payment/order/loading/payment/42",
"data": null
}

// GoCardless API unreachable
{
"success": false,
"message": "Unable to connect to GoCardless at the moment. Please try again later.",
"data": null
}

3. GoCardlessWebhookController

File: app/Http/Controllers/GoCardlessWebhookController.php
Namespace: App\Http\Controllers
Route: POST payment/gocardless/webhook
Purpose: Receives asynchronous GoCardless events, validates the HMAC-SHA256 signature, and dispatches ProcessGoCardlessPayment jobs.

Signature Validation

Webhook-Signature: <hex-encoded HMAC-SHA256 of raw request body, keyed with GOCARDLESS_WEBHOOK_SECRET>

Computed as:

hash_hmac('sha256', $rawBody, config('services.gocardless.webhook_secret'))

Validated using hash_equals() to prevent timing attacks.

HTTP Response Codes

CodeCondition
200 OKSignature valid; events processed
422 Unprocessable EntityPayload valid signature but malformed JSON
498 Token InvalidHMAC signature mismatch or missing header

Handled Event Types

resource_typeactionJob Action
billing_requestsfulfilledSet uid_customer, charge_id, uid_accounting_system; status → Processing
paymentsconfirmedStatus → Confirmed
paymentspaid_outStatus → Paid; invoice status → paid

All other event types are acknowledged with 200 OK but no job is dispatched.


4. ProcessGoCardlessPayment (Job)

File: app/Jobs/ProcessGoCardlessPayment.php
Namespace: App\Jobs
Implements: ShouldQueue
Queue: default
Retries: 3 (backoff: 10 seconds)

Constructor

public function __construct(private readonly array $event)

Receives a GoCardless event array. Both webhook and polling paths produce identically shaped event arrays, so this job is mode-agnostic.

Event Array Shape

[
'id' => 'EV123...', // GoCardless event ID, or 'POLL-...' (synthetic)
'resource_type' => 'billing_requests', // or 'payments'
'action' => 'fulfilled', // or 'confirmed', 'paid_out'
'links' => [
'billing_request' => 'BRQ...', // present on billing_requests events
'mandate' => 'MD...', // present on billing_requests.fulfilled
'customer' => 'CU...', // present on billing_requests.fulfilled
'payment' => 'PM...', // present on most events
],
'source' => 'webhook', // or 'api_polling', 'api_polling_controller'
]

State Transitions


5. PollGoCardlessPayments (Artisan Command)

File: app/Console/Commands/PollGoCardlessPayments.php
Signature: gocardless:poll

Options

OptionDefaultDescription
--dry-runfalseLog status changes without dispatching jobs
--limit50Maximum number of payment records to check per run
--age72Only check records created within the last N hours

Scheduling (Console/Kernel.php)

Every 5 minutes → gocardless:poll (last 72 hours of records)
Daily at 06:00 → gocardless:poll --age=168 --limit=100 (last 7 days — missed payments)

Polling Decision Logic


6. GoCardlessPollingService

File: app/Services/GoCardlessPollingService.php
Namespace: App\Services
Purpose: Per-payment "look at one OrderPayment and tell me what event to dispatch" primitive. Shared between the scheduled poller command and the admin "Check Status" button. Never throws.

Constructor

public function __construct(
private readonly GoCardlessService $goCardlessService,
) {}

pollSinglePayment(OrderPayment $payment): GoCardlessPollResult

Determines the current GoCardless state of one payment and returns a GoCardlessPollResult describing the outcome. The caller cannot distinguish the sub-cases of unchanged.

State machine:

transaction_statusGoCardless callAction on change
Not ProcessedfetchBillingRequest($payment->charge_id)billing_requests.fulfilled event
Processing / PendingfetchPayment($payment->uid_accounting_system)payments.{confirmed|paid_out|failed|cancelled} event
Other (incl. Confirmed)unchanged (no API call)

Note: Confirmed is intentionally not routed to checkGoCardlessPayment() in this initial implementation — it falls through to the default branch and returns unchanged. This is a known pre-existing behaviour inherited from PollGoCardlessPayments. A future fix is tracked separately.

Result outcomes:

OutcomeWhen$event$error
updatedGoCardless reported a state change not yet appliedsynthesized webhook-shaped arraynull
unchangedNo change to apply (still pending, status not in action map, no ID yet, or already applied)nullnull
errorGoCardless ApiException or any other Throwable (already logged)nullthe throwable

Public contract: the service never throws. The controller does not wrap the call in try/catch.


7. GoCardlessPollResult

File: app/Services/GoCardlessPollResult.php
Namespace: App\Services
Purpose: Readonly DTO returned by GoCardlessPollingService::pollSinglePayment(). Three possible outcomes: updated, unchanged, error.

Factory methods

public static function updated(array $event): self;
public static function unchanged(): self;
public static function error(\Throwable $error): self;

Query methods

public function isUpdated(): bool;
public function isUnchanged(): bool;
public function isError(): bool;

Properties

PropertyTypeDescription
$outcomestringOne of OUTCOME_UPDATED, OUTCOME_UNCHANGED, OUTCOME_ERROR
$event?arraySynthesized webhook event (only set when outcome === 'updated')
$error?\ThrowableOriginal throwable (only set when outcome === 'error')

8. OrderController::gocardlessCheckStatus (Admin On-Demand Status Check)

File: app/Http/Controllers/Admin/OrderController.php
Namespace: App\Http\Controllers\Admin
Purpose: On-demand GoCardless status check triggered by the "Check GoCardless Status" button in resources/views/admin/order/show/payment.blade.php. Closes the gap between webhook delivery and the 5-minute cron poller.

Route

POST /admin/order/payment/gocardless/check-status/{payment}
→ name: admin.order.payment.gocardless.checkStatus

Registered in routes/admin.php next to the other payment routes. CSRF-protected (admin uses X-CSRF-TOKEN header from the layout's <meta name="csrf-token">).

Flow

  1. Resolve OrderPayment by id. If not found → 404 envelope.
  2. Server-side guard: reject with 422 if transaction_type !== 'GoCardless' OR transaction_status is in ['succeeded', 'Failed', 'Cancelled']. This is the belt-and-braces guard — the blade hides the button on those rows, but the controller must not trust the client.
  3. Snapshot $previousStatus = $payment->transaction_status.
  4. Call GoCardlessPollingService::pollSinglePayment($payment).
  5. On error → return 500 envelope immediately (service has already logged the cause; do not expose the exception message).
  6. On updatedProcessGoCardlessPayment::dispatchSync($result->event) to apply the change synchronously.
  7. Refresh the payment row and return 200 envelope with previous_status and new_status.

Response envelope (always)

// HTTP 200 — updated
{ "status": 1, "message": "Payment status updated to Paid.",
"data": { "id": 12, "previous_status": "Processing", "new_status": "Paid" } }

// HTTP 200 — no change
{ "status": 1, "message": "No change in payment status.",
"data": { "id": 12, "previous_status": "Processing", "new_status": "Processing" } }

// HTTP 422 — terminal / non-GoCardless
{ "status": 0, "message": "This payment is not eligible for an on-demand status check.",
"data": null }

// HTTP 500 — GoCardless API error
{ "status": 0, "message": "Unable to reach GoCardless at the moment. Please try again in a few moments.",
"data": { "id": 12, "previous_status": "Processing", "new_status": "Processing" } }

// HTTP 404 — payment not found
{ "status": 0, "message": "Payment not found.",
"data": null }

FE behaviour (in payment.blade.php)

ConditionModalPage reload?
status === 1 AND new_status !== previous_statusSuccess-green Sweetalert2 with messageYes, after dismissal
status === 1 AND new_status === previous_statusInfo-blue Sweetalert2 with messageNo
status === 0 (HTTP 4xx/5xx)Warning-orange Sweetalert2 with messageNo

See gocardless_admin_check_status.md for the design rationale.


Database Column Mapping

All GoCardless identifiers are stored on the order_payments table (with two mapped onto the customers table).

Column / TableGoCardless EntityValue ExampleSet When
charge_id (order_payments)BillingRequest IDBRQ0017TQFHXXcheckout() — immediately
transaction_id (order_payments)Payout IDPO001XXXXXXXXpayments.paid_out job (starts null)
transaction_type (order_payments)Literal stringGoCardlesscheckout() — immediately
transaction_status (order_payments)Internal statusNot Processed → … → succeededEach job execution
gocardless_customer_id (customers)GoCardless Customer IDCU001XXXXXXXXbilling_requests.fulfilled job
gocardless_mandate_id (customers)GoCardless Mandate IDMD001XXXXXXXXbilling_requests.fulfilled job
uid_accounting_system (order_payments)GoCardless Payment IDPM001XXXXXXXXbilling_requests.fulfilled job
amount (order_payments)Invoice grandtotal150.00checkout() — immediately
date_payment (order_payments)Timestamp2026-06-09 08:00:00payments.paid_out job
order_invoice_id (order_payments)Invoice FK42checkout() — immediately
supplier_id (order_payments)Supplier FK1checkout() — immediately

Routes Reference

GoCardless routes are split across two files. Customer-facing flows live in routes/web.php; the admin on-demand status check lives in routes/admin.php (under the /admin prefix with the admin. name prefix applied by RouteServiceProvider).

Customer-facing (routes/web.php)

MethodURINameController MethodNotes
POSTpayment/choose/gocardlesspayment.choose.gocardlesscheckoutCSRF-protected
GETpayment/choose/gocardless/confirmpayment.gocardless.confirmconfirmUrlGoCardless redirect
GETpayment/choose/gocardless/cancelpayment.gocardless.cancelcancelUrlGoCardless exit
GETpayment/gocardless/status/{invoiceId}payment.gocardless.statuscheckPaymentStatusAJAX polling
POSTpayment/gocardless/webhookpayment.gocardless.webhook__invokeCSRF-exempt, HMAC-validated

Admin (routes/admin.php)

MethodURINameController MethodNotes
POSTadmin/order/payment/gocardless/check-status/{payment}admin.order.payment.gocardless.checkStatusgocardlessCheckStatusCSRF-protected (X-CSRF-TOKEN); see Admin Check Status

CSRF Exemption:
The webhook URI is listed in app/Http/Middleware/VerifyCsrfToken::$except.
Security is provided by HMAC-SHA256 validation inside the controller.


Configuration Reference

File: config/services.php → key gocardless

Config Key.env VariableDefaultDescription
access_tokenGOCARDLESS_ACCESS_TOKEN''GoCardless API access token
environmentGOCARDLESS_ENVIRONMENTsandbox'sandbox' or 'live'
webhook_secretGOCARDLESS_WEBHOOK_SECRET''Webhook endpoint secret for HMAC validation
enable_pollingGOCARDLESS_ENABLE_POLLINGtrueEnable/disable the scheduled poller
poll_interval_minutesGOCARDLESS_POLL_INTERVAL_MINUTES5Polling frequency in minutes

GoCardless SDK

Package: gocardless/gocardless-pro ^7.2
SDK Classes Used:

ClassUsage
GoCardlessPro\ClientInstantiated in GoCardlessService::__construct()
GoCardlessPro\EnvironmentConstants SANDBOX and LIVE
GoCardlessPro\Resources\BillingRequestReturn type of createBillingRequest() and fetchBillingRequest()
GoCardlessPro\Resources\PaymentReturn type of fetchPayment()
GoCardlessPro\Core\Exception\ApiExceptionCaught in controller polling and Artisan command

Test Coverage

Test FileCovers
tests/Feature/GoCardless/GoCardlessCheckoutTest.phpcheckout, confirmUrl, cancelUrl — redirect flows, invalid invoices, error handling
tests/Feature/GoCardless/GoCardlessWebhookTest.phpHMAC validation (valid/invalid/missing), job dispatch per event type, malformed payload
tests/Feature/GoCardless/ProcessGoCardlessPaymentTest.phpAll three event handlers, missing payment graceful handling, invoice status update, fallback lookup
tests/Feature/GoCardless/PollGoCardlessPaymentsTest.phpPolling command scenarios, --dry-run, API exceptions, checkPaymentStatus controller endpoint
tests/Unit/Services/GoCardlessPollingServiceTest.phpShared per-payment poll primitive — unchanged/updated/error outcomes, billing-request-fulfilled and payment-status event shapes
tests/Feature/GoCardless/AdminCheckStatusTest.phpAdmin gocardlessCheckStatus endpoint — envelope shape, guard (422), error path (500), dispatchSync event payload, billing-request-fulfilled flow