From 90acecfbd92480cd65e84824796fe98706a8a998 Mon Sep 17 00:00:00 2001
From: Etienne <45695613+etiennejouan@users.noreply.github.com>
Date: Wed, 24 Jun 2026 16:31:15 +0200
Subject: [PATCH] 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.
---
...cription-quantity-job-delay-ms.constant.ts | 1 +
.../jobs/update-subscription-quantity.job.ts | 1 -
.../billing-workspace-member.listener.ts | 8 +++-
...illing-subscription-update.service.spec.ts | 4 +-
.../billing-subscription-update.service.ts | 6 ++-
...e-subscription-update-options.util.spec.ts | 39 +++++++++++++++++--
...ompute-subscription-update-options.util.ts | 11 +++++-
7 files changed, 58 insertions(+), 12 deletions(-)
create mode 100644 packages/twenty-server/src/engine/core-modules/billing/constants/update-subscription-quantity-job-delay-ms.constant.ts
diff --git a/packages/twenty-server/src/engine/core-modules/billing/constants/update-subscription-quantity-job-delay-ms.constant.ts b/packages/twenty-server/src/engine/core-modules/billing/constants/update-subscription-quantity-job-delay-ms.constant.ts
new file mode 100644
index 0000000000..3bc0b2c43f
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/billing/constants/update-subscription-quantity-job-delay-ms.constant.ts
@@ -0,0 +1 @@
+export const UPDATE_SUBSCRIPTION_QUANTITY_JOB_DELAY_MS = 24 * 60 * 60 * 1000;
diff --git a/packages/twenty-server/src/engine/core-modules/billing/jobs/update-subscription-quantity.job.ts b/packages/twenty-server/src/engine/core-modules/billing/jobs/update-subscription-quantity.job.ts
index 138548d170..cba98c2068 100644
--- a/packages/twenty-server/src/engine/core-modules/billing/jobs/update-subscription-quantity.job.ts
+++ b/packages/twenty-server/src/engine/core-modules/billing/jobs/update-subscription-quantity.job.ts
@@ -21,7 +21,6 @@ export class UpdateSubscriptionQuantityJob {
constructor(
private readonly billingSubscriptionUpdateService: BillingSubscriptionUpdateService,
- private readonly stripeSubscriptionItemService: StripeSubscriptionItemService,
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
) {}
diff --git a/packages/twenty-server/src/engine/core-modules/billing/listeners/billing-workspace-member.listener.ts b/packages/twenty-server/src/engine/core-modules/billing/listeners/billing-workspace-member.listener.ts
index 60a545d1ea..3ef394a0f8 100644
--- a/packages/twenty-server/src/engine/core-modules/billing/listeners/billing-workspace-member.listener.ts
+++ b/packages/twenty-server/src/engine/core-modules/billing/listeners/billing-workspace-member.listener.ts
@@ -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(
UpdateSubscriptionQuantityJob.name,
{ workspaceId: payload.workspaceId },
+ {
+ delay: UPDATE_SUBSCRIPTION_QUANTITY_JOB_DELAY_MS,
+ },
);
}
}
diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/__test__/billing-subscription-update.service.spec.ts b/packages/twenty-server/src/engine/core-modules/billing/services/__test__/billing-subscription-update.service.spec.ts
index 9d0c9586b8..316fd616e7 100644
--- a/packages/twenty-server/src/engine/core-modules/billing/services/__test__/billing-subscription-update.service.spec.ts
+++ b/packages/twenty-server/src/engine/core-modules/billing/services/__test__/billing-subscription-update.service.spec.ts
@@ -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(
diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription-update.service.ts b/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription-update.service.ts
index 1ee559732c..12e23a1749 100644
--- a/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription-update.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription-update.service.ts
@@ -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
diff --git a/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/compute-subscription-update-options.util.spec.ts b/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/compute-subscription-update-options.util.spec.ts
index 3c8bee0973..ab0986c331 100644
--- a/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/compute-subscription-update-options.util.spec.ts
+++ b/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/compute-subscription-update-options.util.spec.ts
@@ -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',
diff --git a/packages/twenty-server/src/engine/core-modules/billing/utils/compute-subscription-update-options.util.ts b/packages/twenty-server/src/engine/core-modules/billing/utils/compute-subscription-update-options.util.ts
index 2155d96161..dcf9339901 100644
--- a/packages/twenty-server/src/engine/core-modules/billing/utils/compute-subscription-update-options.util.ts
+++ b/packages/twenty-server/src/engine/core-modules/billing/utils/compute-subscription-update-options.util.ts
@@ -9,6 +9,7 @@ import {
export const computeSubscriptionUpdateOptions = (
subscriptionUpdate: SubscriptionUpdate,
+ context?: { currentSeats?: number },
): {
proration: Stripe.SubscriptionUpdateParams.ProrationBehavior;
metadata?: Record;
@@ -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,