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
| Method | Return | Description |
|---|---|---|
createBillingRequest(OrderInvoice $invoice) | BillingRequest | Creates a GoCardless BillingRequest with mandate_request (BECS/AUD) + payment_request for the invoice amount |
createBillingRequestFlow(string $billingRequestId, string $successUrl, string $cancelUrl) | string | Creates a BillingRequestFlow; returns the hosted authorisation_url |
saveGoCardlessPendingPayment(OrderInvoice $invoice, string $billingRequestId, PaymentModule $paymentModule) | OrderPayment | Persists a Not Processed OrderPayment row via PaymentModule::payment() |
fetchBillingRequest(string $billingRequestId) | BillingRequest | Direct API call — retrieves a BillingRequest by ID (used by poller) |
fetchPayment(string $gcPaymentId) | Payment | Direct API call — retrieves a GoCardless Payment by ID (used by poller) |
client() | Client | Returns 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
| Method | HTTP | Route | Description |
|---|---|---|---|
checkout(Request $r) | POST | payment/choose/gocardless | Creates BillingRequest + Flow, saves pending payment, redirects 303 to GoCardless |
confirmUrl(Request $r) | GET | payment/choose/gocardless/confirm | Receives redirect after mandate authorisation; redirects to payment.order.loading-payment |
cancelUrl(Request $r) | GET | payment/choose/gocardless/cancel | Receives redirect after customer cancels; redirects back to payment page |
checkPaymentStatus(Request $r, int $invoiceId) | GET | payment/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
| Code | Condition |
|---|---|
200 OK | Signature valid; events processed |
422 Unprocessable Entity | Payload valid signature but malformed JSON |
498 Token Invalid | HMAC signature mismatch or missing header |
Handled Event Types
resource_type | action | Job Action |
|---|---|---|
billing_requests | fulfilled | Set uid_customer, charge_id, uid_accounting_system; status → Processing |
payments | confirmed | Status → Confirmed |
payments | paid_out | Status → 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
| Option | Default | Description |
|---|---|---|
--dry-run | false | Log status changes without dispatching jobs |
--limit | 50 | Maximum number of payment records to check per run |
--age | 72 | Only 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_status | GoCardless call | Action on change |
|---|---|---|
Not Processed | fetchBillingRequest($payment->charge_id) | billing_requests.fulfilled event |
Processing / Pending | fetchPayment($payment->uid_accounting_system) | payments.{confirmed|paid_out|failed|cancelled} event |
Other (incl. Confirmed) | — | unchanged (no API call) |
Note:
Confirmedis intentionally not routed tocheckGoCardlessPayment()in this initial implementation — it falls through to thedefaultbranch and returnsunchanged. This is a known pre-existing behaviour inherited fromPollGoCardlessPayments. A future fix is tracked separately.
Result outcomes:
| Outcome | When | $event | $error |
|---|---|---|---|
updated | GoCardless reported a state change not yet applied | synthesized webhook-shaped array | null |
unchanged | No change to apply (still pending, status not in action map, no ID yet, or already applied) | null | null |
error | GoCardless ApiException or any other Throwable (already logged) | null | the 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
| Property | Type | Description |
|---|---|---|
$outcome | string | One of OUTCOME_UPDATED, OUTCOME_UNCHANGED, OUTCOME_ERROR |
$event | ?array | Synthesized webhook event (only set when outcome === 'updated') |
$error | ?\Throwable | Original 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
- Resolve
OrderPaymentbyid. If not found → 404 envelope. - Server-side guard: reject with 422 if
transaction_type !== 'GoCardless'ORtransaction_statusis 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. - Snapshot
$previousStatus = $payment->transaction_status. - Call
GoCardlessPollingService::pollSinglePayment($payment). - On
error→ return 500 envelope immediately (service has already logged the cause; do not expose the exception message). - On
updated→ProcessGoCardlessPayment::dispatchSync($result->event)to apply the change synchronously. - Refresh the payment row and return 200 envelope with
previous_statusandnew_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)
| Condition | Modal | Page reload? |
|---|---|---|
status === 1 AND new_status !== previous_status | Success-green Sweetalert2 with message | Yes, after dismissal |
status === 1 AND new_status === previous_status | Info-blue Sweetalert2 with message | No |
status === 0 (HTTP 4xx/5xx) | Warning-orange Sweetalert2 with message | No |
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 / Table | GoCardless Entity | Value Example | Set When |
|---|---|---|---|
charge_id (order_payments) | BillingRequest ID | BRQ0017TQFHXX | checkout() — immediately |
transaction_id (order_payments) | Payout ID | PO001XXXXXXXX | payments.paid_out job (starts null) |
transaction_type (order_payments) | Literal string | GoCardless | checkout() — immediately |
transaction_status (order_payments) | Internal status | Not Processed → … → succeeded | Each job execution |
gocardless_customer_id (customers) | GoCardless Customer ID | CU001XXXXXXXX | billing_requests.fulfilled job |
gocardless_mandate_id (customers) | GoCardless Mandate ID | MD001XXXXXXXX | billing_requests.fulfilled job |
uid_accounting_system (order_payments) | GoCardless Payment ID | PM001XXXXXXXX | billing_requests.fulfilled job |
amount (order_payments) | Invoice grandtotal | 150.00 | checkout() — immediately |
date_payment (order_payments) | Timestamp | 2026-06-09 08:00:00 | payments.paid_out job |
order_invoice_id (order_payments) | Invoice FK | 42 | checkout() — immediately |
supplier_id (order_payments) | Supplier FK | 1 | checkout() — 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)
| Method | URI | Name | Controller Method | Notes |
|---|---|---|---|---|
POST | payment/choose/gocardless | payment.choose.gocardless | checkout | CSRF-protected |
GET | payment/choose/gocardless/confirm | payment.gocardless.confirm | confirmUrl | GoCardless redirect |
GET | payment/choose/gocardless/cancel | payment.gocardless.cancel | cancelUrl | GoCardless exit |
GET | payment/gocardless/status/{invoiceId} | payment.gocardless.status | checkPaymentStatus | AJAX polling |
POST | payment/gocardless/webhook | payment.gocardless.webhook | __invoke | CSRF-exempt, HMAC-validated |
Admin (routes/admin.php)
| Method | URI | Name | Controller Method | Notes |
|---|---|---|---|---|
POST | admin/order/payment/gocardless/check-status/{payment} | admin.order.payment.gocardless.checkStatus | gocardlessCheckStatus | CSRF-protected (X-CSRF-TOKEN); see Admin Check Status |
CSRF Exemption:
The webhook URI is listed inapp/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 Variable | Default | Description |
|---|---|---|---|
access_token | GOCARDLESS_ACCESS_TOKEN | '' | GoCardless API access token |
environment | GOCARDLESS_ENVIRONMENT | sandbox | 'sandbox' or 'live' |
webhook_secret | GOCARDLESS_WEBHOOK_SECRET | '' | Webhook endpoint secret for HMAC validation |
enable_polling | GOCARDLESS_ENABLE_POLLING | true | Enable/disable the scheduled poller |
poll_interval_minutes | GOCARDLESS_POLL_INTERVAL_MINUTES | 5 | Polling frequency in minutes |
GoCardless SDK
Package: gocardless/gocardless-pro ^7.2
SDK Classes Used:
| Class | Usage |
|---|---|
GoCardlessPro\Client | Instantiated in GoCardlessService::__construct() |
GoCardlessPro\Environment | Constants SANDBOX and LIVE |
GoCardlessPro\Resources\BillingRequest | Return type of createBillingRequest() and fetchBillingRequest() |
GoCardlessPro\Resources\Payment | Return type of fetchPayment() |
GoCardlessPro\Core\Exception\ApiException | Caught in controller polling and Artisan command |
Test Coverage
| Test File | Covers |
|---|---|
tests/Feature/GoCardless/GoCardlessCheckoutTest.php | checkout, confirmUrl, cancelUrl — redirect flows, invalid invoices, error handling |
tests/Feature/GoCardless/GoCardlessWebhookTest.php | HMAC validation (valid/invalid/missing), job dispatch per event type, malformed payload |
tests/Feature/GoCardless/ProcessGoCardlessPaymentTest.php | All three event handlers, missing payment graceful handling, invoice status update, fallback lookup |
tests/Feature/GoCardless/PollGoCardlessPaymentsTest.php | Polling command scenarios, --dry-run, API exceptions, checkPaymentStatus controller endpoint |
tests/Unit/Services/GoCardlessPollingServiceTest.php | Shared per-payment poll primitive — unchanged/updated/error outcomes, billing-request-fulfilled and payment-status event shapes |
tests/Feature/GoCardless/AdminCheckStatusTest.php | Admin gocardlessCheckStatus endpoint — envelope shape, guard (422), error path (500), dispatchSync event payload, billing-request-fulfilled flow |