fix(billing) - skip paying upgrade invoices already settled at finalization (#22705)

## Problem

Upgrading resource credits fails with `Invoice is already paid` whenever
the one-off upgrade invoice resolves to a $0 amount due. That is the
case for internal workspaces on the `Twenty Internal - 100% FREE`
coupon, and for customers whose credit balance covers the price
difference.

Reproduced on an internal workspace (5 to 20 credits upgrade):

1. `createImmediateUpgradeInvoice` creates the invoice for the $20 diff
and finalizes it with `auto_advance: true`
2. The 100% coupon brings the amount due to $0, and Stripe settles
zero-due invoices at finalization ("Invoice was finalised and
automatically marked as paid because the amount due was US$0.00")
3. The explicit `stripe.invoices.pay()` that follows is rejected with a
400 `invalid_request_error`: "Invoice is already paid"
4. The error propagates, so the mutation aborts before
`runSubscriptionUpdate`: the Stripe subscription item stays on the old 5
credits price while the upgrade invoice already exists
5. Every retry creates a new invoice item + invoice and fails the same
way, leaving stray $0 invoices on the customer

## History

Third pass on this code path:

- #21097 treated it as a race with `auto_advance` and switched
finalization to `auto_advance: false`. Zero-due invoices are settled at
finalization regardless of that flag, so the failure remained.
- #21450 restored `auto_advance: true` and swallowed the error when
`error.code === 'invoice_already_paid'`. Stripe does not send that code
for this failure (it is not in its documented error codes; the response
only carries the message), so the guard never matched and the error was
always rethrown.

## Fix

Rely on invoice status instead of error codes:

- `finalizeInvoice` returns the finalized invoice; when it comes back
`paid` (the zero-due case), skip `pay` entirely
- if `pay` still fails (the genuine auto_advance race from #21097),
re-retrieve the invoice and only rethrow when it is actually unpaid

## Tests

Unit tests for `createImmediateUpgradeInvoice`:

- open invoice after finalization gets paid
- invoice settled at finalization skips `pay`
- `pay` failure with a meanwhile-paid invoice is swallowed
- `pay` failure with an unpaid invoice is rethrown

---
_Generated by [Claude
Code](https://claude.ai/code/session_01BwoUffgasLUsXsaGuANsAP)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22705?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Félix Malfait
2026-07-09 09:09:58 +02:00
committed by GitHub
parent 1129cc7ae0
commit 0755855768
2 changed files with 154 additions and 11 deletions
@@ -0,0 +1,141 @@
/* @license Enterprise */
import { Test, type TestingModule } from '@nestjs/testing';
import { StripeInvoiceService } from 'src/engine/core-modules/billing/stripe/services/stripe-invoice.service';
import { StripeSDKService } from 'src/engine/core-modules/billing/stripe/stripe-sdk/services/stripe-sdk.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
describe('StripeInvoiceService', () => {
let service: StripeInvoiceService;
const stripeMock = {
invoiceItems: {
create: jest.fn(),
},
invoices: {
create: jest.fn(),
finalizeInvoice: jest.fn(),
pay: jest.fn(),
retrieve: jest.fn(),
},
};
const upgradeInvoiceInput = {
stripeCustomerId: 'cus_1',
stripeSubscriptionId: 'sub_1',
diffAmountInCents: 2000,
currency: 'usd',
description:
'Resource usage - Upgrade resource credit price from $0 to $20',
};
beforeEach(async () => {
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
providers: [
StripeInvoiceService,
{
provide: TwentyConfigService,
useValue: {
get: jest.fn().mockImplementation((key: string) => {
if (key === 'IS_BILLING_ENABLED') {
return true;
}
return 'stripe-api-key';
}),
},
},
{
provide: StripeSDKService,
useValue: {
getStripe: jest.fn().mockReturnValue(stripeMock),
},
},
],
}).compile();
service = module.get(StripeInvoiceService);
stripeMock.invoiceItems.create.mockResolvedValue({ id: 'ii_1' });
stripeMock.invoices.create.mockResolvedValue({
id: 'in_1',
status: 'draft',
});
});
describe('createImmediateUpgradeInvoice', () => {
it('should pay the invoice when it is still open after finalization', async () => {
stripeMock.invoices.finalizeInvoice.mockResolvedValue({
id: 'in_1',
status: 'open',
});
stripeMock.invoices.pay.mockResolvedValue({ id: 'in_1', status: 'paid' });
await service.createImmediateUpgradeInvoice(upgradeInvoiceInput);
expect(stripeMock.invoiceItems.create).toHaveBeenCalledWith({
customer: 'cus_1',
subscription: 'sub_1',
amount: 2000,
currency: 'usd',
description: upgradeInvoiceInput.description,
});
expect(stripeMock.invoices.finalizeInvoice).toHaveBeenCalledWith('in_1', {
auto_advance: true,
});
expect(stripeMock.invoices.pay).toHaveBeenCalledWith('in_1');
});
it('should not attempt payment when the invoice is settled at finalization', async () => {
stripeMock.invoices.finalizeInvoice.mockResolvedValue({
id: 'in_1',
status: 'paid',
});
await expect(
service.createImmediateUpgradeInvoice(upgradeInvoiceInput),
).resolves.toBeUndefined();
expect(stripeMock.invoices.pay).not.toHaveBeenCalled();
});
it('should swallow the payment error when the invoice was paid in the meantime', async () => {
stripeMock.invoices.finalizeInvoice.mockResolvedValue({
id: 'in_1',
status: 'open',
});
stripeMock.invoices.pay.mockRejectedValue(
new Error('Invoice is already paid'),
);
stripeMock.invoices.retrieve.mockResolvedValue({
id: 'in_1',
status: 'paid',
});
await expect(
service.createImmediateUpgradeInvoice(upgradeInvoiceInput),
).resolves.toBeUndefined();
});
it('should rethrow the payment error when the invoice is not paid', async () => {
const paymentError = new Error('Your card was declined.');
stripeMock.invoices.finalizeInvoice.mockResolvedValue({
id: 'in_1',
status: 'open',
});
stripeMock.invoices.pay.mockRejectedValue(paymentError);
stripeMock.invoices.retrieve.mockResolvedValue({
id: 'in_1',
status: 'open',
});
await expect(
service.createImmediateUpgradeInvoice(upgradeInvoiceInput),
).rejects.toBe(paymentError);
});
});
});
@@ -67,21 +67,23 @@ export class StripeInvoiceService {
subscription: stripeSubscriptionId,
});
await this.stripe.invoices.finalizeInvoice(invoice.id, {
auto_advance: true,
});
const finalizedInvoice = await this.stripe.invoices.finalizeInvoice(
invoice.id,
{
auto_advance: true,
},
);
if (finalizedInvoice.status === 'paid') {
return;
}
try {
await this.stripe.invoices.pay(invoice.id);
} catch (error) {
// With auto_advance Stripe may already have collected payment by the time
// we explicitly request it. Only swallow that case, rethrow real failures.
if (
!(
error instanceof this.stripe.errors.StripeInvalidRequestError &&
error.code === 'invoice_already_paid'
)
) {
const refreshedInvoice = await this.stripe.invoices.retrieve(invoice.id);
if (refreshedInvoice.status !== 'paid') {
throw error;
}
}