update subscription with metered products at trial ending (#11319)

Context
- Subscription with metered prices can't be 'paused' at the end of
trialing period
- Currently, pausing subscription have been the process we choose at
Twenty

Two solutions : 
- [x] (The chosen one!) Adding metered products when the trial period is
ended.
- [ ] Switching from 'paused' to 'past_due' status at the end of
trialing period. Tricky because we should handle different cases of
'past_due' subscription status, some causing workspace suspension and
some other not.

closes https://github.com/twentyhq/core-team-issues/issues/676
This commit is contained in:
Etienne
2025-04-01 15:57:01 +02:00
committed by GitHub
parent e74c8723d0
commit 1d4fc5ff4a
6 changed files with 108 additions and 8 deletions

View File

@ -62,11 +62,11 @@ export class BillingPortalWorkspaceService {
const stripeCustomerId = subscription?.stripeCustomerId;
const stripeSubscriptionLineItems =
await this.getStripeSubscriptionLineItems({
quantity,
billingPricesPerPlan,
});
const stripeSubscriptionLineItems = this.getStripeSubscriptionLineItems({
quantity,
billingPricesPerPlan,
forTrialSubscription: !isDefined(subscription),
});
const checkoutSession =
await this.stripeCheckoutService.createCheckoutSession({
@ -128,9 +128,11 @@ export class BillingPortalWorkspaceService {
private getStripeSubscriptionLineItems({
quantity,
billingPricesPerPlan,
forTrialSubscription,
}: {
quantity: number;
billingPricesPerPlan?: BillingGetPricesPerPlanResult;
forTrialSubscription: boolean;
}): Stripe.Checkout.SessionCreateParams.LineItem[] {
if (billingPricesPerPlan) {
return [
@ -138,9 +140,11 @@ export class BillingPortalWorkspaceService {
price: billingPricesPerPlan.baseProductPrice.stripePriceId,
quantity,
},
...billingPricesPerPlan.meteredProductsPrices.map((price) => ({
price: price.stripePriceId,
})),
...(forTrialSubscription
? []
: billingPricesPerPlan.meteredProductsPrices.map((price) => ({
price: price.stripePriceId,
}))),
];
}

View File

@ -14,13 +14,16 @@ import {
} from 'src/engine/core-modules/billing/billing.exception';
import { BillingEntitlement } from 'src/engine/core-modules/billing/entities/billing-entitlement.entity';
import { BillingPrice } from 'src/engine/core-modules/billing/entities/billing-price.entity';
import { BillingProduct } from 'src/engine/core-modules/billing/entities/billing-product.entity';
import { BillingSubscriptionItem } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity';
import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { BillingEntitlementKey } from 'src/engine/core-modules/billing/enums/billing-entitlement-key.enum';
import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum';
import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum';
import { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum';
import { BillingPlanService } from 'src/engine/core-modules/billing/services/billing-plan.service';
import { BillingProductService } from 'src/engine/core-modules/billing/services/billing-product.service';
import { StripeSubscriptionItemService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription-item.service';
import { StripeSubscriptionService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription.service';
import { getPlanKeyFromSubscription } from 'src/engine/core-modules/billing/utils/get-plan-key-from-subscription.util';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
@ -28,6 +31,7 @@ import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
export class BillingSubscriptionService {
protected readonly logger = new Logger(BillingSubscriptionService.name);
constructor(
private readonly stripeSubscriptionItemService: StripeSubscriptionItemService,
private readonly stripeSubscriptionService: StripeSubscriptionService,
private readonly billingPlanService: BillingPlanService,
private readonly billingProductService: BillingProductService,
@ -35,6 +39,8 @@ export class BillingSubscriptionService {
private readonly billingEntitlementRepository: Repository<BillingEntitlement>,
@InjectRepository(BillingSubscription, 'core')
private readonly billingSubscriptionRepository: Repository<BillingSubscription>,
@InjectRepository(BillingProduct, 'core')
private readonly billingProductRepository: Repository<BillingProduct>,
) {}
async getCurrentBillingSubscriptionOrThrow(criteria: {
@ -193,4 +199,50 @@ export class BillingSubscriptionService {
return subscriptionItemsToUpdate;
}
async convertTrialSubscriptionToSubscriptionWithMeteredProducts(
billingSubscription: BillingSubscription,
) {
const meteredProducts = (
await this.billingProductRepository.find({
where: {
active: true,
},
relations: ['billingPrices'],
})
).filter(
(product) =>
product.metadata.priceUsageBased === BillingUsageType.METERED,
);
// subscription update to enable metered product billing
await this.stripeSubscriptionService.updateSubscription(
billingSubscription.stripeSubscriptionId,
{
trial_settings: {
end_behavior: {
missing_payment_method: 'cancel',
},
},
},
);
for (const meteredProduct of meteredProducts) {
const meteredProductPrice = meteredProduct.billingPrices.find(
(price) => price.active,
);
if (!meteredProductPrice) {
throw new BillingException(
`Cannot find active price for product ${meteredProduct.id}`,
BillingExceptionCode.BILLING_PRICE_NOT_FOUND,
);
}
await this.stripeSubscriptionItemService.createSubscriptionItem(
billingSubscription.stripeSubscriptionId,
meteredProductPrice.stripePriceId,
);
}
}
}

View File

@ -27,4 +27,16 @@ export class StripeSubscriptionItemService {
async updateSubscriptionItem(stripeItemId: string, quantity: number) {
await this.stripe.subscriptionItems.update(stripeItemId, { quantity });
}
async createSubscriptionItem(
stripeSubscriptionId: string,
stripePriceId: string,
quantity?: number | undefined,
) {
await this.stripe.subscriptionItems.create({
subscription: stripeSubscriptionId,
price: stripePriceId,
quantity,
});
}
}

View File

@ -76,4 +76,11 @@ export class StripeSubscriptionService {
items: stripeSubscriptionItemsToUpdate,
});
}
async updateSubscription(
stripeSubscriptionId: string,
data: Stripe.SubscriptionUpdateParams,
) {
await this.stripe.subscriptions.update(stripeSubscriptionId, data);
}
}

View File

@ -11,10 +11,13 @@ import { BillingCustomer } from 'src/engine/core-modules/billing/entities/billin
import { BillingSubscriptionItem } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity';
import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum';
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
import { StripeCustomerService } from 'src/engine/core-modules/billing/stripe/services/stripe-customer.service';
import { transformStripeSubscriptionEventToDatabaseCustomer } from 'src/engine/core-modules/billing/webhooks/utils/transform-stripe-subscription-event-to-database-customer.util';
import { transformStripeSubscriptionEventToDatabaseSubscriptionItem } from 'src/engine/core-modules/billing/webhooks/utils/transform-stripe-subscription-event-to-database-subscription-item.util';
import { transformStripeSubscriptionEventToDatabaseSubscription } from 'src/engine/core-modules/billing/webhooks/utils/transform-stripe-subscription-event-to-database-subscription.util';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
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';
@ -54,6 +57,9 @@ export class BillingWebhookSubscriptionService {
private readonly workspaceRepository: Repository<Workspace>,
@InjectRepository(BillingCustomer, 'core')
private readonly billingCustomerRepository: Repository<BillingCustomer>,
@InjectRepository(FeatureFlag, 'core')
private readonly featureFlagRepository: Repository<FeatureFlag>,
private readonly billingSubscriptionService: BillingSubscriptionService,
) {}
async processStripeEvent(
@ -117,6 +123,24 @@ export class BillingWebhookSubscriptionService {
},
);
const wasTrialOrPausedSubscription = [
SubscriptionStatus.Trialing,
SubscriptionStatus.Paused,
].includes(data.previous_attributes?.status as SubscriptionStatus);
const isMeteredProductBillingEnabled =
await this.featureFlagRepository.findOneBy({
key: FeatureFlagKey.IsMeteredProductBillingEnabled,
workspaceId,
value: true,
});
if (wasTrialOrPausedSubscription && isMeteredProductBillingEnabled) {
await this.billingSubscriptionService.convertTrialSubscriptionToSubscriptionWithMeteredProducts(
updatedBillingSubscription,
);
}
if (
BILLING_SUBSCRIPTION_STATUS_BY_WORKSPACE_ACTIVATION_STATUS[
WorkspaceActivationStatus.SUSPENDED

View File

@ -13,4 +13,5 @@ export enum FeatureFlagKey {
IsNewRelationEnabled = 'IS_NEW_RELATION_ENABLED',
IsWorkflowFormActionEnabled = 'IS_WORKFLOW_FORM_ACTION_ENABLED',
IsPermissionsV2Enabled = 'IS_PERMISSIONS_V2_ENABLED',
IsMeteredProductBillingEnabled = 'IS_METERED_PRODUCT_BILLING_ENABLED',
}