Skip to content

Commit

Permalink
feat(billing): implement stripe checkout
Browse files Browse the repository at this point in the history
refs #247
  • Loading branch information
ygrishajev committed Aug 20, 2024
1 parent edf3d3a commit fb4faa6
Show file tree
Hide file tree
Showing 22 changed files with 14,926 additions and 36,817 deletions.
4 changes: 4 additions & 0 deletions apps/api/env/.env.functional.test
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,7 @@ DEPLOYMENT_GRANT_DENOM=uakt
LOG_LEVEL=debug
BILLING_ENABLED=true
ANONYMOUS_USER_TOKEN_SECRET=ANONYMOUS_USER_TOKEN_SECRET
STRIPE_SECRET_KEY=STRIPE_SECRET_KEY
STRIPE_PRICE_ID=STRIPE_PRICE_ID
STRIPE_WEBHOOK_SECRET=STRIPE_WEBHOOK_SECRET
ALLOWED_CHECKOUT_REFERRERS=["http://localhost:3000"]
3 changes: 2 additions & 1 deletion apps/api/env/.env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ POSTGRES_DB_URI=
SecretToken=
SentryDSN=
SENTRY_DSN=
StripeSecretKey=
UserDatabaseCS=
STRIPE_SECRET_KEY=
STRIPE_PRICE_ID=

