fix: void topup invoice when card payment fails (#14104)
When a credit top-up's card charge fails, the finalized invoice was left in an open state with no hook back into our fulfillment service. If that invoice was later paid (manually via the hosted invoice page, or by a future dunning flow), the account would never receive its credits — silent revenue loss. This change voids the invoice the moment the charge fails, so a failed top-up cannot turn into a paid-but-unfulfilled invoice. ## Closes <!-- add the relevant issue / Linear link --> ## How to test 1. On a Business-plan account with a Stripe customer, attach a test card that will decline on charge (e.g. `4000 0000 0000 0002`). 2. From the dashboard, open the credit top-up flow and purchase a credit pack. 3. Observe the API returns an error and the account's captain credits are unchanged. 4. In the Stripe dashboard, confirm the corresponding invoice is in `void` status (not `open`). 5. Repeat with a good card (`4242 4242 4242 4242`) and confirm the happy path still fulfills credits. ## What changed - `finalize_and_pay` in `Enterprise::Billing::TopupCheckoutService` now rescues `Stripe::CardError`, voids the open invoice via `Stripe::Invoice.void_invoice`, and re-raises so the controller surfaces the original decline error to the client. - Rescue is intentionally narrow to `Stripe::CardError` (declines, insufficient funds, SCA `authentication_required`). Transient errors like `APIConnectionError` / `RateLimitError` are left to propagate — the charge may have actually succeeded and voiding could be wrong. - Invoices are created with `auto_advance: false`, so Stripe's Smart Retries won't collect on an open invoice; voiding is the correct terminal state. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
ca66218cb9
commit
f12118a3c0
@@ -79,7 +79,12 @@ class Enterprise::Billing::TopupCheckoutService
|
||||
def finalize_and_pay(invoice_id)
|
||||
Stripe::Invoice.finalize_invoice(invoice_id, { auto_advance: false })
|
||||
invoice = Stripe::Invoice.retrieve(invoice_id)
|
||||
Stripe::Invoice.pay(invoice_id) unless invoice.status == 'paid'
|
||||
return if invoice.status == 'paid'
|
||||
|
||||
Stripe::Invoice.pay(invoice_id)
|
||||
rescue Stripe::CardError
|
||||
Stripe::Invoice.void_invoice(invoice_id)
|
||||
raise
|
||||
end
|
||||
|
||||
def fulfill_credits(credits, topup_option)
|
||||
|
||||
Reference in New Issue
Block a user