fix: skip pay call if invoice already paid after finalize (#13924)

## 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
This commit is contained in:
Tanmay Deep Sharma
2026-03-30 10:37:28 +05:30
committed by GitHub
parent 44a7a13117
commit 04acc16609
3 changed files with 23 additions and 2 deletions

View File

@@ -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)

View File

@@ -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

View File

@@ -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