# Configuration
AKASHLYTICS_CORS_WEBSITE_URLS=
Expand Down
2 changes: 1 addition & 1 deletion apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@
"sequelize": "^6.21.3",
"sequelize-typescript": "^2.1.5",
"sql-formatter": "^15.3.2",
"stripe": "^10.14.0",
"stripe": "^16.8.0",
"tsyringe": "^4.8.0",
"uuid": "^9.0.1"
},
Expand Down
6 changes: 4 additions & 2 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { getSentry, sentryOptions } from "@src/core/providers/sentry.provider";
import { HonoErrorHandlerService } from "@src/core/services/hono-error-handler/hono-error-handler.service";
import { HttpLoggerService } from "@src/core/services/http-logger/http-logger.service";
import { LoggerService } from "@src/core/services/logger/logger.service";
import { RequestContextInterceptor } from "@src/core/services/request-storage/request-context.interceptor";
import { RequestContextInterceptor } from "@src/core/services/request-context-interceptor/request-context.interceptor";
import { HonoInterceptor } from "@src/core/types/hono-interceptor.type";
import packageJson from "../package.json";
import { chainDb, syncUserSchema, userDb } from "./db/dbConnection";
Expand Down Expand Up @@ -73,10 +73,12 @@ if (BILLING_ENABLED === "true") {
const { AuthInterceptor } = require("./auth/services/auth.interceptor");
appHono.use(container.resolve<HonoInterceptor>(AuthInterceptor).intercept());
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { createWalletRouter, getWalletListRouter, signAndBroadcastTxRouter } = require("./billing");
const { createWalletRouter, getWalletListRouter, signAndBroadcastTxRouter, checkoutRouter, stripeWebhook } = require("./billing");
appHono.route("/", createWalletRouter);
appHono.route("/", getWalletListRouter);
appHono.route("/", signAndBroadcastTxRouter);
appHono.route("/", checkoutRouter);
appHono.route("/", stripeWebhook);
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { createAnonymousUserRouter, getAnonymousUserRouter } = require("./user");
appHono.route("/", createAnonymousUserRouter);
Expand Down
8 changes: 7 additions & 1 deletion apps/api/src/billing/config/env.config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { z } from "zod";

import { ValidationSchemaService } from "@src/core/services/validation-schema/validation-schema.service";

const envSchema = z.object({
MASTER_WALLET_MNEMONIC: z.string(),
NETWORK: z.enum(["mainnet", "testnet", "sandbox"]),
Expand All @@ -14,7 +16,11 @@ const envSchema = z.object({
FEE_ALLOWANCE_REFILL_AMOUNT: z.number({ coerce: true }),
DEPLOYMENT_ALLOWANCE_REFILL_AMOUNT: z.number({ coerce: true }),
ALLOWANCE_REFILL_BATCH_SIZE: z.number({ coerce: true }).default(10),
MASTER_WALLET_BATCHING_INTERVAL_MS: z.number().optional().default(1000)
MASTER_WALLET_BATCHING_INTERVAL_MS: z.number().optional().default(1000),
STRIPE_SECRET_KEY: z.string(),
STRIPE_PRICE_ID: z.string(),
STRIPE_WEBHOOK_SECRET: z.string(),
ALLOWED_CHECKOUT_REFERRERS: ValidationSchemaService.json(z.array(z.string()))
});

export const envConfig = envSchema.parse(process.env);
46 changes: 46 additions & 0 deletions apps/api/src/billing/controllers/checkout/checkout.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import type { Context } from "hono";
import { singleton } from "tsyringe";

import { AuthService } from "@src/auth/services/auth.service";
import { BillingConfig, InjectBillingConfig } from "@src/billing/providers";
import { CheckoutService } from "@src/billing/services/checkout/checkout.service";
import { StripeService } from "@src/billing/services/stripe/stripe.service";

@singleton()
export class CheckoutController {
constructor(
private readonly stripe: StripeService,
private readonly authService: AuthService,
@InjectBillingConfig() private readonly billingConfig: BillingConfig,
private readonly checkoutService: CheckoutService
) {}

async checkout(c: Context) {
const { currentUser } = this.authService;
const protocol = c.req.header("x-forwarded-proto");
const host = c.req.header("x-forwarded-host");
const referrer = `${protocol}://${host}`;

if (!this.billingConfig.ALLOWED_CHECKOUT_REFERRERS.includes(referrer)) {
return c.redirect(`${referrer}?forbidden=true`);
}

if (!currentUser?.userId) {
return c.redirect(`${referrer}?unauthorized=true`);
}

const session = await this.checkoutService.checkoutFor(currentUser, referrer);

return c.redirect(session.url);
}

async webhook(signature: string, input: string) {
const event = this.stripe.webhooks.constructEvent(input, signature, this.billingConfig.STRIPE_WEBHOOK_SECRET);

if (event.type) {
const paymentIntentSucceeded = event.data.object;
console.log("DEBUG paymentIntentSucceeded", paymentIntentSucceeded);
// TODO: implement wallet refill here
}
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { and, eq, lte, or } from "drizzle-orm";
import { eq, lte, or } from "drizzle-orm";
import first from "lodash/first";
import pick from "lodash/pick";
import { singleton } from "tsyringe";
Expand All @@ -8,7 +8,7 @@ import { ApiPgDatabase, InjectPg } from "@src/core/providers";
import { AbilityParams, BaseRepository } from "@src/core/repositories/base.repository";
import { TxService } from "@src/core/services";

export type DbUserWalletInput = Partial<UserWalletSchema["$inferSelect"]>;
export type DbUserWalletInput = Partial<UserWalletSchema["$inferInsert"]>;
export type UserWalletInput = Partial<
Omit<DbUserWalletInput, "deploymentAllowance" | "feeAllowance"> & {
deploymentAllowance: number;
Expand All @@ -27,18 +27,14 @@ export interface ListOptions {
offset?: number;
}

interface UpdateOptions {
returning: true;
}

@singleton()
export class UserWalletRepository extends BaseRepository<UserWalletSchema> {
export class UserWalletRepository extends BaseRepository<UserWalletSchema, UserWalletInput, UserWalletOutput> {
constructor(
@InjectPg() protected readonly pg: ApiPgDatabase,
@InjectUserWalletSchema() protected readonly schema: UserWalletSchema,
protected readonly txManager: TxService
) {
super(pg, schema, txManager, "UserWallet");
super(pg, schema, txManager, "UserWallet", "userWalletSchema");
}

accessibleBy(...abilityParams: AbilityParams) {
Expand All @@ -56,42 +52,6 @@ export class UserWalletRepository extends BaseRepository<UserWalletSchema> {
return this.toOutput(first(await this.cursor.insert(this.schema).values(value).returning()));
}

async updateById(id: UserWalletOutput["id"], payload: Partial<UserWalletInput>, options?: UpdateOptions): Promise<UserWalletOutput>;
async updateById(id: UserWalletOutput["id"], payload: Partial<UserWalletInput>): Promise<void>;
async updateById(id: UserWalletOutput["id"], payload: Partial<UserWalletInput>, options?: UpdateOptions): Promise<void | UserWalletOutput> {
return this.updateBy({ id }, payload, options);
}

async updateBy(query: Partial<DbUserWalletOutput>, payload: Partial<UserWalletInput>, options?: UpdateOptions): Promise<UserWalletOutput>;
async updateBy(query: Partial<DbUserWalletOutput>, payload: Partial<UserWalletInput>): Promise<void>;
async updateBy(query: Partial<DbUserWalletOutput>, payload: Partial<UserWalletInput>, options?: UpdateOptions): Promise<void | UserWalletOutput> {
const cursor = this.cursor.update(this.schema).set(this.toInput(payload)).where(this.queryToWhere(query));

if (options?.returning) {
const items = await cursor.returning();
return this.toOutput(first(items));
}

await cursor;

return undefined;
}

async find(query?: Partial<DbUserWalletOutput>) {
return this.toOutputList(
await this.cursor.query.userWalletSchema.findMany({
where: this.queryToWhere(query)
})
);
}

private queryToWhere(query: Partial<DbUserWalletOutput>) {
const fields = query && (Object.keys(query) as Array<keyof DbUserWalletOutput>);
const where = fields?.length ? and(...fields.map(field => eq(this.schema[field], query[field]))) : undefined;

return this.whereAccessibleBy(where);
}

async findDrainingWallets(thresholds = { fee: 0, deployment: 0 }, options?: Pick<ListOptions, "limit">) {
const where = or(lte(this.schema.deploymentAllowance, thresholds.deployment.toString()), lte(this.schema.feeAllowance, thresholds.fee.toString()));

Expand All @@ -103,19 +63,11 @@ export class UserWalletRepository extends BaseRepository<UserWalletSchema> {
);
}

async findById(id: UserWalletOutput["id"]) {
return this.toOutput(await this.cursor.query.userWalletSchema.findFirst({ where: this.whereAccessibleBy(eq(this.schema.id, id)) }));
}

async findByUserId(userId: UserWalletOutput["userId"]) {
return this.toOutput(await this.cursor.query.userWalletSchema.findFirst({ where: this.whereAccessibleBy(eq(this.schema.userId, userId)) }));
}

private toOutputList(dbOutput: UserWalletSchema["$inferSelect"][]): UserWalletOutput[] {
return dbOutput.map(item => this.toOutput(item));
}

private toOutput(dbOutput?: UserWalletSchema["$inferSelect"]): UserWalletOutput {
protected toOutput(dbOutput: DbUserWalletOutput): UserWalletOutput {
return (
dbOutput && {
...dbOutput,
Expand All @@ -126,7 +78,7 @@ export class UserWalletRepository extends BaseRepository<UserWalletSchema> {
);
}

private toInput({ deploymentAllowance, feeAllowance, ...input }: UserWalletInput): DbUserWalletInput {
protected toInput({ deploymentAllowance, feeAllowance, ...input }: UserWalletInput): DbUserWalletInput {
const dbInput: DbUserWalletInput = {
...input,
updatedAt: new Date()
Expand Down
24 changes: 24 additions & 0 deletions apps/api/src/billing/routes/checkout/checkout.router.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { createRoute } from "@hono/zod-openapi";
import { container } from "tsyringe";

import { CheckoutController } from "@src/billing/controllers/checkout/checkout.controller";
import { OpenApiHonoHandled } from "@src/core/services/open-api-hono-handled/open-api-hono-handled";

const route = createRoute({
method: "get",
path: "/v1/checkout",
summary: "Creates a stripe checkout session and redirects to checkout",
tags: ["Wallets"],
request: {},
responses: {
301: {
description: "Redirects to the checkout page"
}
}
});

export const checkoutRouter = new OpenApiHonoHandled();

checkoutRouter.openapi(route, async function routeCheckout(c) {
return await container.resolve(CheckoutController).checkout(c);
});
2 changes: 2 additions & 0 deletions apps/api/src/billing/routes/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
export * from "@src/billing/routes/create-wallet/create-wallet.router";
export * from "@src/billing/routes/get-wallet-list/get-wallet-list.router";
export * from "@src/billing/routes/checkout/checkout.router";
export * from "@src/billing/routes/sign-and-broadcast-tx/sign-and-broadcast-tx.router";
export * from "@src/billing/routes/stripe-webhook/stripe-webhook.router";
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { createRoute } from "@hono/zod-openapi";
import { container } from "tsyringe";
import { z } from "zod";

import { CheckoutController } from "@src/billing/controllers/checkout/checkout.controller";
import { OpenApiHonoHandled } from "@src/core/services/open-api-hono-handled/open-api-hono-handled";

const route = createRoute({
method: "post",
path: "/v1/stripe-webhook",
summary: "",
request: {
body: {
content: {
"application/json": {
schema: z.any()
}
}
}
},
responses: {
200: {
description: "",
content: {
"application/json": {
schema: z.void()
}
}
}
}
});

export const stripeWebhook = new OpenApiHonoHandled();

stripeWebhook.openapi(route, async function routeStripeWebhook(c) {
const sig = c.req.header("stripe-signature");
await container.resolve(CheckoutController).webhook(sig, await c.req.text());
return c.json({}, 200);
});
46 changes: 46 additions & 0 deletions apps/api/src/billing/services/checkout/checkout.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { singleton } from "tsyringe";

import { BillingConfig, InjectBillingConfig } from "@src/billing/providers";
import { StripeService } from "@src/billing/services/stripe/stripe.service";
import { UserOutput, UserRepository } from "@src/user/repositories";

@singleton()
export class CheckoutService {
constructor(
private readonly stripe: StripeService,
private readonly userRepository: UserRepository,
@InjectBillingConfig() private readonly billingConfig: BillingConfig
) {}

async checkoutFor(user: UserOutput, domain: string) {
const { stripeCustomerId } = await this.ensureCustomer(user);

return await this.stripe.startCheckoutSession({
customerId: stripeCustomerId,
domain
});
}

private async ensureCustomer<T extends UserOutput>(user: T): Promise<Omit<T, "stripeCustomerId"> & Required<Pick<T, "stripeCustomerId">>> {
if (user.stripeCustomerId) {
return user;
}

const customer = await this.stripe.customers.create({
email: user.email,
name: user.username,
metadata: {
userId: user.userId
}
});

await this.userRepository.updateById(user.id, {
stripeCustomerId: customer.id
});

return {
...user,
stripeCustomerId: customer.id
};
}
}
39 changes: 39 additions & 0 deletions apps/api/src/billing/services/stripe/stripe.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import Stripe from "stripe";
import { singleton } from "tsyringe";

import { BillingConfig, InjectBillingConfig } from "@src/billing/providers";

interface CheckoutOptions {
customerId: string;
domain: string;
}

@singleton()
export class StripeService extends Stripe {
constructor(@InjectBillingConfig() private readonly billingConfig: BillingConfig) {
super(process.env.STRIPE_SECRET_KEY, {
apiVersion: "2024-06-20"
});
}

async startCheckoutSession(options: CheckoutOptions) {
return await this.checkout.sessions.create({
line_items: [
{
price: this.billingConfig.STRIPE_PRICE_ID,
quantity: 1
}
],
mode: "payment",
customer: options.customerId,
payment_intent_data: {
setup_future_usage: "off_session"
},
saved_payment_method_options: {
payment_method_save: "enabled"
},
success_url: `${options.domain}?payment-success=true`,
cancel_url: `${options.domain}?payment-canceled=true`
});
}
}
Loading

0 comments on commit fb4faa6

Please sign in to comment.