GoCardless Direct Debit — Admin "Check Status" Button
Module: Payment Gateway Integration Document Type: Explanation (Decision Record) Audience: Backend developers and tech leads working on the admin payment view Status: Implemented Last Updated: 2026-07-07
Why This Button Exists
The GoCardless integration has two ways for a payment's transaction_status to advance: webhook events (push) and the scheduled poller (php artisan gocardless:poll, every 5 minutes). Both feed into the same ProcessGoCardlessPayment job.
That works for the happy path, but leaves admins with no way to force a status check on demand. If a payment has been stuck in Processing for hours because the GoCardless hosted page was completed but the webhook was lost (private network, misconfigured endpoint), the admin's only options are:
- Wait up to 5 minutes for the next cron tick, or
- SSH into the box and run
php artisan gocardless:poll --dry-runto inspect, then a real poll to apply.
Neither is acceptable for a customer-facing order view. The "Check GoCardless Status" button closes that gap: a one-click, on-demand reconciliation that reuses the same primitives the poller already uses.
Design Goals
- No duplicated business logic. The state-transition rules live in
ProcessGoCardlessPayment. The button must call into the same code path. - One synchronous round-trip. The admin's request blocks until GoCardless has been queried and the DB has been updated, so the modal can show the fresh status with confidence.
- Universal error handling. GoCardless API errors, missing IDs, and unexpected throwables all surface as a "try again in a few moments" modal — the real cause stays in the logs.
- Belt and braces guard. Even though the blade hides the button on terminal rows, the controller must reject calls against
succeeded/Failed/Cancelledrows with HTTP 422.
The "Reuse, Don't Duplicate" Refactor
Before the new endpoint can exist, the per-payment polling logic that currently lives inside PollGoCardlessPayments::pollSinglePayment() (and its private helpers checkBillingRequest() and checkGoCardlessPayment()) must be available to the admin controller without duplication.
Service shape
app/Services/GoCardlessPollingService.php ← NEW
public function pollSinglePayment(OrderPayment $payment): GoCardlessPollResult
app/Services/GoCardlessPollResult.php ← NEW (readonly DTO)
- outcome: 'updated' | 'unchanged' | 'error'
- event: ?array (synthesized webhook event, when outcome == 'updated')
- error: ?\Throwable (when outcome == 'error')
Status of the refactor
The new service exists. The Artisan command currently has its own parallel copy of the same logic and is not yet refactored to delegate. The duplication is acceptable for this slice; future work (Task 2 of the implementation plan) will thin the command.
The admin controller will use the new service directly. The service:
- Never throws. All exceptions (GoCardless
ApiException, otherThrowable) are caught, logged, and returned asGoCardlessPollResult::error($e). - Returns a
GoCardlessPollResultDTO with one of three outcomes:updated— GoCardless reported a state change;$eventholds the synthesized webhook-shaped array ready forProcessGoCardlessPayment::dispatch().unchanged— no change to apply (GoCardless still pending, status not in action map, payment ID not yet set, or action already applied). The caller cannot distinguish these sub-cases and does not need to.error— GoCardless API error or unexpected throwable;$erroris the original throwable.
This contract is the single source of truth for the admin controller. The controller does not write any try/catch around the GoCardless call.
The New Endpoint
Route
// routes/admin.php
Route::post('order/payment/gocardless/check-status/{payment}',
'OrderController@gocardlessCheckStatus')
->name('order.payment.gocardless.checkStatus');
| Property | Value |
|---|---|
| HTTP verb | POST (the call mutates state) |
| URI | admin/order/payment/gocardless/check-status/{payment} |
| Route name | admin.order.payment.gocardless.checkStatus (with admin. prefix from RouteServiceProvider) |
| Controller method | OrderController@gocardlessCheckStatus |
| Route-model binding | {payment} resolved manually as OrderPayment::find($id), matching the paymentRefund / paymentUpdate pattern in the same controller |
| CSRF | X-CSRF-TOKEN header from <meta name="csrf-token"> |
The method lives on the existing OrderController because:
- The order view (
@show) is rendered from that controller, so the new method's permission/scope context matches. - The only other place that constructs a
GoCardlessServicedirectly in an admin context isOrderController::paymentRefund(line 4116), so the pattern is already established.
Request flow
Response envelope
The endpoint always returns the same envelope shape. The HTTP status code carries the success/failure signal, and the status field inside the body mirrors it for FE convenience.
Success — status changed (HTTP 200)
{
"status": 1,
"message": "Payment status updated to Paid.",
"data": {
"id": 12,
"previous_status": "Processing",
"new_status": "Paid"
}
}
Success — no change (HTTP 200)
{
"status": 1,
"message": "No change in payment status.",
"data": {
"id": 12,
"previous_status": "Processing",
"new_status": "Processing"
}
}
Bad request — terminal status / non-GoCardless type (HTTP 422)
{
"status": 0,
"message": "This payment is not eligible for an on-demand status check.",
"data": null
}
Server error — GoCardless API error / unexpected throwable (HTTP 500)
{
"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"
}
}
The real ApiException message is captured in the application log but never sent to the admin.
FE behavior
The AJAX handler in the blade reads status (0 or 1) and data.new_status:
| 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 |
Blade Changes (resources/views/admin/order/show/payment.blade.php)
Visibility
The button appears only on GoCardless OrderPayment rows in non-terminal states:
@if (
$payment->transaction_type === 'GoCardless'
&& !in_array($payment->transaction_status, ['succeeded', 'Failed', 'Cancelled'], true)
)
This currently doesn't match anywhere in the existing blade — the Pending-state branch was written for Stripe/EFT and shows the wrong buttons for GoCardless (Reconciliation Now modal + Delete Payment). The conditional needs to be widened to cover all non-terminal GoCardless states.
Button HTML + JS
A single button per row, sitting alongside (or instead of) the existing Stripe Reconciliation Now button, only for GoCardless. The click handler is a standard jQuery $.ajax with CSRF header, Sweetalert2 modal, and conditional location.reload().
Out of Scope
- Auto-polling on the admin page. The button is a one-shot check, not a poll loop. The 5-minute cron stays.
- Refunds, cancellations, or any destructive action. Those flows are unchanged.
- Customer-facing changes.
Frontend\GoCardlessController::checkPaymentStatus(used by the customer's loading page) is untouched. - New admin permission gates. Any authenticated admin who can already see the order view can click the button.
- Behavior changes to
PollGoCardlessPayments. The command becomes thinner (delegates to the new service) but its iteration, batching, logging, and--dry-runflag are preserved.
Files Touched
NEW app/Services/GoCardlessPollingService.php
NEW app/Services/GoCardlessPollResult.php
DEFER app/Console/Commands/PollGoCardlessPayments.php (refactor deferred — still has its own copy)
EDIT app/Http/Controllers/Admin/OrderController.php (add gocardlessCheckStatus)
EDIT routes/admin.php (add 1 route)
EDIT resources/views/admin/order/show/payment.blade.php (add button + JS)
NEW tests/Feature/GoCardless/AdminCheckStatusTest.php (feature test)
Note:
app/Services/GoCardlessPollResult.phpis a separate file even though it is referenced inline above for readability. It is consumed exclusively byGoCardlessPollingService.
Known Issues
Mismatched failed / cancelled action handlers
Problem
GoCardlessPollingService::GC_PAYMENT_ACTION_MAP (in app/Services/GoCardlessPollingService.php at lines 46–48) maps GoCardless failed / cancelled to actions failed / cancelled. checkGoCardlessPayment() emits those events, but ProcessGoCardlessPayment::handle() (in app/Jobs/ProcessGoCardlessPayment.php at lines 70–87) has no handler for payments.failed / payments.cancelled — they fall into the default branch and are only logged. Result: a failed/cancelled GoCardless payment never updates transaction_status, and the admin button returns a misleading "No change" (status 1).
Status: documented. Fix not yet implemented. Tracking under a separate ticket.
Impact on the admin button:
- The button can dispatch a
payments.failedorpayments.cancelledevent that has no effect on the DB row. - The endpoint then refreshes the payment and compares statuses. Since the row did not change, it returns HTTP 200 with
status: 1and a "No change in payment status." message — the admin sees a false negative. - The GoCardless API really did report the payment as failed/cancelled; the system just has no code path to apply that state to
order_payments.transaction_status.
Workaround: admins currently have no in-app way to reconcile a failed / cancelled GoCardless payment beyond manually editing the row. Until the handler is added, a paid_out event is the only "happy" terminal state the admin button can actually apply.
Open Questions
None at the time of writing. The intent has been confirmed by the requester.