diff --git a/server/src/honoMiddlewares/routeHandler.ts b/server/src/honoMiddlewares/routeHandler.ts index e40138539..4e02a9977 100644 --- a/server/src/honoMiddlewares/routeHandler.ts +++ b/server/src/honoMiddlewares/routeHandler.ts @@ -1,5 +1,6 @@ import type { Context, Env, Handler, MiddlewareHandler } from "hono"; import type { ZodType, z } from "zod/v4"; +import type { DrizzleCli } from "@/db/initDrizzle.js"; import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; import { validator } from "./validatorMiddleware.js"; @@ -59,6 +60,7 @@ export function createRoute< >(opts: { body?: Body; query?: Query; + withTx?: boolean; handler: ( c: ValidatedContext, ) => Response | Promise; @@ -73,7 +75,19 @@ export function createRoute< } const wrappedHandler = async (c: ValidatedContext) => { - return await opts.handler(c); + if (opts.withTx) { + const db = c.get("ctx").db; + + return await db.transaction(async (tx) => { + c.set("ctx", { + ...c.get("ctx"), + db: tx as unknown as DrizzleCli, + }); + return await opts.handler(c); + }); + } else { + return await opts.handler(c); + } }; return [...middlewares, wrappedHandler as Handler] as const; diff --git a/server/src/initHono.ts b/server/src/initHono.ts index f9f57e497..0c52c6107 100644 --- a/server/src/initHono.ts +++ b/server/src/initHono.ts @@ -8,7 +8,7 @@ import { orgConfigMiddleware } from "./honoMiddlewares/orgConfigMiddleware.js"; import { secretKeyMiddleware } from "./honoMiddlewares/secretKeyMiddleware.js"; import { traceMiddleware } from "./honoMiddlewares/traceMiddleware.js"; import type { HonoEnv } from "./honoUtils/HonoEnv.js"; -import { honoProductRouter } from "./internal/products/honoProductRouter.js"; +import { honoProductRouter } from "./internal/products/productRouter.js"; import { auth } from "./utils/auth.js"; const ALLOWED_ORIGINS = [ diff --git a/server/src/internal/orgs/onboarding/parseChatProducts.ts b/server/src/internal/orgs/onboarding/parseChatProducts.ts index f75408bfd..1c475d7e2 100644 --- a/server/src/internal/orgs/onboarding/parseChatProducts.ts +++ b/server/src/internal/orgs/onboarding/parseChatProducts.ts @@ -1,16 +1,16 @@ -import { DrizzleCli } from "@/db/initDrizzle.js"; -import { handleNewProductItems } from "@/internal/products/product-items/productItemUtils/handleNewProductItems.js"; -import { constructProduct } from "@/internal/products/productUtils.js"; import { AppEnv, CreateProductSchema, EntInsertSchema, - Entitlement, - Feature, - Price, - Product, - ProductV2, + type Entitlement, + type Feature, + type Price, + type Product, + type ProductV2, } from "@autumn/shared"; +import type { DrizzleCli } from "@/db/initDrizzle.js"; +import { handleNewProductItems } from "@/internal/products/product-items/productItemUtils/handleNewProductItems.js"; +import { constructProduct } from "@/internal/products/productUtils.js"; export const parseChatProducts = async ({ db, @@ -25,13 +25,13 @@ export const parseChatProducts = async ({ orgId: string; chatProducts: ProductV2[]; }) => { - let products: Product[] = []; + const products: Product[] = []; - let allPrices: Price[] = []; - let allEnts: Entitlement[] = []; + const allPrices: Price[] = []; + const allEnts: Entitlement[] = []; for (const product of chatProducts) { - let backendProduct: Product = constructProduct({ + const backendProduct: Product = constructProduct({ productData: CreateProductSchema.parse({ ...product, }), @@ -39,7 +39,7 @@ export const parseChatProducts = async ({ env: AppEnv.Sandbox, }); - let { prices, entitlements } = await handleNewProductItems({ + const { prices, entitlements } = await handleNewProductItems({ db, curPrices: [], curEnts: [], diff --git a/server/src/internal/products/free-trials/freeTrialUtils.ts b/server/src/internal/products/free-trials/freeTrialUtils.ts index 033a4307f..a7385c0a2 100644 --- a/server/src/internal/products/free-trials/freeTrialUtils.ts +++ b/server/src/internal/products/free-trials/freeTrialUtils.ts @@ -19,7 +19,7 @@ export const validateOneOffTrial = async ({ freeTrial, }: { prices: Price[]; - freeTrial: FreeTrial | null; + freeTrial: FreeTrial | CreateFreeTrial | null; }) => { if (isOneOff(prices) && freeTrial) { throw new RecaseError({ @@ -194,5 +194,5 @@ export const handleNewFreeTrial = async ({ }); } - return createdFreeTrial; + return createdFreeTrial as FreeTrial; }; diff --git a/server/src/internal/products/handlers/handleCreateProduct.ts b/server/src/internal/products/handlers/handleCreateProduct.ts index dd8c00b1b..793d1e2a4 100644 --- a/server/src/internal/products/handlers/handleCreateProduct.ts +++ b/server/src/internal/products/handlers/handleCreateProduct.ts @@ -1,95 +1,45 @@ import { - type CreateProductParams, - CreateProductParamsSchema, + type CreateProductV2Params, + CreateProductV2ParamsSchema, + type Entitlement, type FreeTrial, - type Product, + type FullProduct, + type Price, ProductAlreadyExistsError, - type ProductItem, } from "@autumn/shared"; import { createRoute } from "@/honoMiddlewares/routeHandler.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { JobName } from "@/queue/JobName.js"; +import { addTaskToQueue } from "@/queue/queueUtils.js"; +import { getEntsWithFeature } from "../entitlements/entitlementUtils.js"; +import { + handleNewFreeTrial, + validateOneOffTrial, +} from "../free-trials/freeTrialUtils.js"; import { ProductService } from "../ProductService.js"; +import { handleNewProductItems } from "../product-items/productItemUtils/handleNewProductItems.js"; import { isDefaultTrial } from "../productUtils/classifyProduct.js"; -import { getGroupToDefaults } from "../productUtils.js"; - -const validateCreateProduct = async ({ - ctx, - body, -}: { - ctx: AutumnContext; - body: CreateProductParams; -}) => { - // const { free_trial, items } = body; - const { org, env, db, features } = ctx; - - // const productData = CreateProductSchema.parse(req.body); - - // validateId("Product", productData.id); - - // if (nullish(req.body.name)) { - // productData.name = keyToTitle(productData.id); - // } - - const existing = await ProductService.get({ - db, - orgId: org.id, - env, - id: body.id, - }); - - // 1. If existing product, throw error - if (existing) { - throw new ProductAlreadyExistsError({ productId: body.id }); - } - - // 2. Validate items if exist - - // if (items && !Array.isArray(items)) { - // throw new RecaseError({ - // message: "Items must be an array", - // code: ErrCode.InvalidRequest, - // }); - // } else if (items) { - // validateProductItems({ - // newItems: items, - // features, - // orgId: req.orgId, - // env: req.env, - // }); - // } - - // 3. Validate free trial if exist - // let freeTrial: FreeTrial | null = null; - // if (notNullish(free_trial)) { - // // console.log("Free trial before:", free_trial); - // freeTrial = validateAndInitFreeTrial({ - // freeTrial: free_trial, - // internalProductId: productData.id, - // isCustom: false, - // }); - // // console.log("Free trial after:", freeTrial); - // } - - return { - features, - // freeTrial, - // productData, - }; -}; +import { getProductResponse } from "../productUtils/productResponseUtils/getProductResponse.js"; +import { + constructProduct, + getGroupToDefaults, + initProductInStripe, +} from "../productUtils.js"; export const disableCurrentDefault = async ({ req, newProduct, - items, - freeTrial, + // items, + // freeTrial, }: { req: AutumnContext; - newProduct: Product; - items: ProductItem[]; - freeTrial: FreeTrial; + newProduct: CreateProductV2Params; + // items: ProductItem[]; + // freeTrial: FreeTrial; }) => { const { db, org, env, logger } = req; + let defaultProds = await ProductService.listDefault({ db, orgId: org.id, @@ -104,13 +54,7 @@ export const disableCurrentDefault = async ({ defaultProds, })?.[newProduct.group]; - const willBeDefaultTrial = isDefaultTrial({ - product: { - ...newProduct, - free_trial: freeTrial, - items: items || [], - }, - }); + const willBeDefaultTrial = isDefaultTrial({ product: newProduct }); if (willBeDefaultTrial) { // Disable current default trial @@ -142,100 +86,102 @@ export const disableCurrentDefault = async ({ * Route: POST /products - Create a product */ export const createProduct = createRoute({ - body: CreateProductParamsSchema, + body: CreateProductV2ParamsSchema, handler: async (c) => { const body = c.req.valid("json"); - const query = c.req.valid("query"); const ctx = c.get("ctx"); + // const query = c.req.valid("query"); const { logger, org, features, env, db } = ctx; - const { items } = body; - // const { features, freeTrial, productData } = await validateCreateProduct({ - // ctx, - // body, - // }); + const existing = await ProductService.get({ + db, + orgId: org.id, + env, + id: body.id, + }); - // const newProduct = constructProduct({ - // productData, - // orgId: org.id, - // env, - // }); + // 1. If existing product, throw error + if (existing) throw new ProductAlreadyExistsError({ productId: body.id }); - return c.json({}); + await disableCurrentDefault({ + req: ctx, + newProduct: body, + }); - // await disableCurrentDefault({ - // req, - // newProduct, - // items, - // freeTrial: freeTrial || null, - // }); + const product = await ProductService.insert({ + db, + product: constructProduct({ + productData: body, + orgId: org.id, + env, + }), + }); - // const product = await ProductService.insert({ db, product: newProduct }); + const { items, free_trial } = body; - // let prices: Price[] = []; - // let entitlements: Entitlement[] = []; - // if (notNullish(items)) { - // const res = await handleNewProductItems({ - // db, - // product, - // features, - // curPrices: [], - // curEnts: [], - // newItems: items, - // logger, - // isCustom: false, - // newVersion: false, - // }); - // prices = res.prices; - // entitlements = res.entitlements; - // } + let prices: Price[] = []; + let entitlements: Entitlement[] = []; + if (items) { + const res = await handleNewProductItems({ + db, + product, + features, + curPrices: [], + curEnts: [], + newItems: items, + logger, + isCustom: false, + newVersion: false, + }); + prices = res.prices; + entitlements = res.entitlements; + } - // await validateOneOffTrial({ - // prices, - // freeTrial: freeTrial || null, - // }); + await validateOneOffTrial({ + prices, + freeTrial: free_trial || null, + }); - // await initProductInStripe({ - // db, - // product: { - // ...product, - // prices, - // entitlements, - // } as FullProduct, - // org, - // env, - // logger, - // }); + let newFreeTrial: FreeTrial | null = null; + if (free_trial) { + newFreeTrial = + (await handleNewFreeTrial({ + db, + newFreeTrial: free_trial, + curFreeTrial: null, + internalProductId: product.internal_id, + isCustom: false, + })) || null; + } - // if (notNullish(freeTrial)) { - // await handleNewFreeTrial({ - // db, - // newFreeTrial: freeTrial, - // curFreeTrial: null, - // internalProductId: product.internal_id, - // isCustom: false, - // }); - // } + const newFullProduct: FullProduct = { + ...product, + prices, + entitlements: getEntsWithFeature({ ents: entitlements, features }), + free_trial: newFreeTrial, + }; - // await addTaskToQueue({ - // jobName: JobName.DetectBaseVariant, - // payload: { - // curProduct: { - // ...product, - // prices, - // entitlements: [], - // }, - // }, - // }); + await initProductInStripe({ + db, + product: newFullProduct, + org, + env, + logger, + }); - // res.status(200).json( - // APIProductSchema.parse({ - // ...product, - // autumn_id: product.internal_id, - // items: items || [], - // free_trial: freeTrial, - // }), - // ); + await addTaskToQueue({ + jobName: JobName.DetectBaseVariant, + payload: { + curProduct: newFullProduct, + }, + }); + + const productResponse = await getProductResponse({ + product: newFullProduct, + features, + }); + + return c.json(productResponse); }, }); diff --git a/server/src/internal/products/handlers/handleUpdateProduct/handleUpdateProduct.ts b/server/src/internal/products/handlers/handleUpdateProduct/handleUpdateProduct.ts index 6448f7e6f..4624c1f6e 100644 --- a/server/src/internal/products/handlers/handleUpdateProduct/handleUpdateProduct.ts +++ b/server/src/internal/products/handlers/handleUpdateProduct/handleUpdateProduct.ts @@ -1,214 +1,214 @@ import { - ErrCode, + CreateProductV2ParamsSchema, + mapToProductV2, + notNullish, ProductNotFoundError, + ProductV2Schema, + RecaseError, + UpdateProductQuerySchema, UpdateProductSchema, + UpdateProductV2ParamsSchema, } from "@autumn/shared"; +import { createRoute } from "@/honoMiddlewares/routeHandler.js"; import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js"; -import { FeatureService } from "@/internal/features/FeatureService.js"; -import { OrgService } from "@/internal/orgs/OrgService.js"; -import { handleNewFreeTrial } from "@/internal/products/free-trials/freeTrialUtils.js"; -import { handleNewProductItems } from "@/internal/products/product-items/productItemUtils/handleNewProductItems.js"; import { RewardProgramService } from "@/internal/rewards/RewardProgramService.js"; import { JobName } from "@/queue/JobName.js"; import { addTaskToQueue } from "@/queue/queueUtils.js"; -import RecaseError from "@/utils/errorUtils.js"; -import { notNullish } from "@/utils/genUtils.js"; -import { routeHandler } from "@/utils/routerUtils.js"; -import { validateOneOffTrial } from "../../free-trials/freeTrialUtils.js"; -import { ProductService } from "../../ProductService.js"; -import { productsAreSame } from "../../productUtils/compareProductUtils.js"; -import { initProductInStripe } from "../../productUtils.js"; -import { mapToProductItems } from "../../productV2Utils.js"; import { - disableCurrentDefault, - handleCreateProduct, -} from "../handleCreateProduct_old.js"; + handleNewFreeTrial, + validateOneOffTrial, +} from "../../free-trials/freeTrialUtils.js"; +import { ProductService } from "../../ProductService.js"; +import { handleNewProductItems } from "../../product-items/productItemUtils/handleNewProductItems.js"; +import { productsAreSame } from "../../productUtils/compareProductUtils.js"; +import { getProductResponse } from "../../productUtils/productResponseUtils/getProductResponse.js"; +import { initProductInStripe } from "../../productUtils.js"; +import { disableCurrentDefault } from "../handleCreateProduct.js"; import { handleVersionProductV2 } from "../handleVersionProduct.js"; import { handleUpdateProductDetails } from "./updateProductDetails.js"; -export const handleUpdateProductV2 = async (req: any, res: any) => - routeHandler({ - req, - res, - action: "Update product", - handler: async () => { - const { productId } = req.params; - const { version, upsert, disable_version } = req.query; - const { orgId, env, logger, db } = req; +export const handleUpdateProductV2 = createRoute({ + body: UpdateProductV2ParamsSchema, + query: UpdateProductQuerySchema, + handler: async (c) => { + const body = c.req.valid("json"); + const ctx = c.get("ctx"); + const productId = c.req.param("productId"); - const [features, org, fullProduct, rewardPrograms, _defaultProds] = - await Promise.all([ - FeatureService.getFromReq(req), - OrgService.getFromReq(req), - ProductService.getFull({ - db, - idOrInternalId: productId, - orgId, - env, - version: version ? parseInt(version) : undefined, - allowNotFound: upsert === "true", - }), - RewardProgramService.getByProductId({ - db, - productIds: [productId], - orgId, - env, - }), - ProductService.listDefault({ - db, - orgId, - env, - }), - ]); + const { db, org, env, features, logger } = ctx; + const { version, upsert, disable_version } = c.req.valid("query"); + // const { productId } = req.params; + // const { orgId, env, logger, db } = req; - if (!fullProduct) { - if (upsert === "true") { - await handleCreateProduct(req, res); - return; - } - - throw new ProductNotFoundError({ productId: productId }); - } - - const cusProductsCurVersion = - await CusProductService.getByInternalProductId({ - db, - internalProductId: fullProduct.internal_id, - }); - - const cusProductExists = cusProductsCurVersion.length > 0; - - await disableCurrentDefault({ - req, - newProduct: { - ...fullProduct, - ...req.body, - }, - items: - req.body.items || - mapToProductItems({ - prices: fullProduct.prices, - entitlements: fullProduct.entitlements, - features, - }), - freeTrial: req.body.free_trial || fullProduct.free_trial || null, - }); - - await handleUpdateProductDetails({ + const [fullProduct, rewardPrograms, _defaultProds] = await Promise.all([ + ProductService.getFull({ db, - curProduct: fullProduct, - newProduct: UpdateProductSchema.parse(req.body), - newFreeTrial: req.body.free_trial, - items: req.body.items, - org, - rewardPrograms, - logger, + idOrInternalId: productId, + orgId: org.id, + env, + version: version ? parseInt(version) : undefined, + allowNotFound: upsert === "true", + }), + RewardProgramService.getByProductId({ + db, + productIds: [productId], + orgId: org.id, + env, + }), + ProductService.listDefault({ + db, + orgId: org.id, + env, + }), + ]); + + if (!fullProduct) throw new ProductNotFoundError({ productId: productId }); + + // // How to go into another route handler? + // if (upsert === "true") await handleCreateProduct(c); + + // Start a transaction? + + const cusProductsCurVersion = + await CusProductService.getByInternalProductId({ + db, + internalProductId: fullProduct.internal_id, }); - const itemsExist = notNullish(req.body.items); - if (cusProductExists && itemsExist) { - if (disable_version === "true") { - throw new RecaseError({ - message: "Cannot auto save product as there are existing customers", - code: ErrCode.InvalidRequest, - statusCode: 400, - }); - } + const curProductV2 = mapToProductV2({ + product: fullProduct, + features, + }); - const { itemsSame, freeTrialsSame } = productsAreSame({ - newProductV2: req.body, - curProductV1: fullProduct, - features, - }); - const productSame = itemsSame && freeTrialsSame; + const cusProductExists = cusProductsCurVersion.length > 0; - if (!productSame) { - await handleVersionProductV2({ - req, - res, - latestProduct: fullProduct, - org, - env, - items: req.body.items, - freeTrial: req.body.free_trial, - }); - return; - } - res.status(200).send(fullProduct); - return; - } + await disableCurrentDefault({ + req: ctx, + newProduct: CreateProductV2ParamsSchema.parse({ + ...fullProduct, + ...body, + }), + }); - const { items, free_trial } = req.body; + await handleUpdateProductDetails({ + db, + curProduct: fullProduct, + newProduct: UpdateProductSchema.parse(body), + newFreeTrial: body.free_trial || curProductV2.free_trial || undefined, + items: body.items || curProductV2.items, + org, + rewardPrograms, + logger: ctx.logger, + }); - if (free_trial !== undefined) { - await validateOneOffTrial({ - prices: fullProduct.prices, - freeTrial: free_trial, + const itemsExist = notNullish(body.items); + + if (cusProductExists && itemsExist) { + const newProductV2 = ProductV2Schema.parse({ + ...body, + items: body.items || [], + }); + + if (disable_version === "true") { + throw new RecaseError({ + message: "Cannot auto save product as there are existing customers", }); } + const { itemsSame, freeTrialsSame } = productsAreSame({ + newProductV2, + curProductV1: fullProduct, + features, + }); + + const productSame = itemsSame && freeTrialsSame; + + if (!productSame) { + const newProduct = await handleVersionProductV2({ + ctx, + newProductV2, + latestProduct: fullProduct, + org, + env, + }); + + return c.json(newProduct); + } + + return c.json(fullProduct); + } + + const { free_trial } = body; + + if (body.items) { await handleNewProductItems({ db, curPrices: fullProduct.prices, curEnts: fullProduct.entitlements, - newItems: items, + newItems: body.items, features, product: fullProduct, - logger, + logger: ctx.logger, isCustom: false, }); + } - // New full product - const newFullProduct = await ProductService.getFull({ - db, - idOrInternalId: fullProduct.id, - orgId, - env, + // New full product + const newFullProduct = await ProductService.getFull({ + db, + idOrInternalId: fullProduct.id, + orgId: org.id, + env, + }); + + if (free_trial !== undefined) { + await validateOneOffTrial({ + prices: newFullProduct.prices, + freeTrial: free_trial, }); - if (free_trial !== undefined) { - await validateOneOffTrial({ - prices: newFullProduct.prices, - freeTrial: free_trial, - }); - - await handleNewFreeTrial({ - db, - curFreeTrial: fullProduct.free_trial, - newFreeTrial: free_trial, - internalProductId: fullProduct.internal_id, - isCustom: false, - product: fullProduct, - }); - } - - // New full product - await initProductInStripe({ + await handleNewFreeTrial({ db, + curFreeTrial: fullProduct.free_trial, + newFreeTrial: free_trial, + internalProductId: fullProduct.internal_id, + isCustom: false, + product: fullProduct, + }); + } + + // New full product + await initProductInStripe({ + db, + product: newFullProduct, + org, + env, + logger, + }); + + logger.info("Adding task to queue to detect base variant"); + await addTaskToQueue({ + jobName: JobName.DetectBaseVariant, + payload: { + curProduct: newFullProduct, + }, + }); + + await addTaskToQueue({ + jobName: JobName.RewardMigration, + payload: { + oldPrices: fullProduct.prices, + productId: fullProduct.id, + orgId: org.id, + env, + }, + }); + + return c.json( + getProductResponse({ product: newFullProduct, - org, - env, - logger, - }); - - logger.info("Adding task to queue to detect base variant"); - await addTaskToQueue({ - jobName: JobName.DetectBaseVariant, - payload: { - curProduct: newFullProduct, - }, - }); - - await addTaskToQueue({ - jobName: JobName.RewardMigration, - payload: { - oldPrices: fullProduct.prices, - productId: fullProduct.id, - orgId: org.id, - env, - }, - }); - res.status(200).send({ message: "Product updated" }); - return; - }, - }); + features, + }), + ); + }, +}); diff --git a/server/src/internal/products/handlers/handleUpdateProduct/handleUpdateProduct_old.ts b/server/src/internal/products/handlers/handleUpdateProduct/handleUpdateProduct_old.ts new file mode 100644 index 000000000..6448f7e6f --- /dev/null +++ b/server/src/internal/products/handlers/handleUpdateProduct/handleUpdateProduct_old.ts @@ -0,0 +1,214 @@ +import { + ErrCode, + ProductNotFoundError, + UpdateProductSchema, +} from "@autumn/shared"; +import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js"; +import { FeatureService } from "@/internal/features/FeatureService.js"; +import { OrgService } from "@/internal/orgs/OrgService.js"; +import { handleNewFreeTrial } from "@/internal/products/free-trials/freeTrialUtils.js"; +import { handleNewProductItems } from "@/internal/products/product-items/productItemUtils/handleNewProductItems.js"; +import { RewardProgramService } from "@/internal/rewards/RewardProgramService.js"; +import { JobName } from "@/queue/JobName.js"; +import { addTaskToQueue } from "@/queue/queueUtils.js"; +import RecaseError from "@/utils/errorUtils.js"; +import { notNullish } from "@/utils/genUtils.js"; +import { routeHandler } from "@/utils/routerUtils.js"; +import { validateOneOffTrial } from "../../free-trials/freeTrialUtils.js"; +import { ProductService } from "../../ProductService.js"; +import { productsAreSame } from "../../productUtils/compareProductUtils.js"; +import { initProductInStripe } from "../../productUtils.js"; +import { mapToProductItems } from "../../productV2Utils.js"; +import { + disableCurrentDefault, + handleCreateProduct, +} from "../handleCreateProduct_old.js"; +import { handleVersionProductV2 } from "../handleVersionProduct.js"; +import { handleUpdateProductDetails } from "./updateProductDetails.js"; + +export const handleUpdateProductV2 = async (req: any, res: any) => + routeHandler({ + req, + res, + action: "Update product", + handler: async () => { + const { productId } = req.params; + const { version, upsert, disable_version } = req.query; + const { orgId, env, logger, db } = req; + + const [features, org, fullProduct, rewardPrograms, _defaultProds] = + await Promise.all([ + FeatureService.getFromReq(req), + OrgService.getFromReq(req), + ProductService.getFull({ + db, + idOrInternalId: productId, + orgId, + env, + version: version ? parseInt(version) : undefined, + allowNotFound: upsert === "true", + }), + RewardProgramService.getByProductId({ + db, + productIds: [productId], + orgId, + env, + }), + ProductService.listDefault({ + db, + orgId, + env, + }), + ]); + + if (!fullProduct) { + if (upsert === "true") { + await handleCreateProduct(req, res); + return; + } + + throw new ProductNotFoundError({ productId: productId }); + } + + const cusProductsCurVersion = + await CusProductService.getByInternalProductId({ + db, + internalProductId: fullProduct.internal_id, + }); + + const cusProductExists = cusProductsCurVersion.length > 0; + + await disableCurrentDefault({ + req, + newProduct: { + ...fullProduct, + ...req.body, + }, + items: + req.body.items || + mapToProductItems({ + prices: fullProduct.prices, + entitlements: fullProduct.entitlements, + features, + }), + freeTrial: req.body.free_trial || fullProduct.free_trial || null, + }); + + await handleUpdateProductDetails({ + db, + curProduct: fullProduct, + newProduct: UpdateProductSchema.parse(req.body), + newFreeTrial: req.body.free_trial, + items: req.body.items, + org, + rewardPrograms, + logger, + }); + + const itemsExist = notNullish(req.body.items); + if (cusProductExists && itemsExist) { + if (disable_version === "true") { + throw new RecaseError({ + message: "Cannot auto save product as there are existing customers", + code: ErrCode.InvalidRequest, + statusCode: 400, + }); + } + + const { itemsSame, freeTrialsSame } = productsAreSame({ + newProductV2: req.body, + curProductV1: fullProduct, + features, + }); + const productSame = itemsSame && freeTrialsSame; + + if (!productSame) { + await handleVersionProductV2({ + req, + res, + latestProduct: fullProduct, + org, + env, + items: req.body.items, + freeTrial: req.body.free_trial, + }); + return; + } + res.status(200).send(fullProduct); + return; + } + + const { items, free_trial } = req.body; + + if (free_trial !== undefined) { + await validateOneOffTrial({ + prices: fullProduct.prices, + freeTrial: free_trial, + }); + } + + await handleNewProductItems({ + db, + curPrices: fullProduct.prices, + curEnts: fullProduct.entitlements, + newItems: items, + features, + product: fullProduct, + logger, + isCustom: false, + }); + + // New full product + const newFullProduct = await ProductService.getFull({ + db, + idOrInternalId: fullProduct.id, + orgId, + env, + }); + + if (free_trial !== undefined) { + await validateOneOffTrial({ + prices: newFullProduct.prices, + freeTrial: free_trial, + }); + + await handleNewFreeTrial({ + db, + curFreeTrial: fullProduct.free_trial, + newFreeTrial: free_trial, + internalProductId: fullProduct.internal_id, + isCustom: false, + product: fullProduct, + }); + } + + // New full product + await initProductInStripe({ + db, + product: newFullProduct, + org, + env, + logger, + }); + + logger.info("Adding task to queue to detect base variant"); + await addTaskToQueue({ + jobName: JobName.DetectBaseVariant, + payload: { + curProduct: newFullProduct, + }, + }); + + await addTaskToQueue({ + jobName: JobName.RewardMigration, + payload: { + oldPrices: fullProduct.prices, + productId: fullProduct.id, + orgId: org.id, + env, + }, + }); + res.status(200).send({ message: "Product updated" }); + return; + }, + }); diff --git a/server/src/internal/products/handlers/handleUpdateProduct/updateProductDetails.ts b/server/src/internal/products/handlers/handleUpdateProduct/updateProductDetails.ts index e8c22d163..ec18b84ec 100644 --- a/server/src/internal/products/handlers/handleUpdateProduct/updateProductDetails.ts +++ b/server/src/internal/products/handlers/handleUpdateProduct/updateProductDetails.ts @@ -1,5 +1,6 @@ import { type AppEnv, + type CreateFreeTrial, type FreeTrial, type FullProduct, isFreeProductV2, @@ -15,7 +16,6 @@ import { createStripeCli } from "@/external/stripe/utils.js"; import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js"; import { isStripeConnected } from "@/internal/orgs/orgUtils.js"; import { notNullish } from "@/utils/genUtils.js"; -import { FreeTrialService } from "../../free-trials/FreeTrialService.js"; import { ProductService } from "../../ProductService.js"; import { usagePriceToProductName } from "../../prices/priceUtils/usagePriceUtils/convertUsagePrice.js"; import { @@ -125,7 +125,7 @@ const willBeDefaultTrial = ({ }: { newProduct: UpdateProduct; curProduct: FullProduct; - newFreeTrial: FreeTrial; + newFreeTrial?: FreeTrial | CreateFreeTrial; newItems: ProductItem[]; }) => { // 1. Get final default @@ -157,7 +157,7 @@ export const handleUpdateProductDetails = async ({ db: DrizzleCli; curProduct: FullProduct; newProduct: UpdateProduct; - newFreeTrial: FreeTrial; + newFreeTrial?: FreeTrial | CreateFreeTrial; items: ProductItem[]; org: Organization; rewardPrograms: RewardProgram[]; @@ -170,11 +170,6 @@ export const handleUpdateProductDetails = async ({ env: curProduct.env as AppEnv, }); - const trialConfig = await FreeTrialService.getByProductId({ - db, - productId: curProduct.internal_id, - }); - // Should error if: // - New product is a default product // - Org is not allowed to have paid default products diff --git a/server/src/internal/products/handlers/handleVersionProduct.ts b/server/src/internal/products/handlers/handleVersionProduct.ts index c11baa571..0d406033a 100644 --- a/server/src/internal/products/handlers/handleVersionProduct.ts +++ b/server/src/internal/products/handlers/handleVersionProduct.ts @@ -1,12 +1,11 @@ import { type AppEnv, - CreateProductSchema, - type FreeTrial, + CreateProductV2ParamsSchema, type FullProduct, type Organization, - type ProductItem, + type ProductV2, } from "@autumn/shared"; -import { FeatureService } from "@/internal/features/FeatureService.js"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { EntitlementService } from "@/internal/products/entitlements/EntitlementService.js"; import { handleNewFreeTrial } from "@/internal/products/free-trials/freeTrialUtils.js"; import { ProductService } from "@/internal/products/ProductService.js"; @@ -22,48 +21,45 @@ import { addTaskToQueue } from "@/queue/queueUtils.js"; import { getEntsWithFeature } from "../entitlements/entitlementUtils.js"; export const handleVersionProductV2 = async ({ - req, - res, + ctx, + newProductV2, latestProduct, org, env, - items, - freeTrial, + // items, + // freeTrial, }: { - req: any; - res: any; + ctx: AutumnContext; + newProductV2: ProductV2; latestProduct: FullProduct; org: Organization; env: AppEnv; - items: ProductItem[]; - freeTrial: FreeTrial; + // items: ProductItem[]; + // freeTrial: FreeTrial; }) => { - const { db } = req; + const { db, features } = ctx; const curVersion = latestProduct.version; const newVersion = curVersion + 1; - const features = await FeatureService.getFromReq(req); - console.log( `Updating product ${latestProduct.id} version from ${curVersion} to ${newVersion}`, ); const newProduct = constructProduct({ - productData: CreateProductSchema.parse({ + productData: CreateProductV2ParamsSchema.parse({ ...latestProduct, - ...req.body, + ...newProductV2, version: newVersion, }), orgId: org.id, env: latestProduct.env as AppEnv, - processor: latestProduct.processor, - baseVariantId: latestProduct.base_variant_id, + processor: latestProduct.processor || undefined, }); // Validate product items... validateProductItems({ - newItems: items, + newItems: newProductV2.items, features, orgId: org.id, env, @@ -85,7 +81,7 @@ export const handleVersionProductV2 = async ({ db, curPrices: latestProduct.prices, curEnts: latestProduct.entitlements, - newItems: items, + newItems: newProductV2.items, features, product: newProduct, logger: console, @@ -104,27 +100,16 @@ export const handleVersionProductV2 = async ({ }); // Handle new free trial - if (freeTrial) { + if (newProductV2.free_trial) { await handleNewFreeTrial({ db, - newFreeTrial: freeTrial, + newFreeTrial: newProductV2.free_trial, curFreeTrial: null, internalProductId: newProduct.internal_id, isCustom: false, }); } - // await addTaskToQueue({ - // jobName: JobName.DetectBaseVariant, - // payload: { - // curProduct: { - // ...newProduct, - // // prices: customPrices, - // // entitlements: getEntsWithFeature({ ents: customEnts, features }), - // }, - // }, - // }); - await initProductInStripe({ db, product: { @@ -142,16 +127,10 @@ export const handleVersionProductV2 = async ({ payload: { oldPrices: latestProduct.prices, productId: latestProduct.id, - // newPrices: customPrices, - // product: { - // ...newProduct, - // prices: customPrices, - // entitlements: getEntsWithFeature({ ents: customEnts, features }), - // }, orgId: org.id, env, }, }); - res.status(200).send(newProduct); + return newProduct; }; diff --git a/server/src/internal/products/honoProductRouter.ts b/server/src/internal/products/honoProductRouter.ts deleted file mode 100644 index 90bff25d8..000000000 --- a/server/src/internal/products/honoProductRouter.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Hono } from "hono"; -import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; -import { createProduct } from "./handlers/handleCreateProduct.js"; - -// Create a Hono app for products -export const honoProductRouter = new Hono(); - -// POST /products - Create a product -honoProductRouter.post("", ...createProduct); diff --git a/server/src/internal/products/productRouter.ts b/server/src/internal/products/productRouter.ts index 912d9427d..8cef7819d 100644 --- a/server/src/internal/products/productRouter.ts +++ b/server/src/internal/products/productRouter.ts @@ -14,7 +14,6 @@ import { handleDeleteProduct } from "./handlers/handleDeleteProduct.js"; import { handleGetProduct } from "./handlers/handleGetProduct.js"; import { handleGetProductDeleteInfo } from "./handlers/handleGetProductDeleteInfo.js"; import { handleListProductsBeta } from "./handlers/handleListProductsBeta.js"; -import { handleUpdateProductV2 } from "./handlers/handleUpdateProduct/handleUpdateProduct.js"; import { productsAreSame } from "./productUtils/compareProductUtils.js"; export const productBetaRouter: Router = Router(); @@ -24,11 +23,9 @@ export const productRouter: Router = Router(); productRouter.get("", handleListProductsBeta); -// productRouter.post("", handleCreateProduct); - productRouter.get("/:productId", handleGetProduct); -productRouter.post("/:productId", handleUpdateProductV2); +// productRouter.post("/:productId", handleUpdateProductV2); productRouter.delete("/:productId", handleDeleteProduct); @@ -148,3 +145,15 @@ productRouter.get("/:productId/has_customers", async (req: any, res: any) => ); productRouter.get("/:productId/deletion_info", handleGetProductDeleteInfo); + +import { Hono } from "hono"; +import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; +import { createProduct } from "./handlers/handleCreateProduct.js"; +import { handleUpdateProductV2 } from "./handlers/handleUpdateProduct/handleUpdateProduct.js"; + +// Create a Hono app for products +export const honoProductRouter = new Hono(); + +// POST /products - Create a product +honoProductRouter.post("", ...createProduct); +honoProductRouter.post("/:productId", ...handleUpdateProductV2); diff --git a/server/src/internal/products/productUtils.ts b/server/src/internal/products/productUtils.ts index 3490c9706..83144d8b0 100644 --- a/server/src/internal/products/productUtils.ts +++ b/server/src/internal/products/productUtils.ts @@ -2,7 +2,7 @@ import { type AppEnv, BillingInterval, BillingType, - type CreateProduct, + type CreateProductV2Params, EntInterval, type Entitlement, EntitlementSchema, @@ -75,21 +75,28 @@ export const constructProduct = ({ orgId, env, processor, - baseVariantId, }: { - productData: CreateProduct; + productData: CreateProductV2Params; orgId: string; env: AppEnv; - processor?: any; - baseVariantId?: string | null; + processor?: { + id: string; + type: string; + }; }) => { const newProduct: Product = { - ...productData, - org_id: orgId, + id: productData.id, + name: productData.name, + is_add_on: productData.is_add_on, + is_default: productData.is_default, + version: productData.version || 1, + group: productData.group, + env, - processor, internal_id: generateId("prod"), + org_id: orgId, created_at: Date.now(), + processor, base_variant_id: null, archived: false, }; diff --git a/server/src/internal/products/productUtils/classifyProduct.ts b/server/src/internal/products/productUtils/classifyProduct.ts index 62381d088..80908ea52 100644 --- a/server/src/internal/products/productUtils/classifyProduct.ts +++ b/server/src/internal/products/productUtils/classifyProduct.ts @@ -1,4 +1,9 @@ -import type { FullProduct, Price, ProductV2 } from "@autumn/shared"; +import type { + CreateProductParams, + FullProduct, + Price, + ProductV2, +} from "@autumn/shared"; import { pricesOnlyOneOff } from "../prices/priceUtils.js"; import { isFeatureItem } from "../product-items/productItemUtils/getItemType.js"; import { isFreeProduct } from "../productUtils.js"; @@ -31,15 +36,19 @@ export const isMainProduct = ({ return !prodIsAddOn({ product }) && !oneOffOrAddOn({ product, prices }); }; -export const isFreeProductV2 = ({ product }: { product: ProductV2 }) => { - return product.items.every((item) => isFeatureItem(item)); +export const isFreeProductV2 = ({ + product, +}: { + product: ProductV2 | CreateProductParams; +}) => { + return (product.items || []).every((item) => isFeatureItem(item)); }; export const isDefaultTrial = ({ product, skipDefault = false, }: { - product: ProductV2; + product: ProductV2 | CreateProductParams; skipDefault?: boolean; }) => { return ( diff --git a/server/src/utils/scriptUtils/createTestProducts.ts b/server/src/utils/scriptUtils/createTestProducts.ts index 0c4217ae0..7dddbc4de 100644 --- a/server/src/utils/scriptUtils/createTestProducts.ts +++ b/server/src/utils/scriptUtils/createTestProducts.ts @@ -1,23 +1,22 @@ -import { - constructBooleanFeature, - constructMeteredFeature, -} from "@/internal/features/utils/constructFeatureUtils.js"; import { AppEnv, BillingInterval, CouponDurationType, - CreateFreeTrial, + type CreateFreeTrial, CreateFreeTrialSchema, - CreateReward, + type CreateReward, FeatureUsageType, - FreeTrial, FreeTrialDuration, - ProductItem, - ProductV2, + type ProductItem, + type ProductV2, RewardType, } from "@autumn/shared"; -import { keyToTitle } from "../genUtils.js"; +import { + constructBooleanFeature, + constructMeteredFeature, +} from "@/internal/features/utils/constructFeatureUtils.js"; import { constructPriceItem } from "@/internal/products/product-items/productItemUtils.js"; +import { keyToTitle } from "../genUtils.js"; export enum TestFeatureType { Boolean = "boolean", @@ -139,11 +138,11 @@ export const constructProduct = ({ ); } - let id_ = + const id_ = id || (isAnnual ? `${type}-annual` : interval ? `${type}-${interval}` : type); - let product: ProductV2 = { + const product: ProductV2 = { id: id_, name: id ? keyToTitle(id) diff --git a/shared/api/products/operations/createProductParams.ts b/shared/api/products/operations/createProductParams.ts index 747a8ffa4..70eb7fc5c 100644 --- a/shared/api/products/operations/createProductParams.ts +++ b/shared/api/products/operations/createProductParams.ts @@ -5,7 +5,9 @@ import { z } from "zod/v4"; export const CreateProductItemParamsSchema = ProductItemSchema; -export const CreateProductParamsSchema = z.object({ +// Base product params + +export const CreateProductV2ParamsSchema = z.object({ id: z.string().nonempty().regex(idRegex), name: z.string().refine((val) => val.length > 0, { @@ -18,7 +20,32 @@ export const CreateProductParamsSchema = z.object({ group: z.string().default(""), items: z.array(CreateProductItemParamsSchema).optional(), - free_trial: CreateFreeTrialSchema.optional(), + free_trial: CreateFreeTrialSchema.nullish().default(null), }); -export type CreateProductParams = z.infer; +export const UpdateProductV2ParamsSchema = CreateProductV2ParamsSchema.extend({ + id: z.string().nonempty().regex(idRegex).optional(), + name: z + .string() + .refine((val) => val.length > 0, { + message: "name must be a non-empty string", + }) + .optional(), + + is_add_on: z.boolean().optional(), + is_default: z.boolean().optional(), + version: z.number().optional(), + group: z.string().optional(), + + // items: z.array(CreateProductItemParamsSchema).optional(), + free_trial: CreateFreeTrialSchema.nullish(), +}); + +export const UpdateProductQuerySchema = z.object({ + version: z.string().optional(), + upsert: z.string().optional(), + disable_version: z.string().optional(), +}); + +export type CreateProductV2Params = z.infer; +export type UpdateProductV2Params = z.infer; diff --git a/shared/models/productModels/productModels.ts b/shared/models/productModels/productModels.ts index a3e163b58..2a830bfb3 100644 --- a/shared/models/productModels/productModels.ts +++ b/shared/models/productModels/productModels.ts @@ -13,7 +13,7 @@ export const ProductSchema = z.object({ version: z.number(), group: z.string(), - env: z.nativeEnum(AppEnv), + env: z.enum(AppEnv), internal_id: z.string(), org_id: z.string(), created_at: z.number(), diff --git a/shared/openapi.yaml b/shared/openapi.yaml index c2c2f12dc..a8afa088a 100644 --- a/shared/openapi.yaml +++ b/shared/openapi.yaml @@ -36,6 +36,174 @@ paths: group: default: "" type: string + items: + type: array + items: + type: object + properties: + feature_id: + anyOf: + - type: string + - type: "null" + feature_type: + anyOf: + - type: string + enum: + - single_use + - continuous_use + - boolean + - static + - type: "null" + included_usage: + anyOf: + - anyOf: + - type: number + - type: string + const: inf + - type: "null" + interval: + anyOf: + - type: string + enum: + - minute + - hour + - day + - week + - month + - quarter + - semi_annual + - year + - type: "null" + interval_count: + anyOf: + - type: number + - type: "null" + entity_feature_id: + anyOf: + - type: string + - type: "null" + usage_model: + anyOf: + - type: string + enum: + - prepaid + - pay_per_use + - type: "null" + price: + anyOf: + - type: number + - type: "null" + tiers: + anyOf: + - type: array + items: + type: object + properties: + to: + anyOf: + - type: number + - type: string + const: inf + amount: + type: number + required: + - to + - amount + - type: "null" + billing_units: + anyOf: + - type: number + - type: "null" + usage_limit: + anyOf: + - type: number + - type: "null" + reset_usage_when_enabled: + anyOf: + - type: boolean + - type: "null" + config: + anyOf: + - type: object + properties: + on_increase: + anyOf: + - type: string + enum: + - bill_immediately + - prorate_immediately + - prorate_next_cycle + - bill_next_cycle + - type: "null" + on_decrease: + anyOf: + - type: string + enum: + - prorate + - prorate_immediately + - prorate_next_cycle + - none + - no_prorations + - type: "null" + rollover: + anyOf: + - type: object + properties: + max: + anyOf: + - type: number + - type: "null" + duration: + default: month + type: string + enum: + - month + - forever + length: + type: number + required: + - max + - length + - type: "null" + - type: "null" + created_at: + anyOf: + - type: number + - type: "null" + entitlement_id: + anyOf: + - type: string + - type: "null" + price_id: + anyOf: + - type: string + - type: "null" + price_config: + anyOf: + - {} + - type: "null" + free_trial: + type: object + properties: + length: + anyOf: + - type: string + - type: number + unique_fingerprint: + default: false + type: boolean + duration: + default: day + type: string + enum: + - day + - month + - year + card_required: + default: true + type: boolean + required: + - length required: - id - name diff --git a/vite/src/views/products/products/components/CreateProductDialog.tsx b/vite/src/views/products/products/components/CreateProductDialog.tsx index 9e15d1e95..35152b437 100644 --- a/vite/src/views/products/products/components/CreateProductDialog.tsx +++ b/vite/src/views/products/products/components/CreateProductDialog.tsx @@ -1,3 +1,9 @@ +import type { ProductV2 } from "@autumn/shared"; +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router"; +import { toast } from "sonner"; +import { WarningBox } from "@/components/general/modal-components/WarningBox"; +import { ToggleButton } from "@/components/general/ToggleButton"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -6,19 +12,11 @@ import { DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; - +import { useProductsQuery } from "@/hooks/queries/useProductsQuery"; import { ProductService } from "@/services/products/ProductService"; -import { useNavigate } from "react-router"; - import { useAxiosInstance } from "@/services/useAxiosInstance"; -import { useEffect, useState } from "react"; -import { toast } from "sonner"; import { getBackendErr, navigateTo } from "@/utils/genUtils"; import { ProductConfig } from "../../ProductConfig"; -import { ProductV2 } from "@autumn/shared"; -import { ToggleButton } from "@/components/general/ToggleButton"; -import { WarningBox } from "@/components/general/modal-components/WarningBox"; -import { useProductsQuery } from "@/hooks/queries/useProductsQuery"; const defaultProduct = { name: "",