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:
+141
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
+13
-11
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user