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:
@ -62,10 +62,10 @@ export class BillingPortalWorkspaceService {
|
||||
|
||||
const stripeCustomerId = subscription?.stripeCustomerId;
|
||||
|
||||
const stripeSubscriptionLineItems =
|
||||
await this.getStripeSubscriptionLineItems({
|
||||
const stripeSubscriptionLineItems = this.getStripeSubscriptionLineItems({
|
||||
quantity,
|
||||
billingPricesPerPlan,
|
||||
forTrialSubscription: !isDefined(subscription),
|
||||
});
|
||||
|
||||
const checkoutSession =
|
||||
@ -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) => ({
|
||||
...(forTrialSubscription
|
||||
? []
|
||||
: billingPricesPerPlan.meteredProductsPrices.map((price) => ({
|
||||
price: price.stripePriceId,
|
||||
})),
|
||||
}))),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -76,4 +76,11 @@ export class StripeSubscriptionService {
|
||||
items: stripeSubscriptionItemsToUpdate,
|
||||
});
|
||||
}
|
||||
|
||||
async updateSubscription(
|
||||
stripeSubscriptionId: string,
|
||||
data: Stripe.SubscriptionUpdateParams,
|
||||
) {
|
||||
await this.stripe.subscriptions.update(stripeSubscriptionId, data);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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',
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user