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:
+1
@@ -0,0 +1 @@
|
||||
export const UPDATE_SUBSCRIPTION_QUANTITY_JOB_DELAY_MS = 24 * 60 * 60 * 1000;
|
||||
-1
@@ -21,7 +21,6 @@ export class UpdateSubscriptionQuantityJob {
|
||||
|
||||
constructor(
|
||||
private readonly billingSubscriptionUpdateService: BillingSubscriptionUpdateService,
|
||||
private readonly stripeSubscriptionItemService: StripeSubscriptionItemService,
|
||||
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
|
||||
) {}
|
||||
|
||||
|
||||
+7
-1
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -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(
|
||||
|
||||
+4
-2
@@ -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
|
||||
|
||||
+35
-4
@@ -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
-2
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user