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,