Add metered product to checkout session (#9788)
Solves https://github.com/twentyhq/private-issues/issues/238 **TLDR:** Add metered product in the checkout session when purchasing a subscription **In order to test:** 1. Have the environment variable IS_BILLING_ENABLED set to true and add the other required environment variables for Billing to work 2. Do a database reset (to ensure that the new feature flag is properly added and that the billing tables are created) 3. Run the command: npx nx run twenty-server:command billing:sync-plans-data (if you don't do that the products and prices will not be present in the database) 4. Run the server , the frontend, the worker, and the stripe listen command (stripe listen --forward-to http://localhost:3000/billing/webhooks) 5. Buy a subscription for the Acme workspace , in the checkout session you should see that there is two products
This commit is contained in:
committed by
GitHub
parent
e7ba1c82b4
commit
cc53cb3b7b
@ -11,6 +11,7 @@ export class BillingException extends CustomException {
|
||||
|
||||
export enum BillingExceptionCode {
|
||||
BILLING_CUSTOMER_NOT_FOUND = 'BILLING_CUSTOMER_NOT_FOUND',
|
||||
BILLING_PLAN_NOT_FOUND = 'BILLING_PLAN_NOT_FOUND',
|
||||
BILLING_PRODUCT_NOT_FOUND = 'BILLING_PRODUCT_NOT_FOUND',
|
||||
BILLING_PRICE_NOT_FOUND = 'BILLING_PRICE_NOT_FOUND',
|
||||
BILLING_SUBSCRIPTION_EVENT_WORKSPACE_NOT_FOUND = 'BILLING_SUBSCRIPTION_EVENT_WORKSPACE_NOT_FOUND',
|
||||
|
||||
@ -16,6 +16,7 @@ import { BillingPlanService } from 'src/engine/core-modules/billing/services/bil
|
||||
import { BillingPortalWorkspaceService } from 'src/engine/core-modules/billing/services/billing-portal.workspace-service';
|
||||
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
|
||||
import { StripePriceService } from 'src/engine/core-modules/billing/stripe/services/stripe-price.service';
|
||||
import { BillingPortalCheckoutSessionParameters } from 'src/engine/core-modules/billing/types/billing-portal-checkout-session-parameters.type';
|
||||
import { formatBillingDatabaseProductToGraphqlDTO } from 'src/engine/core-modules/billing/utils/format-database-product-to-graphql-dto.util';
|
||||
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
|
||||
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
||||
@ -84,42 +85,49 @@ export class BillingResolver {
|
||||
workspace.id,
|
||||
);
|
||||
|
||||
let productPrice;
|
||||
const checkoutSessionParams: BillingPortalCheckoutSessionParameters = {
|
||||
user,
|
||||
workspace,
|
||||
successUrlPath,
|
||||
plan: plan ?? BillingPlanKey.PRO,
|
||||
requirePaymentMethod,
|
||||
};
|
||||
|
||||
if (isBillingPlansEnabled) {
|
||||
const baseProduct = await this.billingPlanService.getPlanBaseProduct(
|
||||
plan ?? BillingPlanKey.PRO,
|
||||
);
|
||||
const billingPricesPerPlan =
|
||||
await this.billingPlanService.getPricesPerPlan({
|
||||
planKey: checkoutSessionParams.plan,
|
||||
interval: recurringInterval,
|
||||
});
|
||||
const checkoutSessionURL =
|
||||
await this.billingPortalWorkspaceService.computeCheckoutSessionURL({
|
||||
...checkoutSessionParams,
|
||||
billingPricesPerPlan,
|
||||
});
|
||||
|
||||
if (!baseProduct) {
|
||||
throw new GraphQLError('Base product not found');
|
||||
}
|
||||
|
||||
productPrice = baseProduct.billingPrices.find(
|
||||
(price) => price.interval === recurringInterval,
|
||||
);
|
||||
} else {
|
||||
productPrice = await this.stripePriceService.getStripePrice(
|
||||
AvailableProduct.BasePlan,
|
||||
recurringInterval,
|
||||
);
|
||||
return {
|
||||
url: checkoutSessionURL,
|
||||
};
|
||||
}
|
||||
|
||||
const productPrice = await this.stripePriceService.getStripePrice(
|
||||
AvailableProduct.BasePlan,
|
||||
recurringInterval,
|
||||
);
|
||||
|
||||
if (!productPrice) {
|
||||
throw new GraphQLError(
|
||||
'Product price not found for the given recurring interval',
|
||||
);
|
||||
}
|
||||
const checkoutSessionURL =
|
||||
await this.billingPortalWorkspaceService.computeCheckoutSessionURL({
|
||||
...checkoutSessionParams,
|
||||
priceId: productPrice.stripePriceId,
|
||||
});
|
||||
|
||||
return {
|
||||
url: await this.billingPortalWorkspaceService.computeCheckoutSessionURL(
|
||||
user,
|
||||
workspace,
|
||||
productPrice.stripePriceId,
|
||||
successUrlPath,
|
||||
plan,
|
||||
requirePaymentMethod,
|
||||
),
|
||||
url: checkoutSessionURL,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -9,8 +9,10 @@ import {
|
||||
} from 'src/engine/core-modules/billing/billing.exception';
|
||||
import { BillingProduct } from 'src/engine/core-modules/billing/entities/billing-product.entity';
|
||||
import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum';
|
||||
import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum';
|
||||
import { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum';
|
||||
import { BillingGetPlanResult } from 'src/engine/core-modules/billing/types/billing-get-plan-result.type';
|
||||
import { BillingGetPricesPerPlanResult } from 'src/engine/core-modules/billing/types/billing-get-prices-per-plan-result.type';
|
||||
|
||||
@Injectable()
|
||||
export class BillingPlanService {
|
||||
@ -104,4 +106,48 @@ export class BillingPlanService {
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async getPricesPerPlan({
|
||||
planKey,
|
||||
interval,
|
||||
}: {
|
||||
planKey: BillingPlanKey;
|
||||
interval: SubscriptionInterval;
|
||||
}): Promise<BillingGetPricesPerPlanResult> {
|
||||
const plans = await this.getPlans();
|
||||
const plan = plans.find((plan) => plan.planKey === planKey);
|
||||
|
||||
if (!plan) {
|
||||
throw new BillingException(
|
||||
'Billing plan not found',
|
||||
BillingExceptionCode.BILLING_PLAN_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
const { baseProduct, meteredProducts, otherLicensedProducts } = plan;
|
||||
const baseProductPrice = baseProduct.billingPrices.find(
|
||||
(price) => price.interval === interval,
|
||||
);
|
||||
|
||||
if (!baseProductPrice) {
|
||||
throw new BillingException(
|
||||
'Base product price not found for given interval',
|
||||
BillingExceptionCode.BILLING_PRICE_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
const filterPricesByInterval = (product: BillingProduct) =>
|
||||
product.billingPrices.filter((price) => price.interval === interval);
|
||||
|
||||
const meteredProductsPrices = meteredProducts.flatMap(
|
||||
filterPricesByInterval,
|
||||
);
|
||||
const otherLicensedProductsPrices = otherLicensedProducts.flatMap(
|
||||
filterPricesByInterval,
|
||||
);
|
||||
|
||||
return {
|
||||
baseProductPrice,
|
||||
meteredProductsPrices,
|
||||
otherLicensedProductsPrices,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,16 +2,22 @@ import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { isDefined } from 'class-validator';
|
||||
import Stripe from 'stripe';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import {
|
||||
BillingException,
|
||||
BillingExceptionCode,
|
||||
} from 'src/engine/core-modules/billing/billing.exception';
|
||||
import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
|
||||
import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum';
|
||||
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
|
||||
import { StripeBillingPortalService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-portal.service';
|
||||
import { StripeCheckoutService } from 'src/engine/core-modules/billing/stripe/services/stripe-checkout.service';
|
||||
import { BillingGetPricesPerPlanResult } from 'src/engine/core-modules/billing/types/billing-get-prices-per-plan-result.type';
|
||||
import { BillingPortalCheckoutSessionParameters } from 'src/engine/core-modules/billing/types/billing-portal-checkout-session-parameters.type';
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
|
||||
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
|
||||
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
||||
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { assert } from 'src/utils/assert';
|
||||
|
||||
@ -22,21 +28,22 @@ export class BillingPortalWorkspaceService {
|
||||
private readonly stripeCheckoutService: StripeCheckoutService,
|
||||
private readonly stripeBillingPortalService: StripeBillingPortalService,
|
||||
private readonly domainManagerService: DomainManagerService,
|
||||
private readonly featureFlagService: FeatureFlagService,
|
||||
@InjectRepository(BillingSubscription, 'core')
|
||||
private readonly billingSubscriptionRepository: Repository<BillingSubscription>,
|
||||
@InjectRepository(UserWorkspace, 'core')
|
||||
private readonly userWorkspaceRepository: Repository<UserWorkspace>,
|
||||
private readonly billingSubscriptionService: BillingSubscriptionService,
|
||||
) {}
|
||||
|
||||
async computeCheckoutSessionURL(
|
||||
user: User,
|
||||
workspace: Workspace,
|
||||
priceId: string,
|
||||
successUrlPath?: string,
|
||||
plan?: BillingPlanKey,
|
||||
requirePaymentMethod?: boolean,
|
||||
): Promise<string> {
|
||||
async computeCheckoutSessionURL({
|
||||
user,
|
||||
workspace,
|
||||
billingPricesPerPlan,
|
||||
successUrlPath,
|
||||
plan,
|
||||
priceId,
|
||||
requirePaymentMethod,
|
||||
}: BillingPortalCheckoutSessionParameters): Promise<string> {
|
||||
const frontBaseUrl = this.domainManagerService.buildWorkspaceURL({
|
||||
subdomain: workspace.subdomain,
|
||||
});
|
||||
@ -56,23 +63,37 @@ export class BillingPortalWorkspaceService {
|
||||
});
|
||||
|
||||
const stripeCustomerId = subscription?.stripeCustomerId;
|
||||
const isBillingPlansEnabled =
|
||||
await this.featureFlagService.isFeatureEnabled(
|
||||
FeatureFlagKey.IsBillingPlansEnabled,
|
||||
workspace.id,
|
||||
);
|
||||
|
||||
const session = await this.stripeCheckoutService.createCheckoutSession({
|
||||
user,
|
||||
workspaceId: workspace.id,
|
||||
priceId,
|
||||
quantity,
|
||||
successUrl,
|
||||
cancelUrl,
|
||||
stripeCustomerId,
|
||||
plan,
|
||||
requirePaymentMethod,
|
||||
withTrialPeriod: !isDefined(subscription),
|
||||
});
|
||||
const stripeSubscriptionLineItems =
|
||||
await this.getStripeSubscriptionLineItems({
|
||||
quantity,
|
||||
isBillingPlansEnabled,
|
||||
billingPricesPerPlan,
|
||||
priceId,
|
||||
});
|
||||
|
||||
assert(session.url, 'Error: missing checkout.session.url');
|
||||
const checkoutSession =
|
||||
await this.stripeCheckoutService.createCheckoutSession({
|
||||
user,
|
||||
workspaceId: workspace.id,
|
||||
stripeSubscriptionLineItems,
|
||||
successUrl,
|
||||
cancelUrl,
|
||||
stripeCustomerId,
|
||||
plan,
|
||||
requirePaymentMethod,
|
||||
withTrialPeriod: !isDefined(subscription),
|
||||
isBillingPlansEnabled,
|
||||
});
|
||||
|
||||
return session.url;
|
||||
assert(checkoutSession.url, 'Error: missing checkout.session.url');
|
||||
|
||||
return checkoutSession.url;
|
||||
}
|
||||
|
||||
async computeBillingPortalSessionURLOrThrow(
|
||||
@ -113,4 +134,39 @@ export class BillingPortalWorkspaceService {
|
||||
|
||||
return session.url;
|
||||
}
|
||||
|
||||
private getStripeSubscriptionLineItems({
|
||||
quantity,
|
||||
isBillingPlansEnabled,
|
||||
billingPricesPerPlan,
|
||||
priceId,
|
||||
}: {
|
||||
quantity: number;
|
||||
isBillingPlansEnabled: boolean;
|
||||
billingPricesPerPlan?: BillingGetPricesPerPlanResult;
|
||||
priceId?: string;
|
||||
}): Stripe.Checkout.SessionCreateParams.LineItem[] {
|
||||
if (isBillingPlansEnabled && billingPricesPerPlan) {
|
||||
return [
|
||||
{
|
||||
price: billingPricesPerPlan.baseProductPrice.stripePriceId,
|
||||
quantity,
|
||||
},
|
||||
...billingPricesPerPlan.meteredProductsPrices.map((price) => ({
|
||||
price: price.stripePriceId,
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
if (priceId && !isBillingPlansEnabled) {
|
||||
return [{ price: priceId, quantity }];
|
||||
}
|
||||
|
||||
throw new BillingException(
|
||||
isBillingPlansEnabled
|
||||
? 'Missing Billing prices per plan'
|
||||
: 'Missing price id',
|
||||
BillingExceptionCode.BILLING_PRICE_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -27,33 +27,28 @@ export class StripeCheckoutService {
|
||||
async createCheckoutSession({
|
||||
user,
|
||||
workspaceId,
|
||||
priceId,
|
||||
quantity,
|
||||
stripeSubscriptionLineItems,
|
||||
successUrl,
|
||||
cancelUrl,
|
||||
stripeCustomerId,
|
||||
plan = BillingPlanKey.PRO,
|
||||
requirePaymentMethod = true,
|
||||
withTrialPeriod,
|
||||
isBillingPlansEnabled = false,
|
||||
}: {
|
||||
user: User;
|
||||
workspaceId: string;
|
||||
priceId: string;
|
||||
quantity: number;
|
||||
stripeSubscriptionLineItems: Stripe.Checkout.SessionCreateParams.LineItem[];
|
||||
successUrl?: string;
|
||||
cancelUrl?: string;
|
||||
stripeCustomerId?: string;
|
||||
plan?: BillingPlanKey;
|
||||
requirePaymentMethod?: boolean;
|
||||
withTrialPeriod: boolean;
|
||||
isBillingPlansEnabled: boolean;
|
||||
}): Promise<Stripe.Checkout.Session> {
|
||||
return await this.stripe.checkout.sessions.create({
|
||||
line_items: [
|
||||
{
|
||||
price: priceId,
|
||||
quantity,
|
||||
},
|
||||
],
|
||||
line_items: stripeSubscriptionLineItems,
|
||||
mode: 'subscription',
|
||||
subscription_data: {
|
||||
metadata: {
|
||||
@ -68,7 +63,11 @@ export class StripeCheckoutService {
|
||||
: 'BILLING_FREE_TRIAL_WITHOUT_CREDIT_CARD_DURATION_IN_DAYS',
|
||||
),
|
||||
trial_settings: {
|
||||
end_behavior: { missing_payment_method: 'pause' },
|
||||
end_behavior: {
|
||||
missing_payment_method: isBillingPlansEnabled
|
||||
? 'create_invoice'
|
||||
: 'pause',
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
|
||||
@ -0,0 +1,7 @@
|
||||
import { BillingPrice } from 'src/engine/core-modules/billing/entities/billing-price.entity';
|
||||
|
||||
export type BillingGetPricesPerPlanResult = {
|
||||
baseProductPrice: BillingPrice;
|
||||
meteredProductsPrices: BillingPrice[];
|
||||
otherLicensedProductsPrices: BillingPrice[];
|
||||
};
|
||||
@ -0,0 +1,14 @@
|
||||
import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum';
|
||||
import { BillingGetPricesPerPlanResult } from 'src/engine/core-modules/billing/types/billing-get-prices-per-plan-result.type';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
export type BillingPortalCheckoutSessionParameters = {
|
||||
user: User;
|
||||
workspace: Workspace;
|
||||
billingPricesPerPlan?: BillingGetPricesPerPlanResult;
|
||||
successUrlPath?: string;
|
||||
plan: BillingPlanKey;
|
||||
priceId?: string;
|
||||
requirePaymentMethod?: boolean;
|
||||
};
|
||||
Reference in New Issue
Block a user