feat(billing): invoice on seat increase (#22083)

## Context

Two billing improvements around workspace seat changes:

1. **Delay subscription quantity updates.** Every workspace member
create/delete/destroy event used to enqueue an
`UpdateSubscriptionQuantityJob` immediately.

2. **Invoice immediately when seats increase.** Seat increases
previously used `create_prorations`, which defers the charge to the next
billing cycle. We now bill the proration right away on increases, while
keeping deferred prorations on decreases / no-ops.

## Changes

### Job delaying
- `BillingWorkspaceMemberListener` now enqueues the job with the
per-workspace id and a 24h delay. Re-adds within the window coalesce to
a single delayed run per workspace, collapsing bursts of member changes
into one Stripe update.

### Proration behavior
- `computeSubscriptionUpdateOptions` now accepts an optional `{
currentSeats }` context. For `SEATS` updates it returns `always_invoice`
when `newSeats > currentSeats`, otherwise `create_prorations` (decrease
or unchanged).
- `BillingSubscriptionUpdateService` passes `currentSeats:
licensedItem.quantity` so the decision is based on the actual current
subscription quantity.

## Tests
- `compute-subscription-update-options.util.spec.ts`: added cases for
seat increase (`always_invoice`), decrease (`create_prorations`), and
unchanged (`create_prorations`).
- `billing-subscription-update.service.spec.ts`: updated expectations to
`always_invoice` for the seat-increase paths.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22083?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:
Etienne
2026-06-24 16:31:15 +02:00
committed by GitHub
parent 965a2753d1
commit 90acecfbd9
7 changed files with 58 additions and 12 deletions
@@ -0,0 +1 @@
export const UPDATE_SUBSCRIPTION_QUANTITY_JOB_DELAY_MS = 24 * 60 * 60 * 1000;
@@ -21,7 +21,6 @@ export class UpdateSubscriptionQuantityJob {
constructor(
private readonly billingSubscriptionUpdateService: BillingSubscriptionUpdateService,
private readonly stripeSubscriptionItemService: StripeSubscriptionItemService,
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
) {}
@@ -10,6 +10,7 @@ import {
UpdateSubscriptionQuantityJob,
type UpdateSubscriptionQuantityJobData,
} from 'src/engine/core-modules/billing/jobs/update-subscription-quantity.job';
import { UPDATE_SUBSCRIPTION_QUANTITY_JOB_DELAY_MS } from 'src/engine/core-modules/billing/constants/update-subscription-quantity-job-delay-ms.constant';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
@@ -36,10 +37,15 @@ export class BillingWorkspaceMemberListener {
if (!this.twentyConfigService.get('IS_BILLING_ENABLED')) {
return;
}
// The 24h delay is a settling window: upgrade/churn during the day coalesces
// into one net update (count is read at run time), so transient member changes
// don't each generate a new Stripe invoice.
await this.messageQueueService.add<UpdateSubscriptionQuantityJobData>(
UpdateSubscriptionQuantityJob.name,
{ workspaceId: payload.workspaceId },
{
delay: UPDATE_SUBSCRIPTION_QUANTITY_JOB_DELAY_MS,
},
);
}
}
@@ -1079,7 +1079,7 @@ describe('BillingSubscriptionUpdateService', () => {
quantity: 1,
},
],
proration_behavior: 'create_prorations',
proration_behavior: 'always_invoice',
},
);
expect(
@@ -1183,7 +1183,7 @@ describe('BillingSubscriptionUpdateService', () => {
quantity: 1,
},
],
proration_behavior: 'create_prorations',
proration_behavior: 'always_invoice',
},
);
expect(
@@ -270,8 +270,10 @@ export class BillingSubscriptionUpdateService {
});
}
} else {
const subscriptionOptions =
computeSubscriptionUpdateOptions(subscriptionUpdate);
const subscriptionOptions = computeSubscriptionUpdateOptions(
subscriptionUpdate,
{ currentSeats: licensedItem.quantity },
);
if (
subscriptionUpdate.type === SubscriptionUpdateType.RESOURCE_CREDIT_PRICE
@@ -44,11 +44,42 @@ describe('computeSubscriptionUpdateOptions', () => {
});
});
it('returns only proration for SEATS update type', () => {
const result = computeSubscriptionUpdateOptions({
type: SubscriptionUpdateType.SEATS,
newSeats: 10,
it('returns always_invoice when increasing seats', () => {
const result = computeSubscriptionUpdateOptions(
{
type: SubscriptionUpdateType.SEATS,
newSeats: 10,
},
{ currentSeats: 5 },
);
expect(result).toEqual({
proration: 'always_invoice',
});
});
it('returns create_prorations when decreasing seats', () => {
const result = computeSubscriptionUpdateOptions(
{
type: SubscriptionUpdateType.SEATS,
newSeats: 5,
},
{ currentSeats: 10 },
);
expect(result).toEqual({
proration: 'create_prorations',
});
});
it('returns create_prorations when seat count is unchanged', () => {
const result = computeSubscriptionUpdateOptions(
{
type: SubscriptionUpdateType.SEATS,
newSeats: 10,
},
{ currentSeats: 10 },
);
expect(result).toEqual({
proration: 'create_prorations',
@@ -9,6 +9,7 @@ import {
export const computeSubscriptionUpdateOptions = (
subscriptionUpdate: SubscriptionUpdate,
context?: { currentSeats?: number },
): {
proration: Stripe.SubscriptionUpdateParams.ProrationBehavior;
metadata?: Record<string, string>;
@@ -31,10 +32,16 @@ export const computeSubscriptionUpdateOptions = (
proration: 'create_prorations',
anchor: 'now',
};
case SubscriptionUpdateType.SEATS:
case SubscriptionUpdateType.SEATS: {
const currentSeats = context?.currentSeats ?? subscriptionUpdate.newSeats;
return {
proration: 'create_prorations',
proration:
subscriptionUpdate.newSeats > currentSeats
? 'always_invoice'
: 'create_prorations',
};
}
default:
return assertUnreachable(
subscriptionUpdate,