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:
Ana Sofia Marin Alexandre
2025-01-23 10:28:13 -03:00
committed by GitHub
parent e7ba1c82b4
commit cc53cb3b7b
7 changed files with 192 additions and 61 deletions

View File

@ -11,6 +11,7 @@ export class BillingException extends CustomException {
export enum BillingExceptionCode { export enum BillingExceptionCode {
BILLING_CUSTOMER_NOT_FOUND = 'BILLING_CUSTOMER_NOT_FOUND', 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_PRODUCT_NOT_FOUND = 'BILLING_PRODUCT_NOT_FOUND',
BILLING_PRICE_NOT_FOUND = 'BILLING_PRICE_NOT_FOUND', BILLING_PRICE_NOT_FOUND = 'BILLING_PRICE_NOT_FOUND',
BILLING_SUBSCRIPTION_EVENT_WORKSPACE_NOT_FOUND = 'BILLING_SUBSCRIPTION_EVENT_WORKSPACE_NOT_FOUND', BILLING_SUBSCRIPTION_EVENT_WORKSPACE_NOT_FOUND = 'BILLING_SUBSCRIPTION_EVENT_WORKSPACE_NOT_FOUND',

View File

@ -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 { 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 { 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 { 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 { 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 { 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 { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
@ -84,42 +85,49 @@ export class BillingResolver {
workspace.id, workspace.id,
); );
let productPrice; const checkoutSessionParams: BillingPortalCheckoutSessionParameters = {
user,
workspace,
successUrlPath,
plan: plan ?? BillingPlanKey.PRO,
requirePaymentMethod,
};
if (isBillingPlansEnabled) { if (isBillingPlansEnabled) {
const baseProduct = await this.billingPlanService.getPlanBaseProduct( const billingPricesPerPlan =
plan ?? BillingPlanKey.PRO, await this.billingPlanService.getPricesPerPlan({
); planKey: checkoutSessionParams.plan,
interval: recurringInterval,
});
const checkoutSessionURL =
await this.billingPortalWorkspaceService.computeCheckoutSessionURL({
...checkoutSessionParams,
billingPricesPerPlan,
});
if (!baseProduct) { return {
throw new GraphQLError('Base product not found'); url: checkoutSessionURL,
} };
productPrice = baseProduct.billingPrices.find(
(price) => price.interval === recurringInterval,
);
} else {
productPrice = await this.stripePriceService.getStripePrice(
AvailableProduct.BasePlan,
recurringInterval,
);
} }
const productPrice = await this.stripePriceService.getStripePrice(
AvailableProduct.BasePlan,
recurringInterval,
);
if (!productPrice) { if (!productPrice) {
throw new GraphQLError( throw new GraphQLError(
'Product price not found for the given recurring interval', 'Product price not found for the given recurring interval',
); );
} }
const checkoutSessionURL =
await this.billingPortalWorkspaceService.computeCheckoutSessionURL({
...checkoutSessionParams,
priceId: productPrice.stripePriceId,
});
return { return {
url: await this.billingPortalWorkspaceService.computeCheckoutSessionURL( url: checkoutSessionURL,
user,
workspace,
productPrice.stripePriceId,
successUrlPath,
plan,
requirePaymentMethod,
),
}; };
} }

View File

@ -9,8 +9,10 @@ import {
} from 'src/engine/core-modules/billing/billing.exception'; } from 'src/engine/core-modules/billing/billing.exception';
import { BillingProduct } from 'src/engine/core-modules/billing/entities/billing-product.entity'; 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 { 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 { 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 { 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() @Injectable()
export class BillingPlanService { 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,
};
}
} }

View File

@ -2,16 +2,22 @@ import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'class-validator'; import { isDefined } from 'class-validator';
import Stripe from 'stripe';
import { Repository } from 'typeorm'; 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 { 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 { 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 { 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 { 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 { 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 { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { assert } from 'src/utils/assert'; import { assert } from 'src/utils/assert';
@ -22,21 +28,22 @@ export class BillingPortalWorkspaceService {
private readonly stripeCheckoutService: StripeCheckoutService, private readonly stripeCheckoutService: StripeCheckoutService,
private readonly stripeBillingPortalService: StripeBillingPortalService, private readonly stripeBillingPortalService: StripeBillingPortalService,
private readonly domainManagerService: DomainManagerService, private readonly domainManagerService: DomainManagerService,
private readonly featureFlagService: FeatureFlagService,
@InjectRepository(BillingSubscription, 'core') @InjectRepository(BillingSubscription, 'core')
private readonly billingSubscriptionRepository: Repository<BillingSubscription>, private readonly billingSubscriptionRepository: Repository<BillingSubscription>,
@InjectRepository(UserWorkspace, 'core') @InjectRepository(UserWorkspace, 'core')
private readonly userWorkspaceRepository: Repository<UserWorkspace>, private readonly userWorkspaceRepository: Repository<UserWorkspace>,
private readonly billingSubscriptionService: BillingSubscriptionService,
) {} ) {}
async computeCheckoutSessionURL( async computeCheckoutSessionURL({
user: User, user,
workspace: Workspace, workspace,
priceId: string, billingPricesPerPlan,
successUrlPath?: string, successUrlPath,
plan?: BillingPlanKey, plan,
requirePaymentMethod?: boolean, priceId,
): Promise<string> { requirePaymentMethod,
}: BillingPortalCheckoutSessionParameters): Promise<string> {
const frontBaseUrl = this.domainManagerService.buildWorkspaceURL({ const frontBaseUrl = this.domainManagerService.buildWorkspaceURL({
subdomain: workspace.subdomain, subdomain: workspace.subdomain,
}); });
@ -56,23 +63,37 @@ export class BillingPortalWorkspaceService {
}); });
const stripeCustomerId = subscription?.stripeCustomerId; const stripeCustomerId = subscription?.stripeCustomerId;
const isBillingPlansEnabled =
await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IsBillingPlansEnabled,
workspace.id,
);
const session = await this.stripeCheckoutService.createCheckoutSession({ const stripeSubscriptionLineItems =
user, await this.getStripeSubscriptionLineItems({
workspaceId: workspace.id, quantity,
priceId, isBillingPlansEnabled,
quantity, billingPricesPerPlan,
successUrl, priceId,
cancelUrl, });
stripeCustomerId,
plan,
requirePaymentMethod,
withTrialPeriod: !isDefined(subscription),
});
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( async computeBillingPortalSessionURLOrThrow(
@ -113,4 +134,39 @@ export class BillingPortalWorkspaceService {
return session.url; 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,
);
}
} }

View File

@ -27,33 +27,28 @@ export class StripeCheckoutService {
async createCheckoutSession({ async createCheckoutSession({
user, user,
workspaceId, workspaceId,
priceId, stripeSubscriptionLineItems,
quantity,
successUrl, successUrl,
cancelUrl, cancelUrl,
stripeCustomerId, stripeCustomerId,
plan = BillingPlanKey.PRO, plan = BillingPlanKey.PRO,
requirePaymentMethod = true, requirePaymentMethod = true,
withTrialPeriod, withTrialPeriod,
isBillingPlansEnabled = false,
}: { }: {
user: User; user: User;
workspaceId: string; workspaceId: string;
priceId: string; stripeSubscriptionLineItems: Stripe.Checkout.SessionCreateParams.LineItem[];
quantity: number;
successUrl?: string; successUrl?: string;
cancelUrl?: string; cancelUrl?: string;
stripeCustomerId?: string; stripeCustomerId?: string;
plan?: BillingPlanKey; plan?: BillingPlanKey;
requirePaymentMethod?: boolean; requirePaymentMethod?: boolean;
withTrialPeriod: boolean; withTrialPeriod: boolean;
isBillingPlansEnabled: boolean;
}): Promise<Stripe.Checkout.Session> { }): Promise<Stripe.Checkout.Session> {
return await this.stripe.checkout.sessions.create({ return await this.stripe.checkout.sessions.create({
line_items: [ line_items: stripeSubscriptionLineItems,
{
price: priceId,
quantity,
},
],
mode: 'subscription', mode: 'subscription',
subscription_data: { subscription_data: {
metadata: { metadata: {
@ -68,7 +63,11 @@ export class StripeCheckoutService {
: 'BILLING_FREE_TRIAL_WITHOUT_CREDIT_CARD_DURATION_IN_DAYS', : 'BILLING_FREE_TRIAL_WITHOUT_CREDIT_CARD_DURATION_IN_DAYS',
), ),
trial_settings: { trial_settings: {
end_behavior: { missing_payment_method: 'pause' }, end_behavior: {
missing_payment_method: isBillingPlansEnabled
? 'create_invoice'
: 'pause',
},
}, },
} }
: {}), : {}),

View File

@ -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[];
};

View File

@ -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;
};