39 create subscription and success modale (#4208)

* Init add choose your plan page component

* Update price format

* Add billing refund trial duration env variable

* Add billing benefits

* Add Button

* Call checkout endpoint

* Fix theme color

* Add Payment success modale

* Add loader to createWorkspace submit button

* Fix lint

* Fix dark mode

* Code review returns

* Use a resolver for front requests

* Fix 'create workspace' loader at sign up

* Fix 'create workspace' with enter key bug
This commit is contained in:
martmull
2024-02-28 19:51:04 +01:00
committed by GitHub
parent e0bf8e43d1
commit 9ca3dbeb70
38 changed files with 761 additions and 164 deletions

View File

@ -1,31 +1,17 @@
import {
Body,
Controller,
Get,
Param,
Headers,
Req,
RawBodyRequest,
Logger,
Post,
Res,
UseGuards,
} from '@nestjs/common';
import { Response } from 'express';
import {
AvailableProduct,
BillingService,
PriceData,
RecurringInterval,
WebhookEvent,
} from 'src/core/billing/billing.service';
import { BillingService, WebhookEvent } from 'src/core/billing/billing.service';
import { StripeService } from 'src/core/billing/stripe/stripe.service';
import { EnvironmentService } from 'src/integrations/environment/environment.service';
import { AuthUser } from 'src/decorators/auth/auth-user.decorator';
import { User } from 'src/core/user/user.entity';
import { JwtAuthGuard } from 'src/guards/jwt.auth.guard';
@Controller('billing')
export class BillingController {
@ -34,96 +20,8 @@ export class BillingController {
constructor(
private readonly stripeService: StripeService,
private readonly billingService: BillingService,
private readonly environmentService: EnvironmentService,
) {}
@Get('/product-prices/:product')
async get(
@Param() params: { product: AvailableProduct },
@Res() res: Response<PriceData | { error: string }>,
) {
const stripeProductId = this.billingService.getProductStripeId(
params.product,
);
if (!stripeProductId) {
res.status(404).send({
error: `Product '${
params.product
}' not found, available products are ['${Object.values(
AvailableProduct,
).join("','")}']`,
});
return;
}
res.json(await this.billingService.getProductPrices(stripeProductId));
}
@UseGuards(JwtAuthGuard)
@Post('/checkout')
async post(
@AuthUser() user: User,
@Body() body: { recurringInterval: RecurringInterval },
@Res() res: Response,
) {
const productId = this.billingService.getProductStripeId(
AvailableProduct.BasePlan,
);
if (!productId) {
res
.status(404)
.send(
'BasePlan productId not found, please check your BILLING_STRIPE_BASE_PLAN_PRODUCT_ID env variable',
);
return;
}
const productPrices = await this.billingService.getProductPrices(productId);
const recurringInterval = body.recurringInterval;
const priceId = productPrices[recurringInterval]?.id;
if (!priceId) {
res
.status(404)
.send(
`BasePlan priceId not found, please check body.recurringInterval and product '${AvailableProduct.BasePlan}' prices`,
);
return;
}
const frontBaseUrl = this.environmentService.getFrontBaseUrl();
const session = await this.stripeService.stripe.checkout.sessions.create({
line_items: [
{
price: priceId,
quantity: 1,
},
],
mode: 'subscription',
subscription_data: {
metadata: {
workspaceId: user.defaultWorkspace.id,
},
},
customer_email: user.email,
success_url: frontBaseUrl,
cancel_url: frontBaseUrl,
});
if (!session.url) {
res.status(400).send('Error: missing checkout.session.url');
return;
}
this.logger.log(`Stripe Checkout Session Url Redirection: ${session.url}`);
res.redirect(303, session.url);
}
@Post('/webhooks')
async handleWebhooks(
@Headers('stripe-signature') signature: string,

View File

@ -2,12 +2,12 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BillingController } from 'src/core/billing/billing.controller';
import { EnvironmentModule } from 'src/integrations/environment/environment.module';
import { BillingService } from 'src/core/billing/billing.service';
import { StripeModule } from 'src/core/billing/stripe/stripe.module';
import { BillingSubscription } from 'src/core/billing/entities/billing-subscription.entity';
import { BillingSubscriptionItem } from 'src/core/billing/entities/billing-subscription-item.entity';
import { Workspace } from 'src/core/workspace/workspace.entity';
import { BillingResolver } from 'src/core/billing/billing.resolver';
@Module({
imports: [
@ -18,6 +18,6 @@ import { Workspace } from 'src/core/workspace/workspace.entity';
),
],
controllers: [BillingController],
providers: [EnvironmentModule, BillingService],
providers: [BillingService, BillingResolver],
})
export class BillingModule {}

View File

@ -0,0 +1,76 @@
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { UseGuards } from '@nestjs/common';
import {
AvailableProduct,
BillingService,
} from 'src/core/billing/billing.service';
import { ProductInput } from 'src/core/billing/dto/product.input';
import { assert } from 'src/utils/assert';
import { ProductPricesEntity } from 'src/core/billing/dto/product-prices.entity';
import { JwtAuthGuard } from 'src/guards/jwt.auth.guard';
import { AuthUser } from 'src/decorators/auth/auth-user.decorator';
import { User } from 'src/core/user/user.entity';
import { CheckoutInput } from 'src/core/billing/dto/checkout.input';
import { CheckoutEntity } from 'src/core/billing/dto/checkout.entity';
@Resolver()
export class BillingResolver {
constructor(private readonly billingService: BillingService) {}
@Query(() => ProductPricesEntity)
async getProductPrices(@Args() { product }: ProductInput) {
const stripeProductId = this.billingService.getProductStripeId(product);
assert(
stripeProductId,
`Product '${product}' not found, available products are ['${Object.values(
AvailableProduct,
).join("','")}']`,
);
const productPrices =
await this.billingService.getProductPrices(stripeProductId);
return {
totalNumberOfPrices: productPrices.length,
productPrices: productPrices,
};
}
@Mutation(() => CheckoutEntity)
@UseGuards(JwtAuthGuard)
async checkout(
@AuthUser() user: User,
@Args() { recurringInterval, successUrlPath }: CheckoutInput,
) {
const stripeProductId = this.billingService.getProductStripeId(
AvailableProduct.BasePlan,
);
assert(
stripeProductId,
'BasePlan productId not found, please check your BILLING_STRIPE_BASE_PLAN_PRODUCT_ID env variable',
);
const productPrices =
await this.billingService.getProductPrices(stripeProductId);
const stripePriceId = productPrices.filter(
(price) => price.recurringInterval === recurringInterval,
)?.[0]?.stripePriceId;
assert(
stripePriceId,
`BasePlan priceId not found, please check body.recurringInterval and product '${AvailableProduct.BasePlan}' prices`,
);
return {
url: await this.billingService.checkout(
user,
stripePriceId,
successUrlPath,
),
};
}
}

View File

@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import Stripe from 'stripe';
@ -9,17 +9,13 @@ import { StripeService } from 'src/core/billing/stripe/stripe.service';
import { BillingSubscription } from 'src/core/billing/entities/billing-subscription.entity';
import { BillingSubscriptionItem } from 'src/core/billing/entities/billing-subscription-item.entity';
import { Workspace } from 'src/core/workspace/workspace.entity';
import { ProductPriceEntity } from 'src/core/billing/dto/product-price.entity';
import { User } from 'src/core/user/user.entity';
import { assert } from 'src/utils/assert';
export type PriceData = Partial<
Record<Stripe.Price.Recurring.Interval, Stripe.Price>
>;
export enum AvailableProduct {
BasePlan = 'base-plan',
}
export enum RecurringInterval {
MONTH = 'month',
YEAR = 'year',
}
export enum WebhookEvent {
CUSTOMER_SUBSCRIPTION_UPDATED = 'customer.subscription.updated',
@ -27,6 +23,7 @@ export enum WebhookEvent {
@Injectable()
export class BillingService {
protected readonly logger = new Logger(BillingService.name);
constructor(
private readonly stripeService: StripeService,
private readonly environmentService: EnvironmentService,
@ -53,23 +50,57 @@ export class BillingService {
}
formatProductPrices(prices: Stripe.Price[]) {
const result: PriceData = {};
const result: Record<string, ProductPriceEntity> = {};
prices.forEach((item) => {
const recurringInterval = item.recurring?.interval;
const interval = item.recurring?.interval;
if (!recurringInterval) {
if (!interval || !item.unit_amount) {
return;
}
if (
!result[recurringInterval] ||
item.created > (result[recurringInterval]?.created || 0)
!result[interval] ||
item.created > (result[interval]?.created || 0)
) {
result[recurringInterval] = item;
result[interval] = {
unitAmount: item.unit_amount,
recurringInterval: interval,
created: item.created,
stripePriceId: item.id,
};
}
});
return result;
return Object.values(result).sort((a, b) => a.unitAmount - b.unitAmount);
}
async checkout(user: User, priceId: string, successUrlPath?: string) {
const frontBaseUrl = this.environmentService.getFrontBaseUrl();
const session = await this.stripeService.stripe.checkout.sessions.create({
line_items: [
{
price: priceId,
quantity: 1,
},
],
mode: 'subscription',
subscription_data: {
metadata: {
workspaceId: user.defaultWorkspace.id,
},
},
customer_email: user.email,
success_url: successUrlPath
? frontBaseUrl + successUrlPath
: frontBaseUrl,
cancel_url: frontBaseUrl,
});
assert(session.url, 'Error: missing checkout.session.url');
this.logger.log(`Stripe Checkout Session Url Redirection: ${session.url}`);
return session.url;
}
async createBillingSubscription(

View File

@ -0,0 +1,7 @@
import { Field, ObjectType } from '@nestjs/graphql';
@ObjectType()
export class CheckoutEntity {
@Field(() => String)
url: string;
}

View File

@ -0,0 +1,17 @@
import { ArgsType, Field } from '@nestjs/graphql';
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
import Stripe from 'stripe';
@ArgsType()
export class CheckoutInput {
@Field(() => String)
@IsString()
@IsNotEmpty()
recurringInterval: Stripe.Price.Recurring.Interval;
@Field(() => String, { nullable: true })
@IsString()
@IsOptional()
successUrlPath?: string;
}

View File

@ -0,0 +1,18 @@
import { Field, ObjectType } from '@nestjs/graphql';
import Stripe from 'stripe';
@ObjectType()
export class ProductPriceEntity {
@Field(() => String)
recurringInterval: Stripe.Price.Recurring.Interval;
@Field(() => Number)
unitAmount: number;
@Field(() => Number)
created: number;
@Field(() => String)
stripePriceId: string;
}

View File

@ -0,0 +1,12 @@
import { Field, Int, ObjectType } from '@nestjs/graphql';
import { ProductPriceEntity } from 'src/core/billing/dto/product-price.entity';
@ObjectType()
export class ProductPricesEntity {
@Field(() => Int)
totalNumberOfPrices: number;
@Field(() => [ProductPriceEntity])
productPrices: ProductPriceEntity[];
}

View File

@ -0,0 +1,13 @@
import { ArgsType, Field } from '@nestjs/graphql';
import { IsNotEmpty, IsString } from 'class-validator';
import { AvailableProduct } from 'src/core/billing/billing.service';
@ArgsType()
export class ProductInput {
@Field(() => String)
@IsString()
@IsNotEmpty()
product: AvailableProduct;
}

View File

@ -28,6 +28,9 @@ class Billing {
@Field(() => String)
billingUrl: string;
@Field(() => Number, { nullable: true })
billingFreeTrialDurationInDays: number | undefined;
}
@ObjectType()

View File

@ -24,6 +24,8 @@ export class ClientConfigResolver {
billing: {
isBillingEnabled: this.environmentService.isBillingEnabled(),
billingUrl: this.environmentService.getBillingUrl(),
billingFreeTrialDurationInDays:
this.environmentService.getBillingFreeTrialDurationInDays(),
},
signInPrefilled: this.environmentService.isSignInPrefilled(),
signUpDisabled: this.environmentService.isSignUpDisabled(),