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,11 +62,11 @@ export class BillingPortalWorkspaceService {
|
|||||||
|
|
||||||
const stripeCustomerId = subscription?.stripeCustomerId;
|
const stripeCustomerId = subscription?.stripeCustomerId;
|
||||||
|
|
||||||
const stripeSubscriptionLineItems =
|
const stripeSubscriptionLineItems = this.getStripeSubscriptionLineItems({
|
||||||
await this.getStripeSubscriptionLineItems({
|
quantity,
|
||||||
quantity,
|
billingPricesPerPlan,
|
||||||
billingPricesPerPlan,
|
forTrialSubscription: !isDefined(subscription),
|
||||||
});
|
});
|
||||||
|
|
||||||
const checkoutSession =
|
const checkoutSession =
|
||||||
await this.stripeCheckoutService.createCheckoutSession({
|
await this.stripeCheckoutService.createCheckoutSession({
|
||||||
@ -128,9 +128,11 @@ export class BillingPortalWorkspaceService {
|
|||||||
private getStripeSubscriptionLineItems({
|
private getStripeSubscriptionLineItems({
|
||||||
quantity,
|
quantity,
|
||||||
billingPricesPerPlan,
|
billingPricesPerPlan,
|
||||||
|
forTrialSubscription,
|
||||||
}: {
|
}: {
|
||||||
quantity: number;
|
quantity: number;
|
||||||
billingPricesPerPlan?: BillingGetPricesPerPlanResult;
|
billingPricesPerPlan?: BillingGetPricesPerPlanResult;
|
||||||
|
forTrialSubscription: boolean;
|
||||||
}): Stripe.Checkout.SessionCreateParams.LineItem[] {
|
}): Stripe.Checkout.SessionCreateParams.LineItem[] {
|
||||||
if (billingPricesPerPlan) {
|
if (billingPricesPerPlan) {
|
||||||
return [
|
return [
|
||||||
@ -138,9 +140,11 @@ export class BillingPortalWorkspaceService {
|
|||||||
price: billingPricesPerPlan.baseProductPrice.stripePriceId,
|
price: billingPricesPerPlan.baseProductPrice.stripePriceId,
|
||||||
quantity,
|
quantity,
|
||||||
},
|
},
|
||||||
...billingPricesPerPlan.meteredProductsPrices.map((price) => ({
|
...(forTrialSubscription
|
||||||
price: price.stripePriceId,
|
? []
|
||||||
})),
|
: billingPricesPerPlan.meteredProductsPrices.map((price) => ({
|
||||||
|
price: price.stripePriceId,
|
||||||
|
}))),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -14,13 +14,16 @@ import {
|
|||||||
} from 'src/engine/core-modules/billing/billing.exception';
|
} from 'src/engine/core-modules/billing/billing.exception';
|
||||||
import { BillingEntitlement } from 'src/engine/core-modules/billing/entities/billing-entitlement.entity';
|
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 { 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 { 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 { 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 { 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 { 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 { 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 { BillingPlanService } from 'src/engine/core-modules/billing/services/billing-plan.service';
|
||||||
import { BillingProductService } from 'src/engine/core-modules/billing/services/billing-product.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 { 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 { getPlanKeyFromSubscription } from 'src/engine/core-modules/billing/utils/get-plan-key-from-subscription.util';
|
||||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
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 {
|
export class BillingSubscriptionService {
|
||||||
protected readonly logger = new Logger(BillingSubscriptionService.name);
|
protected readonly logger = new Logger(BillingSubscriptionService.name);
|
||||||
constructor(
|
constructor(
|
||||||
|
private readonly stripeSubscriptionItemService: StripeSubscriptionItemService,
|
||||||
private readonly stripeSubscriptionService: StripeSubscriptionService,
|
private readonly stripeSubscriptionService: StripeSubscriptionService,
|
||||||
private readonly billingPlanService: BillingPlanService,
|
private readonly billingPlanService: BillingPlanService,
|
||||||
private readonly billingProductService: BillingProductService,
|
private readonly billingProductService: BillingProductService,
|
||||||
@ -35,6 +39,8 @@ export class BillingSubscriptionService {
|
|||||||
private readonly billingEntitlementRepository: Repository<BillingEntitlement>,
|
private readonly billingEntitlementRepository: Repository<BillingEntitlement>,
|
||||||
@InjectRepository(BillingSubscription, 'core')
|
@InjectRepository(BillingSubscription, 'core')
|
||||||
private readonly billingSubscriptionRepository: Repository<BillingSubscription>,
|
private readonly billingSubscriptionRepository: Repository<BillingSubscription>,
|
||||||
|
@InjectRepository(BillingProduct, 'core')
|
||||||
|
private readonly billingProductRepository: Repository<BillingProduct>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getCurrentBillingSubscriptionOrThrow(criteria: {
|
async getCurrentBillingSubscriptionOrThrow(criteria: {
|
||||||
@ -193,4 +199,50 @@ export class BillingSubscriptionService {
|
|||||||
|
|
||||||
return subscriptionItemsToUpdate;
|
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) {
|
async updateSubscriptionItem(stripeItemId: string, quantity: number) {
|
||||||
await this.stripe.subscriptionItems.update(stripeItemId, { quantity });
|
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,
|
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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
|
||||||
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
|
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>,
|
private readonly workspaceRepository: Repository<Workspace>,
|
||||||
@InjectRepository(BillingCustomer, 'core')
|
@InjectRepository(BillingCustomer, 'core')
|
||||||
private readonly billingCustomerRepository: Repository<BillingCustomer>,
|
private readonly billingCustomerRepository: Repository<BillingCustomer>,
|
||||||
|
@InjectRepository(FeatureFlag, 'core')
|
||||||
|
private readonly featureFlagRepository: Repository<FeatureFlag>,
|
||||||
|
private readonly billingSubscriptionService: BillingSubscriptionService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async processStripeEvent(
|
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 (
|
if (
|
||||||
BILLING_SUBSCRIPTION_STATUS_BY_WORKSPACE_ACTIVATION_STATUS[
|
BILLING_SUBSCRIPTION_STATUS_BY_WORKSPACE_ACTIVATION_STATUS[
|
||||||
WorkspaceActivationStatus.SUSPENDED
|
WorkspaceActivationStatus.SUSPENDED
|
||||||
|
|||||||
@ -13,4 +13,5 @@ export enum FeatureFlagKey {
|
|||||||
IsNewRelationEnabled = 'IS_NEW_RELATION_ENABLED',
|
IsNewRelationEnabled = 'IS_NEW_RELATION_ENABLED',
|
||||||
IsWorkflowFormActionEnabled = 'IS_WORKFLOW_FORM_ACTION_ENABLED',
|
IsWorkflowFormActionEnabled = 'IS_WORKFLOW_FORM_ACTION_ENABLED',
|
||||||
IsPermissionsV2Enabled = 'IS_PERMISSIONS_V2_ENABLED',
|
IsPermissionsV2Enabled = 'IS_PERMISSIONS_V2_ENABLED',
|
||||||
|
IsMeteredProductBillingEnabled = 'IS_METERED_PRODUCT_BILLING_ENABLED',
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user