Skip to content

Commit

Permalink
Add metered product to checkout session (#9788)
Browse files Browse the repository at this point in the history
Solves twentyhq/private-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
  • Loading branch information
anamarn authored Jan 23, 2025
1 parent e7ba1c8 commit cc53cb3
Show file tree
Hide file tree
Showing 7 changed files with 193 additions and 62 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
);

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,
);
const billingPricesPerPlan =
await this.billingPlanService.getPricesPerPlan({
planKey: checkoutSessionParams.plan,
interval: recurringInterval,
});
const checkoutSessionURL =
await this.billingPortalWorkspaceService.computeCheckoutSessionURL({
...checkoutSessionParams,
billingPricesPerPlan,
});

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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,
});
Expand All @@ -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,
});

const checkoutSession =
await this.stripeCheckoutService.createCheckoutSession({
user,
workspaceId: workspace.id,
stripeSubscriptionLineItems,
successUrl,
cancelUrl,
stripeCustomerId,
plan,
requirePaymentMethod,
withTrialPeriod: !isDefined(subscription),
isBillingPlansEnabled,
});

assert(session.url, 'Error: missing checkout.session.url');
assert(checkoutSession.url, 'Error: missing checkout.session.url');

return session.url;
return checkoutSession.url;
}

async computeBillingPortalSessionURLOrThrow(
Expand Down Expand Up @@ -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,
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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',
},
},
}
: {}),
Expand Down
Original file line number Diff line number Diff line change
@@ -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[];
};
Original file line number Diff line number Diff line change
@@ -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;
};

0 comments on commit cc53cb3

Please sign in to comment.