diff --git a/apps/api/env/.env.functional.test b/apps/api/env/.env.functional.test index 4cc861b24..c0512b940 100644 --- a/apps/api/env/.env.functional.test +++ b/apps/api/env/.env.functional.test @@ -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"] \ No newline at end of file diff --git a/apps/api/env/.env.sample b/apps/api/env/.env.sample index 6f409fc99..3e0439b3a 100644 --- a/apps/api/env/.env.sample +++ b/apps/api/env/.env.sample @@ -8,8 +8,9 @@ POSTGRES_DB_URI= SecretToken= SentryDSN= SENTRY_DSN= -StripeSecretKey= UserDatabaseCS= +STRIPE_SECRET_KEY= +STRIPE_PRICE_ID= # Configuration AKASHLYTICS_CORS_WEBSITE_URLS= diff --git a/apps/api/package.json b/apps/api/package.json index 29f6a4888..bf9d5d7f5 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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" }, diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index eaaefd4b2..3290a049c 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -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"; @@ -73,10 +73,12 @@ if (BILLING_ENABLED === "true") { const { AuthInterceptor } = require("./auth/services/auth.interceptor"); appHono.use(container.resolve(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); diff --git a/apps/api/src/billing/config/env.config.ts b/apps/api/src/billing/config/env.config.ts index 61f97f5ed..24b7f7fa5 100644 --- a/apps/api/src/billing/config/env.config.ts +++ b/apps/api/src/billing/config/env.config.ts @@ -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"]), @@ -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); diff --git a/apps/api/src/billing/controllers/checkout/checkout.controller.ts b/apps/api/src/billing/controllers/checkout/checkout.controller.ts new file mode 100644 index 000000000..23beb47bb --- /dev/null +++ b/apps/api/src/billing/controllers/checkout/checkout.controller.ts @@ -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 + } + } +} diff --git a/apps/api/src/billing/repositories/user-wallet/user-wallet.repository.ts b/apps/api/src/billing/repositories/user-wallet/user-wallet.repository.ts index c79055ea1..d84283a71 100644 --- a/apps/api/src/billing/repositories/user-wallet/user-wallet.repository.ts +++ b/apps/api/src/billing/repositories/user-wallet/user-wallet.repository.ts @@ -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"; @@ -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; +export type DbUserWalletInput = Partial; export type UserWalletInput = Partial< Omit & { deploymentAllowance: number; @@ -27,18 +27,14 @@ export interface ListOptions { offset?: number; } -interface UpdateOptions { - returning: true; -} - @singleton() -export class UserWalletRepository extends BaseRepository { +export class UserWalletRepository extends BaseRepository { 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) { @@ -56,42 +52,6 @@ export class UserWalletRepository extends BaseRepository { return this.toOutput(first(await this.cursor.insert(this.schema).values(value).returning())); } - async updateById(id: UserWalletOutput["id"], payload: Partial, options?: UpdateOptions): Promise; - async updateById(id: UserWalletOutput["id"], payload: Partial): Promise; - async updateById(id: UserWalletOutput["id"], payload: Partial, options?: UpdateOptions): Promise { - return this.updateBy({ id }, payload, options); - } - - async updateBy(query: Partial, payload: Partial, options?: UpdateOptions): Promise; - async updateBy(query: Partial, payload: Partial): Promise; - async updateBy(query: Partial, payload: Partial, options?: UpdateOptions): Promise { - 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) { - return this.toOutputList( - await this.cursor.query.userWalletSchema.findMany({ - where: this.queryToWhere(query) - }) - ); - } - - private queryToWhere(query: Partial) { - const fields = query && (Object.keys(query) as Array); - 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) { const where = or(lte(this.schema.deploymentAllowance, thresholds.deployment.toString()), lte(this.schema.feeAllowance, thresholds.fee.toString())); @@ -103,19 +63,11 @@ export class UserWalletRepository extends BaseRepository { ); } - 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, @@ -126,7 +78,7 @@ export class UserWalletRepository extends BaseRepository { ); } - private toInput({ deploymentAllowance, feeAllowance, ...input }: UserWalletInput): DbUserWalletInput { + protected toInput({ deploymentAllowance, feeAllowance, ...input }: UserWalletInput): DbUserWalletInput { const dbInput: DbUserWalletInput = { ...input, updatedAt: new Date() diff --git a/apps/api/src/billing/routes/checkout/checkout.router.ts b/apps/api/src/billing/routes/checkout/checkout.router.ts new file mode 100644 index 000000000..f4e15d229 --- /dev/null +++ b/apps/api/src/billing/routes/checkout/checkout.router.ts @@ -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); +}); diff --git a/apps/api/src/billing/routes/index.ts b/apps/api/src/billing/routes/index.ts index 575d02aa7..79da4eed4 100644 --- a/apps/api/src/billing/routes/index.ts +++ b/apps/api/src/billing/routes/index.ts @@ -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"; diff --git a/apps/api/src/billing/routes/stripe-webhook/stripe-webhook.router.ts b/apps/api/src/billing/routes/stripe-webhook/stripe-webhook.router.ts new file mode 100644 index 000000000..dbd442c27 --- /dev/null +++ b/apps/api/src/billing/routes/stripe-webhook/stripe-webhook.router.ts @@ -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); +}); diff --git a/apps/api/src/billing/services/checkout/checkout.service.ts b/apps/api/src/billing/services/checkout/checkout.service.ts new file mode 100644 index 000000000..422196016 --- /dev/null +++ b/apps/api/src/billing/services/checkout/checkout.service.ts @@ -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(user: T): Promise & Required>> { + 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 + }; + } +} diff --git a/apps/api/src/billing/services/stripe/stripe.service.ts b/apps/api/src/billing/services/stripe/stripe.service.ts new file mode 100644 index 000000000..b6666bd9c --- /dev/null +++ b/apps/api/src/billing/services/stripe/stripe.service.ts @@ -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` + }); + } +} diff --git a/apps/api/src/core/repositories/base.repository.ts b/apps/api/src/core/repositories/base.repository.ts index a02bc6be4..e7e2ad326 100644 --- a/apps/api/src/core/repositories/base.repository.ts +++ b/apps/api/src/core/repositories/base.repository.ts @@ -1,6 +1,8 @@ import { AnyAbility } from "@casl/ability"; +import { and, eq } from "drizzle-orm"; import { PgTableWithColumns } from "drizzle-orm/pg-core/table"; import { SQL } from "drizzle-orm/sql/sql"; +import first from "lodash/first"; import { ApiPgDatabase, InjectPg, TxService } from "@src/core"; import { DrizzleAbility } from "@src/lib/drizzle-ability/drizzle-ability"; @@ -8,18 +10,39 @@ import { InjectUserSchema } from "@src/user/providers"; export type AbilityParams = [AnyAbility, Parameters[0]]; -export abstract class BaseRepository> { +interface UpdateOptions { + returning: true; +} + +export interface BaseRecordInput { + id?: T; +} + +export interface BaseRecordOutput { + id: T; +} + +export abstract class BaseRepository< + T extends PgTableWithColumns, + Input extends BaseRecordInput, + Output extends BaseRecordOutput +> { protected ability?: DrizzleAbility; get cursor() { return this.txManager.getPgTx() || this.pg; } + get queryCursor() { + return this.cursor.query[this.schemaName] as unknown as T; + } + constructor( @InjectPg() protected readonly pg: ApiPgDatabase, @InjectUserSchema() protected readonly schema: T, protected readonly txManager: TxService, - protected readonly entityName: string + protected readonly entityName: string, + protected readonly schemaName: keyof ApiPgDatabase["query"] ) {} protected withAbility(ability: AnyAbility, action: Parameters[0]) { @@ -32,4 +55,64 @@ export abstract class BaseRepository> { } abstract accessibleBy(...abilityParams: AbilityParams): this; + + async findById(id: Output["id"]) { + return this.toOutput(await this.queryCursor.findFirst({ where: this.whereAccessibleBy(eq(this.schema.id, id)) })); + } + + async findOneBy(query?: Partial) { + return this.toOutput( + await this.queryCursor.findFirst({ + where: this.queryToWhere(query) + }) + ); + } + + async find(query?: Partial) { + return this.toOutputList( + await this.queryCursor.findMany({ + where: this.queryToWhere(query) + }) + ); + } + + async updateById(id: Output["id"], payload: Partial, options?: UpdateOptions): Promise; + async updateById(id: Output["id"], payload: Partial): Promise; + async updateById(id: Output["id"], payload: Partial, options?: UpdateOptions): Promise { + return this.updateBy({ id } as Partial, payload, options); + } + + async updateBy(query: Partial, payload: Partial, options?: UpdateOptions): Promise; + async updateBy(query: Partial, payload: Partial): Promise; + async updateBy(query: Partial, payload: Partial, options?: UpdateOptions): Promise { + 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; + } + + protected queryToWhere(query: Partial) { + const fields = query && (Object.keys(query) as Array); + const where = fields?.length ? and(...fields.map(field => eq(this.schema[field], query[field]))) : undefined; + + return this.whereAccessibleBy(where); + } + + protected toInput(payload: Partial): Partial { + return payload as Partial; + } + + protected toOutputList(dbOutput: T["$inferSelect"][]): Output[] { + return dbOutput.map(item => this.toOutput(item)); + } + + protected toOutput(payload: Partial): Output { + return payload as Output; + } } diff --git a/apps/api/src/core/services/request-storage/request-context.interceptor.ts b/apps/api/src/core/services/request-context-interceptor/request-context.interceptor.ts similarity index 84% rename from apps/api/src/core/services/request-storage/request-context.interceptor.ts rename to apps/api/src/core/services/request-context-interceptor/request-context.interceptor.ts index 0c2830ead..2b148df84 100644 --- a/apps/api/src/core/services/request-storage/request-context.interceptor.ts +++ b/apps/api/src/core/services/request-context-interceptor/request-context.interceptor.ts @@ -1,5 +1,4 @@ import { Context, Next } from "hono"; -import { AsyncLocalStorage } from "node:async_hooks"; import { singleton } from "tsyringe"; import { ExecutionContextService } from "@src/core/services/execution-context/execution-context.service"; @@ -9,8 +8,6 @@ import type { HonoInterceptor } from "@src/core/types/hono-interceptor.type"; export class RequestContextInterceptor implements HonoInterceptor { private readonly HTTP_CONTEXT_KEY = "HTTP_CONTEXT"; - private readonly storage = new AsyncLocalStorage>(); - constructor(private readonly executionContextService: ExecutionContextService) {} intercept() { diff --git a/apps/api/src/core/services/validation-schema/validation-schema.service.ts b/apps/api/src/core/services/validation-schema/validation-schema.service.ts new file mode 100644 index 000000000..6cad65387 --- /dev/null +++ b/apps/api/src/core/services/validation-schema/validation-schema.service.ts @@ -0,0 +1,43 @@ +import { + baseObjectInputType, + baseObjectOutputType, + objectUtil, + z, + ZodArray, + ZodEffects, + ZodObject, + ZodPipeline, + ZodRawShape, + ZodString, + ZodTypeAny +} from "zod"; + +export class ValidationSchemaService { + static json< + S extends ZodRawShape, + T extends ZodObject< + S, + "strip", + ZodTypeAny, + { [k in keyof objectUtil.addQuestionMarks, any>]: objectUtil.addQuestionMarks, any>[k] }, + { [k_1 in keyof baseObjectInputType]: baseObjectInputType[k_1] } + > + >(schema: T): ZodPipeline, T>; + static json>(schema: T): ZodPipeline, T>; + static json(schema: T): ZodPipeline, T> { + return z + .string() + .transform((content, ctx) => { + try { + return JSON.parse(content); + } catch (error) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "invalid json" + }); + return z.never; + } + }) + .pipe(schema); + } +} diff --git a/apps/api/src/routers/userRouter.ts b/apps/api/src/routers/userRouter.ts index de1f9a591..4e95d7ef4 100644 --- a/apps/api/src/routers/userRouter.ts +++ b/apps/api/src/routers/userRouter.ts @@ -24,7 +24,6 @@ import { subscribeToNewsletter, updateSettings } from "@src/services/db/userDataService"; -import { getBillingPortalUrl, getCheckoutUrl } from "@src/services/external/stripeService"; import { isValidBech32Address } from "@src/utils/addresses"; export const userRouter = new Hono(); @@ -35,29 +34,6 @@ userRequiredRouter.use("*", requiredUserMiddleware); const userOptionalRouter = new Hono(); userOptionalRouter.use("*", optionalUserMiddleware); -userRequiredRouter.post("/manage-subscription", async c => { - const userId = getCurrentUserId(c); - const portalUrl = await getBillingPortalUrl(userId); - - return c.redirect(portalUrl); -}); - -userRequiredRouter.post("/subscribe", async c => { - const userId = getCurrentUserId(c); - const { planCode, period } = await c.req.json(); // TODO Test - - if (!planCode) { - return c.text("Missing plan code", 400); - } - if (!period) { - return c.text("Missing period", 400); - } - - const checkoutUrl = await getCheckoutUrl(userId, planCode, period === "monthly"); - - return c.redirect(checkoutUrl, 303); -}); - userOptionalRouter.get("/byUsername/:username", async c => { const username = c.req.param("username"); diff --git a/apps/api/src/services/external/stripeService.ts b/apps/api/src/services/external/stripeService.ts deleted file mode 100644 index 5cc55ff4d..000000000 --- a/apps/api/src/services/external/stripeService.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { UserSetting } from "@akashnetwork/database/dbSchemas/user"; -import { PlanCode } from "@akashnetwork/database/plans"; -import Stripe from "stripe"; - -import { env } from "@src/utils/env"; - -const stripe = new Stripe(process.env.StripeSecretKey, { apiVersion: "2022-08-01" }); - -export async function getBillingPortalUrl(userId: string) { - const userSettings = await UserSetting.findOne({ where: { userId: userId } }); - - if (!userSettings.stripeCustomerId) { - throw new Error("User has no stripe customer id"); - } - - const session = await stripe.billingPortal.sessions.create({ - customer: userSettings.stripeCustomerId, - return_url: `${env.WebsiteUrl}/settings` - }); - - return session.url; -} - -export async function getCheckoutUrl(userId: string, planCode: string, isMonthly: boolean) { - const userSettings = await UserSetting.findOne({ where: { userId: userId } }); - - if (!userSettings.email) { - throw new Error("User email not set: " + userId); - } - - const products = await stripe.products.search({ - query: `metadata["code"]:"${planCode}"` - }); - - if (products.data.length === 0) { - throw new Error("Plan not found: " + planCode); - } else if (products.data.length > 1) { - throw new Error("Multiple plans found: " + planCode); - } - - const prices = await stripe.prices.list({ - product: products.data[0].id - }); - - const price = prices.data.find(x => x.recurring.interval === (isMonthly ? "month" : "year") && x.active === true); - - if (!userSettings.stripeCustomerId) { - const createdCustomer = await stripe.customers.create({ email: userSettings.email }); - userSettings.stripeCustomerId = createdCustomer.id; - await userSettings.save(); - } - - const session = await stripe.checkout.sessions.create({ - billing_address_collection: "auto", - customer: userSettings.stripeCustomerId || undefined, - customer_email: userSettings.stripeCustomerId ? undefined : userSettings.email, - line_items: [ - { - price: price.id, - quantity: 1 - } - ], - automatic_tax: { - enabled: true - }, - customer_update: { - address: "auto" - }, - mode: "subscription", - success_url: `${env.WebsiteUrl}/settings`, - cancel_url: `${env.WebsiteUrl}/pricing` - }); - - return session.url; -} - -export async function getUserPlan(stripeCustomerId: string): Promise { - if (!stripeCustomerId) { - return "COMMUNITY"; - } - - const subscriptions = await stripe.subscriptions.list({ - customer: stripeCustomerId, - limit: 1 - }); - - if (subscriptions.data.length === 0) { - console.warn("Stripe customer with no subscriptions: " + stripeCustomerId); - return "COMMUNITY"; - } - - const productId = subscriptions.data[0].items.data[0].price.product; - - const product = await stripe.products.retrieve(productId as string); - - if (!product.metadata.code) { - throw new Error("Subscription product has no code: " + subscriptions.data[0].id); - } - - return product.metadata.code as PlanCode; -} diff --git a/apps/api/src/user/repositories/user/user.repository.ts b/apps/api/src/user/repositories/user/user.repository.ts index 0ec67c1ae..f5d6e9f96 100644 --- a/apps/api/src/user/repositories/user/user.repository.ts +++ b/apps/api/src/user/repositories/user/user.repository.ts @@ -8,33 +8,30 @@ import { TxService } from "@src/core/services"; import { InjectUserSchema, UserSchema } from "@src/user/providers"; export type UserOutput = UserSchema["$inferSelect"]; +export type UserInput = Partial; @singleton() -export class UserRepository extends BaseRepository { +export class UserRepository extends BaseRepository { constructor( @InjectPg() protected readonly pg: ApiPgDatabase, @InjectUserSchema() protected readonly schema: UserSchema, protected readonly txManager: TxService ) { - super(pg, schema, txManager, "User"); + super(pg, schema, txManager, "User", "userSchema"); } accessibleBy(...abilityParams: AbilityParams) { return new UserRepository(this.pg, this.schema, this.txManager).withAbility(...abilityParams) as this; } - async create() { - return first(await this.cursor.insert(this.schema).values({}).returning({ id: this.schema.id })); + async create(input: Partial = {}) { + return first(await this.cursor.insert(this.schema).values(input).returning()); } async findByUserId(userId: UserOutput["userId"]) { return await this.cursor.query.userSchema.findFirst({ where: this.whereAccessibleBy(eq(this.schema.userId, userId)) }); } - async findById(id: UserOutput["id"]) { - return await this.cursor.query.userSchema.findFirst({ where: this.whereAccessibleBy(eq(this.schema.id, id)) }); - } - async findAnonymousById(id: UserOutput["id"]) { return await this.cursor.query.userSchema.findFirst({ where: this.whereAccessibleBy(and(eq(this.schema.id, id), isNull(this.schema.userId))) }); } diff --git a/apps/api/test/functional/user-init.spec.ts b/apps/api/test/functional/user-init.spec.ts index 070165d53..c6febe313 100644 --- a/apps/api/test/functional/user-init.spec.ts +++ b/apps/api/test/functional/user-init.spec.ts @@ -87,8 +87,8 @@ describe("User Init", () => { expect(res.status).toBe(200); expect(res.body).toMatchObject({ - ...omit(auth0Payload, "wantedUsername"), - ...omit(anonymousUser, ["createdAt", "username"]) + ...omit(anonymousUser, ["createdAt", "username"]), + ...omit(auth0Payload, "wantedUsername") }); }); diff --git a/apps/deploy-web/src/components/home/HomeContainer.tsx b/apps/deploy-web/src/components/home/HomeContainer.tsx index e3e2436fa..f2e363837 100644 --- a/apps/deploy-web/src/components/home/HomeContainer.tsx +++ b/apps/deploy-web/src/components/home/HomeContainer.tsx @@ -5,6 +5,7 @@ import { Spinner } from "@akashnetwork/ui/components"; import dynamic from "next/dynamic"; import { Footer } from "@src/components/layout/Footer"; +import { envConfig } from "@src/config/env.config"; import { useLocalNotes } from "@src/context/LocalNoteProvider"; import { useSettings } from "@src/context/SettingsProvider"; import { useWallet } from "@src/context/WalletProvider"; @@ -80,6 +81,7 @@ export function HomeContainer() { )} + {envConfig.NEXT_PUBLIC_BILLING_ENABLED === "true" && Checkout}