From dcbb20eef65851b7fa88bc97254d9c143adbcd80 Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Tue, 17 Feb 2026 14:36:21 +0000 Subject: [PATCH 1/4] feat: rpc router for plans --- server/src/external/autumn/autumnRpcCli.ts | 136 +++++++++++ .../refreshProductsCacheMiddleware.ts | 5 + .../handleCreatePlan.ts | 14 +- .../handleCreateProduct/handleCreatePlanV2.ts | 51 ++++ .../products/handlers/handleDeleteProduct.ts | 80 ------- .../handleDeleteProduct/handleDeletePlanV2.ts | 18 ++ .../handleDeleteProduct.ts | 29 +++ .../{ => handleGetProduct}/handleGetPlan.ts | 2 +- .../handleGetProduct/handleGetPlanV2.ts | 40 ++++ .../handleUpdateProduct/handleUpdatePlanV2.ts | 108 +++++++++ .../handlers/productActions/deleteProduct.ts | 66 ++++++ .../handlers/productActions/updateProduct.ts | 6 +- server/src/internal/products/planRpcRouter.ts | 13 + server/src/internal/products/productRouter.ts | 6 +- server/src/routers/apiRouter.ts | 2 + server/src/routers/rpcRouter.ts | 7 + server/tests/_temp/temp.test.ts | 159 +++++++++++-- .../rpc-regression/rest-rpc-roundtrip.test.ts | 84 +++++++ .../rpc/create-plan-advanced.rpc.test.ts | 129 ++++++++++ .../plans/rpc/create-plan-basic.rpc.test.ts | 96 ++++++++ .../crud/plans/rpc/delete-plan.rpc.test.ts | 44 ++++ .../plans/rpc/get/get-plan-basic.rpc.test.ts | 45 ++++ ...plans-cross-version.rpc-regression.test.ts | 59 +++++ .../crud/plans/rpc/update-plan.rpc.test.ts | 223 ++++++++++++++++++ .../api/products/crud/deletePlanParamsV2.ts | 6 + shared/api/products/crud/index.ts | 1 + .../api/products/crud/updatePlanParamsV0.ts | 23 ++ 27 files changed, 1336 insertions(+), 116 deletions(-) create mode 100644 server/src/external/autumn/autumnRpcCli.ts rename server/src/internal/products/handlers/{ => handleCreateProduct}/handleCreatePlan.ts (86%) create mode 100644 server/src/internal/products/handlers/handleCreateProduct/handleCreatePlanV2.ts delete mode 100644 server/src/internal/products/handlers/handleDeleteProduct.ts create mode 100644 server/src/internal/products/handlers/handleDeleteProduct/handleDeletePlanV2.ts create mode 100644 server/src/internal/products/handlers/handleDeleteProduct/handleDeleteProduct.ts rename server/src/internal/products/handlers/{ => handleGetProduct}/handleGetPlan.ts (94%) create mode 100644 server/src/internal/products/handlers/handleGetProduct/handleGetPlanV2.ts create mode 100644 server/src/internal/products/handlers/handleUpdateProduct/handleUpdatePlanV2.ts create mode 100644 server/src/internal/products/handlers/productActions/deleteProduct.ts create mode 100644 server/src/internal/products/planRpcRouter.ts create mode 100644 server/src/routers/rpcRouter.ts create mode 100644 server/tests/integration/crud/plans/rpc-regression/rest-rpc-roundtrip.test.ts create mode 100644 server/tests/integration/crud/plans/rpc/create-plan-advanced.rpc.test.ts create mode 100644 server/tests/integration/crud/plans/rpc/create-plan-basic.rpc.test.ts create mode 100644 server/tests/integration/crud/plans/rpc/delete-plan.rpc.test.ts create mode 100644 server/tests/integration/crud/plans/rpc/get/get-plan-basic.rpc.test.ts create mode 100644 server/tests/integration/crud/plans/rpc/list/list-plans-cross-version.rpc-regression.test.ts create mode 100644 server/tests/integration/crud/plans/rpc/update-plan.rpc.test.ts create mode 100644 shared/api/products/crud/deletePlanParamsV2.ts diff --git a/server/src/external/autumn/autumnRpcCli.ts b/server/src/external/autumn/autumnRpcCli.ts new file mode 100644 index 000000000..c92207de8 --- /dev/null +++ b/server/src/external/autumn/autumnRpcCli.ts @@ -0,0 +1,136 @@ +/** biome-ignore-all lint/suspicious/noExplicitAny: RPC test client needs flexible payload typing */ +import dotenv from "dotenv"; + +dotenv.config(); + +import { ErrCode, type OrgConfig } from "@autumn/shared"; +import AutumnError from "./autumnCli.js"; + +export class AutumnRpcCli { + private apiKey: string; + public headers: Record; + public baseUrl: string; + + constructor({ + apiKey, + secretKey, + baseUrl, + version, + orgConfig, + liveUrl = false, + }: { + apiKey?: string; + secretKey?: string; + baseUrl?: string; + version?: string; + orgConfig?: Partial; + liveUrl?: boolean; + } = {}) { + this.apiKey = + apiKey || secretKey || process.env.UNIT_TEST_AUTUMN_SECRET_KEY || ""; + + this.headers = { + Authorization: `Bearer ${this.apiKey}`, + "Content-Type": "application/json", + }; + + if (version) { + this.headers["x-api-version"] = version; + } + + if (orgConfig) { + this.headers["org-config"] = JSON.stringify(orgConfig); + } + + this.baseUrl = + baseUrl || + (liveUrl ? "https://api.useautumn.com/v1" : "http://localhost:8080/v1"); + } + + private resolvePath(path: string) { + return path.startsWith("/") ? path : `/${path}`; + } + + async post(path: string, body: any) { + const response = await fetch(`${this.baseUrl}${this.resolvePath(path)}`, { + method: "POST", + headers: this.headers, + body: JSON.stringify(body), + }); + + if (response.status !== 200) { + // Handle rate limit errors + if (response.status === 429) { + throw new AutumnError({ + message: "request failed, rate limit exceeded", + code: "rate_limit_exceeded", + }); + } + + let error: any; + try { + error = await response.json(); + } catch (error) { + throw new AutumnError({ + message: `request failed, error: ${error}`, + code: ErrCode.InternalError, + }); + } + + throw new AutumnError({ + message: error.message, + code: error.code, + }); + } + + return response.json(); + } + + rpc = { + call: async ({ + method, + body, + }: { + method: string; + body: any; + }): Promise => { + return (await this.post(method, body)) as T; + }, + }; + + plans = { + create: async ( + plan: TInput, + ): Promise => { + return await this.post("/plans.create", plan); + }, + + get: async (planId: string): Promise => { + return await this.post("/plans.get", { + plan_id: planId, + }); + }, + + update: async ( + planId: string, + updates: TInput, + ): Promise => { + return await this.post("/plans.update", { + ...(updates as Record), + plan_id: planId, + }); + }, + + delete: async ( + planId: string, + { allVersions = false }: { allVersions?: boolean } = {}, + ): Promise<{ success: boolean }> => { + return await this.post("/plans.delete", { + plan_id: planId, + all_versions: allVersions, + }); + }, + }; +} + +export default AutumnRpcCli; diff --git a/server/src/honoMiddlewares/refreshProductsCacheMiddleware.ts b/server/src/honoMiddlewares/refreshProductsCacheMiddleware.ts index 57438a7a9..f50d81dfe 100644 --- a/server/src/honoMiddlewares/refreshProductsCacheMiddleware.ts +++ b/server/src/honoMiddlewares/refreshProductsCacheMiddleware.ts @@ -19,6 +19,11 @@ const productRoutes = [ { method: "POST", url: "/plans/:plan_id" }, { method: "PATCH", url: "/plans/:plan_id" }, { method: "DELETE", url: "/plans/:plan_id" }, + + // RPC plan routes + { method: "POST", url: "/plans.create" }, + { method: "POST", url: "/plans.update" }, + { method: "POST", url: "/plans.delete" }, ]; /** diff --git a/server/src/internal/products/handlers/handleCreatePlan.ts b/server/src/internal/products/handlers/handleCreateProduct/handleCreatePlan.ts similarity index 86% rename from server/src/internal/products/handlers/handleCreatePlan.ts rename to server/src/internal/products/handlers/handleCreateProduct/handleCreatePlan.ts index 096d0ce10..426612090 100644 --- a/server/src/internal/products/handlers/handleCreatePlan.ts +++ b/server/src/internal/products/handlers/handleCreateProduct/handleCreatePlan.ts @@ -18,17 +18,17 @@ import { createRoute } from "@/honoMiddlewares/routeHandler.js"; import { JobName } from "@/queue/JobName.js"; import { addTaskToQueue } from "@/queue/queueUtils.js"; import { captureOrgEvent } from "@/utils/posthog.js"; -import { getEntsWithFeature } from "../entitlements/entitlementUtils.js"; +import { getEntsWithFeature } from "../../entitlements/entitlementUtils.js"; import { handleNewFreeTrial, validateOneOffTrial, -} from "../free-trials/freeTrialUtils.js"; +} from "../../free-trials/freeTrialUtils.js"; -import { ProductService } from "../ProductService.js"; -import { handleNewProductItems } from "../product-items/productItemUtils/handleNewProductItems.js"; -import { getPlanResponse } from "../productUtils/productResponseUtils/getPlanResponse.js"; -import { constructProduct, initProductInStripe } from "../productUtils.js"; -import { validateDefaultFlag } from "./productActions/validateDefaultFlag.js"; +import { ProductService } from "../../ProductService.js"; +import { handleNewProductItems } from "../../product-items/productItemUtils/handleNewProductItems.js"; +import { getPlanResponse } from "../../productUtils/productResponseUtils/getPlanResponse.js"; +import { constructProduct, initProductInStripe } from "../../productUtils.js"; +import { validateDefaultFlag } from "../productActions/validateDefaultFlag.js"; /** * Route: POST /products - Create a product diff --git a/server/src/internal/products/handlers/handleCreateProduct/handleCreatePlanV2.ts b/server/src/internal/products/handlers/handleCreateProduct/handleCreatePlanV2.ts new file mode 100644 index 000000000..c4382f0bc --- /dev/null +++ b/server/src/internal/products/handlers/handleCreateProduct/handleCreatePlanV2.ts @@ -0,0 +1,51 @@ +import { + AffectedResource, + apiPlan, + CreatePlanParamsV1Schema, + type CreateProductV2Params, +} from "@autumn/shared"; +import { createRoute } from "@/honoMiddlewares/routeHandler.js"; +import { FeatureService } from "@/internal/features/FeatureService.js"; +import { ProductService } from "../../ProductService.js"; +import { getPlanResponse } from "../../productUtils/productResponseUtils/getPlanResponse.js"; +import { createProduct } from "../productActions/createProduct.js"; + +export const handleCreatePlanV2 = createRoute({ + body: CreatePlanParamsV1Schema, + resource: AffectedResource.Product, + handler: async (c) => { + const body = c.req.valid("json"); + const ctx = c.get("ctx"); + + const createParams = apiPlan.map.paramsV1ToProductV2({ + ctx, + params: body, + }) as CreateProductV2Params; + + await createProduct({ + ctx, + data: createParams, + }); + + const [fullProduct, features] = await Promise.all([ + ProductService.getFull({ + db: ctx.db, + idOrInternalId: body.id, + orgId: ctx.org.id, + env: ctx.env, + }), + FeatureService.list({ + db: ctx.db, + orgId: ctx.org.id, + env: ctx.env, + }), + ]); + + const latestPlan = await getPlanResponse({ + product: fullProduct, + features, + }); + + return c.json(latestPlan); + }, +}); diff --git a/server/src/internal/products/handlers/handleDeleteProduct.ts b/server/src/internal/products/handlers/handleDeleteProduct.ts deleted file mode 100644 index 1b56b1677..000000000 --- a/server/src/internal/products/handlers/handleDeleteProduct.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { - AffectedResource, - ProductNotFoundError, - RecaseError, -} from "@autumn/shared"; -import { z } from "zod/v4"; -import { createRoute } from "@/honoMiddlewares/routeHandler.js"; -import { CusProdReadService } from "@/internal/customers/cusProducts/CusProdReadService.js"; -import { ProductService } from "@/internal/products/ProductService.js"; - -const DeleteProductParamsSchema = z.object({ - product_id: z.string(), -}); - -const DeleteProductQuerySchema = z.object({ - all_versions: z.boolean().default(false), -}); - -export const handleDeleteProduct = createRoute({ - params: DeleteProductParamsSchema, - query: DeleteProductQuerySchema, - resource: AffectedResource.Product, - handler: async (c) => { - const { product_id } = c.req.param(); - const { all_versions } = c.req.valid("query"); - const { db, org, env } = c.get("ctx"); - - const product = await ProductService.get({ - db, - id: product_id, - orgId: org.id, - env, - }); - - if (!product) { - throw new ProductNotFoundError({ productId: product_id }); - } - - const [latestCounts, allCounts] = await Promise.all([ - CusProdReadService.getCounts({ - db, - internalProductId: product.internal_id, - }), - CusProdReadService.getCountsForAllVersions({ - db, - productId: product_id, - orgId: org.id, - env, - }), - ]); - - const deleteAllVersions = all_versions === true; - const cusProdCount = deleteAllVersions ? allCounts.all : latestCounts.all; - - if (cusProdCount > 0) { - throw new RecaseError({ - message: `Product ${product_id} has ${cusProdCount} customers (expired or active) on it and therefore cannot be deleted`, - }); - } - - // 2. Delete prices, entitlements, and product - if (deleteAllVersions) { - await ProductService.deleteByProductId({ - db, - productId: product_id, - orgId: org.id, - env, - }); - } else { - await ProductService.deleteByInternalId({ - db, - internalId: product.internal_id, - orgId: org.id, - env, - }); - } - - return c.json({ success: true }); - }, -}); diff --git a/server/src/internal/products/handlers/handleDeleteProduct/handleDeletePlanV2.ts b/server/src/internal/products/handlers/handleDeleteProduct/handleDeletePlanV2.ts new file mode 100644 index 000000000..9637fb0ad --- /dev/null +++ b/server/src/internal/products/handlers/handleDeleteProduct/handleDeletePlanV2.ts @@ -0,0 +1,18 @@ +import { AffectedResource, DeletePlanV2BodySchema } from "@autumn/shared"; +import { createRoute } from "@/honoMiddlewares/routeHandler.js"; +import { deleteProduct } from "../productActions/deleteProduct.js"; + +export const handleDeletePlanV2 = createRoute({ + body: DeletePlanV2BodySchema, + resource: AffectedResource.Product, + handler: async (c) => { + const { plan_id, all_versions = false } = c.req.valid("json"); + const { success } = await deleteProduct({ + ctx: c.get("ctx"), + productId: plan_id, + allVersions: all_versions, + }); + + return c.json({ success }); + }, +}); diff --git a/server/src/internal/products/handlers/handleDeleteProduct/handleDeleteProduct.ts b/server/src/internal/products/handlers/handleDeleteProduct/handleDeleteProduct.ts new file mode 100644 index 000000000..54852cda1 --- /dev/null +++ b/server/src/internal/products/handlers/handleDeleteProduct/handleDeleteProduct.ts @@ -0,0 +1,29 @@ +import { AffectedResource } from "@autumn/shared"; +import { z } from "zod/v4"; +import { createRoute } from "@/honoMiddlewares/routeHandler.js"; +import { deleteProduct } from "../productActions/deleteProduct.js"; + +const DeleteProductParamsSchema = z.object({ + product_id: z.string(), +}); + +const DeleteProductQuerySchema = z.object({ + all_versions: z.boolean().default(false), +}); + +export const handleDeleteProduct = createRoute({ + params: DeleteProductParamsSchema, + query: DeleteProductQuerySchema, + resource: AffectedResource.Product, + handler: async (c) => { + const { product_id } = c.req.param(); + const { all_versions } = c.req.valid("query"); + const { success } = await deleteProduct({ + ctx: c.get("ctx"), + productId: product_id, + allVersions: all_versions, + }); + + return c.json({ success }); + }, +}); diff --git a/server/src/internal/products/handlers/handleGetPlan.ts b/server/src/internal/products/handlers/handleGetProduct/handleGetPlan.ts similarity index 94% rename from server/src/internal/products/handlers/handleGetPlan.ts rename to server/src/internal/products/handlers/handleGetProduct/handleGetPlan.ts index e89bc52cd..0a3c3fb7e 100644 --- a/server/src/internal/products/handlers/handleGetPlan.ts +++ b/server/src/internal/products/handlers/handleGetProduct/handleGetPlan.ts @@ -9,7 +9,7 @@ import { import { z } from "zod/v4"; import { createRoute } from "@/honoMiddlewares/routeHandler.js"; import { ProductService } from "@/internal/products/ProductService.js"; -import { getPlanResponse } from "../productUtils/productResponseUtils/getPlanResponse.js"; +import { getPlanResponse } from "../../productUtils/productResponseUtils/getPlanResponse.js"; const GetProductQuerySchema = z.object({ schemaVersion: z.string().optional(), diff --git a/server/src/internal/products/handlers/handleGetProduct/handleGetPlanV2.ts b/server/src/internal/products/handlers/handleGetProduct/handleGetPlanV2.ts new file mode 100644 index 000000000..6e8e8b074 --- /dev/null +++ b/server/src/internal/products/handlers/handleGetProduct/handleGetPlanV2.ts @@ -0,0 +1,40 @@ +import { AffectedResource } from "@autumn/shared"; +import { z } from "zod/v4"; +import { createRoute } from "@/honoMiddlewares/routeHandler.js"; +import { FeatureService } from "@/internal/features/FeatureService.js"; +import { ProductService } from "../../ProductService.js"; +import { getPlanResponse } from "../../productUtils/productResponseUtils/getPlanResponse.js"; + +const GetPlanV2BodySchema = z.object({ + plan_id: z.string().nonempty(), +}); + +export const handleGetPlanV2 = createRoute({ + body: GetPlanV2BodySchema, + resource: AffectedResource.Product, + handler: async (c) => { + const { plan_id } = c.req.valid("json"); + const ctx = c.get("ctx"); + + const [fullProduct, features] = await Promise.all([ + ProductService.getFull({ + db: ctx.db, + idOrInternalId: plan_id, + orgId: ctx.org.id, + env: ctx.env, + }), + FeatureService.list({ + db: ctx.db, + orgId: ctx.org.id, + env: ctx.env, + }), + ]); + + const latestPlan = await getPlanResponse({ + product: fullProduct, + features, + }); + + return c.json(latestPlan); + }, +}); diff --git a/server/src/internal/products/handlers/handleUpdateProduct/handleUpdatePlanV2.ts b/server/src/internal/products/handlers/handleUpdateProduct/handleUpdatePlanV2.ts new file mode 100644 index 000000000..d565dcd09 --- /dev/null +++ b/server/src/internal/products/handlers/handleUpdateProduct/handleUpdatePlanV2.ts @@ -0,0 +1,108 @@ +import { + AffectedResource, + apiPlan, + UpdatePlanParamsV2Schema, + type UpdateProductV2Params, +} from "@autumn/shared"; +import { createRoute } from "@/honoMiddlewares/routeHandler.js"; +import { FeatureService } from "@/internal/features/FeatureService.js"; +import { ProductService } from "../../ProductService.js"; +import { getPlanResponse } from "../../productUtils/productResponseUtils/getPlanResponse.js"; +import { updateProduct } from "../productActions/updateProduct.js"; + +const hasOwn = (obj: object, key: string): boolean => + Object.getOwnPropertyDescriptor(obj, key) !== undefined; + +export const handleUpdatePlanV2 = createRoute({ + body: UpdatePlanParamsV2Schema, + resource: AffectedResource.Product, + handler: async (c) => { + const body = c.req.valid("json"); + + const { plan_id, new_plan_id, ...planParams } = body; + const ctx = c.get("ctx"); + + const updates: UpdateProductV2Params = {}; + + if (new_plan_id) { + updates.id = new_plan_id; + } + + const shouldMapPlanParams = + hasOwn(planParams, "name") || + hasOwn(planParams, "description") || + hasOwn(planParams, "group") || + hasOwn(planParams, "add_on") || + hasOwn(planParams, "auto_enable") || + hasOwn(planParams, "items") || + hasOwn(planParams, "price") || + hasOwn(planParams, "free_trial"); + + if (shouldMapPlanParams) { + const mappedUpdates = apiPlan.map.paramsV1ToProductV2({ + ctx, + params: { + id: plan_id, + ...planParams, + }, + }) as UpdateProductV2Params; + + if (hasOwn(planParams, "name")) { + updates.name = mappedUpdates.name; + } + + if (hasOwn(planParams, "description")) { + updates.description = mappedUpdates.description; + } + + if (hasOwn(planParams, "group")) { + updates.group = mappedUpdates.group; + } + + if (hasOwn(planParams, "add_on")) { + updates.is_add_on = mappedUpdates.is_add_on; + } + + if (hasOwn(planParams, "auto_enable")) { + updates.is_default = mappedUpdates.is_default; + } + + if (hasOwn(planParams, "items")) { + updates.items = mappedUpdates.items; + } + + if (hasOwn(planParams, "free_trial")) { + updates.free_trial = mappedUpdates.free_trial; + } + } + + await updateProduct({ + ctx, + productId: plan_id, + query: {}, + updates, + }); + + const latestPlanId = new_plan_id || plan_id; + const [latestFullProduct, features] = await Promise.all([ + ProductService.getFull({ + db: ctx.db, + idOrInternalId: latestPlanId, + orgId: ctx.org.id, + env: ctx.env, + }), + FeatureService.list({ + db: ctx.db, + orgId: ctx.org.id, + env: ctx.env, + }), + ]); + + const latestPlan = await getPlanResponse({ + product: latestFullProduct, + features, + }); + + return c.json(latestPlan); + }, +}); diff --git a/server/src/internal/products/handlers/productActions/deleteProduct.ts b/server/src/internal/products/handlers/productActions/deleteProduct.ts new file mode 100644 index 000000000..1e694a13c --- /dev/null +++ b/server/src/internal/products/handlers/productActions/deleteProduct.ts @@ -0,0 +1,66 @@ +import { ProductNotFoundError, RecaseError } from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { CusProdReadService } from "@/internal/customers/cusProducts/CusProdReadService.js"; +import { ProductService } from "../../ProductService.js"; + +export const deleteProduct = async ({ + ctx, + productId, + allVersions = false, +}: { + ctx: AutumnContext; + productId: string; + allVersions?: boolean; +}) => { + const { db, org, env } = ctx; + + const product = await ProductService.get({ + db, + id: productId, + orgId: org.id, + env, + }); + + if (!product) { + throw new ProductNotFoundError({ productId: productId }); + } + + const [latestCounts, allCounts] = await Promise.all([ + CusProdReadService.getCounts({ + db, + internalProductId: product.internal_id, + }), + CusProdReadService.getCountsForAllVersions({ + db, + productId: productId, + orgId: org.id, + env, + }), + ]); + + const cusProdCount = allVersions ? allCounts.all : latestCounts.all; + + if (cusProdCount > 0) { + throw new RecaseError({ + message: `Product ${productId} has ${cusProdCount} customers (expired or active) on it and therefore cannot be deleted`, + }); + } + + if (allVersions) { + await ProductService.deleteByProductId({ + db, + productId: productId, + orgId: org.id, + env, + }); + } else { + await ProductService.deleteByInternalId({ + db, + internalId: product.internal_id, + orgId: org.id, + env, + }); + } + + return { success: true }; +}; diff --git a/server/src/internal/products/handlers/productActions/updateProduct.ts b/server/src/internal/products/handlers/productActions/updateProduct.ts index 71d24c009..d6e6e14fb 100644 --- a/server/src/internal/products/handlers/productActions/updateProduct.ts +++ b/server/src/internal/products/handlers/productActions/updateProduct.ts @@ -160,10 +160,12 @@ export const updateProduct = async ({ }); } + const latestProductId = updates.id || fullProduct.id; + // New full product const newFullProduct = await ProductService.getFull({ db, - idOrInternalId: fullProduct.id, + idOrInternalId: latestProductId, orgId: org.id, env, }); @@ -202,7 +204,7 @@ export const updateProduct = async ({ jobName: JobName.RewardMigration, payload: { oldPrices: fullProduct.prices, - productId: fullProduct.id, + productId: latestProductId, orgId: org.id, env, }, diff --git a/server/src/internal/products/planRpcRouter.ts b/server/src/internal/products/planRpcRouter.ts new file mode 100644 index 000000000..3009db7d2 --- /dev/null +++ b/server/src/internal/products/planRpcRouter.ts @@ -0,0 +1,13 @@ +import { Hono } from "hono"; +import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; +import { handleCreatePlanV2 } from "./handlers/handleCreateProduct/handleCreatePlanV2.js"; +import { handleDeletePlanV2 } from "./handlers/handleDeleteProduct/handleDeletePlanV2.js"; +import { handleGetPlanV2 } from "./handlers/handleGetProduct/handleGetPlanV2.js"; +import { handleUpdatePlanV2 } from "./handlers/handleUpdateProduct/handleUpdatePlanV2.js"; + +export const planRpcRouter = new Hono(); + +planRpcRouter.post("/plans.create", ...handleCreatePlanV2); +planRpcRouter.post("/plans.get", ...handleGetPlanV2); +planRpcRouter.post("/plans.update", ...handleUpdatePlanV2); +planRpcRouter.post("/plans.delete", ...handleDeletePlanV2); diff --git a/server/src/internal/products/productRouter.ts b/server/src/internal/products/productRouter.ts index 7a45205a1..ddae7c5a2 100644 --- a/server/src/internal/products/productRouter.ts +++ b/server/src/internal/products/productRouter.ts @@ -2,9 +2,9 @@ import { Hono } from "hono"; import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; import { handlePlanHasCustomersV2 } from "@/internal/products/handlers/handlePlanHasCustomersV2.js"; import { handleCopyProductV2 } from "./handlers/handleCopyProduct/handleCopyProductV2.js"; -import { handleCreatePlan } from "./handlers/handleCreatePlan.js"; -import { handleDeleteProduct as handleDeleteProductHono } from "./handlers/handleDeleteProduct.js"; -import { handleGetPlan } from "./handlers/handleGetPlan.js"; +import { handleCreatePlan } from "./handlers/handleCreateProduct/handleCreatePlan.js"; +import { handleDeleteProduct as handleDeleteProductHono } from "./handlers/handleDeleteProduct/handleDeleteProduct.js"; +import { handleGetPlan } from "./handlers/handleGetProduct/handleGetPlan.js"; import { handleGetPlanDeleteInfo } from "./handlers/handleGetPlanDeleteInfo.js"; import { handleListPlans } from "./handlers/handleListPlans.js"; import { handleMigrateProductV2 } from "./handlers/handleMigrateProductV2.js"; diff --git a/server/src/routers/apiRouter.ts b/server/src/routers/apiRouter.ts index 7576d57bd..3d2868840 100644 --- a/server/src/routers/apiRouter.ts +++ b/server/src/routers/apiRouter.ts @@ -33,6 +33,7 @@ import { honoProductRouter, migrationRouter, } from "../internal/products/productRouter.js"; +import { rpcRouter } from "./rpcRouter.js"; export const apiRouter = new Hono(); @@ -50,6 +51,7 @@ apiRouter.route("", billingRouter); apiRouter.route("", balancesRouter); apiRouter.route("", migrationRouter); apiRouter.route("", entityRouter); +apiRouter.route("", rpcRouter); apiRouter.route("/customers", cusRouter); apiRouter.route("/invoices", invoiceRouter); diff --git a/server/src/routers/rpcRouter.ts b/server/src/routers/rpcRouter.ts new file mode 100644 index 000000000..e429265ac --- /dev/null +++ b/server/src/routers/rpcRouter.ts @@ -0,0 +1,7 @@ +import { Hono } from "hono"; +import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; +import { planRpcRouter } from "../internal/products/planRpcRouter.js"; + +export const rpcRouter = new Hono(); + +rpcRouter.route("", planRpcRouter); diff --git a/server/tests/_temp/temp.test.ts b/server/tests/_temp/temp.test.ts index 55db1f454..0cee5fc17 100644 --- a/server/tests/_temp/temp.test.ts +++ b/server/tests/_temp/temp.test.ts @@ -1,32 +1,145 @@ -import { test } from "bun:test"; -import { items } from "@tests/utils/fixtures/items"; -import { products } from "@tests/utils/fixtures/products"; -import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import { expect, test } from "bun:test"; +import { ApiVersion, FreeTrialDuration, ResetInterval } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; import chalk from "chalk"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { ProductService } from "@/internal/products/ProductService.js"; -/** - * Test: Attach free default product, then attach pro with invoice mode - */ -test.concurrent(`${chalk.yellowBright("attach: pro plan with failed payment method")}`, async () => { - const messagesItem = items.monthlyMessages({ includedUsage: 100 }); - const pro = products.pro({ - id: "pro", - items: [messagesItem], - }); +const autumnV2_1 = new AutumnInt({ version: ApiVersion.V2_1 }); +const { db, org, env } = ctx; - const { autumnV1 } = await initScenario({ - customerId: "test-failed-pm", - setup: [ - s.customer({ paymentMethod: "fail" }), // Failed payment method - s.products({ list: [pro] }), +test.concurrent(`${chalk.yellowBright("temp: rest update then rpc inverse update returns product to baseline")}`, async () => { + const productId = `temp_rpc_roundtrip_${Date.now()}`; + const baselineGroup = `baseline_group_${productId}`; + const changedGroup = `changed_group_${productId}`; + + const baseline = { + name: "Temp RPC Baseline", + description: "baseline description", + group: baselineGroup, + add_on: false, + auto_enable: false, + items: [ + { + feature_id: TestFeature.Messages, + included: 100, + reset: { interval: ResetInterval.Month }, + }, ], - actions: [], + free_trial: { + duration_type: FreeTrialDuration.Day, + duration_length: 7, + card_required: false, + }, + }; + + const restUpdates = { + name: "Temp RPC Changed", + group: changedGroup, + add_on: true, + auto_enable: true, + items: [ + { + feature_id: TestFeature.Messages, + included: 250, + reset: { interval: ResetInterval.Month }, + }, + ], + free_trial: { + duration_type: FreeTrialDuration.Day, + duration_length: 14, + card_required: true, + }, + }; + + try { + await autumnV2_1.products.delete(productId); + } catch (_error) {} + + await autumnV2_1.products.create({ + id: productId, + ...baseline, }); - const result = await autumnV1.attach({ - customer_id: "test-failed-pm", - product_id: pro.id, + const initialFull = await ProductService.getFull({ + db, + idOrInternalId: productId, + orgId: org.id, + env, }); - console.log("Attach response:", JSON.stringify(result, null, 2)); + expect(initialFull.name).toBe(baseline.name); + expect(initialFull.description).toBe(baseline.description); + expect(initialFull.group).toBe(baseline.group); + expect(initialFull.is_add_on).toBe(baseline.add_on); + expect(initialFull.is_default).toBe(baseline.auto_enable); + expect( + initialFull.entitlements.find((ent) => ent.feature_id === TestFeature.Messages) + ?.allowance, + ).toBe(100); + + await autumnV2_1.products.update(productId, restUpdates); + + const afterRestUpdate = await ProductService.getFull({ + db, + idOrInternalId: productId, + orgId: org.id, + env, + }); + + expect(afterRestUpdate.name).toBe(restUpdates.name); + expect(afterRestUpdate.group).toBe(restUpdates.group); + expect(afterRestUpdate.is_add_on).toBe(restUpdates.add_on); + expect( + afterRestUpdate.entitlements.find( + (ent) => ent.feature_id === TestFeature.Messages, + )?.allowance, + ).toBe(250); + + const rpcResponse = await autumnV2_1.post("/plans.update", { + plan_id: productId, + ...baseline, + }); + + expect(rpcResponse.id).toBe(productId); + expect(rpcResponse.name).toBe(baseline.name); + expect(rpcResponse.description).toBe(baseline.description); + expect(rpcResponse.group).toBe(baseline.group); + expect(rpcResponse.add_on).toBe(baseline.add_on); + expect(rpcResponse.auto_enable).toBe(baseline.auto_enable); + + const finalFull = await ProductService.getFull({ + db, + idOrInternalId: productId, + orgId: org.id, + env, + }); + + expect(finalFull.version).toBe(initialFull.version); + expect(finalFull.name).toBe(baseline.name); + expect(finalFull.description).toBe(baseline.description); + expect(finalFull.group).toBe(baseline.group); + expect(finalFull.is_add_on).toBe(baseline.add_on); + expect(finalFull.is_default).toBe(baseline.auto_enable); + expect(finalFull.free_trial?.duration).toBe( + baseline.free_trial.duration_type, + ); + expect(finalFull.free_trial?.length).toBe( + baseline.free_trial.duration_length, + ); + expect(finalFull.free_trial?.card_required).toBe( + baseline.free_trial.card_required, + ); + expect( + finalFull.entitlements.find((ent) => ent.feature_id === TestFeature.Messages) + ?.allowance, + ).toBe(100); + + const finalApi = await autumnV2_1.products.get(productId); + expect(finalApi.name).toBe(baseline.name); + expect(finalApi.description).toBe(baseline.description); + expect(finalApi.group).toBe(baseline.group); + expect(finalApi.add_on).toBe(baseline.add_on); + expect(finalApi.auto_enable).toBe(baseline.auto_enable); }); diff --git a/server/tests/integration/crud/plans/rpc-regression/rest-rpc-roundtrip.test.ts b/server/tests/integration/crud/plans/rpc-regression/rest-rpc-roundtrip.test.ts new file mode 100644 index 000000000..7fae648ed --- /dev/null +++ b/server/tests/integration/crud/plans/rpc-regression/rest-rpc-roundtrip.test.ts @@ -0,0 +1,84 @@ +import { expect, test } from "bun:test"; +import { ApiVersion, FreeTrialDuration, ResetInterval } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; +import { AutumnRpcCli } from "@/external/autumn/autumnRpcCli.js"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { ProductService } from "@/internal/products/ProductService.js"; + +const autumnV2 = new AutumnInt({ version: ApiVersion.V2_0 }); +const autumnRpc = new AutumnRpcCli({ version: ApiVersion.V2_1 }); + +const { db, org, env } = ctx; +const getSuffix = () => Math.random().toString(36).slice(2, 9); + +test.concurrent(`${chalk.yellowBright("rpc regression: rest update then rpc inverse keeps product stable")}`, async () => { + const productId = `rpc_roundtrip_${getSuffix()}`; + const baselineGroup = `rpc_regression_baseline_${productId}`; + const changedGroup = `rpc_regression_changed_${productId}`; + + const baseline = { + name: "RPC Regression Baseline", + group: baselineGroup, + add_on: false, + auto_enable: false, + items: [ + { + feature_id: TestFeature.Messages, + included: 100, + reset: { interval: ResetInterval.Month }, + }, + ], + free_trial: { + duration_type: FreeTrialDuration.Day, + duration_length: 7, + card_required: false, + }, + }; + + const restUpdates = { + name: "RPC Regression Changed", + group: changedGroup, + add_on: true, + items: [ + { + feature_id: TestFeature.Messages, + included: 250, + reset: { interval: ResetInterval.Month }, + }, + ], + free_trial: { + duration_type: FreeTrialDuration.Day, + duration_length: 14, + card_required: true, + }, + }; + + try { + await autumnRpc.plans.delete(productId, { allVersions: true }); + } catch (_error) {} + + await autumnRpc.plans.create({ + id: productId, + ...baseline, + }); + + await autumnV2.products.update(productId, restUpdates); + await autumnRpc.plans.update(productId, baseline); + + const finalFull = await ProductService.getFull({ + db, + idOrInternalId: productId, + orgId: org.id, + env, + }); + + expect(finalFull.name).toBe(baseline.name); + expect(finalFull.group).toBe(baseline.group); + expect(finalFull.is_add_on).toBe(baseline.add_on); + expect( + finalFull.entitlements.find((ent) => ent.feature_id === TestFeature.Messages) + ?.allowance, + ).toBe(100); +}); diff --git a/server/tests/integration/crud/plans/rpc/create-plan-advanced.rpc.test.ts b/server/tests/integration/crud/plans/rpc/create-plan-advanced.rpc.test.ts new file mode 100644 index 000000000..f8337d4fc --- /dev/null +++ b/server/tests/integration/crud/plans/rpc/create-plan-advanced.rpc.test.ts @@ -0,0 +1,129 @@ +import { expect, test } from "bun:test"; +import { + type ApiPlanV1, + type ApiProduct, + ApiVersion, + BillingInterval, + BillingMethod, + type CreatePlanParamsInput, + ProductItemInterval, + ResetInterval, + TierInfinite, + UsageModel, +} from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features"; +import chalk from "chalk"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { AutumnRpcCli } from "@/external/autumn/autumnRpcCli.js"; + +const autumnRpc = new AutumnRpcCli({ version: ApiVersion.V2_1 }); +const autumnV1_2 = new AutumnInt({ version: ApiVersion.V1_2 }); + +const getSuffix = () => Math.random().toString(36).slice(2, 9); + +test.concurrent(`${chalk.yellowBright("rpc create: metered feature with monthly reset")}`, async () => { + const productId = `rpc_metered_${getSuffix()}`; + const group = `rpc_group_${productId}`; + try { + await autumnRpc.plans.delete(productId, { allVersions: true }); + } catch (_error) {} + + await autumnRpc.plans.create({ + id: productId, + name: "RPC Metered Monthly", + group, + auto_enable: false, + items: [ + { + feature_id: TestFeature.Messages, + included: 1200, + reset: { interval: ResetInterval.Month }, + }, + ], + }); + + const v1_2 = await autumnV1_2.products.get(productId); + const messagesItem = v1_2.items.find( + (item) => item.feature_id === TestFeature.Messages, + ); + expect(messagesItem?.included_usage).toBe(1200); + expect(messagesItem?.interval).toBe(ProductItemInterval.Month); +}); + +test.concurrent(`${chalk.yellowBright("rpc create: tiered usage pricing")}`, async () => { + const productId = `rpc_tiered_${getSuffix()}`; + const group = `rpc_group_${productId}`; + try { + await autumnRpc.plans.delete(productId, { allVersions: true }); + } catch (_error) {} + + await autumnRpc.plans.create({ + id: productId, + name: "RPC Tiered Pricing", + group, + auto_enable: false, + items: [ + { + feature_id: TestFeature.Messages, + price: { + tiers: [ + { to: 100, amount: 0.1 }, + { to: 500, amount: 0.08 }, + { to: TierInfinite, amount: 0.05 }, + ], + interval: BillingInterval.Month, + billing_method: BillingMethod.UsageBased, + billing_units: 1, + }, + }, + ], + }); + + const v1_2 = await autumnV1_2.products.get(productId); + const messagesItem = v1_2.items.find( + (item) => item.feature_id === TestFeature.Messages, + ); + expect(messagesItem?.tiers).toHaveLength(3); + expect(messagesItem?.usage_model).toBe(UsageModel.PayPerUse); +}); + +test.concurrent(`${chalk.yellowBright("rpc create: validation rejects reset/price interval mismatch")}`, async () => { + const productId = `rpc_invalid_${getSuffix()}`; + const group = `rpc_group_${productId}`; + try { + await autumnRpc.plans.delete(productId, { allVersions: true }); + } catch (_error) {} + + let err: { code?: string } | null = null; + try { + await autumnRpc.plans.create({ + id: productId, + name: "RPC Invalid Intervals", + group, + auto_enable: false, + items: [ + { + feature_id: TestFeature.Messages, + included: 100, + reset: { interval: ResetInterval.Minute }, + price: { + amount: 10, + interval: BillingInterval.Month, + billing_method: BillingMethod.UsageBased, + billing_units: 1, + }, + }, + ], + }); + } catch (error: unknown) { + if (error && typeof error === "object" && "code" in error) { + err = error as { code?: string }; + } + } + + expect(err).toBeDefined(); + if (err === null) { + throw new Error("Expected request to fail with invalid_inputs"); + } + expect(err.code).toBe("invalid_inputs"); +}); diff --git a/server/tests/integration/crud/plans/rpc/create-plan-basic.rpc.test.ts b/server/tests/integration/crud/plans/rpc/create-plan-basic.rpc.test.ts new file mode 100644 index 000000000..3c151bc2c --- /dev/null +++ b/server/tests/integration/crud/plans/rpc/create-plan-basic.rpc.test.ts @@ -0,0 +1,96 @@ +import { expect, test } from "bun:test"; +import { + type ApiPlanV1, + ApiPlanV1Schema, + type ApiProduct, + ApiVersion, + BillingInterval, + type CreatePlanParamsInput, +} from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features"; +import chalk from "chalk"; +import { AutumnRpcCli } from "@/external/autumn/autumnRpcCli.js"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; + +const autumnRpc = new AutumnRpcCli({ version: ApiVersion.V2_1 }); +const autumnV1_2 = new AutumnInt({ version: ApiVersion.V1_2 }); + +const getSuffix = () => Math.random().toString(36).slice(2, 9); + +test.concurrent(`${chalk.yellowBright("rpc create: minimal plan (id + name only)")}`, async () => { + const productId = `rpc_min_plan_${getSuffix()}`; + const group = `rpc_group_${productId}`; + try { + await autumnRpc.plans.delete(productId, { allVersions: true }); + } catch (_error) {} + + const created = await autumnRpc.plans.create({ + id: productId, + name: "RPC Minimal Plan", + group, + auto_enable: false, + }); + + ApiPlanV1Schema.parse(created); + expect(created.id).toBe(productId); + expect(created.items).toHaveLength(0); + + const v1_2 = await autumnV1_2.products.get(productId); + expect(v1_2.items).toHaveLength(0); + expect(v1_2.is_add_on).toBe(false); +}); + +test.concurrent(`${chalk.yellowBright("rpc create: with base price and flags")}`, async () => { + const productId = `rpc_flags_${getSuffix()}`; + const group = `rpc_group_${productId}`; + try { + await autumnRpc.plans.delete(productId, { allVersions: true }); + } catch (_error) {} + + const created = await autumnRpc.plans.create({ + id: productId, + name: "RPC Flags Plan", + group, + add_on: true, + auto_enable: false, + price: { + amount: 4900, + interval: BillingInterval.Month, + }, + }); + + expect(created.add_on).toBe(true); + expect(created.auto_enable).toBe(false); + expect(created.price?.amount).toBe(4900); + expect(created.price?.interval).toBe(BillingInterval.Month); + + const v1_2 = await autumnV1_2.products.get(productId); + expect(v1_2.is_add_on).toBe(true); + expect(v1_2.is_default).toBe(false); +}); + +test.concurrent(`${chalk.yellowBright("rpc create: boolean feature")}`, async () => { + const productId = `rpc_bool_${getSuffix()}`; + const group = `rpc_group_${productId}`; + try { + await autumnRpc.plans.delete(productId, { allVersions: true }); + } catch (_error) {} + + const created = await autumnRpc.plans.create({ + id: productId, + name: "RPC Boolean Plan", + group, + auto_enable: false, + items: [{ feature_id: TestFeature.Dashboard }], + }); + + expect(created.items.length).toBeGreaterThanOrEqual(1); + const booleanItem = created.items.find( + (item: any) => item.feature_id === TestFeature.Dashboard, + ); + expect(booleanItem).toBeDefined(); + + const v1_2 = await autumnV1_2.products.get(productId); + expect(v1_2.items).toHaveLength(1); + expect(v1_2.items[0].feature_id).toBe(TestFeature.Dashboard); +}); diff --git a/server/tests/integration/crud/plans/rpc/delete-plan.rpc.test.ts b/server/tests/integration/crud/plans/rpc/delete-plan.rpc.test.ts new file mode 100644 index 000000000..c00d0e7b5 --- /dev/null +++ b/server/tests/integration/crud/plans/rpc/delete-plan.rpc.test.ts @@ -0,0 +1,44 @@ +import { expect, test } from "bun:test"; +import { ApiVersion, BillingInterval, type CreatePlanParamsInput } from "@autumn/shared"; +import chalk from "chalk"; +import { AutumnRpcCli } from "@/external/autumn/autumnRpcCli.js"; + +const autumnRpc = new AutumnRpcCli({ version: ApiVersion.V2_1 }); +const getSuffix = () => Math.random().toString(36).slice(2, 9); + +test.concurrent(`${chalk.yellowBright("rpc delete: create then delete plan successfully")}`, async () => { + const productId = `rpc_delete_${getSuffix()}`; + const group = `rpc_group_${productId}`; + + try { + await autumnRpc.plans.delete(productId, { allVersions: true }); + } catch (_error) {} + + await autumnRpc.plans.create({ + id: productId, + name: "RPC Delete Test", + group, + auto_enable: false, + price: { + amount: 1900, + interval: BillingInterval.Month, + }, + }); + + const beforeDelete = await autumnRpc.plans.get(productId); + expect(beforeDelete.id).toBe(productId); + + const deleteResult = await autumnRpc.plans.delete(productId, { + allVersions: false, + }); + expect(deleteResult.success).toBe(true); + + let err: any = null; + try { + await autumnRpc.plans.get(productId); + } catch (error) { + err = error; + } + + expect(err).toBeDefined(); +}); diff --git a/server/tests/integration/crud/plans/rpc/get/get-plan-basic.rpc.test.ts b/server/tests/integration/crud/plans/rpc/get/get-plan-basic.rpc.test.ts new file mode 100644 index 000000000..d48e2fc82 --- /dev/null +++ b/server/tests/integration/crud/plans/rpc/get/get-plan-basic.rpc.test.ts @@ -0,0 +1,45 @@ +import { expect, test } from "bun:test"; +import { type ApiPlanV1, ApiPlanV1Schema, ApiVersion } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { AutumnRpcCli } from "@/external/autumn/autumnRpcCli.js"; + +const testCase = "get-plan-basic-rpc"; +const autumnRpc = new AutumnRpcCli({ version: ApiVersion.V2_1 }); + +const messagesItem = items.monthlyMessages({ includedUsage: 100 }); +const wordsItem = items.consumableWords({ includedUsage: 10 }); +const creditsItem = items.monthlyCredits({ includedUsage: 10 }); + +const pro = products.pro({ + id: "pro", + items: [messagesItem, wordsItem, creditsItem], +}); + +test.concurrent(`${chalk.yellowBright("rpc get: get plan response in latest format")}`, async () => { + await initScenario({ + setup: [s.products({ list: [pro], prefix: testCase })], + actions: [], + }); + + const plan = await autumnRpc.plans.get(pro.id); + ApiPlanV1Schema.parse(plan); + + const messagesResponseItem = plan.items.find( + (item: any) => item.feature_id === TestFeature.Messages, + ); + const wordsResponseItem = plan.items.find( + (item: any) => item.feature_id === TestFeature.Words, + ); + const creditsResponseItem = plan.items.find( + (item: any) => item.feature_id === TestFeature.Credits, + ); + + expect(messagesResponseItem).toBeDefined(); + expect(wordsResponseItem).toBeDefined(); + expect(creditsResponseItem).toBeDefined(); + expect(plan.price).toBeDefined(); +}); diff --git a/server/tests/integration/crud/plans/rpc/list/list-plans-cross-version.rpc-regression.test.ts b/server/tests/integration/crud/plans/rpc/list/list-plans-cross-version.rpc-regression.test.ts new file mode 100644 index 000000000..946c5374a --- /dev/null +++ b/server/tests/integration/crud/plans/rpc/list/list-plans-cross-version.rpc-regression.test.ts @@ -0,0 +1,59 @@ +import { expect, test } from "bun:test"; +import { + type ApiPlan, + ApiPlanV0Schema, + type ApiPlanV1, + ApiPlanV1Schema, + type ApiProduct, + ApiProductSchema, + ApiVersion, + type CreatePlanParamsInput, +} from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import chalk from "chalk"; +import { AutumnRpcCli } from "@/external/autumn/autumnRpcCli.js"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; + +const autumnRpc = new AutumnRpcCli({ version: ApiVersion.V2_1 }); +const autumnV1 = new AutumnInt({ version: ApiVersion.V1_2 }); +const autumnV2_0 = new AutumnInt({ version: ApiVersion.V2_0 }); +const autumnV2_1 = new AutumnInt({ version: ApiVersion.V2_1 }); + +const getSuffix = () => Math.random().toString(36).slice(2, 9); + +test.concurrent(`${chalk.yellowBright("rpc regression: rest list stays cross-version compatible after rpc create")}`, async () => { + const suffix = getSuffix(); + const freeId = `rpc_list_free_${suffix}`; + const freeGroup = `rpc_list_group_free_${suffix}`; + + try { + await autumnRpc.plans.delete(freeId, { allVersions: true }); + } catch (_error) { + // no-op + } + + await autumnRpc.plans.create({ + id: freeId, + name: "RPC List Free", + group: freeGroup, + items: [{ feature_id: TestFeature.Credits, included: 500 }], + }); + + const plansV2_1 = await autumnV2_1.products.list(); + const plansV2_0 = await autumnV2_0.products.list(); + const productsV1 = await autumnV1.products.list(); + + for (const plan of plansV2_1.list) { + ApiPlanV1Schema.parse(plan); + } + for (const plan of plansV2_0.list) { + ApiPlanV0Schema.parse(plan); + } + for (const product of productsV1.list) { + ApiProductSchema.parse(product); + } + + expect(plansV2_1.list.some((plan) => plan.id === freeId)).toBe(true); + expect(plansV2_0.list.some((plan) => plan.id === freeId)).toBe(true); + expect(productsV1.list.some((product) => product.id === freeId)).toBe(true); +}); diff --git a/server/tests/integration/crud/plans/rpc/update-plan.rpc.test.ts b/server/tests/integration/crud/plans/rpc/update-plan.rpc.test.ts new file mode 100644 index 000000000..a21f17be5 --- /dev/null +++ b/server/tests/integration/crud/plans/rpc/update-plan.rpc.test.ts @@ -0,0 +1,223 @@ +import { expect, test } from "bun:test"; +import { + type ApiPlanV1, + type ApiProduct, + ApiVersion, + type CreateProductV2ParamsInput, + ProductItemInterval, + ResetInterval, + type UpdatePlanParamsInput, +} from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { AutumnRpcCli } from "@/external/autumn/autumnRpcCli.js"; +import { ProductService } from "@/internal/products/ProductService.js"; + +const autumnRpc = new AutumnRpcCli({ version: ApiVersion.V2_1 }); +const autumnV1_2 = new AutumnInt({ version: ApiVersion.V1_2 }); + +const { db, org, env } = ctx; + +test.concurrent(`${chalk.yellowBright("rpc update: match existing entitlement by feature_id (no entitlement_id)")}`, async () => { + const productId = "rpc_update_match_1"; + const group = `rpc_group_${productId}`; + try { + await autumnRpc.plans.delete(productId, { allVersions: true }); + } catch (_error) {} + + await autumnV1_2.products.create({ + id: productId, + name: "RPC Update Match Test", + group, + is_default: false, + items: [ + { + feature_id: TestFeature.Messages, + included_usage: 1000, + interval: ProductItemInterval.Month, + }, + ], + }); + + const initialFull = await ProductService.getFull({ + db, + idOrInternalId: productId, + orgId: org.id, + env, + }); + expect( + initialFull.entitlements.find((e) => e.feature_id === TestFeature.Messages) + ?.id, + ).toBeDefined(); + + await autumnRpc.plans.update(productId, { + items: [ + { + feature_id: TestFeature.Messages, + included: 2000, + reset: { interval: ResetInterval.Month }, + }, + ], + }); + + const updatedFull = await ProductService.getFull({ + db, + idOrInternalId: productId, + orgId: org.id, + env, + }); + + expect( + updatedFull.entitlements.find((e) => e.feature_id === TestFeature.Messages) + ?.allowance, + ).toBe(2000); +}); + +test.concurrent(`${chalk.yellowBright("rpc update: match entitlement with same feature + interval")}`, async () => { + const productId = "rpc_update_match_2"; + const group = `rpc_group_${productId}`; + try { + await autumnRpc.plans.delete(productId, { allVersions: true }); + } catch (_error) {} + + await autumnV1_2.products.create({ + id: productId, + name: "RPC Quarterly Match Test", + group, + is_default: false, + items: [ + { + feature_id: TestFeature.Messages, + included_usage: 500, + interval: ProductItemInterval.Quarter, + }, + ], + }); + + await autumnRpc.plans.update(productId, { + items: [ + { + feature_id: TestFeature.Messages, + included: 1500, + reset: { interval: ResetInterval.Quarter }, + }, + ], + }); + + const updatedFull = await ProductService.getFull({ + db, + idOrInternalId: productId, + orgId: org.id, + env, + }); + + expect( + updatedFull.entitlements.find((e) => e.feature_id === TestFeature.Messages) + ?.allowance, + ).toBe(1500); +}); + +test.concurrent(`${chalk.yellowBright("rpc update: create NEW entitlement when interval changes")}`, async () => { + const productId = "rpc_update_interval_change"; + const group = `rpc_group_${productId}`; + try { + await autumnRpc.plans.delete(productId, { allVersions: true }); + } catch (_error) {} + + await autumnV1_2.products.create({ + id: productId, + name: "RPC Interval Change Test", + group, + is_default: false, + items: [ + { + feature_id: TestFeature.Messages, + included_usage: 1000, + interval: ProductItemInterval.Month, + }, + ], + }); + + await autumnRpc.plans.update(productId, { + items: [ + { + feature_id: TestFeature.Messages, + included: 3000, + reset: { interval: ResetInterval.Quarter }, + }, + ], + }); + + const updatedFull = await ProductService.getFull({ + db, + idOrInternalId: productId, + orgId: org.id, + env, + }); + + expect( + updatedFull.entitlements.find((e) => e.feature_id === TestFeature.Messages) + ?.allowance, + ).toBe(3000); +}); + +test.concurrent(`${chalk.yellowBright("rpc update: handle multiple features with same feature_id (different intervals)")}`, async () => { + const productId = "rpc_multi_interval"; + const group = `rpc_group_${productId}`; + try { + await autumnRpc.plans.delete(productId, { allVersions: true }); + } catch (_error) {} + + await autumnV1_2.products.create({ + id: productId, + name: "RPC Multi Interval Test", + group, + is_default: false, + items: [ + { + feature_id: TestFeature.Messages, + included_usage: 1000, + interval: ProductItemInterval.Month, + }, + { + feature_id: TestFeature.Messages, + included_usage: 3000, + interval: ProductItemInterval.Quarter, + }, + ], + }); + + await autumnRpc.plans.update(productId, { + items: [ + { + feature_id: TestFeature.Messages, + included: 1500, + reset: { interval: ResetInterval.Month }, + }, + { + feature_id: TestFeature.Messages, + included: 4500, + reset: { interval: ResetInterval.Quarter }, + }, + ], + }); + + const updatedFull = await ProductService.getFull({ + db, + idOrInternalId: productId, + orgId: org.id, + env, + }); + + const monthlyEnt = updatedFull.entitlements.find( + (e) => e.feature_id === TestFeature.Messages && e.interval === "month", + ); + const quarterlyEnt = updatedFull.entitlements.find( + (e) => e.feature_id === TestFeature.Messages && e.interval === "quarter", + ); + + expect(monthlyEnt?.allowance).toBe(1500); + expect(quarterlyEnt?.allowance).toBe(4500); +}); diff --git a/shared/api/products/crud/deletePlanParamsV2.ts b/shared/api/products/crud/deletePlanParamsV2.ts new file mode 100644 index 000000000..a376bf12d --- /dev/null +++ b/shared/api/products/crud/deletePlanParamsV2.ts @@ -0,0 +1,6 @@ +import { z } from "zod/v4"; + +export const DeletePlanV2BodySchema = z.object({ + plan_id: z.string().nonempty(), + all_versions: z.boolean().default(false).optional(), +}); diff --git a/shared/api/products/crud/index.ts b/shared/api/products/crud/index.ts index 2d138edff..565a1890d 100644 --- a/shared/api/products/crud/index.ts +++ b/shared/api/products/crud/index.ts @@ -1,3 +1,4 @@ export * from "./createPlanParamsV0.js"; +export * from "./deletePlanParamsV2.js"; export * from "./listPlanParams.js"; export * from "./updatePlanParamsV0.js"; diff --git a/shared/api/products/crud/updatePlanParamsV0.ts b/shared/api/products/crud/updatePlanParamsV0.ts index fcfe95330..99e6bb4c3 100644 --- a/shared/api/products/crud/updatePlanParamsV0.ts +++ b/shared/api/products/crud/updatePlanParamsV0.ts @@ -1,6 +1,29 @@ import { CreatePlanParamsV1Schema } from "@api/products/crud/createPlanParamsV0"; +import { idRegex } from "@utils/utils"; import { z } from "zod/v4"; +const UpdatePlanBaseFieldsSchema = z.object({ + name: CreatePlanParamsV1Schema.shape.name.optional(), + description: CreatePlanParamsV1Schema.shape.description + .removeDefault() + .optional(), + group: CreatePlanParamsV1Schema.shape.group.removeDefault().optional(), + add_on: CreatePlanParamsV1Schema.shape.add_on.removeDefault().optional(), + auto_enable: CreatePlanParamsV1Schema.shape.auto_enable + .removeDefault() + .optional(), + price: CreatePlanParamsV1Schema.shape.price.optional(), + items: CreatePlanParamsV1Schema.shape.items.optional(), + free_trial: CreatePlanParamsV1Schema.shape.free_trial.optional(), +}); + +export const UpdatePlanParamsV2Schema = z + .object({ + plan_id: z.string().nonempty().regex(idRegex), + new_plan_id: z.string().nonempty().regex(idRegex).optional(), + }) + .extend(UpdatePlanBaseFieldsSchema.shape); + export const UpdatePlanParamsV1Schema = CreatePlanParamsV1Schema.partial().extend({ version: z.number().optional(), From 766cb5616b9d94ee85b32a9fc9d27568abb5298c Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Thu, 19 Feb 2026 11:18:42 +0000 Subject: [PATCH 2/4] refactor: align plan rpc handlers and actions paths --- .../productActions/createProduct.ts | 0 .../productActions/deleteProduct.ts | 0 .../productActions/updateProduct.ts | 4 +- .../productActions/validateDefaultFlag.ts | 0 .../handleCreateProduct/handleCreatePlan.ts | 2 +- .../handleCreateProduct/handleCreatePlanV2.ts | 5 +- .../handleDeletePlan.ts} | 12 +- .../handleDeletePlanV2.ts | 2 +- .../handleGetProduct/handleGetPlanV2.ts | 22 +--- .../handleUpdateProduct/handleUpdatePlan.ts | 2 +- .../handleUpdateProduct/handleUpdatePlanV2.ts | 123 +++++++++++++++--- server/src/internal/products/planRpcRouter.ts | 2 +- server/src/internal/products/productRouter.ts | 4 +- .../crud/mappers/planParamsV1ToProductV2.ts | 96 +++++++++----- 14 files changed, 192 insertions(+), 82 deletions(-) rename server/src/internal/products/{handlers => actions}/productActions/createProduct.ts (100%) rename server/src/internal/products/{handlers => actions}/productActions/deleteProduct.ts (100%) rename server/src/internal/products/{handlers => actions}/productActions/updateProduct.ts (96%) rename server/src/internal/products/{handlers => actions}/productActions/validateDefaultFlag.ts (100%) rename server/src/internal/products/handlers/{handleDeleteProduct/handleDeleteProduct.ts => handleDeletePlan/handleDeletePlan.ts} (66%) rename server/src/internal/products/handlers/{handleDeleteProduct => handleDeletePlan}/handleDeletePlanV2.ts (86%) diff --git a/server/src/internal/products/handlers/productActions/createProduct.ts b/server/src/internal/products/actions/productActions/createProduct.ts similarity index 100% rename from server/src/internal/products/handlers/productActions/createProduct.ts rename to server/src/internal/products/actions/productActions/createProduct.ts diff --git a/server/src/internal/products/handlers/productActions/deleteProduct.ts b/server/src/internal/products/actions/productActions/deleteProduct.ts similarity index 100% rename from server/src/internal/products/handlers/productActions/deleteProduct.ts rename to server/src/internal/products/actions/productActions/deleteProduct.ts diff --git a/server/src/internal/products/handlers/productActions/updateProduct.ts b/server/src/internal/products/actions/productActions/updateProduct.ts similarity index 96% rename from server/src/internal/products/handlers/productActions/updateProduct.ts rename to server/src/internal/products/actions/productActions/updateProduct.ts index d6e6e14fb..84425368d 100644 --- a/server/src/internal/products/handlers/productActions/updateProduct.ts +++ b/server/src/internal/products/actions/productActions/updateProduct.ts @@ -22,8 +22,8 @@ import { ProductService } from "../../ProductService.js"; import { handleNewProductItems } from "../../product-items/productItemUtils/handleNewProductItems.js"; import { getProductResponse } from "../../productUtils/productResponseUtils/getProductResponse.js"; import { initProductInStripe } from "../../productUtils.js"; -import { handleUpdateProductDetails } from "../handleUpdateProduct/updateProductDetails.js"; -import { handleVersionProductV2 } from "../handleVersionProduct.js"; +import { handleUpdateProductDetails } from "../../handlers/handleUpdateProduct/updateProductDetails.js"; +import { handleVersionProductV2 } from "../../handlers/handleVersionProduct.js"; import { validateDefaultFlag } from "./validateDefaultFlag.js"; interface UpdateProductParams { diff --git a/server/src/internal/products/handlers/productActions/validateDefaultFlag.ts b/server/src/internal/products/actions/productActions/validateDefaultFlag.ts similarity index 100% rename from server/src/internal/products/handlers/productActions/validateDefaultFlag.ts rename to server/src/internal/products/actions/productActions/validateDefaultFlag.ts diff --git a/server/src/internal/products/handlers/handleCreateProduct/handleCreatePlan.ts b/server/src/internal/products/handlers/handleCreateProduct/handleCreatePlan.ts index 426612090..e952ba540 100644 --- a/server/src/internal/products/handlers/handleCreateProduct/handleCreatePlan.ts +++ b/server/src/internal/products/handlers/handleCreateProduct/handleCreatePlan.ts @@ -28,7 +28,7 @@ import { ProductService } from "../../ProductService.js"; import { handleNewProductItems } from "../../product-items/productItemUtils/handleNewProductItems.js"; import { getPlanResponse } from "../../productUtils/productResponseUtils/getPlanResponse.js"; import { constructProduct, initProductInStripe } from "../../productUtils.js"; -import { validateDefaultFlag } from "../productActions/validateDefaultFlag.js"; +import { validateDefaultFlag } from "../../actions/productActions/validateDefaultFlag.js"; /** * Route: POST /products - Create a product diff --git a/server/src/internal/products/handlers/handleCreateProduct/handleCreatePlanV2.ts b/server/src/internal/products/handlers/handleCreateProduct/handleCreatePlanV2.ts index c4382f0bc..6146dc4ba 100644 --- a/server/src/internal/products/handlers/handleCreateProduct/handleCreatePlanV2.ts +++ b/server/src/internal/products/handlers/handleCreateProduct/handleCreatePlanV2.ts @@ -2,13 +2,12 @@ import { AffectedResource, apiPlan, CreatePlanParamsV1Schema, - type CreateProductV2Params, } from "@autumn/shared"; import { createRoute } from "@/honoMiddlewares/routeHandler.js"; import { FeatureService } from "@/internal/features/FeatureService.js"; import { ProductService } from "../../ProductService.js"; import { getPlanResponse } from "../../productUtils/productResponseUtils/getPlanResponse.js"; -import { createProduct } from "../productActions/createProduct.js"; +import { createProduct } from "../../actions/productActions/createProduct.js"; export const handleCreatePlanV2 = createRoute({ body: CreatePlanParamsV1Schema, @@ -20,7 +19,7 @@ export const handleCreatePlanV2 = createRoute({ const createParams = apiPlan.map.paramsV1ToProductV2({ ctx, params: body, - }) as CreateProductV2Params; + }); await createProduct({ ctx, diff --git a/server/src/internal/products/handlers/handleDeleteProduct/handleDeleteProduct.ts b/server/src/internal/products/handlers/handleDeletePlan/handleDeletePlan.ts similarity index 66% rename from server/src/internal/products/handlers/handleDeleteProduct/handleDeleteProduct.ts rename to server/src/internal/products/handlers/handleDeletePlan/handleDeletePlan.ts index 54852cda1..9e4d075b1 100644 --- a/server/src/internal/products/handlers/handleDeleteProduct/handleDeleteProduct.ts +++ b/server/src/internal/products/handlers/handleDeletePlan/handleDeletePlan.ts @@ -1,19 +1,19 @@ import { AffectedResource } from "@autumn/shared"; import { z } from "zod/v4"; import { createRoute } from "@/honoMiddlewares/routeHandler.js"; -import { deleteProduct } from "../productActions/deleteProduct.js"; +import { deleteProduct } from "../../actions/productActions/deleteProduct.js"; -const DeleteProductParamsSchema = z.object({ +const DeletePlanParamsSchema = z.object({ product_id: z.string(), }); -const DeleteProductQuerySchema = z.object({ +const DeletePlanQuerySchema = z.object({ all_versions: z.boolean().default(false), }); -export const handleDeleteProduct = createRoute({ - params: DeleteProductParamsSchema, - query: DeleteProductQuerySchema, +export const handleDeletePlan = createRoute({ + params: DeletePlanParamsSchema, + query: DeletePlanQuerySchema, resource: AffectedResource.Product, handler: async (c) => { const { product_id } = c.req.param(); diff --git a/server/src/internal/products/handlers/handleDeleteProduct/handleDeletePlanV2.ts b/server/src/internal/products/handlers/handleDeletePlan/handleDeletePlanV2.ts similarity index 86% rename from server/src/internal/products/handlers/handleDeleteProduct/handleDeletePlanV2.ts rename to server/src/internal/products/handlers/handleDeletePlan/handleDeletePlanV2.ts index 9637fb0ad..deb6036e2 100644 --- a/server/src/internal/products/handlers/handleDeleteProduct/handleDeletePlanV2.ts +++ b/server/src/internal/products/handlers/handleDeletePlan/handleDeletePlanV2.ts @@ -1,6 +1,6 @@ import { AffectedResource, DeletePlanV2BodySchema } from "@autumn/shared"; import { createRoute } from "@/honoMiddlewares/routeHandler.js"; -import { deleteProduct } from "../productActions/deleteProduct.js"; +import { deleteProduct } from "../../actions/productActions/deleteProduct.js"; export const handleDeletePlanV2 = createRoute({ body: DeletePlanV2BodySchema, diff --git a/server/src/internal/products/handlers/handleGetProduct/handleGetPlanV2.ts b/server/src/internal/products/handlers/handleGetProduct/handleGetPlanV2.ts index 6e8e8b074..544471374 100644 --- a/server/src/internal/products/handlers/handleGetProduct/handleGetPlanV2.ts +++ b/server/src/internal/products/handlers/handleGetProduct/handleGetPlanV2.ts @@ -1,7 +1,6 @@ import { AffectedResource } from "@autumn/shared"; import { z } from "zod/v4"; import { createRoute } from "@/honoMiddlewares/routeHandler.js"; -import { FeatureService } from "@/internal/features/FeatureService.js"; import { ProductService } from "../../ProductService.js"; import { getPlanResponse } from "../../productUtils/productResponseUtils/getPlanResponse.js"; @@ -16,23 +15,16 @@ export const handleGetPlanV2 = createRoute({ const { plan_id } = c.req.valid("json"); const ctx = c.get("ctx"); - const [fullProduct, features] = await Promise.all([ - ProductService.getFull({ - db: ctx.db, - idOrInternalId: plan_id, - orgId: ctx.org.id, - env: ctx.env, - }), - FeatureService.list({ - db: ctx.db, - orgId: ctx.org.id, - env: ctx.env, - }), - ]); + const fullProduct = await ProductService.getFull({ + db: ctx.db, + idOrInternalId: plan_id, + orgId: ctx.org.id, + env: ctx.env, + }); const latestPlan = await getPlanResponse({ product: fullProduct, - features, + features: ctx.features, }); return c.json(latestPlan); diff --git a/server/src/internal/products/handlers/handleUpdateProduct/handleUpdatePlan.ts b/server/src/internal/products/handlers/handleUpdateProduct/handleUpdatePlan.ts index ceb939e46..f7ba5d399 100644 --- a/server/src/internal/products/handlers/handleUpdateProduct/handleUpdatePlan.ts +++ b/server/src/internal/products/handlers/handleUpdateProduct/handleUpdatePlan.ts @@ -33,7 +33,7 @@ import { handleNewProductItems } from "../../product-items/productItemUtils/hand import { getPlanResponse } from "../../productUtils/productResponseUtils/getPlanResponse.js"; import { initProductInStripe } from "../../productUtils.js"; import { handleVersionProductV2 } from "../handleVersionProduct.js"; -import { validateDefaultFlag } from "../productActions/validateDefaultFlag.js"; +import { validateDefaultFlag } from "../../actions/productActions/validateDefaultFlag.js"; import { handleUpdateProductDetails } from "./updateProductDetails.js"; export const handleUpdatePlan = createRoute({ diff --git a/server/src/internal/products/handlers/handleUpdateProduct/handleUpdatePlanV2.ts b/server/src/internal/products/handlers/handleUpdateProduct/handleUpdatePlanV2.ts index d565dcd09..2f5a15df9 100644 --- a/server/src/internal/products/handlers/handleUpdateProduct/handleUpdatePlanV2.ts +++ b/server/src/internal/products/handlers/handleUpdateProduct/handleUpdatePlanV2.ts @@ -8,10 +8,7 @@ import { createRoute } from "@/honoMiddlewares/routeHandler.js"; import { FeatureService } from "@/internal/features/FeatureService.js"; import { ProductService } from "../../ProductService.js"; import { getPlanResponse } from "../../productUtils/productResponseUtils/getPlanResponse.js"; -import { updateProduct } from "../productActions/updateProduct.js"; - -const hasOwn = (obj: object, key: string): boolean => - Object.getOwnPropertyDescriptor(obj, key) !== undefined; +import { updateProduct } from "../../actions/productActions/updateProduct.js"; export const handleUpdatePlanV2 = createRoute({ body: UpdatePlanParamsV2Schema, @@ -28,50 +25,134 @@ export const handleUpdatePlanV2 = createRoute({ updates.id = new_plan_id; } + const shouldMapItems = + planParams.items !== undefined || planParams.price !== undefined; + const shouldMapPlanParams = - hasOwn(planParams, "name") || - hasOwn(planParams, "description") || - hasOwn(planParams, "group") || - hasOwn(planParams, "add_on") || - hasOwn(planParams, "auto_enable") || - hasOwn(planParams, "items") || - hasOwn(planParams, "price") || - hasOwn(planParams, "free_trial"); + planParams.name !== undefined || + planParams.description !== undefined || + planParams.group !== undefined || + planParams.add_on !== undefined || + planParams.auto_enable !== undefined || + shouldMapItems || + planParams.free_trial !== undefined; if (shouldMapPlanParams) { + let itemsForMapping = planParams.items; + let priceForMapping = planParams.price; + + if ( + shouldMapItems && + (itemsForMapping === undefined || priceForMapping === undefined) + ) { + const currentFullProduct = await ProductService.getFull({ + db: ctx.db, + idOrInternalId: plan_id, + orgId: ctx.org.id, + env: ctx.env, + }); + + if (currentFullProduct) { + const currentPlan = await getPlanResponse({ + product: currentFullProduct, + features: ctx.features, + }); + + itemsForMapping = + itemsForMapping ?? + currentPlan.items.map((item) => ({ + feature_id: item.feature_id, + included: item.included, + unlimited: item.unlimited, + reset: item.reset ?? undefined, + price: item.price + ? { + amount: item.price.amount, + tiers: item.price.tiers, + interval: item.price.interval, + interval_count: item.price.interval_count, + billing_units: item.price.billing_units, + billing_method: item.price.billing_method, + max_purchase: item.price.max_purchase ?? undefined, + } + : undefined, + rollover: item.rollover + ? { + max: item.rollover.max ?? undefined, + expiry_duration_type: item.rollover.expiry_duration_type, + expiry_duration_length: + item.rollover.expiry_duration_length, + } + : undefined, + })); + priceForMapping = + priceForMapping ?? + (currentPlan.price + ? { + amount: currentPlan.price.amount, + interval: currentPlan.price.interval, + interval_count: currentPlan.price.interval_count, + } + : undefined); + } + } + const mappedUpdates = apiPlan.map.paramsV1ToProductV2({ ctx, params: { id: plan_id, - ...planParams, + ...(planParams.name !== undefined + ? { name: planParams.name } + : {}), + ...(planParams.description !== undefined + ? { description: planParams.description } + : {}), + ...(planParams.group !== undefined + ? { group: planParams.group } + : {}), + ...(planParams.add_on !== undefined + ? { add_on: planParams.add_on } + : {}), + ...(planParams.auto_enable !== undefined + ? { auto_enable: planParams.auto_enable } + : {}), + ...(planParams.free_trial !== undefined + ? { free_trial: planParams.free_trial } + : {}), + ...(shouldMapItems + ? { + items: itemsForMapping, + price: priceForMapping, + } + : {}), }, - }) as UpdateProductV2Params; + }); - if (hasOwn(planParams, "name")) { + if (planParams.name !== undefined) { updates.name = mappedUpdates.name; } - if (hasOwn(planParams, "description")) { + if (planParams.description !== undefined) { updates.description = mappedUpdates.description; } - if (hasOwn(planParams, "group")) { + if (planParams.group !== undefined) { updates.group = mappedUpdates.group; } - if (hasOwn(planParams, "add_on")) { + if (planParams.add_on !== undefined) { updates.is_add_on = mappedUpdates.is_add_on; } - if (hasOwn(planParams, "auto_enable")) { + if (planParams.auto_enable !== undefined) { updates.is_default = mappedUpdates.is_default; } - if (hasOwn(planParams, "items")) { + if (shouldMapItems) { updates.items = mappedUpdates.items; } - if (hasOwn(planParams, "free_trial")) { + if (planParams.free_trial !== undefined) { updates.free_trial = mappedUpdates.free_trial; } } diff --git a/server/src/internal/products/planRpcRouter.ts b/server/src/internal/products/planRpcRouter.ts index 3009db7d2..a1b9e7481 100644 --- a/server/src/internal/products/planRpcRouter.ts +++ b/server/src/internal/products/planRpcRouter.ts @@ -1,7 +1,7 @@ import { Hono } from "hono"; import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; import { handleCreatePlanV2 } from "./handlers/handleCreateProduct/handleCreatePlanV2.js"; -import { handleDeletePlanV2 } from "./handlers/handleDeleteProduct/handleDeletePlanV2.js"; +import { handleDeletePlanV2 } from "./handlers/handleDeletePlan/handleDeletePlanV2.js"; import { handleGetPlanV2 } from "./handlers/handleGetProduct/handleGetPlanV2.js"; import { handleUpdatePlanV2 } from "./handlers/handleUpdateProduct/handleUpdatePlanV2.js"; diff --git a/server/src/internal/products/productRouter.ts b/server/src/internal/products/productRouter.ts index ddae7c5a2..fac6d3f09 100644 --- a/server/src/internal/products/productRouter.ts +++ b/server/src/internal/products/productRouter.ts @@ -3,7 +3,7 @@ import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; import { handlePlanHasCustomersV2 } from "@/internal/products/handlers/handlePlanHasCustomersV2.js"; import { handleCopyProductV2 } from "./handlers/handleCopyProduct/handleCopyProductV2.js"; import { handleCreatePlan } from "./handlers/handleCreateProduct/handleCreatePlan.js"; -import { handleDeleteProduct as handleDeleteProductHono } from "./handlers/handleDeleteProduct/handleDeleteProduct.js"; +import { handleDeletePlan as handleDeletePlanHono } from "./handlers/handleDeletePlan/handleDeletePlan.js"; import { handleGetPlan } from "./handlers/handleGetProduct/handleGetPlan.js"; import { handleGetPlanDeleteInfo } from "./handlers/handleGetPlanDeleteInfo.js"; import { handleListPlans } from "./handlers/handleListPlans.js"; @@ -27,7 +27,7 @@ honoProductRouter.post("", ...handleCreatePlan); honoProductRouter.get("/:product_id", ...handleGetPlan); honoProductRouter.post("/:product_id", ...handleUpdatePlan); // will be deprecated honoProductRouter.patch("/:product_id", ...handleUpdatePlan); // will be deprecated -honoProductRouter.delete("/:product_id", ...handleDeleteProductHono); +honoProductRouter.delete("/:product_id", ...handleDeletePlanHono); // Others honoProductRouter.post("/:product_id/copy", ...handleCopyProductV2); diff --git a/shared/api/products/crud/mappers/planParamsV1ToProductV2.ts b/shared/api/products/crud/mappers/planParamsV1ToProductV2.ts index a53663e17..9da3adb4f 100644 --- a/shared/api/products/crud/mappers/planParamsV1ToProductV2.ts +++ b/shared/api/products/crud/mappers/planParamsV1ToProductV2.ts @@ -2,16 +2,16 @@ import type { CreatePlanParams } from "@api/products/crud/createPlanParamsV0"; import type { UpdatePlanParams } from "@api/products/crud/updatePlanParamsV0"; import { planItemParamsV1ToPlanItemV0 } from "@api/products/items/mappers/planItemParamsV1ToPlanItemV0"; import { planV0ToProductItems } from "@api/products/mappers/planV0ToProductItems"; +import type { + CreateProductV2Params, + UpdateProductV2Params, +} from "@api/products/productOpModels"; import type { AppEnv } from "@models/genModels/genEnums"; -import type { ProductV2 } from "@models/productV2Models/productV2Models"; import type { SharedContext } from "../../../../types/sharedContext"; -export function planParamsV1ToProductV2({ - ctx, - params, -}: { +type MapperArgs = { ctx: SharedContext; - params: CreatePlanParams | UpdatePlanParams; + params: TParams; // Here to enforce type checking, probably not used but just to be sure. overrides?: { @@ -19,18 +19,59 @@ export function planParamsV1ToProductV2({ env: AppEnv; created_at: number; }; -}): Partial { - const planFeatures = - params.items?.map((item) => planItemParamsV1ToPlanItemV0({ ctx, item })) ?? - []; +}; - const price = params.price; +export function planParamsV1ToProductV2({ + ctx, + params, +}: MapperArgs): CreateProductV2Params; +export function planParamsV1ToProductV2({ + ctx, + params, +}: MapperArgs): UpdateProductV2Params; +export function planParamsV1ToProductV2({ + ctx, + params, +}: MapperArgs): + | CreateProductV2Params + | UpdateProductV2Params { + const mapped: Partial = {}; - // Convert plan to items using shared utility - const items = planV0ToProductItems({ - ctx, - plan: { features: planFeatures, price: price ?? null }, - }); + if (params.id !== undefined) { + mapped.id = params.id; + } + + if (params.name !== undefined) { + mapped.name = params.name; + } + + if ("description" in params) { + mapped.description = params.description; + } + + if (params.add_on !== undefined) { + mapped.is_add_on = params.add_on; + } + + if (params.auto_enable !== undefined) { + mapped.is_default = params.auto_enable; + } + + if (params.group !== undefined) { + mapped.group = params.group; + } + + const shouldMapItems = params.items !== undefined || params.price !== undefined; + if (shouldMapItems) { + const planFeatures = + params.items?.map((item) => planItemParamsV1ToPlanItemV0({ ctx, item })) ?? + []; + + mapped.items = planV0ToProductItems({ + ctx, + plan: { features: planFeatures, price: params.price ?? null }, + }); + } // Check if archived field exists on plan (it's on ApiPlan, not CreatePlanParams) const archived = @@ -38,23 +79,20 @@ export function planParamsV1ToProductV2({ ? params.archived : undefined; - return { - id: params.id, // fallback just for placeholders... - name: params.name, - description: params.description ?? null, - is_add_on: params.add_on, - is_default: params.auto_enable, - - group: params.group ?? "", - items, - free_trial: params.free_trial + if ("free_trial" in params) { + mapped.free_trial = params.free_trial ? { duration: params.free_trial.duration_type, length: params.free_trial.duration_length, unique_fingerprint: false, card_required: params.free_trial.card_required, } - : null, - ...(archived !== undefined && { archived }), - }; + : params.free_trial; + } + + if (archived !== undefined) { + mapped.archived = archived; + } + + return mapped as CreateProductV2Params | UpdateProductV2Params; } From 6af95ba325d3586c59a4c11d0f7e42c9a28ebc62 Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Thu, 19 Feb 2026 11:20:47 +0000 Subject: [PATCH 3/4] refactor: move product actions to internal/product/actions --- .../actions}/createProduct.ts | 12 ++++++------ .../actions}/deleteProduct.ts | 2 +- .../actions}/updateProduct.ts | 14 +++++++------- .../actions}/validateDefaultFlag.ts | 6 +++--- .../handleCopyEnvironment/handleCopyProducts.ts | 4 ++-- .../handleCreateProduct/handleCreatePlan.ts | 2 +- .../handleCreateProduct/handleCreatePlanV2.ts | 2 +- .../handlers/handleDeletePlan/handleDeletePlan.ts | 2 +- .../handleDeletePlan/handleDeletePlanV2.ts | 2 +- .../handleUpdateProduct/handleUpdatePlan.ts | 2 +- .../handleUpdateProduct/handleUpdatePlanV2.ts | 2 +- 11 files changed, 25 insertions(+), 25 deletions(-) rename server/src/internal/{products/actions/productActions => product/actions}/createProduct.ts (80%) rename server/src/internal/{products/actions/productActions => product/actions}/deleteProduct.ts (95%) rename server/src/internal/{products/actions/productActions => product/actions}/updateProduct.ts (87%) rename server/src/internal/{products/actions/productActions => product/actions}/validateDefaultFlag.ts (95%) diff --git a/server/src/internal/products/actions/productActions/createProduct.ts b/server/src/internal/product/actions/createProduct.ts similarity index 80% rename from server/src/internal/products/actions/productActions/createProduct.ts rename to server/src/internal/product/actions/createProduct.ts index fe191f1e0..52065f4df 100644 --- a/server/src/internal/products/actions/productActions/createProduct.ts +++ b/server/src/internal/product/actions/createProduct.ts @@ -9,15 +9,15 @@ import { ProductAlreadyExistsError } from "@autumn/shared"; 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 { getEntsWithFeature } from "@/internal/products/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 { getProductResponse } from "../../productUtils/productResponseUtils/getProductResponse.js"; -import { constructProduct, initProductInStripe } from "../../productUtils.js"; +} from "@/internal/products/free-trials/freeTrialUtils.js"; +import { ProductService } from "@/internal/products/ProductService.js"; +import { handleNewProductItems } from "@/internal/products/product-items/productItemUtils/handleNewProductItems.js"; +import { getProductResponse } from "@/internal/products/productUtils/productResponseUtils/getProductResponse.js"; +import { constructProduct, initProductInStripe } from "@/internal/products/productUtils.js"; import { validateDefaultFlag } from "./validateDefaultFlag.js"; export const createProduct = async ({ diff --git a/server/src/internal/products/actions/productActions/deleteProduct.ts b/server/src/internal/product/actions/deleteProduct.ts similarity index 95% rename from server/src/internal/products/actions/productActions/deleteProduct.ts rename to server/src/internal/product/actions/deleteProduct.ts index 1e694a13c..2e7d4b125 100644 --- a/server/src/internal/products/actions/productActions/deleteProduct.ts +++ b/server/src/internal/product/actions/deleteProduct.ts @@ -1,7 +1,7 @@ import { ProductNotFoundError, RecaseError } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { CusProdReadService } from "@/internal/customers/cusProducts/CusProdReadService.js"; -import { ProductService } from "../../ProductService.js"; +import { ProductService } from "@/internal/products/ProductService.js"; export const deleteProduct = async ({ ctx, diff --git a/server/src/internal/products/actions/productActions/updateProduct.ts b/server/src/internal/product/actions/updateProduct.ts similarity index 87% rename from server/src/internal/products/actions/productActions/updateProduct.ts rename to server/src/internal/product/actions/updateProduct.ts index 84425368d..5b3d2f210 100644 --- a/server/src/internal/products/actions/productActions/updateProduct.ts +++ b/server/src/internal/product/actions/updateProduct.ts @@ -17,13 +17,13 @@ import { addTaskToQueue } from "@/queue/queueUtils.js"; import { handleNewFreeTrial, validateOneOffTrial, -} from "../../free-trials/freeTrialUtils.js"; -import { ProductService } from "../../ProductService.js"; -import { handleNewProductItems } from "../../product-items/productItemUtils/handleNewProductItems.js"; -import { getProductResponse } from "../../productUtils/productResponseUtils/getProductResponse.js"; -import { initProductInStripe } from "../../productUtils.js"; -import { handleUpdateProductDetails } from "../../handlers/handleUpdateProduct/updateProductDetails.js"; -import { handleVersionProductV2 } from "../../handlers/handleVersionProduct.js"; +} from "@/internal/products/free-trials/freeTrialUtils.js"; +import { ProductService } from "@/internal/products/ProductService.js"; +import { handleNewProductItems } from "@/internal/products/product-items/productItemUtils/handleNewProductItems.js"; +import { getProductResponse } from "@/internal/products/productUtils/productResponseUtils/getProductResponse.js"; +import { initProductInStripe } from "@/internal/products/productUtils.js"; +import { handleUpdateProductDetails } from "@/internal/products/handlers/handleUpdateProduct/updateProductDetails.js"; +import { handleVersionProductV2 } from "@/internal/products/handlers/handleVersionProduct.js"; import { validateDefaultFlag } from "./validateDefaultFlag.js"; interface UpdateProductParams { diff --git a/server/src/internal/products/actions/productActions/validateDefaultFlag.ts b/server/src/internal/product/actions/validateDefaultFlag.ts similarity index 95% rename from server/src/internal/products/actions/productActions/validateDefaultFlag.ts rename to server/src/internal/product/actions/validateDefaultFlag.ts index b1ac2aa2d..88d582e39 100644 --- a/server/src/internal/products/actions/productActions/validateDefaultFlag.ts +++ b/server/src/internal/product/actions/validateDefaultFlag.ts @@ -8,13 +8,13 @@ import { RecaseError, type UpdateProductV2Params, } from "@autumn/shared"; -import type { AutumnContext } from "../../../../honoUtils/HonoEnv"; -import { ProductService } from "../../ProductService"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { ProductService } from "@/internal/products/ProductService.js"; import { getGroupToDefaults, isFreeProduct, isOneOff, -} from "../../productUtils"; +} from "@/internal/products/productUtils.js"; const disableCurrentDefault = async ({ ctx, diff --git a/server/src/internal/products/handlers/handleCopyEnvironment/handleCopyProducts.ts b/server/src/internal/products/handlers/handleCopyEnvironment/handleCopyProducts.ts index b0a50406a..aedb19bfd 100644 --- a/server/src/internal/products/handlers/handleCopyEnvironment/handleCopyProducts.ts +++ b/server/src/internal/products/handlers/handleCopyEnvironment/handleCopyProducts.ts @@ -8,8 +8,8 @@ import { import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { FeatureService } from "@/internal/features/FeatureService.js"; import { ProductService } from "../../ProductService.js"; -import { createProduct } from "../productActions/createProduct.js"; -import { updateProduct } from "../productActions/updateProduct.js"; +import { createProduct } from "../../../product/actions/createProduct.js"; +import { updateProduct } from "../../../product/actions/updateProduct.js"; const conformProductToSchema = ( product: ProductV2, diff --git a/server/src/internal/products/handlers/handleCreateProduct/handleCreatePlan.ts b/server/src/internal/products/handlers/handleCreateProduct/handleCreatePlan.ts index e952ba540..d377bb107 100644 --- a/server/src/internal/products/handlers/handleCreateProduct/handleCreatePlan.ts +++ b/server/src/internal/products/handlers/handleCreateProduct/handleCreatePlan.ts @@ -28,7 +28,7 @@ import { ProductService } from "../../ProductService.js"; import { handleNewProductItems } from "../../product-items/productItemUtils/handleNewProductItems.js"; import { getPlanResponse } from "../../productUtils/productResponseUtils/getPlanResponse.js"; import { constructProduct, initProductInStripe } from "../../productUtils.js"; -import { validateDefaultFlag } from "../../actions/productActions/validateDefaultFlag.js"; +import { validateDefaultFlag } from "../../../product/actions/validateDefaultFlag.js"; /** * Route: POST /products - Create a product diff --git a/server/src/internal/products/handlers/handleCreateProduct/handleCreatePlanV2.ts b/server/src/internal/products/handlers/handleCreateProduct/handleCreatePlanV2.ts index 6146dc4ba..e111e4560 100644 --- a/server/src/internal/products/handlers/handleCreateProduct/handleCreatePlanV2.ts +++ b/server/src/internal/products/handlers/handleCreateProduct/handleCreatePlanV2.ts @@ -7,7 +7,7 @@ import { createRoute } from "@/honoMiddlewares/routeHandler.js"; import { FeatureService } from "@/internal/features/FeatureService.js"; import { ProductService } from "../../ProductService.js"; import { getPlanResponse } from "../../productUtils/productResponseUtils/getPlanResponse.js"; -import { createProduct } from "../../actions/productActions/createProduct.js"; +import { createProduct } from "../../../product/actions/createProduct.js"; export const handleCreatePlanV2 = createRoute({ body: CreatePlanParamsV1Schema, diff --git a/server/src/internal/products/handlers/handleDeletePlan/handleDeletePlan.ts b/server/src/internal/products/handlers/handleDeletePlan/handleDeletePlan.ts index 9e4d075b1..23d814499 100644 --- a/server/src/internal/products/handlers/handleDeletePlan/handleDeletePlan.ts +++ b/server/src/internal/products/handlers/handleDeletePlan/handleDeletePlan.ts @@ -1,7 +1,7 @@ import { AffectedResource } from "@autumn/shared"; import { z } from "zod/v4"; import { createRoute } from "@/honoMiddlewares/routeHandler.js"; -import { deleteProduct } from "../../actions/productActions/deleteProduct.js"; +import { deleteProduct } from "../../../product/actions/deleteProduct.js"; const DeletePlanParamsSchema = z.object({ product_id: z.string(), diff --git a/server/src/internal/products/handlers/handleDeletePlan/handleDeletePlanV2.ts b/server/src/internal/products/handlers/handleDeletePlan/handleDeletePlanV2.ts index deb6036e2..230af774f 100644 --- a/server/src/internal/products/handlers/handleDeletePlan/handleDeletePlanV2.ts +++ b/server/src/internal/products/handlers/handleDeletePlan/handleDeletePlanV2.ts @@ -1,6 +1,6 @@ import { AffectedResource, DeletePlanV2BodySchema } from "@autumn/shared"; import { createRoute } from "@/honoMiddlewares/routeHandler.js"; -import { deleteProduct } from "../../actions/productActions/deleteProduct.js"; +import { deleteProduct } from "../../../product/actions/deleteProduct.js"; export const handleDeletePlanV2 = createRoute({ body: DeletePlanV2BodySchema, diff --git a/server/src/internal/products/handlers/handleUpdateProduct/handleUpdatePlan.ts b/server/src/internal/products/handlers/handleUpdateProduct/handleUpdatePlan.ts index f7ba5d399..bf16f0d18 100644 --- a/server/src/internal/products/handlers/handleUpdateProduct/handleUpdatePlan.ts +++ b/server/src/internal/products/handlers/handleUpdateProduct/handleUpdatePlan.ts @@ -33,7 +33,7 @@ import { handleNewProductItems } from "../../product-items/productItemUtils/hand import { getPlanResponse } from "../../productUtils/productResponseUtils/getPlanResponse.js"; import { initProductInStripe } from "../../productUtils.js"; import { handleVersionProductV2 } from "../handleVersionProduct.js"; -import { validateDefaultFlag } from "../../actions/productActions/validateDefaultFlag.js"; +import { validateDefaultFlag } from "../../../product/actions/validateDefaultFlag.js"; import { handleUpdateProductDetails } from "./updateProductDetails.js"; export const handleUpdatePlan = createRoute({ diff --git a/server/src/internal/products/handlers/handleUpdateProduct/handleUpdatePlanV2.ts b/server/src/internal/products/handlers/handleUpdateProduct/handleUpdatePlanV2.ts index 2f5a15df9..622890cb9 100644 --- a/server/src/internal/products/handlers/handleUpdateProduct/handleUpdatePlanV2.ts +++ b/server/src/internal/products/handlers/handleUpdateProduct/handleUpdatePlanV2.ts @@ -8,7 +8,7 @@ import { createRoute } from "@/honoMiddlewares/routeHandler.js"; import { FeatureService } from "@/internal/features/FeatureService.js"; import { ProductService } from "../../ProductService.js"; import { getPlanResponse } from "../../productUtils/productResponseUtils/getPlanResponse.js"; -import { updateProduct } from "../../actions/productActions/updateProduct.js"; +import { updateProduct } from "../../../product/actions/updateProduct.js"; export const handleUpdatePlanV2 = createRoute({ body: UpdatePlanParamsV2Schema, From c4a5bfb049eac5e6aa1189b65b31454e90bc5a26 Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Thu, 19 Feb 2026 11:23:40 +0000 Subject: [PATCH 4/4] Push SirTenzin PR 750 --- .../configs/handlers/handlePushOrganisationConfiguration.ts | 2 +- .../misc/pricingAgent/handlers/handleSyncPreviewPricing.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/server/src/internal/misc/configs/handlers/handlePushOrganisationConfiguration.ts b/server/src/internal/misc/configs/handlers/handlePushOrganisationConfiguration.ts index 9da072ef4..b63f7709f 100644 --- a/server/src/internal/misc/configs/handlers/handlePushOrganisationConfiguration.ts +++ b/server/src/internal/misc/configs/handlers/handlePushOrganisationConfiguration.ts @@ -10,7 +10,7 @@ import type { DrizzleCli } from "@/db/initDrizzle"; import { createRoute } from "@/honoMiddlewares/routeHandler"; import { FeatureService } from "@/internal/features/FeatureService"; import { createFeature } from "@/internal/features/featureActions/createFeature"; -import { createProduct } from "@/internal/products/handlers/productActions/createProduct"; +import { createProduct } from "@/internal/product/actions/createProduct"; import { ProductService } from "@/internal/products/ProductService"; import { invalidateProductsCache } from "@/internal/products/productCacheUtils"; diff --git a/server/src/internal/misc/pricingAgent/handlers/handleSyncPreviewPricing.ts b/server/src/internal/misc/pricingAgent/handlers/handleSyncPreviewPricing.ts index 98feadbab..48b9a585a 100644 --- a/server/src/internal/misc/pricingAgent/handlers/handleSyncPreviewPricing.ts +++ b/server/src/internal/misc/pricingAgent/handlers/handleSyncPreviewPricing.ts @@ -13,7 +13,7 @@ import { CusService } from "@/internal/customers/CusService.js"; import { FeatureService } from "@/internal/features/FeatureService.js"; import { createFeature } from "@/internal/features/featureActions/createFeature.js"; import { OrgService } from "@/internal/orgs/OrgService.js"; -import { createProduct } from "@/internal/products/handlers/productActions/createProduct.js"; +import { createProduct } from "@/internal/product/actions/createProduct.js"; import { ProductService } from "@/internal/products/ProductService.js"; import { invalidateProductsCache } from "@/internal/products/productCacheUtils.js"; import { buildPreviewOrgSlug } from "./handleSetupPreviewOrg.js";