From f12118a3c07d8de30a710357b84f18dddacbed0b Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com> Date: Wed, 22 Apr 2026 11:21:26 +0700 Subject: [PATCH] fix: void topup invoice when card payment fails (#14104) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ## 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) --- .../services/enterprise/billing/topup_checkout_service.rb | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/enterprise/app/services/enterprise/billing/topup_checkout_service.rb b/enterprise/app/services/enterprise/billing/topup_checkout_service.rb index d0ec3e372..00e2b1646 100644 --- a/enterprise/app/services/enterprise/billing/topup_checkout_service.rb +++ b/enterprise/app/services/enterprise/billing/topup_checkout_service.rb @@ -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)