From 04acc16609ca3eb20d8adea52b607c6fa865a974 Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com> Date: Mon, 30 Mar 2026 10:37:28 +0530 Subject: [PATCH] fix: skip pay call if invoice already paid after finalize (#13924) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description When a customer downgrades from Enterprise to Business, they may retain unused Stripe credit balance. During an AI credits topup, Stripe::Invoice.finalize_invoice auto-applies that credit balance to the invoice. If the credit balance fully covers the invoice amount, Stripe marks it as paid immediately upon finalization. Calling Stripe::Invoice.pay on an already-paid invoice throws an error, breaking the topup flow. This fix retrieves the invoice status after finalization and skips the pay call if Stripe has already settled it via credits. ## Type of change Please delete options that are not relevant. - [ ] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? Tested against Stripe test mode with the following scenarios: - Full credit balance payment: Customer has enough Stripe credit balance to cover the entire invoice. Invoice is marked paid after finalize_invoice — pay is correctly skipped. Credits are fulfilled successfully. - Partial credit balance payment: Customer has some Stripe credit balance but not enough to cover the full amount. Invoice remains open after finalization — pay is called and charges the remaining amount to the default payment method. Credits are fulfilled successfully. - Zero credit balance (normal payment): Customer has no Stripe credit balance. Invoice remains open after finalization — pay charges the full amount. Credits are fulfilled successfully. ## Checklist: - [ ] My code follows the style guidelines of this project - [ ] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --- .../enterprise/billing/topup_checkout_service.rb | 9 +++++++-- .../enterprise/api/v1/accounts_controller_spec.rb | 1 + .../billing/topup_checkout_service_spec.rb | 15 +++++++++++++++ 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/enterprise/app/services/enterprise/billing/topup_checkout_service.rb b/enterprise/app/services/enterprise/billing/topup_checkout_service.rb index e11269110..d0ec3e372 100644 --- a/enterprise/app/services/enterprise/billing/topup_checkout_service.rb +++ b/enterprise/app/services/enterprise/billing/topup_checkout_service.rb @@ -73,8 +73,13 @@ class Enterprise::Billing::TopupCheckoutService description: description ) - Stripe::Invoice.finalize_invoice(invoice.id, { auto_advance: false }) - Stripe::Invoice.pay(invoice.id) + finalize_and_pay(invoice.id) + end + + 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' end def fulfill_credits(credits, topup_option) diff --git a/spec/enterprise/controllers/enterprise/api/v1/accounts_controller_spec.rb b/spec/enterprise/controllers/enterprise/api/v1/accounts_controller_spec.rb index 9089b089a..b2a920b07 100644 --- a/spec/enterprise/controllers/enterprise/api/v1/accounts_controller_spec.rb +++ b/spec/enterprise/controllers/enterprise/api/v1/accounts_controller_spec.rb @@ -281,6 +281,7 @@ RSpec.describe 'Enterprise Billing APIs', type: :request do allow(Stripe::Invoice).to receive(:create).and_return(stripe_invoice) allow(Stripe::InvoiceItem).to receive(:create) allow(Stripe::Invoice).to receive(:finalize_invoice) + allow(Stripe::Invoice).to receive(:retrieve).and_return(Struct.new(:status).new('open')) allow(Stripe::Invoice).to receive(:pay) allow(Stripe::Billing::CreditGrant).to receive(:create) end diff --git a/spec/enterprise/services/enterprise/billing/topup_checkout_service_spec.rb b/spec/enterprise/services/enterprise/billing/topup_checkout_service_spec.rb index 24c7889de..fa4c052a1 100644 --- a/spec/enterprise/services/enterprise/billing/topup_checkout_service_spec.rb +++ b/spec/enterprise/services/enterprise/billing/topup_checkout_service_spec.rb @@ -24,6 +24,7 @@ describe Enterprise::Billing::TopupCheckoutService do allow(Stripe::Invoice).to receive(:create).and_return(stripe_invoice) allow(Stripe::InvoiceItem).to receive(:create) allow(Stripe::Invoice).to receive(:finalize_invoice) + allow(Stripe::Invoice).to receive(:retrieve).and_return(Struct.new(:status).new('open')) allow(Stripe::Invoice).to receive(:pay) allow(Stripe::Billing::CreditGrant).to receive(:create) end @@ -58,5 +59,19 @@ describe Enterprise::Billing::TopupCheckoutService do expect(error.message).to eq(I18n.t('errors.topup.plan_not_eligible')) end end + + it 'calls pay when invoice is open after finalization' do + service.create_checkout_session(credits: 1000) + + expect(Stripe::Invoice).to have_received(:pay).with('inv_test123') + end + + it 'skips pay when invoice is already paid via Stripe credits' do + allow(Stripe::Invoice).to receive(:retrieve).and_return(Struct.new(:status).new('paid')) + + service.create_checkout_session(credits: 1000) + + expect(Stripe::Invoice).not_to have_received(:pay) + end end end