Back to Paid GitHub Issues
GH
Paid Issue

Stripe ACH auto-bill: false success on InvalidRequestException, webhook crash on charge.succeeded, missing off-session/mandate for legacy ba_ tokens (v5.13.26)

invoiceninja/invoiceninja

Key metric
USD 72.00
Match score82
Summary
USD 72.00
Repository
invoiceninja/invoiceninja
Stars
9882
Comments
16
Platform
GitHub
Difficulty
Medium
Scam risk
Low
Status
open
JavaScriptPHPFlutterLaravelmore info requested

Description

<!-- Before posting please check our "Troubleshooting" category in the docs: https://invoiceninja.github.io/en/self-host-troubleshooting/ --> ## Environment IN version: 5.13.26 PHP: 8.2.31 (web + CLI/cron) Gateway: Stripe (ACH / us_bank_account, legacy ba_ bank account tokens) Auto-bill: Recurring invoices with auto_bill = always Queue: Redis ## Describe the bug On Invoice Ninja v5.13.26 (self-hosted, PHP 8.2, Stripe live mode), recurring ACH auto-bill fails silently while logging success. Stripe shows daily Incomplete PaymentIntents (HTTP 400 on confirm). Stripe webhooks also crash on charge.succeeded. Three related code issues in the Stripe ACH / webhook stack appear to compound the problem. ### Steps To Reproduce 1. Cron auto-bill logs Auto Bill payment captured for R2026-XXXX and records AUTOBILL_SUCCESS activity, but no payments row is created (payment_hashes.payment_id stays NULL). 2. Stripe dashboard shows Incomplete PaymentIntents with POST /v1/payment_intents → 400, often retried daily for the same unpaid invoice. 3. auto_bill_tries stays at 0 because failures are treated as success. 4. Laravel log shows webhook errors since at least April 2025: ``` foreach() argument must be of type array|object, null given at app/PaymentDrivers/StripePaymentDriver.php:767 ``` 5. ACH payments that reach processing in Stripe may never flip to Paid in IN when webhooks fail. ## Issue 1 — ACH auto-bill treats InvalidRequestException as success (critical) File: app/PaymentDrivers/Stripe/ACH.php Method: paymentIntentTokenBilling() When Stripe returns InvalidRequestException (e.g. HTTP 400 on PaymentIntent confirm during off-session ACH), the catch block returns an HTTP redirect: ``` case $e instanceof InvalidRequestException: return redirect()->route('client.payment_methods.verification', [...]); ``` Auto-bill calls tokenBilling() → paymentIntentTokenBilling(..., $client_present = false) from cron/queue with no browser session. The redirect response is still truthy, so AutoBillInvoice.php logs success: ``` if ($payment) { nlog('Auto Bill payment captured for ' . $this->invoice->number); event(new InvoiceAutoBillSuccess(...)); } ``` Result: False success, no payment record, auto_bill_tries never increments, stale-invoice job retries indefinitely → duplicate Incomplete PaymentIntents in Stripe. Proposed Issue 1 fix: Only redirect when the client is present (portal checkout). During auto-bill, log the error and return false: ``` case $e instanceof InvalidRequestException: if ($client_present) { return redirect()->route('client.payment_methods.verification', [ 'payment_method' => $cgt->hashed_id, 'method' => GatewayType::BANK_TRANSFER, ]); } $data['message'] = $e->getError()->message ?? $e->getMessage(); break; ``` tokenBilling() already passes $client_present = false for auto-bill: public function tokenBilling(ClientGatewayToken $cgt, PaymentHash $payment_hash) ``` { // ... return $this->paymentIntentTokenBilling($amount, $description, $cgt, false); } ``` ## Issue 2 — charge.succeeded webhook iterates wrong payload shape (critical) File: app/PaymentDrivers/StripePaymentDriver.php Method: processWebhookRequest() (~line 766) Current code: ``` if ($request->type === 'charge.succeeded') { foreach ($request->data as $transaction) { $payment = self::findPaymentByStripeReference( $this->company_gateway->company_id, $transaction ); // ... } } ``` Stripe webhook data is { "object": { ...charge... }, "previous_attributes": {...} }, not a list of transactions. When $request->data is null or malformed, this throws: `foreach() argument must be of type array|object, null given` Modern ACH uses PaymentIntents (payment_intent.processing / payment_intent.succeeded are dispatched correctly), but charge.succeeded still runs for many ACH charges and can crash the webhook handler before other logic completes. findPaymentByStripeReference() in app/PaymentDrivers/Stripe/Utilities.php already expects a single charge/PI object array, not a wrapper. Proposed fix for Issue 2: ``` if ($request->type === 'charge.succeeded') { $charge = $request->data['object'] ?? null; if (! is_array($charge)) { return response()->json([], 200); } $payment = self::findPaymentByStripeReference( $this->company_gateway->company_id, $charge ); if ($payment) { if (isset($charge['payment_method_details']['au_becs_debit'])) { $payment->transaction_reference = $charge['id']; } $payment->status_id = Payment::STATUS_COMPLETED; $payment->save(); } return response()->json([], 200); } ``` ## Issue 3 — Same foreach bug on legacy source.chargeable handler (medium) File: app/PaymentDrivers/StripePaymentDriver.php (~line 783) ``` } elseif ($request->type === 'source.chargeable') { // ... foreach ($request->data as $transaction) { ``` Same incorrect assumption. Recommend the same pattern: read $request->data['object'] once, or no-op with 200 if missing. ## Issue 4 — ACH auto-bill missing off_session and mandate for legacy ba_ tokens (critical) File: app/PaymentDrivers/Stripe/ACH.php Method: paymentIntentTokenBilling() app/PaymentDrivers/Stripe/Charge.php sets off-session for card auto-bill: ``` if (! auth()->guard('contact')->check()) { $data['off_session'] = true; } ``` ACH paymentIntentTokenBilling() does not set off_session at all for cron auto-bill. Mandate data is only added when token state is inactive: ``` if (str_starts_with($cgt->token, 'ba_') && isset($cgt->meta->state) && $cgt->meta->state == 'inactive') { $data['mandate_data'] = [ 'customer_acceptance' => ['type' => 'offline'], ]; $data['setup_future_usage'] = 'off_session'; } ``` Legacy Stripe ba_ tokens often remain meta.state = authorized in IN after migration from Sources API. Off-session confirm then fails with Stripe 400 (missing mandate / off_session), while Issue 1 masks the failure as success. Proposed fix for Issue 4: When ! $client_present (auto-bill), always set off-session. For legacy ba_ tokens, include offline mandate acceptance (not only when inactive): ``` if (! $client_present) { $data['off_session'] = true; } if ( ! $client_present && str_starts_with($cgt->token, 'ba_') && ($cgt->meta->state ?? null) !== 'unauthorized' ) { $data['mandate_data'] = [ 'customer_acceptance' => [ 'type' => 'offline', ], ]; } ``` **Note**: Long-term, migrating customers to pm_ + Financial Connections SetupIntents is preferable; this patch helps existing ba_ subscribers. ## Issue 5 — Misleading auto-bill log message (minor / UX) File: app/Services/Invoice/AutoBillInvoice.php (~line 191) `nlog('Auto Bill payment captured for ' . $this->invoice->number);` This runs when tokenBilling() returns any truthy value, before Stripe settlement and even when no payment row exists. Suggest: `nlog('Auto Bill payment initiated for ' . $this->invoice->number);` Or only log after $payment->id is confirmed and linked to payment_hash. ## Reproduction (self-hosted) 1. Client with recurring invoice, auto_bill = always, default ACH token (gateway_type_id = 2, legacy ba_ token). 2. Let cron run auto-bill-job / stale-invoice-job. 3. Observe IN log: Auto Bill payment captured + AUTOBILL_SUCCESS. 4. DB: payment_hashes row with payment_id IS NULL; invoice balance unchanged. 5. Stripe: Incomplete PaymentIntent, 400 on confirm. 6. Send test charge.succeeded webhook → PHP error at StripePaymentDriver.php:767. ## Expected behavior 1. Auto-bill failures increment auto_bill_tries, fire InvoiceAutoBillFailed, and do not log success. 2. Off-session ACH includes off_session and valid mandate for legacy tokens. 3. Webhooks handle charge.succeeded without fatal errors. 4. Completed Stripe ACH charges mark invoices paid via webhook or pending → completed flow. ### ### Log excerpts (`storage/logs/laravel.log`, redacted) **False auto-bill success (no matching failure lines anywhere in log):** ``` [2026-06-09 07:05:04] production.INFO: Auto Bill payment captured for R2026-1300 … (19 total occurrences through 2026-06-27, same message) … [2026-06-16 10:05:05] production.INFO: Auto Bill payment captured for R2026-1301 ``` Never observed: `payment NOT captured for R2026-1300` or `InvoiceAutoBillFailed`. **Working ACH auto-bill (contrast — same client, earlier cycle):** ``` [2026-06-01 07:05:05] production.INFO: Auto Bill payment captured for R2026-1295 [2026-06-01 07:05:06] production.INFO: Broadcasting [App\Events\Invoice\InvoiceWasPaid] … ``` **Stripe webhook fatal (Issue 2):** ``` [2026-04-19 19:43:30] production.ERROR: foreach() argument must be of type array|object, null given at app/PaymentDrivers/StripePaymentDriver.php:767 via PaymentWebhookController.php:30 → processWebhookRequest() ``` ### Gateway responses (`system_logs`, type_id=301 — not in laravel.log) ``` Successful ACH PI logged for R2026-1295 (2026-06-01): status `processing`, gateway_type_id `2`, us_bank_account. Failed attempts for R2026-1301 (2026-06-16): **no** system_logs failure row — InvalidRequestException path returns redirect before SystemLogger dispatch. ``` ### Stripe Dashboard Evidence: Setup: Live mode, ACH auto-bill (us_bank_account), legacy ba_ bank tokens, Invoice Ninja v5.13.26. `Incomplete PaymentIntent, POST /v1/payment_intents → 400, metadata.payment_hash matches IN payment_hashes row.` Observed pattern - Auto-bill creates a PaymentIntent with confirm: true (off-session). - Stripe logs: POST /v1/payment_intents → 400 ERR. - PaymentIntent stays Incomplete (not Failed/Declined). - Same unpaid invoice retried daily → many Incomplete rows for the same amount and customer. Example A — $25 invoice Amount: $25.00 USD - Status: Incomplete - Payment method: ACH (us_bank_account), legacy ba_* token - Stripe log: POST /v1/payment_intents → 400 ERR - IN metadata on PI: gateway_type_id: 2, payment_hash: [REDACTED] - IN DB: matching payment_hashes row, payment_id NULL; no payments record Example B — $50 invoice (daily retries) - Amount: $50.00 USD - Status: Incomplete on every attempt (19+ over ~3 weeks) - Timing: ~same time daily (cron-driven) - Same customer/token; new PaymentIntent ID each retry - IN: invoice still unpaid; auto_bill_tries remained 0 until local patch Contrast — successful ACH (same gateway, earlier cycle) - Different invoice: $72.00, same client account - PaymentIntent reached processing; charge created - IN: payment recorded, invoice marked paid Payments list (summary) - Many Incomplete rows, often daily repeats for one invoice/customer - Empty decline reason (Incomplete, not card decline)

Open original source