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:
Tanmay Deep Sharma
2026-04-22 11:21:26 +07:00
committed by GitHub
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)