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. Review in cubic --- ...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,