From 4e68c3dc9a7eb8891b69f91ef539c2b63bc5f843 Mon Sep 17 00:00:00 2001 From: John Yeo Date: Fri, 14 Nov 2025 15:59:39 +0000 Subject: [PATCH] feat: created new features API version --- AGENTS.md | 2 + CLAUDE.md | 2 + server/src/external/autumn/autumnCli.ts | 4 - server/src/external/autumn/autumnCliV2.ts | 4 - server/src/honoMiddlewares/routeHandler.ts | 4 +- .../src/honoMiddlewares/versionedValidator.ts | 1 + server/src/initHono.ts | 2 + server/src/internal/api/apiRouter.ts | 25 +- .../api/components/componentRouter.ts | 26 +- .../balances/track/syncUtils/syncItem.ts | 3 + .../customers/add-product/initCusEnt.ts | 2 +- .../upgradeDiffIntFlow/createUsageInvoice.ts | 12 +- .../internal/customers/attach/attachRouter.ts | 10 +- .../attachParamsUtils/getPricesAndEnts.ts | 2 +- .../attachParamsUtils/getStripeCusData.ts | 15 +- .../initCusEnt/initNextResetAt.ts | 28 +- server/src/internal/customers/cusRouter.ts | 128 +---- .../getApiBalance/apiBalanceUtils.ts | 8 +- .../getApiBalance/getApiBalance.ts | 4 +- .../handlers/handleAddCouponToCus.ts | 84 --- .../handlers/handleAddCouponToCusV2.ts | 69 +++ .../createBillingPortalSession.ts | 86 +++ .../createDefaultPortalConfig.ts | 36 ++ .../handleCreateBillingPortal.ts | 45 ++ .../handlers/handleCreateBillingPortal.ts | 165 ------ .../handlers/handleCreateCustomer.ts | 2 - .../handlers/handleDeleteCustomer.ts | 108 ---- .../handlers/handleDeleteCustomerV2.ts | 73 +++ .../customers/handlers/handleGetCustomers.ts | 39 -- .../handlers/handleTransferProduct.ts | 151 ------ .../handleDecreaseAndTransfer.ts | 25 +- .../handlers/handleTransferProductV2.ts | 133 +++++ .../handlers/handleUpdateBalances.ts | 504 +++++++++--------- .../handlers/handleUpdateBalancesV2.ts | 51 ++ .../handlers/handleUpdateCustomer.ts | 148 ----- .../handlers/handleUpdateCustomerV2.ts | 41 +- .../internal/customers/internalCusRouter.ts | 4 +- .../internalHandlers/handleGetCusReferrals.ts | 2 +- .../features/archives/handleCreateFeature.ts | 18 + .../features/archives/handleDeleteFeature.ts | 67 +++ .../features/archives/handleUpdateFeature.ts | 32 ++ .../features/featureActions/createFeature.ts | 24 +- .../features/featureActions/updateFeature.ts | 8 +- server/src/internal/features/featureRouter.ts | 365 +++++++------ server/src/internal/features/featureUtils.ts | 10 +- .../features/handlers/handleCreateFeature.ts | 53 +- .../features/handlers/handleDeleteFeature.ts | 101 ++-- .../features/handlers/handleGetFeature.ts | 42 ++ .../handlers/handleGetFeatureDeletionInfo.ts | 64 --- .../features/handlers/handleListFeatures.ts | 15 + .../features/handlers/handleUpdateFeature.ts | 98 +++- .../features/internalFeatureRouter.ts | 93 ---- .../handleGetFeatureDeletionInfo.ts | 70 +++ .../features/utils/mapFeatureUtils.ts | 36 +- .../getObjectsUsingFeature.ts | 0 .../handleFeatureIdChanged.ts | 0 .../handleFeatureTypeChanged.ts | 0 .../handleFeatureUsageTypeChanged.ts | 0 server/src/internal/mainRouter.ts | 2 - .../products/entitlements/entitlementUtils.ts | 21 +- .../handleCopyProduct/handleCopyProductV2.ts | 21 +- .../products/handlers/handleDeleteProduct.ts | 7 +- .../handlers/handleGetPlanDeleteInfo.ts | 50 ++ .../handlers/handlePlanHasCustomers.ts | 58 ++ .../updateProductDetails.ts | 1 - .../products/handlers/handleVersionProduct.ts | 4 +- .../handlers/productActions/updateProduct.ts | 11 +- .../products/prices/billingIntervalUtils2.ts | 16 +- .../productItemUtils/addIdsToProductItems.ts | 7 +- server/src/internal/products/productRouter.ts | 210 +++----- .../getProductResponse.ts | 2 +- .../src/internal/products/productV2Utils.ts | 1 - server/src/queue/createWorkerContext.ts | 1 + server/src/trigger/handleThresholdReached.ts | 8 +- server/src/trigger/updateBalanceTask.ts | 17 +- server/src/trigger/updateUsageTask.ts | 14 +- server/src/utils/cacheUtils/cacheUtils.ts | 61 +-- .../utils/checkUtils/checkCustomerCorrect.ts | 4 +- .../utils/scriptUtils/createTestProducts.ts | 21 +- .../testUtils/createSharedProduct.ts | 33 +- .../utils/workerUtils/createAutumnContext.ts | 1 + server/src/websockets/WsManager.ts | 18 - server/src/websockets/initWs.ts | 127 ----- server/tests/_temp/temp1.test.ts | 65 +-- server/tests/attach/basic/basic1.test.ts | 2 +- server/tests/attach/basic/basic8.test.ts | 1 + .../tests/balances/check/basic/check8.test.ts | 35 +- .../balances/update/balances-update3.test.ts | 33 +- server/tests/contUse/entities/entity4.test.ts | 4 +- .../plan/create-plan-cross-version.test.ts | 3 +- server/tests/testRunner/outputParser.ts | 2 +- .../utils/expectUtils/expectContUseUtils.ts | 4 +- .../openapi1.2/featuresOpenApi.ts | 8 +- .../prevVersions/legacyUpdateBalanceModels.ts | 33 +- shared/api/core/checkModels.ts | 130 ----- .../changes/V1.1_FeaturesArrayToObject.ts | 2 +- .../api/customers/cusFeatures/apiBalance.ts | 4 +- .../changes/V1.2_CusFeatureChange.ts | 28 +- .../previousVersions/apiCusFeatureV3.ts | 2 +- shared/api/customers/customerOpModels.ts | 11 +- shared/api/errors/classes/cusErrClasses.ts | 11 + shared/api/errors/codes/cusErrCodes.ts | 1 + shared/api/features/apiFeatureV1.ts | 31 ++ .../features/changes/V1.2_FeatureChange.ts | 90 ++++ .../changes/V1.2_FeatureParamsChange.ts | 71 +++ shared/api/features/featureV1OpModels.ts | 94 ++++ shared/api/features/featuresOpenApi.ts | 9 +- .../apiFeatureV0.ts} | 4 +- .../featureV0OpModels.ts} | 10 +- shared/api/features/updateFeatureParams.ts | 2 +- shared/api/models.ts | 14 +- shared/api/products/planExpand.ts | 0 .../products/planFeature/apiPlanFeature.ts | 4 +- .../previousVersions/apiProductItem.ts | 4 +- .../versionChangeUtils/VersionChange.ts | 1 + .../versionChangeRegistry.ts | 6 + shared/index.ts | 1 - .../featureConfig/creditConfig.ts | 2 +- .../productModels/entModels/entEnums.ts | 0 shared/utils/featureUtils.ts | 4 +- .../featureUtils/apiFeatureToDbFeature.ts | 175 +++++- .../utils/featureUtils/convertFeatureUtils.ts | 25 + vite/src/hooks/queries/useGeneralQuery.tsx | 1 + vite/src/hooks/stores/useProductStore.ts | 20 +- vite/src/services/FeatureService.tsx | 25 +- vite/src/services/useAxiosInstance.tsx | 13 +- .../actions/useFeatureCreationActions.tsx | 18 +- .../onboarding3/hooks/useOnboardingSteps.tsx | 4 +- .../components/CreateFeatureSheet.tsx | 16 +- .../components/UpdateFeatureSheet.tsx | 7 +- .../archives/CreateCreditSystem.tsx | 108 ---- .../archives/CreditSystemConfig.tsx | 220 -------- .../archives/UpdateCreditSystem.tsx | 83 --- .../components/CreateCreditSystemSheet.tsx | 9 +- .../components/UpdateCreditSystemSheet.tsx | 24 +- .../DeleteFeatureDialog.tsx | 19 +- .../feature-row-toolbar/FeatureRowToolbar.tsx | 12 +- .../archives/DeleteFeatureDialog.tsx | 163 ------ 138 files changed, 2561 insertions(+), 2967 deletions(-) delete mode 100644 server/src/internal/customers/handlers/handleAddCouponToCus.ts create mode 100644 server/src/internal/customers/handlers/handleAddCouponToCusV2.ts create mode 100644 server/src/internal/customers/handlers/handleBillingPortal/createBillingPortalSession.ts create mode 100644 server/src/internal/customers/handlers/handleBillingPortal/createDefaultPortalConfig.ts create mode 100644 server/src/internal/customers/handlers/handleBillingPortal/handleCreateBillingPortal.ts delete mode 100644 server/src/internal/customers/handlers/handleCreateBillingPortal.ts delete mode 100644 server/src/internal/customers/handlers/handleDeleteCustomer.ts create mode 100644 server/src/internal/customers/handlers/handleDeleteCustomerV2.ts delete mode 100644 server/src/internal/customers/handlers/handleGetCustomers.ts delete mode 100644 server/src/internal/customers/handlers/handleTransferProduct.ts create mode 100644 server/src/internal/customers/handlers/handleTransferProductV2.ts create mode 100644 server/src/internal/customers/handlers/handleUpdateBalancesV2.ts delete mode 100644 server/src/internal/customers/handlers/handleUpdateCustomer.ts create mode 100644 server/src/internal/features/archives/handleCreateFeature.ts create mode 100644 server/src/internal/features/archives/handleDeleteFeature.ts create mode 100644 server/src/internal/features/archives/handleUpdateFeature.ts create mode 100644 server/src/internal/features/handlers/handleGetFeature.ts delete mode 100644 server/src/internal/features/handlers/handleGetFeatureDeletionInfo.ts create mode 100644 server/src/internal/features/handlers/handleListFeatures.ts delete mode 100644 server/src/internal/features/internalFeatureRouter.ts create mode 100644 server/src/internal/features/internalHandlers/handleGetFeatureDeletionInfo.ts rename server/src/internal/features/{handlers/handleUpdateFeature => utils/updateFeatureUtils}/getObjectsUsingFeature.ts (100%) rename server/src/internal/features/{handlers/handleUpdateFeature => utils/updateFeatureUtils}/handleFeatureIdChanged.ts (100%) rename server/src/internal/features/{handlers/handleUpdateFeature => utils/updateFeatureUtils}/handleFeatureTypeChanged.ts (100%) rename server/src/internal/features/{handlers/handleUpdateFeature => utils/updateFeatureUtils}/handleFeatureUsageTypeChanged.ts (100%) create mode 100644 server/src/internal/products/handlers/handleGetPlanDeleteInfo.ts create mode 100644 server/src/internal/products/handlers/handlePlanHasCustomers.ts delete mode 100644 server/src/websockets/WsManager.ts delete mode 100644 server/src/websockets/initWs.ts delete mode 100644 shared/api/core/checkModels.ts create mode 100644 shared/api/features/apiFeatureV1.ts create mode 100644 shared/api/features/changes/V1.2_FeatureChange.ts create mode 100644 shared/api/features/changes/V1.2_FeatureParamsChange.ts create mode 100644 shared/api/features/featureV1OpModels.ts rename shared/api/features/{apiFeature.ts => prevVersions/apiFeatureV0.ts} (93%) rename shared/api/features/{featureOpModels.ts => prevVersions/featureV0OpModels.ts} (86%) delete mode 100644 shared/api/products/planExpand.ts delete mode 100644 shared/models/productModels/entModels/entEnums.ts delete mode 100644 vite/src/views/products/features/credit-systems/archives/CreateCreditSystem.tsx delete mode 100644 vite/src/views/products/features/credit-systems/archives/CreditSystemConfig.tsx delete mode 100644 vite/src/views/products/features/credit-systems/archives/UpdateCreditSystem.tsx delete mode 100644 vite/src/views/products/features/feature-row-toolbar/archives/DeleteFeatureDialog.tsx diff --git a/AGENTS.md b/AGENTS.md index f8f78d0be..0c17ccb22 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,6 +25,8 @@ - Always prefer foo({ bar }) over foo(bar) method signatures - no matter if we are using only one argument or not, object as param are always better, as in the future when wanting to change the order of parameters, or add new ones - its easier. +- **ALWAYS use `c.req.param()` to get route parameters in Hono handlers**, NOT `c.req.valid("param")`. Example: `const { customer_id } = c.req.param();` + - When creating "hooks" folders, don't nest them under "components" - Functions (unless there's a very good reason) should always take in objects as arguments. Object params are named and easy to understand. diff --git a/CLAUDE.md b/CLAUDE.md index b43011b75..86f897dcd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,6 +31,8 @@ - Do not run "npx tsc" - run "tsc" instead as it is installed globally. - **ALWAYS use `.meta()` for zod-openapi schema registration**, NOT `.openapi()`. Example: `ApiProductSchema.meta({ id: "Product" })` + +- **ALWAYS use `c.req.param()` to get route parameters in Hono handlers**, NOT `c.req.valid("param")`. Example: `const { customer_id } = c.req.param();` ## Error Handling in API Routes - NEVER use `c.json({ message: "...", code: "..." }, statusCode)` pattern for input validation or expected errors in Hono routes - ALWAYS throw `RecaseError` from `@autumn/shared` for all validation errors, not found errors, forbidden errors, etc. diff --git a/server/src/external/autumn/autumnCli.ts b/server/src/external/autumn/autumnCli.ts index f84bc12c7..e28aab50b 100644 --- a/server/src/external/autumn/autumnCli.ts +++ b/server/src/external/autumn/autumnCli.ts @@ -589,10 +589,6 @@ export class AutumnInt { return data; }; - initStripe = async () => { - await this.post(`/products/all/init_stripe`, {}); - }; - balances = { update: async (params: BalancesUpdateParams) => { const data = await this.post(`/balances/update`, params); diff --git a/server/src/external/autumn/autumnCliV2.ts b/server/src/external/autumn/autumnCliV2.ts index 96b8ea7b6..5cc0999c0 100644 --- a/server/src/external/autumn/autumnCliV2.ts +++ b/server/src/external/autumn/autumnCliV2.ts @@ -496,8 +496,4 @@ export class AutumnCliV2 { }) => { return await this.post(`/migrations`, params); }; - - initStripe = async () => { - await this.post(`/products/all/init_stripe`, {}); - }; } diff --git a/server/src/honoMiddlewares/routeHandler.ts b/server/src/honoMiddlewares/routeHandler.ts index 3a0a613d9..4a25b7e98 100644 --- a/server/src/honoMiddlewares/routeHandler.ts +++ b/server/src/honoMiddlewares/routeHandler.ts @@ -3,9 +3,9 @@ import type { Context, Env, Handler, MiddlewareHandler } from "hono"; import type { ZodType, z } from "zod/v4"; import type { DrizzleCli } from "@/db/initDrizzle.js"; import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; +import { expandMiddleware } from "./expandMiddleware.js"; import { validator } from "./validatorMiddleware.js"; import { versionedValidator } from "./versionedValidator.js"; -import { expandMiddleware } from "./expandMiddleware.js"; /** * Extended context type that includes validated input @@ -56,7 +56,7 @@ type VersionedSchemas = Partial< * handler: async (c) => { * const body = c.req.valid("json"); // ✅ Fully typed! * const query = c.req.valid("query"); // ✅ Fully typed! - * const params = c.req.valid("param"); // ✅ Fully typed! + * const params = c.req.param(); // ✅ Fully typed! * return c.json({ success: true }); * } * }); diff --git a/server/src/honoMiddlewares/versionedValidator.ts b/server/src/honoMiddlewares/versionedValidator.ts index a246f6fa2..6fe897bda 100644 --- a/server/src/honoMiddlewares/versionedValidator.ts +++ b/server/src/honoMiddlewares/versionedValidator.ts @@ -100,6 +100,7 @@ export const versionedValidator = ({ c.req.addValidatedData(target, validatedData); } else { // For other targets, use zValidator + const validatorMiddleware = zValidator(target, schema, (result, _c) => { if (!result.success) { // Validation errors reference fields from user's version ✅ diff --git a/server/src/initHono.ts b/server/src/initHono.ts index 4400eaa20..6fb2a209c 100644 --- a/server/src/initHono.ts +++ b/server/src/initHono.ts @@ -18,6 +18,7 @@ import { billingRouter } from "./internal/billing/billingRouter.js"; import { cusRouter } from "./internal/customers/cusRouter.js"; import { internalCusRouter } from "./internal/customers/internalCusRouter.js"; import { entityRouter } from "./internal/entities/entityRouter.js"; +import { featureRouter } from "./internal/features/featureRouter.js"; import { handleOAuthCallback } from "./internal/orgs/handlers/stripeHandlers/handleOAuthCallback.js"; import { honoOrgRouter } from "./internal/orgs/orgRouter.js"; import { platformBetaRouter } from "./internal/platform/platformBeta/platformBetaRouter.js"; @@ -120,6 +121,7 @@ export const createHonoApp = () => { app.route("v1/products_beta", honoProductBetaRouter); app.route("v1/products", honoProductRouter); app.route("v1/plans", honoProductRouter); + app.route("v1/features", featureRouter); app.route("v1", balancesRouter); app.route("v1/platform", platformBetaRouter); diff --git a/server/src/internal/api/apiRouter.ts b/server/src/internal/api/apiRouter.ts index 24ee2e29e..719a00d49 100644 --- a/server/src/internal/api/apiRouter.ts +++ b/server/src/internal/api/apiRouter.ts @@ -7,17 +7,10 @@ import { refreshCacheMiddleware } from "@/middleware/refreshCacheMiddleware.js"; import { analyticsRouter } from "../analytics/analyticsRouter.js"; import { attachRouter } from "../customers/attach/attachRouter.js"; import cancelRouter from "../customers/cancel/cancelRouter.js"; -import { expressCusRouter } from "../customers/cusRouter.js"; -import { handleCreateBillingPortal } from "../customers/handlers/handleCreateBillingPortal.js"; -import { featureRouter } from "../features/featureRouter.js"; -import { internalFeatureRouter } from "../features/internalFeatureRouter.js"; import { handleGetOrg } from "../orgs/handlers/handleGetOrg.js"; import { platformRouter } from "../platform/platformLegacy/platformRouter.js"; -import { productRouter } from "../products/productRouter.js"; import { componentRouter } from "./components/componentRouter.js"; -import { usageRouter } from "./events/usageRouter.js"; - import { invoiceRouter } from "./invoiceRouter.js"; import { redemptionRouter, referralRouter } from "./rewards/referralRouter.js"; import { rewardProgramRouter } from "./rewards/rewardProgramRouter.js"; @@ -31,15 +24,9 @@ apiRouter.use(analyticsMiddleware); apiRouter.use(expressApiVersionMiddleware as any); apiRouter.use(refreshCacheMiddleware); -apiRouter.use("/customers", expressCusRouter); apiRouter.use("/invoices", invoiceRouter); -apiRouter.use("/products", productRouter); apiRouter.use("/components", componentRouter); apiRouter.use("/rewards", rewardRouter); -apiRouter.use("/features", featureRouter); -apiRouter.use("/internal_features", internalFeatureRouter); - -apiRouter.use("/usage", usageRouter); // REWARDS apiRouter.use("/reward_programs", rewardProgramRouter); @@ -50,14 +37,6 @@ apiRouter.use("/redemptions", redemptionRouter); apiRouter.use("", attachRouter); apiRouter.use("/cancel", cancelRouter); -// apiRouter.use("/entitled", checkRouter); -// apiRouter.use("/check", checkRouter); -// apiRouter.use("/usage", usageRouter); - -// apiRouter.use("/events", eventsRouter); -// apiRouter.use("/track", eventsRouter); -apiRouter.post("/billing_portal", handleCreateBillingPortal); - // Analytics apiRouter.use("/query", analyticsRouter); apiRouter.use("/platform", platformRouter); @@ -68,3 +47,7 @@ apiRouter.use("/platform", platformRouter); apiRouter.get("/organization", handleGetOrg); export { apiRouter }; + +// Features +// type: boolean, metered or credit system +// resets_periodically: true / false diff --git a/server/src/internal/api/components/componentRouter.ts b/server/src/internal/api/components/componentRouter.ts index 149004fce..8519a5836 100644 --- a/server/src/internal/api/components/componentRouter.ts +++ b/server/src/internal/api/components/componentRouter.ts @@ -1,14 +1,14 @@ -import { getExistingCusProducts } from "@/internal/customers/cusProducts/cusProductUtils/getExistingCusProducts.js"; +import type { ProductV2 } from "@autumn/shared"; +import { Router } from "express"; import { CusService } from "@/internal/customers/CusService.js"; +import { getExistingCusProducts } from "@/internal/customers/cusProducts/cusProductUtils/getExistingCusProducts.js"; import { FeatureService } from "@/internal/features/FeatureService.js"; import { OrgService } from "@/internal/orgs/OrgService.js"; -import { toPricecnProduct } from "@/internal/products/pricecn/pricecnUtils.js"; import { ProductService } from "@/internal/products/ProductService.js"; -import { isProductUpgrade } from "@/internal/products/productUtils.js"; +import { toPricecnProduct } from "@/internal/products/pricecn/pricecnUtils.js"; import { getProductResponse } from "@/internal/products/productUtils/productResponseUtils/getProductResponse.js"; +import { isProductUpgrade } from "@/internal/products/productUtils.js"; import { routeHandler } from "@/utils/routerUtils.js"; -import { ProductV2 } from "@autumn/shared"; -import { Router } from "express"; export const componentRouter: Router = Router(); @@ -19,7 +19,7 @@ componentRouter.get("/pricing_table", async (req: any, res) => action: "get pricing table", handler: async () => { const { orgId, env, db } = req; - let customerId = req.query.customer_id; + const customerId = req.query.customer_id; const [org, features, products, customer] = await Promise.all([ OrgService.getFromReq(req), @@ -45,7 +45,7 @@ componentRouter.get("/pricing_table", async (req: any, res) => // 1. Sort products by price products.sort((a, b) => { - let isUpgradeA = isProductUpgrade({ + const isUpgradeA = isProductUpgrade({ prices1: a.prices, prices2: b.prices, usageAlwaysUpgrade: false, @@ -58,13 +58,13 @@ componentRouter.get("/pricing_table", async (req: any, res) => } }); - let batchResponse = []; - for (let p of products) { - let prod = await getProductResponse({ product: p, features }); + const batchResponse = []; + for (const p of products) { + const prod = await getProductResponse({ product: p, features }); let curMainProduct, curScheduledProduct; if (customer) { - let res = getExistingCusProducts({ + const res = getExistingCusProducts({ product: p, cusProducts: customer.customer_products, }); @@ -82,13 +82,13 @@ componentRouter.get("/pricing_table", async (req: any, res) => features, curMainProduct, curScheduledProduct, - otherProducts: products.filter((other) => other.id != p.id), + otherProducts: products.filter((other) => other.id !== p.id), fullCus: customer, }), ); } - let pricecnProds = await Promise.all(batchResponse); + const pricecnProds = await Promise.all(batchResponse); // let pricecnProds = await Promise.all( // products diff --git a/server/src/internal/balances/track/syncUtils/syncItem.ts b/server/src/internal/balances/track/syncUtils/syncItem.ts index fdf587bf1..8b162b421 100644 --- a/server/src/internal/balances/track/syncUtils/syncItem.ts +++ b/server/src/internal/balances/track/syncUtils/syncItem.ts @@ -80,6 +80,7 @@ export const syncItem = async ({ // For sync, we need the raw balance for that specific scope (not merged) let redisEntity: ApiCustomer | ApiEntityV1; + ctx.skipCache = false; if (entityId) { const { apiEntity } = await getCachedApiEntity({ ctx, @@ -97,6 +98,8 @@ export const syncItem = async ({ redisEntity = apiCustomer; } + console.log("Redis entity: ", redisEntity); + // Get fresh customer from DB (no locking - let deduction handle it) const fullCus = await CusService.getFull({ db, diff --git a/server/src/internal/customers/add-product/initCusEnt.ts b/server/src/internal/customers/add-product/initCusEnt.ts index dca8e1ce7..64dc580e3 100644 --- a/server/src/internal/customers/add-product/initCusEnt.ts +++ b/server/src/internal/customers/add-product/initCusEnt.ts @@ -180,7 +180,7 @@ export const initCusEntitlement = ({ if (notNullish(productOptions?.quantity) && notNullish(newBalance)) { newBalance = new Decimal(newBalance!) - .mul(productOptions?.quantity!) + .mul(productOptions?.quantity || 1) .toNumber(); } diff --git a/server/src/internal/customers/attach/attachFunctions/upgradeDiffIntFlow/createUsageInvoice.ts b/server/src/internal/customers/attach/attachFunctions/upgradeDiffIntFlow/createUsageInvoice.ts index 54ead4441..325608744 100644 --- a/server/src/internal/customers/attach/attachFunctions/upgradeDiffIntFlow/createUsageInvoice.ts +++ b/server/src/internal/customers/attach/attachFunctions/upgradeDiffIntFlow/createUsageInvoice.ts @@ -1,13 +1,13 @@ -import { DrizzleCli } from "@/db/initDrizzle.js"; -import { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js"; -import { FullCusProduct } from "@autumn/shared"; -import Stripe from "stripe"; +import type { FullCusProduct } from "@autumn/shared"; +import type Stripe from "stripe"; +import type { DrizzleCli } from "@/db/initDrizzle.js"; +import { payForInvoice } from "@/external/stripe/stripeInvoiceUtils.js"; +import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js"; +import { insertInvoiceFromAttach } from "@/internal/invoices/invoiceUtils.js"; import { createUsageInvoiceItems, resetUsageBalances, } from "./createUsageInvoiceItems.js"; -import { payForInvoice } from "@/external/stripe/stripeInvoiceUtils.js"; -import { insertInvoiceFromAttach } from "@/internal/invoices/invoiceUtils.js"; export const createUsageInvoice = async ({ db, diff --git a/server/src/internal/customers/attach/attachRouter.ts b/server/src/internal/customers/attach/attachRouter.ts index de9f3a0fa..c0a40ce2e 100644 --- a/server/src/internal/customers/attach/attachRouter.ts +++ b/server/src/internal/customers/attach/attachRouter.ts @@ -25,11 +25,7 @@ import { isFreeProduct, } from "@/internal/products/productUtils.js"; import RecaseError from "@/utils/errorUtils.js"; -import { - notNullish, - notNullOrUndefined, - nullOrUndefined, -} from "@/utils/genUtils.js"; +import { notNullish, nullOrUndefined } from "@/utils/genUtils.js"; import { handleCheckout } from "./checkout/handleCheckout.js"; import { handleAttach } from "./handleAttach.js"; import { handleAttachPreview } from "./handleAttachPreview/handleAttachPreview.js"; @@ -79,7 +75,7 @@ export const handlePrepaidErrors = async ({ } // 3. Quantity cannot be negative - if (notNullish(options?.quantity) && options?.quantity! < 0) { + if (notNullish(options?.quantity) && options?.quantity < 0) { throw new RecaseError({ message: `Quantity cannot be negative`, code: ErrCode.InvalidOptions, @@ -249,7 +245,7 @@ export const customerHasPm = async ({ stripeId: attachParams.customer.processor?.id, }); - return notNullOrUndefined(paymentMethod) ? true : false; + return notNullish(paymentMethod); }; attachRouter.post("/attach", handleAttach); diff --git a/server/src/internal/customers/attach/attachUtils/attachParams/attachParamsUtils/getPricesAndEnts.ts b/server/src/internal/customers/attach/attachUtils/attachParams/attachParamsUtils/getPricesAndEnts.ts index 6bf5933f2..6cee9d054 100644 --- a/server/src/internal/customers/attach/attachUtils/attachParams/attachParamsUtils/getPricesAndEnts.ts +++ b/server/src/internal/customers/attach/attachUtils/attachParams/attachParamsUtils/getPricesAndEnts.ts @@ -30,7 +30,7 @@ export const getPricesAndEnts = async ({ customer: FullCustomer; products: FullProduct[]; }) => { - const { options: optionsInput, is_custom, items, free_trial } = attachBody; + const { options: optionsInput, is_custom, free_trial } = attachBody; const { features, db, org, logger } = req; const { curMainProduct, curSameProduct } = getExistingCusProducts({ diff --git a/server/src/internal/customers/attach/attachUtils/attachParams/attachParamsUtils/getStripeCusData.ts b/server/src/internal/customers/attach/attachUtils/attachParams/attachParamsUtils/getStripeCusData.ts index 4bd236682..c4449ab44 100644 --- a/server/src/internal/customers/attach/attachUtils/attachParams/attachParamsUtils/getStripeCusData.ts +++ b/server/src/internal/customers/attach/attachUtils/attachParams/attachParamsUtils/getStripeCusData.ts @@ -1,11 +1,10 @@ -import { DrizzleCli } from "@/db/initDrizzle.js"; +import type { AppEnv, Customer, Organization } from "@autumn/shared"; +import type Stripe from "stripe"; +import type { DrizzleCli } from "@/db/initDrizzle.js"; import { createStripeCusIfNotExists, listCusPaymentMethods, } from "@/external/stripe/stripeCusUtils.js"; -import RecaseError from "@/utils/errorUtils.js"; -import { AppEnv, Customer, ErrCode, Organization } from "@autumn/shared"; -import Stripe from "stripe"; export const getStripeCusData = async ({ stripeCli, @@ -28,7 +27,7 @@ export const getStripeCusData = async ({ return { stripeCus: undefined, paymentMethod: null, now: undefined }; } - let stripeCus = (await createStripeCusIfNotExists({ + const stripeCus = (await createStripeCusIfNotExists({ db, org, env, @@ -36,16 +35,16 @@ export const getStripeCusData = async ({ logger, })) as Stripe.Customer; - let testClock = stripeCus.test_clock as Stripe.TestHelpers.TestClock | null; + const testClock = stripeCus.test_clock as Stripe.TestHelpers.TestClock | null; // let now = testClock ? testClock.frozen_time * 1000 : Date.now(); - let now = testClock ? testClock.frozen_time * 1000 : undefined; + const now = testClock ? testClock.frozen_time * 1000 : undefined; let paymentMethod = stripeCus.invoice_settings ?.default_payment_method as Stripe.PaymentMethod | null; if (!paymentMethod) { - let paymentMethods = await listCusPaymentMethods({ + const paymentMethods = await listCusPaymentMethods({ stripeCli, stripeId: stripeCus.id, }); diff --git a/server/src/internal/customers/cusProducts/insertCusProduct/initCusEnt/initNextResetAt.ts b/server/src/internal/customers/cusProducts/insertCusProduct/initCusEnt/initNextResetAt.ts index 4388f87a9..1ec7f5f6c 100644 --- a/server/src/internal/customers/cusProducts/insertCusProduct/initCusEnt/initNextResetAt.ts +++ b/server/src/internal/customers/cusProducts/insertCusProduct/initCusEnt/initNextResetAt.ts @@ -39,7 +39,7 @@ export const initNextResetAt = ({ // 2. If nextResetAt is provided, return it... if (nextResetAt) return nextResetAt; - // 3. Calculate next reset at... + // 3. Get next reset at: let nextResetAtCalculated = null; const trialEndTimestamp = trialEndsAt ? Math.round(trialEndsAt / 1000) @@ -47,29 +47,41 @@ export const initNextResetAt = ({ ? freeTrialToStripeTimestamp({ freeTrial, now }) : null; - if ( - freeTrial && - applyTrialToEntitlement(entitlement, freeTrial) && - trialEndTimestamp - ) { + const shouldApplyTrial = applyTrialToEntitlement(entitlement, freeTrial); + + // console.log( + // "Trial end timestamp: ", + // formatUnixToDateTime(trialEndTimestamp! * 1000), + // ); + // console.log("Should apply trial: ", shouldApplyTrial); + // console.log("Anchor to unix: ", formatUnixToDateTime(anchorToUnix!)); + + if (freeTrial && shouldApplyTrial && trialEndTimestamp) { nextResetAtCalculated = new UTCDate(trialEndTimestamp! * 1000); } const resetInterval = entitlement.interval as EntInterval; + const startDate = nextResetAtCalculated || new UTCDate(now); nextResetAtCalculated = getNextEntitlementReset( - nextResetAtCalculated || new UTCDate(now), + startDate, resetInterval, entitlement.interval_count || 1, ).getTime(); + // console.log( + // "Next reset at calculated: ", + // formatUnixToDateTime(nextResetAtCalculated), + // ); + // If anchorToUnix, align next reset at to anchorToUnix... if ( anchorToUnix && nextResetAtCalculated && Object.values(BillingInterval).includes( entitlement.interval as unknown as BillingInterval, - ) + ) && + !shouldApplyTrial ) { nextResetAtCalculated = getAlignedUnix({ anchor: anchorToUnix, diff --git a/server/src/internal/customers/cusRouter.ts b/server/src/internal/customers/cusRouter.ts index 88483ca39..2e66faf17 100644 --- a/server/src/internal/customers/cusRouter.ts +++ b/server/src/internal/customers/cusRouter.ts @@ -1,122 +1,30 @@ -import { ErrCode } from "@autumn/shared"; -import { Router } from "express"; import { Hono } from "hono"; -import { StatusCodes } from "http-status-codes"; -import { createStripeCli } from "@/external/connect/createStripeCli.js"; -import { createStripeCusIfNotExists } from "@/external/stripe/stripeCusUtils.js"; import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; -import { OrgService } from "@/internal/orgs/OrgService.js"; -import RecaseError, { handleRequestError } from "@/utils/errorUtils.js"; -import { toSuccessUrl } from "../orgs/orgUtils/convertOrgUtils.js"; -import { CusService } from "./CusService.js"; -import { handleAddCouponToCus } from "./handlers/handleAddCouponToCus.js"; -import { handleCreateBillingPortal } from "./handlers/handleCreateBillingPortal.js"; -import { handleDeleteCustomer } from "./handlers/handleDeleteCustomer.js"; +import { handleAddCouponToCusV2 } from "./handlers/handleAddCouponToCusV2.js"; +import { handleCreateBillingPortal } from "./handlers/handleBillingPortal/handleCreateBillingPortal.js"; +import { handleDeleteCustomerV2 } from "./handlers/handleDeleteCustomerV2.js"; import { handleGetCustomerV2 } from "./handlers/handleGetCustomerV2.js"; import { handleListCustomers } from "./handlers/handleListCustomers.js"; import { handlePostCustomer } from "./handlers/handlePostCustomerV2.js"; -import { handleTransferProduct } from "./handlers/handleTransferProduct.js"; -import { handleUpdateBalances } from "./handlers/handleUpdateBalances.js"; -import { handleUpdateCustomer } from "./handlers/handleUpdateCustomer.js"; +import { handleTransferProductV2 } from "./handlers/handleTransferProductV2.js"; +import { handleUpdateBalancesV2 } from "./handlers/handleUpdateBalancesV2.js"; import { handleUpdateCustomerV2 } from "./handlers/handleUpdateCustomerV2.js"; -import { handleUpdateEntitlement } from "./handlers/handleUpdateEntitlement.js"; - -export const expressCusRouter: Router = Router(); - -// expressCusRouter.post("", handlePostCustomerRequest); - -// cusRouter.get("/:customer_id", handleGetCustomer); - -expressCusRouter.delete("/:customer_id", handleDeleteCustomer); - -// Update customer entitlement directly -expressCusRouter.post( - "/:customer_id/entitlements/:customer_entitlement_id", - handleUpdateEntitlement, -); - -expressCusRouter.post("/:customer_id/balances", handleUpdateBalances); - -expressCusRouter.get( - "/:customer_id/billing_portal", - async (req: any, res: any) => { - try { - const returnUrl = req.query.return_url; - const customerId = req.params.customer_id; - const [org, customer] = await Promise.all([ - OrgService.getFromReq(req), - CusService.get({ - db: req.db, - idOrInternalId: customerId, - orgId: req.orgId, - env: req.env, - }), - ]); - - if (!customer) { - throw new RecaseError({ - message: `Customer ${customerId} not found`, - code: ErrCode.CustomerNotFound, - statusCode: StatusCodes.NOT_FOUND, - }); - } - - const stripeCli = createStripeCli({ org, env: req.env }); - - let stripeCusId: string = customer.processor?.id; - if (!customer.processor?.id) { - const newCus = await createStripeCusIfNotExists({ - db: req.db, - org, - env: req.env, - customer, - logger: req.logger, - }); - - if (!newCus) { - throw new RecaseError({ - message: `Failed to create Stripe customer`, - code: ErrCode.StripeError, - statusCode: StatusCodes.INTERNAL_SERVER_ERROR, - }); - } - - stripeCusId = newCus.id; - } - - const portal = await stripeCli.billingPortal.sessions.create({ - customer: stripeCusId, - return_url: returnUrl || toSuccessUrl({ org, env: req.env }), - }); - - res.status(200).json({ - customer_id: customer.id || null, - url: portal.url, - }); - // if (org.api_version >= LegacyVersion.v1_1) { - // } else { - // res.status(200).json({ - // url: portal.url, - // }); - // } - } catch (error) { - handleRequestError({ req, error, res, action: "get billing portal" }); - } - }, -); - -expressCusRouter.post( - "/:customer_id/billing_portal", - handleCreateBillingPortal, -); - -expressCusRouter.post("/:customer_id/coupons/:coupon_id", handleAddCouponToCus); - -expressCusRouter.post("/:customer_id/transfer", handleTransferProduct); export const cusRouter = new Hono(); cusRouter.get("", ...handleListCustomers); -cusRouter.get("/:customer_id", ...handleGetCustomerV2); cusRouter.post("", ...handlePostCustomer); +cusRouter.get("/:customer_id", ...handleGetCustomerV2); cusRouter.post("/:customer_id", ...handleUpdateCustomerV2); +cusRouter.patch("/:customer_id", ...handleUpdateCustomerV2); +cusRouter.delete("/:customer_id", ...handleDeleteCustomerV2); + +cusRouter.post("/:customer_id/coupons/:coupon_id", ...handleAddCouponToCusV2); +cusRouter.post("/:customer_id/transfer", ...handleTransferProductV2); + +// Billing portal +cusRouter.post("/:customer_id/billing_portal", ...handleCreateBillingPortal); +cusRouter.get("/:customer_id/billing_portal", ...handleCreateBillingPortal); + +// Legacy... +cusRouter.post("/:customer_id/balances", ...handleUpdateBalancesV2); diff --git a/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/apiBalanceUtils.ts b/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/apiBalanceUtils.ts index aa932fbd6..80d961c5e 100644 --- a/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/apiBalanceUtils.ts +++ b/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/apiBalanceUtils.ts @@ -2,7 +2,7 @@ import { type ApiBalance, type ApiBalanceReset, type ApiBalanceRollover, - type ApiFeature, + type ApiFeatureV1, entIntvToResetIntv, type Feature, type FullCusEntWithFullCusProduct, @@ -94,7 +94,7 @@ export const getBooleanApiBalance = ({ apiFeature, }: { cusEnts: FullCusEntWithFullCusProduct[]; - apiFeature?: ApiFeature; + apiFeature?: ApiFeatureV1; }): ApiBalance => { const feature = cusEnts[0].entitlement.feature; return { @@ -121,7 +121,7 @@ export const getUnlimitedApiBalance = ({ apiFeature, cusEnts, }: { - apiFeature?: ApiFeature; + apiFeature?: ApiFeatureV1; cusEnts: FullCusEntWithFullCusProduct[]; }): ApiBalance => { const feature = cusEnts[0].entitlement.feature; @@ -150,7 +150,7 @@ export const getNoCusEntsApiBalance = ({ apiFeature, featureId, }: { - apiFeature?: ApiFeature; + apiFeature?: ApiFeatureV1; featureId: string; }): ApiBalance => { return { diff --git a/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalance.ts b/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalance.ts index f4535a338..34fe1db67 100644 --- a/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalance.ts +++ b/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalance.ts @@ -16,6 +16,7 @@ import { cusEntToGrantedBalance, cusEntToKey, cusEntToPurchasedBalance, + dbToApiFeatureV1, expandIncludes, type Feature, FeatureType, @@ -23,7 +24,6 @@ import { isPrepaidPrice, notNullish, sumValues, - toApiFeature, } from "@autumn/shared"; import { Decimal } from "decimal.js"; import type { RequestContext } from "@/honoUtils/HonoEnv.js"; @@ -139,7 +139,7 @@ export const getApiBalance = ({ expand: ctx.expand, includes: [CheckExpand.BalanceFeature, CusExpand.BalancesFeature], }) - ? toApiFeature({ feature }) + ? dbToApiFeatureV1({ dbFeature: feature }) : undefined; // 1. If feature is boolean diff --git a/server/src/internal/customers/handlers/handleAddCouponToCus.ts b/server/src/internal/customers/handlers/handleAddCouponToCus.ts deleted file mode 100644 index 9359b5825..000000000 --- a/server/src/internal/customers/handlers/handleAddCouponToCus.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { ErrCode } from "@autumn/shared"; -import { StatusCodes } from "http-status-codes"; -import { createStripeCli } from "@/external/connect/createStripeCli.js"; -import { createStripeCusIfNotExists } from "@/external/stripe/stripeCusUtils.js"; -import { CusService } from "@/internal/customers/CusService.js"; -import { OrgService } from "@/internal/orgs/OrgService.js"; -import { RewardService } from "@/internal/rewards/RewardService.js"; -import RecaseError, { handleRequestError } from "@/utils/errorUtils.js"; - -export const handleAddCouponToCus = async (req: any, res: any) => { - try { - const { customer_id, coupon_id } = req.params; - const { db, orgId, env, logger } = req; - - const [org, customer, coupon] = await Promise.all([ - OrgService.getFromReq(req), - CusService.get({ - db, - idOrInternalId: customer_id, - orgId, - env, - }), - RewardService.get({ - db, - idOrInternalId: coupon_id, - orgId: req.orgId, - env: req.env, - }), - ]); - - if (!customer) { - throw new RecaseError({ - message: `Customer ${customer_id} not found`, - code: ErrCode.CustomerNotFound, - statusCode: StatusCodes.NOT_FOUND, - }); - } - - if (!coupon) { - throw new RecaseError({ - message: `Coupon ${coupon_id} not found`, - code: ErrCode.RewardNotFound, - statusCode: StatusCodes.NOT_FOUND, - }); - } - - const stripeCli = createStripeCli({ - org, - env, - // apiVersion: "2025-02-24.acacia", - legacyVersion: true, - }); - - await createStripeCusIfNotExists({ - db, - org, - env, - customer, - logger, - }); - - // Attach coupon to customer - // curl https://api.stripe.com/v1/customers/cus_123456/discounts \ - // -u sk_test_your_key: \ - // -d coupon=COUPON_ID - - await stripeCli.rawRequest( - "POST", - `/v1/customers/${customer.processor.id}`, - { - coupon: coupon.id, - }, - ); - // await stripeCli.customers.update(customer.processor.id, { - // coupon: coupon.id, - - // discounts: [{ coupon: coupon.id }], - // }); - - res.status(200).json({ customer, coupon }); - } catch (error) { - handleRequestError({ req, error, res, action: "add coupon to customer" }); - } -}; diff --git a/server/src/internal/customers/handlers/handleAddCouponToCusV2.ts b/server/src/internal/customers/handlers/handleAddCouponToCusV2.ts new file mode 100644 index 000000000..8437703f7 --- /dev/null +++ b/server/src/internal/customers/handlers/handleAddCouponToCusV2.ts @@ -0,0 +1,69 @@ +import { + AffectedResource, + CustomerNotFoundError, + RecaseError, +} from "@autumn/shared"; +import { createStripeCli } from "@/external/connect/createStripeCli.js"; +import { createStripeCusIfNotExists } from "@/external/stripe/stripeCusUtils.js"; +import { createRoute } from "@/honoMiddlewares/routeHandler.js"; +import { RewardService } from "../../rewards/RewardService.js"; +import { CusService } from "../CusService.js"; + +export const handleAddCouponToCusV2 = createRoute({ + resource: AffectedResource.Customer, + handler: async (c) => { + const ctx = c.get("ctx"); + const { db, org, env, logger } = ctx; + const { customer_id, coupon_id } = c.req.param(); + + const [customer, coupon] = await Promise.all([ + CusService.get({ + db, + idOrInternalId: customer_id, + orgId: org.id, + env, + }), + RewardService.get({ + db, + idOrInternalId: coupon_id, + orgId: org.id, + env, + }), + ]); + + if (!customer) { + throw new CustomerNotFoundError({ customerId: customer_id }); + } + + if (!coupon) { + throw new RecaseError({ + message: `Coupon ${coupon_id} not found`, + }); + } + + const stripeCli = createStripeCli({ + org, + env, + legacyVersion: true, + }); + + await createStripeCusIfNotExists({ + db, + org, + env, + customer, + logger, + }); + + // Attach coupon to customer + await stripeCli.rawRequest( + "POST", + `/v1/customers/${customer.processor.id}`, + { + coupon: coupon.id, + }, + ); + + return c.json({ customer, coupon }); + }, +}); diff --git a/server/src/internal/customers/handlers/handleBillingPortal/createBillingPortalSession.ts b/server/src/internal/customers/handlers/handleBillingPortal/createBillingPortalSession.ts new file mode 100644 index 000000000..22745e5eb --- /dev/null +++ b/server/src/internal/customers/handlers/handleBillingPortal/createBillingPortalSession.ts @@ -0,0 +1,86 @@ +import { type Customer, InternalError } from "@autumn/shared"; +import { createStripeCli } from "../../../../external/connect/createStripeCli"; +import { createStripeCusIfNotExists } from "../../../../external/stripe/stripeCusUtils"; +import type { AutumnContext } from "../../../../honoUtils/HonoEnv"; +import { toSuccessUrl } from "../../../orgs/orgUtils/convertOrgUtils"; +import { createDefaultPortalConfig } from "./createDefaultPortalConfig"; + +export const createBillingPortalSession = async ({ + ctx, + customer, + returnUrl, +}: { + ctx: AutumnContext; + customer: Customer; + returnUrl?: string; +}) => { + const { db, org, env, logger } = ctx; + const stripeCli = createStripeCli({ org, env }); + + // Determine the Stripe customer ID to use + let stripeCustomerId: string; + + if (!customer.processor?.id) { + const newCus = await createStripeCusIfNotExists({ + db, + org, + env, + customer, + logger, + }); + + if (!newCus) { + throw new InternalError({ + message: `Failed to create Stripe customer`, + }); + } + + stripeCustomerId = newCus.id; + } else { + stripeCustomerId = customer.processor.id; + } + + // 1. Try to create billing portal session + try { + return await stripeCli.billingPortal.sessions.create({ + customer: stripeCustomerId, + return_url: returnUrl || toSuccessUrl({ org, env }), + }); + } catch (error: any) { + // If not a missing configuration error, rethrow + if ( + !error.message?.includes("default configuration has not been created") + ) { + throw error; + } + + // Handle missing configuration by creating default and retrying + logger.info( + `Creating default billing portal configuration for customer ${customer.id}`, + ); + + const configuration = await createDefaultPortalConfig(stripeCli).catch( + (configError: any) => { + logger.error("Failed to create billing portal configuration", { + error: configError.message, + orgId: org.id, + }); + throw new InternalError({ + message: `Failed to create billing portal configuration: ${configError.message}`, + }); + }, + ); + + logger.info("Successfully created billing portal configuration", { + configurationId: configuration.id, + orgId: org.id, + }); + + // Retry with new configuration + return await stripeCli.billingPortal.sessions.create({ + customer: stripeCustomerId, + return_url: returnUrl || toSuccessUrl({ org, env }), + configuration: configuration.id, + }); + } +}; diff --git a/server/src/internal/customers/handlers/handleBillingPortal/createDefaultPortalConfig.ts b/server/src/internal/customers/handlers/handleBillingPortal/createDefaultPortalConfig.ts new file mode 100644 index 000000000..fb92dce64 --- /dev/null +++ b/server/src/internal/customers/handlers/handleBillingPortal/createDefaultPortalConfig.ts @@ -0,0 +1,36 @@ +import { ErrCode, RecaseError } from "@autumn/shared"; +import { StatusCodes } from "http-status-codes"; +import type Stripe from "stripe"; + +export const createDefaultPortalConfig = async (stripeCli: Stripe) => { + try { + const configuration = await stripeCli.billingPortal.configurations.create({ + features: { + customer_update: { + allowed_updates: ["email", "address"], + enabled: true, + }, + invoice_history: { + enabled: true, + }, + payment_method_update: { + enabled: true, + }, + subscription_cancel: { + enabled: true, + }, + }, + // business_profile: { + // privacy_policy_url: "https://example.com/privacy", + // terms_of_service_url: "https://example.com/terms", + // }, + }); + return configuration; + } catch (error: any) { + throw new RecaseError({ + message: `Failed to create billing portal configuration: ${error.message}`, + code: ErrCode.StripeError, + statusCode: StatusCodes.INTERNAL_SERVER_ERROR, + }); + } +}; diff --git a/server/src/internal/customers/handlers/handleBillingPortal/handleCreateBillingPortal.ts b/server/src/internal/customers/handlers/handleBillingPortal/handleCreateBillingPortal.ts new file mode 100644 index 000000000..3e08802f9 --- /dev/null +++ b/server/src/internal/customers/handlers/handleBillingPortal/handleCreateBillingPortal.ts @@ -0,0 +1,45 @@ +import { CustomerNotFoundError } from "@autumn/shared"; +import { z } from "zod/v4"; +import { createRoute } from "../../../../honoMiddlewares/routeHandler"; +import { CusService } from "../../CusService"; +import { createBillingPortalSession } from "./createBillingPortalSession"; + +export const handleCreateBillingPortal = createRoute({ + query: z.object({ + return_url: z.string().optional(), + }), + body: z.object({ + return_url: z.string().optional(), + }), + handler: async (c) => { + const ctx = c.get("ctx"); + + const { return_url: queryReturnUrl } = c.req.valid("query"); + const { return_url: bodyReturnUrl } = c.req.valid("json"); + const returnUrl = queryReturnUrl ?? bodyReturnUrl; + + const customerId = c.req.param("customer_id"); + + const customer = await CusService.get({ + db: ctx.db, + idOrInternalId: customerId, + orgId: ctx.org.id, + env: ctx.env, + }); + + if (!customer) { + throw new CustomerNotFoundError({ customerId }); + } + + const session = await createBillingPortalSession({ + ctx, + customer, + returnUrl, + }); + + return c.json({ + customer_id: customer.id, + url: session.url, + }); + }, +}); diff --git a/server/src/internal/customers/handlers/handleCreateBillingPortal.ts b/server/src/internal/customers/handlers/handleCreateBillingPortal.ts deleted file mode 100644 index f3821a495..000000000 --- a/server/src/internal/customers/handlers/handleCreateBillingPortal.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { ErrCode } from "@autumn/shared"; -import { StatusCodes } from "http-status-codes"; -import type Stripe from "stripe"; -import { createStripeCli } from "@/external/connect/createStripeCli.js"; -import { createStripeCusIfNotExists } from "@/external/stripe/stripeCusUtils.js"; -import { CusService } from "@/internal/customers/CusService.js"; -import { OrgService } from "@/internal/orgs/OrgService.js"; -import { toSuccessUrl } from "@/internal/orgs/orgUtils/convertOrgUtils.js"; -import RecaseError from "@/utils/errorUtils.js"; -import { routeHandler } from "@/utils/routerUtils.js"; - -const createDefaultBillingPortalConfiguration = async (stripeCli: Stripe) => { - try { - const configuration = await stripeCli.billingPortal.configurations.create({ - features: { - customer_update: { - allowed_updates: ["email", "address"], - enabled: true, - }, - invoice_history: { - enabled: true, - }, - payment_method_update: { - enabled: true, - }, - subscription_cancel: { - enabled: true, - }, - }, - // business_profile: { - // privacy_policy_url: "https://example.com/privacy", - // terms_of_service_url: "https://example.com/terms", - // }, - }); - return configuration; - } catch (error: any) { - throw new RecaseError({ - message: `Failed to create billing portal configuration: ${error.message}`, - code: ErrCode.StripeError, - statusCode: StatusCodes.INTERNAL_SERVER_ERROR, - }); - } -}; - -export const handleCreateBillingPortal = async (req: any, res: any) => - routeHandler({ - req, - res, - action: "create_billing_portal", - handler: async (req: any, res: any) => { - const customerId = req.params.customer_id || req.body.customer_id; - const returnUrl = req.body.return_url; - - const [org, customer] = await Promise.all([ - OrgService.getFromReq(req), - CusService.get({ - db: req.db, - idOrInternalId: customerId, - orgId: req.orgId, - env: req.env, - }), - ]); - - if (!customer) { - throw new RecaseError({ - message: `Customer ${customerId} not found`, - code: ErrCode.CustomerNotFound, - statusCode: StatusCodes.NOT_FOUND, - }); - } - - const stripeCli = createStripeCli({ org, env: req.env }); - - // Determine the Stripe customer ID to use - let stripeCustomerId: string; - - if (!customer.processor?.id) { - try { - const newCus = await createStripeCusIfNotExists({ - db: req.db, - org, - env: req.env, - customer, - logger: req.logger, - }); - - if (!newCus) { - throw new RecaseError({ - message: `Failed to create Stripe customer`, - code: ErrCode.StripeError, - statusCode: StatusCodes.INTERNAL_SERVER_ERROR, - }); - } - - stripeCustomerId = newCus.id; - } catch (error: any) { - throw new RecaseError({ - message: `Failed to create Stripe customer`, - code: ErrCode.StripeError, - statusCode: StatusCodes.INTERNAL_SERVER_ERROR, - }); - } - } else { - stripeCustomerId = customer.processor.id; - } - - // Create billing portal session - let portal; - try { - portal = await stripeCli.billingPortal.sessions.create({ - customer: stripeCustomerId, - return_url: returnUrl || toSuccessUrl({ org, env: req.env }), - }); - } catch (error: any) { - console.log(`Code: ${error.code}, Message: ${error.message}`); - - // Check if the error is due to missing default configuration - if ( - error.message?.includes("default configuration has not been created") - ) { - try { - // Create a default billing portal configuration - req.logger?.info( - `Creating default billing portal configuration for customer ${customer.id}`, - ); - - const configuration = - await createDefaultBillingPortalConfiguration(stripeCli); - - req.logger?.info( - "Successfully created billing portal configuration", - { - configurationId: configuration.id, - orgId: org.id, - }, - ); - - // Retry creating the portal session with the new configuration - portal = await stripeCli.billingPortal.sessions.create({ - customer: stripeCustomerId, - return_url: returnUrl || toSuccessUrl({ org, env: req.env }), - configuration: configuration.id, - }); - } catch (configError: any) { - req.logger?.error("Failed to create billing portal configuration", { - error: configError.message, - orgId: org.id, - }); - throw new RecaseError({ - message: `Failed to create billing portal configuration: ${configError.message}`, - code: ErrCode.StripeError, - statusCode: StatusCodes.INTERNAL_SERVER_ERROR, - }); - } - } else { - throw error; - } - } - - res.status(200).json({ - customer_id: customer.id, - url: portal.url, - }); - }, - }); diff --git a/server/src/internal/customers/handlers/handleCreateCustomer.ts b/server/src/internal/customers/handlers/handleCreateCustomer.ts index 47203573d..e79fa1b71 100644 --- a/server/src/internal/customers/handlers/handleCreateCustomer.ts +++ b/server/src/internal/customers/handlers/handleCreateCustomer.ts @@ -189,8 +189,6 @@ export const handleCreateCustomer = async ({ }) => { const newCus = CreateCustomerSchema.parse(cusData); - console.log("Create default products:", createDefaultProducts); - // 1. If no ID and email is not NULL let createdCustomer: Customer; diff --git a/server/src/internal/customers/handlers/handleDeleteCustomer.ts b/server/src/internal/customers/handlers/handleDeleteCustomer.ts deleted file mode 100644 index 5ef270637..000000000 --- a/server/src/internal/customers/handlers/handleDeleteCustomer.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { type AppEnv, ErrCode, type Organization } from "@autumn/shared"; -import chalk from "chalk"; -import { StatusCodes } from "http-status-codes"; -import type { DrizzleCli } from "@/db/initDrizzle.js"; -import { deleteStripeCustomer } from "@/external/stripe/stripeCusUtils.js"; -import { CusService } from "@/internal/customers/CusService.js"; -import { deleteCachedApiCustomer } from "@/internal/customers/cusUtils/apiCusCacheUtils/deleteCachedApiCustomer.js"; -import RecaseError from "@/utils/errorUtils.js"; -import type { - ExtendedRequest, - ExtendedResponse, -} from "@/utils/models/Request.js"; -import { routeHandler } from "@/utils/routerUtils.js"; - -export const deleteCusById = async ({ - db, - org, - customerId, - env, - logger, - deleteInStripe = false, -}: { - db: DrizzleCli; - org: Organization; - customerId: string; - env: AppEnv; - logger: any; - deleteInStripe?: boolean; -}) => { - const orgId = org.id; - - const customer = await CusService.get({ - db, - idOrInternalId: customerId, - orgId, - env, - }); - - if (!customer) { - throw new RecaseError({ - message: `Customer ${customerId} not found`, - code: ErrCode.CustomerNotFound, - statusCode: StatusCodes.NOT_FOUND, - }); - } - - const response = { - customer, - success: true, - }; - - try { - if (customer.processor?.id && deleteInStripe) { - await deleteStripeCustomer({ - org, - env: env, - stripeId: customer.processor.id, - }); - } - } catch (error: any) { - console.log( - `Couldn't delete ${chalk.yellow("stripe customer")} ${ - customer.processor.id - }`, - error?.message || error, - ); - - response.success = false; - } - - await CusService.deleteByInternalId({ - db, - internalId: customer.internal_id, - orgId, - env: env, - }); - - // Delete customer and all entity caches atomically - await deleteCachedApiCustomer({ - customerId: customer.id ?? "", - orgId, - env, - }); - - return response; -}; - -export const handleDeleteCustomer = async (req: any, res: any) => - routeHandler({ - req, - res, - action: "delete customer", - handler: async (req: ExtendedRequest, res: ExtendedResponse) => { - const { env, logger, db, org } = req; - const { delete_in_stripe } = req.query; - - const data = await deleteCusById({ - db, - org, - customerId: req.params.customer_id, - env, - logger, - deleteInStripe: delete_in_stripe === "true", - }); - - res.status(200).json(data); - }, - }); diff --git a/server/src/internal/customers/handlers/handleDeleteCustomerV2.ts b/server/src/internal/customers/handlers/handleDeleteCustomerV2.ts new file mode 100644 index 000000000..1db1a367f --- /dev/null +++ b/server/src/internal/customers/handlers/handleDeleteCustomerV2.ts @@ -0,0 +1,73 @@ +import { AffectedResource, CustomerNotFoundError } from "@autumn/shared"; +import chalk from "chalk"; +import { z } from "zod/v4"; +import { deleteStripeCustomer } from "@/external/stripe/stripeCusUtils.js"; +import { createRoute } from "@/honoMiddlewares/routeHandler.js"; +import { CusService } from "../CusService.js"; +import { deleteCachedApiCustomer } from "../cusUtils/apiCusCacheUtils/deleteCachedApiCustomer.js"; + +const DeleteCustomerQuerySchema = z.object({ + delete_in_stripe: z.boolean().optional().default(false), +}); + +export const handleDeleteCustomerV2 = createRoute({ + query: DeleteCustomerQuerySchema, + resource: AffectedResource.Customer, + handler: async (c) => { + const ctx = c.get("ctx"); + const { db, org, env } = ctx; + const { customer_id } = c.req.param(); + const { delete_in_stripe } = c.req.valid("query"); + + const customer = await CusService.get({ + db, + idOrInternalId: customer_id, + orgId: org.id, + env, + }); + + if (!customer) { + throw new CustomerNotFoundError({ customerId: customer_id }); + } + + const response = { + customer, + success: true, + }; + + try { + if (customer.processor?.id && delete_in_stripe) { + await deleteStripeCustomer({ + org, + env, + stripeId: customer.processor.id, + }); + } + } catch (error: any) { + console.log( + `Couldn't delete ${chalk.yellow("stripe customer")} ${ + customer.processor.id + }`, + error?.message || error, + ); + + response.success = false; + } + + await CusService.deleteByInternalId({ + db, + internalId: customer.internal_id, + orgId: org.id, + env, + }); + + // Delete customer and all entity caches atomically + await deleteCachedApiCustomer({ + customerId: customer.id ?? "", + orgId: org.id, + env, + }); + + return c.json(response); + }, +}); diff --git a/server/src/internal/customers/handlers/handleGetCustomers.ts b/server/src/internal/customers/handlers/handleGetCustomers.ts deleted file mode 100644 index fcd8d468e..000000000 --- a/server/src/internal/customers/handlers/handleGetCustomers.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { - AffectedResource, - ApiVersion, - ListCustomersQuerySchema, -} from "@autumn/shared"; -import { createRoute } from "@/honoMiddlewares/routeHandler.js"; -import { CusBatchService } from "../CusBatchService.js"; - -export const handleListCustomers = createRoute({ - versionedQuery: { - latest: ListCustomersQuerySchema, - [ApiVersion.V1_2]: ListCustomersQuerySchema, - }, - query: ListCustomersQuerySchema, - resource: AffectedResource.Customer, - handler: async (c) => { - const ctx = c.get("ctx"); - const query = c.req.valid("query"); - - const { limit = 10, offset = 0 } = query; - - // Note: expand and statuses are not exposed in the query params for list endpoint - const statuses: any[] = []; - - const customers = await CusBatchService.getPage({ - ctx, - limit, - offset, - statuses, - }); - - return c.json({ - list: customers, - total: customers.length, - limit, - offset, - }); - }, -}); diff --git a/server/src/internal/customers/handlers/handleTransferProduct.ts b/server/src/internal/customers/handlers/handleTransferProduct.ts deleted file mode 100644 index 809bc01f6..000000000 --- a/server/src/internal/customers/handlers/handleTransferProduct.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { - AttachScenario, - CusProductAlreadyExistsError, - CusProductNotFoundError, - ErrCode, -} from "@autumn/shared"; -import { z } from "zod"; -import { addProductsUpdatedWebhookTask } from "@/internal/analytics/handlers/handleProductsUpdated.js"; -import { ProductService } from "@/internal/products/ProductService.js"; -import RecaseError from "@/utils/errorUtils.js"; -import { nullish } from "@/utils/genUtils.js"; -import type { - ExtendedRequest, - ExtendedResponse, -} from "@/utils/models/Request.js"; -import { routeHandler } from "@/utils/routerUtils.js"; -import { CusService } from "../CusService.js"; -import { CusProductService } from "../cusProducts/CusProductService.js"; -import { handleDecreaseAndTransfer } from "./handleTransferProduct/handleDecreaseAndTransfer.js"; - -const TransferProductSchema = z.object({ - from_entity_id: z.string().nullish(), - to_entity_id: z.string(), - product_id: z.string(), -}); - -export const handleTransferProduct = async (req: any, res: any) => - routeHandler({ - req, - res, - action: "transfer product", - handler: async (req: ExtendedRequest, res: ExtendedResponse) => { - const { customer_id } = req.params; - const { from_entity_id, to_entity_id, product_id } = - TransferProductSchema.parse(req.body); - - const customer = await CusService.getFull({ - idOrInternalId: customer_id, - orgId: req.orgId, - env: req.env, - db: req.db, - withEntities: true, - // entityId: from_entity_id, - }); - - const product = await ProductService.get({ - id: product_id, - orgId: req.orgId, - env: req.env, - db: req.db, - }); - - if (!product) { - throw new CusProductNotFoundError({ - customerId: customer_id, - productId: product_id, - }); - } - - const fromEntity = customer.entities.find( - (e: any) => e.id === from_entity_id, - ); - - const toEntity = customer.entities.find( - (e: any) => e.id === to_entity_id, - ); - - // if (!fromEntity) { - // throw new RecaseError({ - // code: ErrCode.EntityNotFound, - // message: `Entity ${from_entity_id} not found`, - // statusCode: 404, - // }); - // } - - if (!toEntity) { - throw new RecaseError({ - code: ErrCode.EntityNotFound, - message: `Entity ${to_entity_id} not found`, - statusCode: 404, - }); - } - - const cusProduct = customer.customer_products.find( - (cp: any) => - (fromEntity - ? cp.internal_entity_id === fromEntity.internal_id - : nullish(cp.internal_entity_id)) && cp.product.id === product_id, - ); - - const toCusProduct = customer.customer_products.find( - (cp: any) => - cp.internal_entity_id === toEntity.internal_id && - cp.product.group === product.group, - ); - - if (toCusProduct) { - throw new CusProductAlreadyExistsError({ - productId: product_id, - entityId: toEntity.id, - }); - } - - if (!cusProduct) { - throw new CusProductNotFoundError({ - customerId: customer_id, - productId: product_id, - entityId: from_entity_id || undefined, - }); - } - - // 1. If cus product has quantity > 1, only transfer 1... - if (cusProduct.quantity > 1) { - await handleDecreaseAndTransfer({ - req: req, - fullCus: customer, - cusProduct: cusProduct, - toEntity: toEntity, - }); - } else { - await CusProductService.update({ - db: req.db, - cusProductId: cusProduct.id, - updates: { - entity_id: toEntity.id, - internal_entity_id: toEntity.internal_id, - }, - }); - - await addProductsUpdatedWebhookTask({ - req, - internalCustomerId: customer.internal_id, - org: req.org, - env: req.env, - customerId: customer.id || customer.internal_id, - scenario: AttachScenario.New, - cusProduct: { - ...cusProduct, - entity_id: toEntity.id, - internal_entity_id: toEntity.internal_id, - }, - logger: req.logger, - }); - } - - res.status(200).json({ - // message: "Product transferred successfully", - success: true, - }); - }, - }); diff --git a/server/src/internal/customers/handlers/handleTransferProduct/handleDecreaseAndTransfer.ts b/server/src/internal/customers/handlers/handleTransferProduct/handleDecreaseAndTransfer.ts index cd87f7187..1a452dccc 100644 --- a/server/src/internal/customers/handlers/handleTransferProduct/handleDecreaseAndTransfer.ts +++ b/server/src/internal/customers/handlers/handleTransferProduct/handleDecreaseAndTransfer.ts @@ -8,27 +8,27 @@ import { getStartingBalance, } from "@autumn/shared"; import { createStripeCli } from "@/external/connect/createStripeCli.js"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { getEntOptions } from "@/internal/products/prices/priceUtils.js"; import { attachToInsertParams } from "@/internal/products/productUtils.js"; -import type { ExtendedRequest } from "@/utils/models/Request.js"; import { createFullCusProduct } from "../../add-product/createFullCusProduct.js"; import { CusProductService } from "../../cusProducts/CusProductService.js"; import { CusEntService } from "../../cusProducts/cusEnts/CusEntitlementService.js"; import { getRelatedCusPrice } from "../../cusProducts/cusEnts/cusEntUtils.js"; export const handleDecreaseAndTransfer = async ({ - req, + ctx, fullCus, cusProduct, toEntity, }: { - req: ExtendedRequest; + ctx: AutumnContext; fullCus: FullCustomer; cusProduct: FullCusProduct; toEntity: Entity; }) => { // 1. Create new cus product for entity... - const { org, env } = req; + const { org, env, db, logger, features } = ctx; const stripeCli = createStripeCli({ org, env }); const product = cusProductToProduct({ cusProduct }); @@ -50,7 +50,7 @@ export const handleDecreaseAndTransfer = async ({ batchDecrement.push( CusEntService.decrement({ - db: req.db, + db, id: cusEnt.id, amount: resetBalance, }), @@ -60,37 +60,36 @@ export const handleDecreaseAndTransfer = async ({ await Promise.all(batchDecrement); await CusProductService.update({ - db: req.db, + db, cusProductId: cusProduct.id, updates: { quantity: cusProduct.quantity - 1, }, }); - const newCusProduct = await createFullCusProduct({ - db: req.db, - logger: req.logger, + await createFullCusProduct({ + db, + logger, trialEndsAt: cusProduct.trial_ends_at || undefined, subscriptionIds: cusProduct.subscription_ids || [], attachParams: attachToInsertParams( { - req, + req: ctx as any, // Pass ctx as req for now (AttachParams still uses req) customer: fullCus, products: [product], prices: product.prices, entitlements: product.entitlements, - org: req.org, + org, stripeCli: stripeCli, paymentMethod: null, freeTrial: cusProduct.free_trial || null, optionsList: cusProduct.options, scenario: AttachScenario.New, - // scenario: AttachScenario.New, cusProducts: fullCus.customer_products, replaceables: [], entities: fullCus.entities, - features: req.features, + features, internalEntityId: toEntity.internal_id, entityId: toEntity.id, }, diff --git a/server/src/internal/customers/handlers/handleTransferProductV2.ts b/server/src/internal/customers/handlers/handleTransferProductV2.ts new file mode 100644 index 000000000..46022c3db --- /dev/null +++ b/server/src/internal/customers/handlers/handleTransferProductV2.ts @@ -0,0 +1,133 @@ +import { + AffectedResource, + AttachScenario, + CusProductAlreadyExistsError, + CusProductNotFoundError, + RecaseError, +} from "@autumn/shared"; +import { z } from "zod/v4"; +import { createRoute } from "@/honoMiddlewares/routeHandler.js"; +import { addProductsUpdatedWebhookTask } from "@/internal/analytics/handlers/handleProductsUpdated.js"; +import { ProductService } from "@/internal/products/ProductService.js"; +import { nullish } from "@/utils/genUtils.js"; +import type { ExtendedRequest } from "../../../utils/models/Request.js"; +import { CusService } from "../CusService.js"; +import { CusProductService } from "../cusProducts/CusProductService.js"; +import { handleDecreaseAndTransfer } from "./handleTransferProduct/handleDecreaseAndTransfer.js"; + +const TransferProductSchema = z.object({ + from_entity_id: z.string().nullish(), + to_entity_id: z.string(), + product_id: z.string(), +}); + +export const handleTransferProductV2 = createRoute({ + body: TransferProductSchema, + resource: AffectedResource.Customer, + handler: async (c) => { + const ctx = c.get("ctx"); + const { db, org, env } = ctx; + const { customer_id } = c.req.param(); + const { from_entity_id, to_entity_id, product_id } = c.req.valid("json"); + + const customer = await CusService.getFull({ + idOrInternalId: customer_id, + orgId: org.id, + env, + db, + withEntities: true, + }); + + const product = await ProductService.get({ + id: product_id, + orgId: org.id, + env, + db, + }); + + if (!product) { + throw new CusProductNotFoundError({ + customerId: customer_id, + productId: product_id, + }); + } + + const fromEntity = customer.entities.find( + (e: any) => e.id === from_entity_id, + ); + + const toEntity = customer.entities.find((e: any) => e.id === to_entity_id); + + if (!toEntity) { + throw new RecaseError({ + message: `Entity ${to_entity_id} not found`, + }); + } + + const cusProduct = customer.customer_products.find( + (cp: any) => + (fromEntity + ? cp.internal_entity_id === fromEntity.internal_id + : nullish(cp.internal_entity_id)) && cp.product.id === product_id, + ); + + const toCusProduct = customer.customer_products.find( + (cp: any) => + cp.internal_entity_id === toEntity.internal_id && + cp.product.group === product.group, + ); + + if (toCusProduct) { + throw new CusProductAlreadyExistsError({ + productId: product_id, + entityId: toEntity.id, + }); + } + + if (!cusProduct) { + throw new CusProductNotFoundError({ + customerId: customer_id, + productId: product_id, + entityId: from_entity_id || undefined, + }); + } + + // 1. If cus product has quantity > 1, only transfer 1... + if (cusProduct.quantity > 1) { + await handleDecreaseAndTransfer({ + ctx, + fullCus: customer, + cusProduct: cusProduct, + toEntity: toEntity, + }); + } else { + await CusProductService.update({ + db, + cusProductId: cusProduct.id, + updates: { + entity_id: toEntity.id, + internal_entity_id: toEntity.internal_id, + }, + }); + + await addProductsUpdatedWebhookTask({ + req: ctx as ExtendedRequest, + internalCustomerId: customer.internal_id, + org: ctx.org, + env: ctx.env, + customerId: customer.id || customer.internal_id, + scenario: AttachScenario.New, + cusProduct: { + ...cusProduct, + entity_id: toEntity.id, + internal_entity_id: toEntity.internal_id, + }, + logger: ctx.logger, + }); + } + + return c.json({ + success: true, + }); + }, +}); diff --git a/server/src/internal/customers/handlers/handleUpdateBalances.ts b/server/src/internal/customers/handlers/handleUpdateBalances.ts index 96c8ccfa8..0c92f8bc4 100644 --- a/server/src/internal/customers/handlers/handleUpdateBalances.ts +++ b/server/src/internal/customers/handlers/handleUpdateBalances.ts @@ -1,295 +1,295 @@ -import { ErrCode, getCusEntBalance } from "@autumn/shared"; -import { Decimal } from "decimal.js"; -import { StatusCodes } from "http-status-codes"; -import { CusService } from "@/internal/customers/CusService.js"; -import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js"; -import { getUnlimitedAndUsageAllowed } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils.js"; -import { FeatureService } from "@/internal/features/FeatureService.js"; -import { OrgService } from "@/internal/orgs/OrgService.js"; -import { - deductAllowanceFromCusEnt, - deductFromUsageBasedCusEnt, -} from "@/trigger/updateBalanceTask.js"; -import RecaseError, { handleRequestError } from "@/utils/errorUtils.js"; -import { notNullish } from "@/utils/genUtils.js"; -import { getCusEntsInFeatures } from "../cusUtils/cusUtils.js"; +// import { ErrCode, getCusEntBalance } from "@autumn/shared"; +// import { Decimal } from "decimal.js"; +// import { StatusCodes } from "http-status-codes"; +// import { CusService } from "@/internal/customers/CusService.js"; +// import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js"; +// import { getUnlimitedAndUsageAllowed } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils.js"; +// import { FeatureService } from "@/internal/features/FeatureService.js"; +// import { OrgService } from "@/internal/orgs/OrgService.js"; +// import { +// deductAllowanceFromCusEnt, +// deductFromUsageBasedCusEnt, +// } from "@/trigger/updateBalanceTask.js"; +// import RecaseError, { handleRequestError } from "@/utils/errorUtils.js"; +// import { notNullish } from "@/utils/genUtils.js"; +// import { getCusEntsInFeatures } from "../cusUtils/cusUtils.js"; -const getCusFeaturesAndOrg = async (req: any, customerId: string) => { - // 1. Get customer - const [customer, features, org] = await Promise.all([ - CusService.getFull({ - db: req.db, - idOrInternalId: customerId, - orgId: req.orgId, - env: req.env, - entityId: req.params.entity_id, - }), - FeatureService.getFromReq(req), - OrgService.getFromReq(req), - ]); +// const getCusFeaturesAndOrg = async (req: any, customerId: string) => { +// // 1. Get customer +// const [customer, features, org] = await Promise.all([ +// CusService.getFull({ +// db: req.db, +// idOrInternalId: customerId, +// orgId: req.orgId, +// env: req.env, +// entityId: req.params.entity_id, +// }), +// FeatureService.getFromReq(req), +// OrgService.getFromReq(req), +// ]); - if (!customer) { - throw new RecaseError({ - message: `Customer ${customerId} not found`, - code: ErrCode.CustomerNotFound, - statusCode: StatusCodes.NOT_FOUND, - }); - } +// if (!customer) { +// throw new RecaseError({ +// message: `Customer ${customerId} not found`, +// code: ErrCode.CustomerNotFound, +// statusCode: StatusCodes.NOT_FOUND, +// }); +// } - return { customer, features, org }; -}; +// return { customer, features, org }; +// }; -export const handleUpdateBalances = async (req: any, res: any) => { - try { - const logger = req.logger; - const cusId = req.params.customer_id; - const { env, db, features } = req; - const { balances } = req.body; +// export const handleUpdateBalances = async (req: any, res: any) => { +// try { +// const logger = req.logger; +// const cusId = req.params.customer_id; +// const { env, db, features } = req; +// const { balances } = req.body; - if (!Array.isArray(balances)) { - throw new RecaseError({ - message: "Balances must be an array", - code: ErrCode.InvalidRequest, - statusCode: StatusCodes.BAD_REQUEST, - }); - } +// if (!Array.isArray(balances)) { +// throw new RecaseError({ +// message: "Balances must be an array", +// code: ErrCode.InvalidRequest, +// statusCode: StatusCodes.BAD_REQUEST, +// }); +// } - const { customer, org } = await getCusFeaturesAndOrg(req, cusId); +// const { customer, org } = await getCusFeaturesAndOrg(req, cusId); - const featuresToUpdate = features.filter((f: any) => - balances.map((b: any) => b.feature_id).includes(f.id), - ); +// const featuresToUpdate = features.filter((f: any) => +// balances.map((b: any) => b.feature_id).includes(f.id), +// ); - if (featuresToUpdate.length === 0) { - throw new RecaseError({ - message: "No valid features found to update", - code: ErrCode.InvalidRequest, - statusCode: StatusCodes.BAD_REQUEST, - }); - } +// if (featuresToUpdate.length === 0) { +// throw new RecaseError({ +// message: "No valid features found to update", +// code: ErrCode.InvalidRequest, +// statusCode: StatusCodes.BAD_REQUEST, +// }); +// } - const { cusEnts, cusPrices } = await getCusEntsInFeatures({ - customer, - internalFeatureIds: featuresToUpdate.map((f: any) => f.internal_id!), - logger: req.logger, - }); +// const { cusEnts, cusPrices } = await getCusEntsInFeatures({ +// customer, +// internalFeatureIds: featuresToUpdate.map((f: any) => f.internal_id!), +// logger: req.logger, +// }); - logger.info("--------------------------------"); - logger.info( - `REQUEST: UPDATE BALANCES FOR CUSTOMER ${customer.id}, ORG: ${org.slug}`, - ); - logger.info( - `Features to update: ${balances.map( - (b: any) => - `${b.feature_id} - ${b.unlimited ? "unlimited" : b.balance}`, - )}`, - ); +// logger.info("--------------------------------"); +// logger.info( +// `REQUEST: UPDATE BALANCES FOR CUSTOMER ${customer.id}, ORG: ${org.slug}`, +// ); +// logger.info( +// `Features to update: ${balances.map( +// (b: any) => +// `${b.feature_id} - ${b.unlimited ? "unlimited" : b.balance}`, +// )}`, +// ); - // Get deductions for each feature - const featureDeductions = []; - for (const balance of balances) { - if (!balance.feature_id) { - throw new RecaseError({ - message: "Feature ID is required", - code: ErrCode.InvalidRequest, - statusCode: StatusCodes.BAD_REQUEST, - }); - } +// // Get deductions for each feature +// const featureDeductions = []; +// for (const balance of balances) { +// if (!balance.feature_id) { +// throw new RecaseError({ +// message: "Feature ID is required", +// code: ErrCode.InvalidRequest, +// statusCode: StatusCodes.BAD_REQUEST, +// }); +// } - if (typeof balance.balance !== "number" && balance.unlimited !== true) { - throw new RecaseError({ - message: "Balance must be a number", - code: ErrCode.InvalidRequest, - statusCode: StatusCodes.BAD_REQUEST, - }); - } +// if (typeof balance.balance !== "number" && balance.unlimited !== true) { +// throw new RecaseError({ +// message: "Balance must be a number", +// code: ErrCode.InvalidRequest, +// statusCode: StatusCodes.BAD_REQUEST, +// }); +// } - const feature = featuresToUpdate.find( - (f: any) => f.id === balance.feature_id, - ); +// const feature = featuresToUpdate.find( +// (f: any) => f.id === balance.feature_id, +// ); - if (balance.unlimited === true) { - featureDeductions.push({ - feature, - unlimited: true, - toDeduct: 0, - }); - continue; - } +// if (balance.unlimited === true) { +// featureDeductions.push({ +// feature, +// unlimited: true, +// toDeduct: 0, +// }); +// continue; +// } - const { unlimited } = getUnlimitedAndUsageAllowed({ - cusEnts, - internalFeatureId: feature!.internal_id!, - }); +// const { unlimited } = getUnlimitedAndUsageAllowed({ +// cusEnts, +// internalFeatureId: feature!.internal_id!, +// }); - if (unlimited) { - throw new RecaseError({ - message: `Can't set balance for unlimited feature: ${feature!.id}`, - code: ErrCode.InvalidRequest, - statusCode: StatusCodes.BAD_REQUEST, - }); - } +// if (unlimited) { +// throw new RecaseError({ +// message: `Can't set balance for unlimited feature: ${feature!.id}`, +// code: ErrCode.InvalidRequest, +// statusCode: StatusCodes.BAD_REQUEST, +// }); +// } - // Get deductions - const newBalance = balance.balance; - let curBalance = new Decimal(0); - const properties = structuredClone(balance); - delete properties.feature_id; - delete properties.balance; +// // Get deductions +// const newBalance = balance.balance; +// let curBalance = new Decimal(0); +// const properties = structuredClone(balance); +// delete properties.feature_id; +// delete properties.balance; - for (const cusEnt of cusEnts) { - const cusEntIntCount = cusEnt.entitlement.interval_count || 1; - const deductionIntCount = balance.interval_count || 1; +// for (const cusEnt of cusEnts) { +// const cusEntIntCount = cusEnt.entitlement.interval_count || 1; +// const deductionIntCount = balance.interval_count || 1; - const intCountMatch = notNullish(balance.interval_count) - ? cusEntIntCount === deductionIntCount - : true; +// const intCountMatch = notNullish(balance.interval_count) +// ? cusEntIntCount === deductionIntCount +// : true; - const intMatch = notNullish(balance.interval) - ? balance.interval === cusEnt.entitlement.interval - : true; +// const intMatch = notNullish(balance.interval) +// ? balance.interval === cusEnt.entitlement.interval +// : true; - if ( - cusEnt.internal_feature_id !== feature!.internal_id! || - !intMatch || - !intCountMatch - ) { - continue; - } +// if ( +// cusEnt.internal_feature_id !== feature!.internal_id! || +// !intMatch || +// !intCountMatch +// ) { +// continue; +// } - const { balance: cusEntBalance } = getCusEntBalance({ - cusEnt, - entityId: balance.entity_id, - }); +// const { balance: cusEntBalance } = getCusEntBalance({ +// cusEnt, +// entityId: balance.entity_id, +// }); - curBalance = curBalance.add(new Decimal(cusEntBalance!)); - } +// curBalance = curBalance.add(new Decimal(cusEntBalance!)); +// } - const toDeduct = curBalance.sub(newBalance).toNumber(); +// const toDeduct = curBalance.sub(newBalance).toNumber(); - if (toDeduct === 0) { - logger.info(`Skipping ${feature!.id} -- no change`); - } +// if (toDeduct === 0) { +// logger.info(`Skipping ${feature!.id} -- no change`); +// } - featureDeductions.push({ - feature, - toDeduct, - properties, - interval: balance.interval, - intervalCount: balance.interval_count, - }); - } +// featureDeductions.push({ +// feature, +// toDeduct, +// properties, +// interval: balance.interval, +// intervalCount: balance.interval_count, +// }); +// } - const batchDeduct = []; +// const batchDeduct = []; - for (const featureDeduction of featureDeductions) { - // 1. Deduct from allowance - const performDeduction = async () => { - let { toDeduct, feature, properties, interval } = featureDeduction; +// for (const featureDeduction of featureDeductions) { +// // 1. Deduct from allowance +// const performDeduction = async () => { +// let { toDeduct, feature, properties, interval } = featureDeduction; - // Handle unlimited - if (featureDeduction.unlimited) { - // Get one active cusEnt and set unlimited to true +// // Handle unlimited +// if (featureDeduction.unlimited) { +// // Get one active cusEnt and set unlimited to true - const cusEnt = notNullish(interval) - ? cusEnts.find((cusEnt) => { - const cusEntIntCount = cusEnt.entitlement.interval_count || 1; - const deductionIntCount = featureDeduction.intervalCount || 1; +// const cusEnt = notNullish(interval) +// ? cusEnts.find((cusEnt) => { +// const cusEntIntCount = cusEnt.entitlement.interval_count || 1; +// const deductionIntCount = featureDeduction.intervalCount || 1; - return ( - cusEnt.internal_feature_id === feature!.internal_id! && - cusEnt.entitlement.interval === interval && - cusEntIntCount === deductionIntCount - ); - }) - : cusEnts.find( - (cusEnt) => - cusEnt.internal_feature_id === feature!.internal_id!, - ); +// return ( +// cusEnt.internal_feature_id === feature!.internal_id! && +// cusEnt.entitlement.interval === interval && +// cusEntIntCount === deductionIntCount +// ); +// }) +// : cusEnts.find( +// (cusEnt) => +// cusEnt.internal_feature_id === feature!.internal_id!, +// ); - if (!cusEnt) { - logger.warn( - `No active cus ent to set unlimited balance for feature: ${ - feature!.id - }`, - ); - return; - } +// if (!cusEnt) { +// logger.warn( +// `No active cus ent to set unlimited balance for feature: ${ +// feature!.id +// }`, +// ); +// return; +// } - await CusEntService.update({ - db, - id: cusEnt.id, - updates: { - unlimited: true, - next_reset_at: null, - }, - }); +// await CusEntService.update({ +// db, +// id: cusEnt.id, +// updates: { +// unlimited: true, +// next_reset_at: null, +// }, +// }); - return; - } +// return; +// } - for (const cusEnt of cusEnts) { - const cusEntIntCount = cusEnt.entitlement.interval_count || 1; - const deductionIntCount = featureDeduction.intervalCount || 1; +// for (const cusEnt of cusEnts) { +// const cusEntIntCount = cusEnt.entitlement.interval_count || 1; +// const deductionIntCount = featureDeduction.intervalCount || 1; - const intCountMatch = notNullish(featureDeduction.intervalCount) - ? cusEntIntCount === deductionIntCount - : true; +// const intCountMatch = notNullish(featureDeduction.intervalCount) +// ? cusEntIntCount === deductionIntCount +// : true; - const intMatch = notNullish(featureDeduction.interval) - ? featureDeduction.interval === cusEnt.entitlement.interval - : true; +// const intMatch = notNullish(featureDeduction.interval) +// ? featureDeduction.interval === cusEnt.entitlement.interval +// : true; - if ( - cusEnt.internal_feature_id !== - featureDeduction.feature!.internal_id! || - !intMatch || - !intCountMatch - ) { - continue; - } +// if ( +// cusEnt.internal_feature_id !== +// featureDeduction.feature!.internal_id! || +// !intMatch || +// !intCountMatch +// ) { +// continue; +// } - toDeduct = await deductAllowanceFromCusEnt({ - toDeduct, - deductParams: { - db, - feature: featureDeduction.feature!, - env: req.env, - org, - cusPrices: cusPrices as any[], - customer, - }, - cusEnt, - featureDeductions: [], // not important because not deducting credits - willDeductCredits: false, - }); - } +// toDeduct = await deductAllowanceFromCusEnt({ +// toDeduct, +// deductParams: { +// db, +// feature: featureDeduction.feature!, +// env: req.env, +// org, +// cusPrices: cusPrices as any[], +// customer, +// }, +// cusEnt, +// featureDeductions: [], // not important because not deducting credits +// willDeductCredits: false, +// }); +// } - if (toDeduct === 0) { - return; - } +// if (toDeduct === 0) { +// return; +// } - await deductFromUsageBasedCusEnt({ - toDeduct, - cusEnts, - deductParams: { - db, - feature: featureDeduction.feature!, - env, - org, - cusPrices: cusPrices as any[], - customer, - }, - }); - }; - batchDeduct.push(performDeduction()); - } - await Promise.all(batchDeduct); +// await deductFromUsageBasedCusEnt({ +// toDeduct, +// cusEnts, +// deductParams: { +// db, +// feature: featureDeduction.feature!, +// env, +// org, +// cusPrices: cusPrices as any[], +// customer, +// }, +// }); +// }; +// batchDeduct.push(performDeduction()); +// } +// await Promise.all(batchDeduct); - logger.info(" ✅ Successfully updated balances"); +// logger.info(" ✅ Successfully updated balances"); - res.status(200).json({ success: true }); - } catch (error) { - handleRequestError({ req, error, res, action: "update customer balances" }); - } -}; +// res.status(200).json({ success: true }); +// } catch (error) { +// handleRequestError({ req, error, res, action: "update customer balances" }); +// } +// }; diff --git a/server/src/internal/customers/handlers/handleUpdateBalancesV2.ts b/server/src/internal/customers/handlers/handleUpdateBalancesV2.ts new file mode 100644 index 000000000..647855e0d --- /dev/null +++ b/server/src/internal/customers/handlers/handleUpdateBalancesV2.ts @@ -0,0 +1,51 @@ +import { + FeatureNotFoundError, + UpdateBalancesParamsSchema, +} from "@autumn/shared"; +import { createRoute } from "../../../honoMiddlewares/routeHandler"; +import type { FeatureDeduction } from "../../balances/track/trackUtils/getFeatureDeductions"; +import { runDeductionTx } from "../../balances/track/trackUtils/runDeductionTx"; +import { CusService } from "../CusService"; + +export const handleUpdateBalancesV2 = createRoute({ + body: UpdateBalancesParamsSchema, + handler: async (c) => { + const ctx = c.get("ctx"); + + const { org, env, db, features } = ctx; + const { customer_id } = c.req.param(); + const { balances } = c.req.valid("json"); + + const fullCus = await CusService.getFull({ + db, + idOrInternalId: customer_id, + orgId: org.id, + env, + }); + + for (const balance of balances) { + const feature = features.find((f) => f.id === balance.feature_id); + if (!feature) { + throw new FeatureNotFoundError({ featureId: balance.feature_id }); + } + } + + const featureDeductions = balances.map((b) => ({ + feature: features.find((f) => f.id === b.feature_id)!, + deduction: 0, + targetBalance: b.balance, + })) satisfies FeatureDeduction[]; + + await runDeductionTx({ + ctx, + customerId: customer_id, + deductions: featureDeductions, + entityId: fullCus.entity?.id, + skipAdditionalBalance: true, + alterGrantedBalance: true, + refreshCache: true, + }); + + return c.json({ success: true }); + }, +}); diff --git a/server/src/internal/customers/handlers/handleUpdateCustomer.ts b/server/src/internal/customers/handlers/handleUpdateCustomer.ts deleted file mode 100644 index 98f7a6c9c..000000000 --- a/server/src/internal/customers/handlers/handleUpdateCustomer.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { CreateCustomerSchema, ErrCode, ProcessorType } from "@autumn/shared"; -import { StatusCodes } from "http-status-codes"; -import { createStripeCli } from "@/external/connect/createStripeCli.js"; -import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; -import { CusService } from "@/internal/customers/CusService.js"; -import { FeatureService } from "@/internal/features/FeatureService.js"; -import RecaseError from "@/utils/errorUtils.js"; -import { notNullish } from "@/utils/genUtils.js"; -import type { - ExtendedRequest, - ExtendedResponse, -} from "@/utils/models/Request.js"; -import { routeHandler } from "@/utils/routerUtils.js"; -import { getApiCustomer } from "../cusUtils/apiCusUtils/getApiCustomer.js"; -import { parseCusExpand } from "../cusUtils/cusUtils.js"; - -export const handleUpdateCustomer = async (req: any, res: any) => - routeHandler({ - req, - res, - action: "POST/customers/:customer_id", - handler: async (req: ExtendedRequest, res: ExtendedResponse) => { - const { orgId, env, db, org } = req; - const customerId = req.params.customer_id; - const [originalCustomer, features] = await Promise.all([ - CusService.get({ - db, - idOrInternalId: customerId, - orgId, - env, - }), - - FeatureService.getFromReq(req), - ]); - - if (!originalCustomer) { - throw new RecaseError({ - message: `Update customer: Customer ${customerId} not found`, - code: ErrCode.CustomerNotFound, - statusCode: StatusCodes.NOT_FOUND, - }); - } - - const newCusData: any = CreateCustomerSchema.parse(req.body); - - if (req.body.id === null) { - throw new RecaseError({ - message: `Update customer: Can't change customer ID to null`, - code: ErrCode.InvalidUpdateCustomerParams, - statusCode: StatusCodes.BAD_REQUEST, - }); - } - - if (notNullish(newCusData.id) && originalCustomer.id !== newCusData.id) { - // Fetch for existing customer - const existingCustomer = await CusService.get({ - db: req.db, - idOrInternalId: newCusData.id, - orgId: req.orgId, - env: req.env, - }); - - if (existingCustomer) { - throw new RecaseError({ - message: `Update customer: Customer ${newCusData.id} already exists, can't change to this ID`, - code: ErrCode.DuplicateCustomerId, - statusCode: StatusCodes.CONFLICT, - }); - } - } else { - delete newCusData.id; - } - - // Try to update stripe ID - let stripeId = originalCustomer.processor?.id; - const newStripeId = newCusData.stripe_id; - - if (notNullish(newStripeId) && stripeId !== newStripeId) { - const stripeCli = createStripeCli({ org, env: req.env }); - await stripeCli.customers.retrieve(newStripeId); - - stripeId = newCusData.stripe_id; - req.logger.info( - `Updating customer's Stripe ID from ${originalCustomer.processor?.id} to ${stripeId}`, - ); - } - - // 2. Check if customer email is being changed - const oldMetadata = originalCustomer.metadata || {}; - const newMetadata = newCusData.metadata || {}; - for (const key in newMetadata) { - if (newMetadata[key] === null) { - delete newMetadata[key]; - delete oldMetadata[key]; - } - } - - const stripeUpdate = { - email: - originalCustomer.email !== newCusData.email - ? newCusData.email - : undefined, - name: - originalCustomer.name !== newCusData.name - ? newCusData.name - : undefined, - }; - - if (Object.keys(stripeUpdate).length > 0 && stripeId) { - const stripeCli = createStripeCli({ org, env: req.env }); - await stripeCli.customers.update(stripeId, stripeUpdate as any); - } - - await CusService.update({ - db: req.db, - idOrInternalId: originalCustomer.internal_id, - orgId: req.orgId, - env: req.env, - update: { - ...newCusData, - processor: newStripeId - ? { id: newStripeId, type: ProcessorType.Stripe } - : undefined, - metadata: { - ...oldMetadata, - ...newMetadata, - }, - }, - }); - - const finalCustomer = await CusService.getFull({ - db, - idOrInternalId: originalCustomer.internal_id, - orgId: req.orgId, - env: req.env, - withEntities: true, - }); - - // res.status(200).json({ customer: updatedCustomer }); - const customerDetails = await getApiCustomer({ - ctx: req as AutumnContext, - fullCus: finalCustomer, - expand: parseCusExpand(req.query.expand as string), - }); - - res.status(200).json(customerDetails); - }, - }); diff --git a/server/src/internal/customers/handlers/handleUpdateCustomerV2.ts b/server/src/internal/customers/handlers/handleUpdateCustomerV2.ts index 4a8312730..d627c4ac6 100644 --- a/server/src/internal/customers/handlers/handleUpdateCustomerV2.ts +++ b/server/src/internal/customers/handlers/handleUpdateCustomerV2.ts @@ -1,24 +1,21 @@ import { AffectedResource, ApiVersion, - ErrCode, + CustomerAlreadyExistsError, + CustomerNotFoundError, GetCustomerQuerySchema, ProcessorType, + RecaseError, UpdateCustomerParamsSchema, } from "@autumn/shared"; -import { StatusCodes } from "http-status-codes"; -import { z } from "zod/v4"; import { createStripeCli } from "@/external/connect/createStripeCli.js"; import { createRoute } from "@/honoMiddlewares/routeHandler.js"; -import RecaseError from "@/utils/errorUtils.js"; + import { notNullish } from "@/utils/genUtils.js"; import { CusService } from "../CusService.js"; import { getApiCustomer } from "../cusUtils/apiCusUtils/getApiCustomer.js"; export const handleUpdateCustomerV2 = createRoute({ - params: z.object({ - customer_id: z.string(), - }), body: UpdateCustomerParamsSchema, versionedQuery: { latest: GetCustomerQuerySchema, @@ -28,9 +25,8 @@ export const handleUpdateCustomerV2 = createRoute({ handler: async (c) => { const ctx = c.get("ctx"); const { db, org, env, logger } = ctx; - const { customer_id } = c.req.valid("param"); + const { customer_id } = c.req.param(); const newCusData = c.req.valid("json"); - const { expand } = ctx; const originalCustomer = await CusService.get({ db, @@ -40,18 +36,12 @@ export const handleUpdateCustomerV2 = createRoute({ }); if (!originalCustomer) { - throw new RecaseError({ - message: `Update customer: Customer ${customer_id} not found`, - code: ErrCode.CustomerNotFound, - statusCode: StatusCodes.NOT_FOUND, - }); + throw new CustomerNotFoundError({ customerId: customer_id }); } if (newCusData.id === null) { throw new RecaseError({ message: `Update customer: Can't change customer ID to null`, - code: ErrCode.InvalidUpdateCustomerParams, - statusCode: StatusCodes.BAD_REQUEST, }); } @@ -65,10 +55,9 @@ export const handleUpdateCustomerV2 = createRoute({ }); if (existingCustomer) { - throw new RecaseError({ - message: `Update customer: Customer ${newCusData.id} already exists, can't change to this ID`, - code: ErrCode.DuplicateCustomerId, - statusCode: StatusCodes.CONFLICT, + throw new CustomerAlreadyExistsError({ + message: `Customer with ID ${newCusData.id} already exists, can't change to this ID`, + customerId: newCusData.id, }); } } @@ -138,20 +127,12 @@ export const handleUpdateCustomerV2 = createRoute({ update: updateData, }); - const finalCustomer = await CusService.getFull({ - db, - idOrInternalId: originalCustomer.internal_id, - orgId: org.id, - env, - withEntities: true, - }); - + ctx.skipCache = true; const customerDetails = await getApiCustomer({ ctx, - fullCus: finalCustomer, + customerId: customer_id, }); return c.json(customerDetails); }, }); - diff --git a/server/src/internal/customers/internalCusRouter.ts b/server/src/internal/customers/internalCusRouter.ts index 779482d92..68e480e9a 100644 --- a/server/src/internal/customers/internalCusRouter.ts +++ b/server/src/internal/customers/internalCusRouter.ts @@ -8,7 +8,6 @@ import { import { Router } from "express"; import { Hono } from "hono"; import { StatusCodes } from "http-status-codes"; -import { z } from "zod/v4"; import { createRoute } from "@/honoMiddlewares/routeHandler.js"; import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; import RecaseError, { handleFrontendReqError } from "@/utils/errorUtils.js"; @@ -192,10 +191,9 @@ cusRouter.get( export const internalCusRouter = new Hono(); export const handleGetCustomerInternal = createRoute({ - params: z.object({ customer_id: z.string() }), handler: async (c) => { const { db, org, env } = c.get("ctx"); - const { customer_id } = c.req.valid("param"); + const { customer_id } = c.req.param(); const fullCus = await CusService.getFull({ db, diff --git a/server/src/internal/customers/internalHandlers/handleGetCusReferrals.ts b/server/src/internal/customers/internalHandlers/handleGetCusReferrals.ts index 293686206..7f29b6c72 100644 --- a/server/src/internal/customers/internalHandlers/handleGetCusReferrals.ts +++ b/server/src/internal/customers/internalHandlers/handleGetCusReferrals.ts @@ -10,7 +10,7 @@ export const handleGetCusReferrals = createRoute({ params: z.object({ customer_id: z.string() }), handler: async (c) => { const { env, db, org } = c.get("ctx"); - const { customer_id } = c.req.valid("param"); + const { customer_id } = c.req.param(); const internalCustomer = await CusService.get({ db, diff --git a/server/src/internal/features/archives/handleCreateFeature.ts b/server/src/internal/features/archives/handleCreateFeature.ts new file mode 100644 index 000000000..bb523ecb5 --- /dev/null +++ b/server/src/internal/features/archives/handleCreateFeature.ts @@ -0,0 +1,18 @@ +import { handleFrontendReqError } from "@/utils/errorUtils.js"; +import { createFeature } from "../featureActions/createFeature.js"; + +export const handleCreateFeature = async (req: any, res: any) => { + try { + console.log("Trying to create feature"); + const data = req.body; + + const insertedFeature = await createFeature({ + ctx: req, + data, + }); + + res.status(200).json(insertedFeature); + } catch (error) { + handleFrontendReqError({ req, error, res, action: "Create feature" }); + } +}; diff --git a/server/src/internal/features/archives/handleDeleteFeature.ts b/server/src/internal/features/archives/handleDeleteFeature.ts new file mode 100644 index 000000000..0e3a95f9d --- /dev/null +++ b/server/src/internal/features/archives/handleDeleteFeature.ts @@ -0,0 +1,67 @@ +// import { ErrCode } from "@autumn/shared"; +// import { getCreditSystemsFromFeature } from "@/internal/features/creditSystemUtils.js"; +// import { FeatureService } from "@/internal/features/FeatureService.js"; +// import { EntitlementService } from "@/internal/products/entitlements/EntitlementService.js"; +// import RecaseError from "@/utils/errorUtils.js"; +// import type { +// ExtendedRequest, +// ExtendedResponse, +// } from "@/utils/models/Request.js"; +// import { routeHandler } from "@/utils/routerUtils.js"; + +// export const handleDeleteFeature = async (req: any, res: any) => +// routeHandler({ +// req, +// res, +// action: "Delete feature", +// handler: async (req: ExtendedRequest, res: ExtendedResponse) => { +// const { db, orgId } = req; + +// const { featureId } = req.params; +// const features = await FeatureService.getFromReq(req); +// const feature = features.find((f) => f.id === featureId); +// const creditSystems = getCreditSystemsFromFeature({ +// featureId, +// features, +// }); + +// if (!feature) { +// throw new RecaseError({ +// message: `Feature ${featureId} not found`, +// code: ErrCode.FeatureNotFound, +// statusCode: 404, +// }); +// } + +// if (creditSystems.length > 0) { +// throw new RecaseError({ +// message: `Feature ${featureId} is used by credit system ${creditSystems[0].id}`, +// code: ErrCode.InvalidFeature, +// statusCode: 400, +// }); +// } + +// // Get prices that use this feature +// const ent = await EntitlementService.getByFeature({ +// db, +// internalFeatureId: feature.internal_id!, +// }); + +// if (ent) { +// throw new RecaseError({ +// message: `Feature ${featureId} is used in a product. You must delete the product first, or archive it instead.`, +// code: ErrCode.InvalidFeature, +// statusCode: 400, +// }); +// } + +// await FeatureService.delete({ +// db: req.db, +// orgId, +// featureId, +// env: req.env, +// }); + +// res.status(200).json({ success: true }); +// }, +// }); diff --git a/server/src/internal/features/archives/handleUpdateFeature.ts b/server/src/internal/features/archives/handleUpdateFeature.ts new file mode 100644 index 000000000..e5b876853 --- /dev/null +++ b/server/src/internal/features/archives/handleUpdateFeature.ts @@ -0,0 +1,32 @@ +// import { routeHandler } from "@/utils/routerUtils.js"; +// import { updateFeature } from "../featureActions/updateFeature.js"; + +// export const handleUpdateFeature = async ( +// req: any, +// res: any, +// _fromApi: boolean = false, +// ) => +// routeHandler({ +// req, +// res, +// action: "Update feature", +// handler: async (req: any, res: any) => { +// const featureId = req.params.feature_id; +// const data = req.body; + +// // Use the abstracted updateFeature function +// const updatedFeature = await updateFeature({ +// ctx: req, +// featureId, +// updates: data, +// }); + +// res +// .status(200) +// .json( +// updatedFeature + +// : undefined, +// ); +// }, +// }); diff --git a/server/src/internal/features/featureActions/createFeature.ts b/server/src/internal/features/featureActions/createFeature.ts index 99b9768f9..ced1ff255 100644 --- a/server/src/internal/features/featureActions/createFeature.ts +++ b/server/src/internal/features/featureActions/createFeature.ts @@ -1,10 +1,30 @@ -import type { Feature } from "@autumn/shared"; +import { CreateFeatureSchema, type Feature, FeatureType } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { JobName } from "@/queue/JobName.js"; import { addTaskToQueue } from "@/queue/queueUtils.js"; import { generateId } from "@/utils/genUtils.js"; import { FeatureService } from "../FeatureService.js"; -import { validateFeature } from "../internalFeatureRouter.js"; +import { + validateCreditSystem, + validateFeatureId, + validateMeteredConfig, +} from "../featureUtils.js"; + +export const validateFeature = (data: any) => { + const featureType = data.type; + + validateFeatureId(data.id); + + let config = data.config; + if (featureType === FeatureType.Metered) { + config = validateMeteredConfig(config); + } else if (featureType === FeatureType.CreditSystem) { + config = validateCreditSystem(config); + } + + const parsedFeature = CreateFeatureSchema.parse({ ...data, config }); + return parsedFeature; +}; interface CreateFeatureParams { ctx: AutumnContext; diff --git a/server/src/internal/features/featureActions/updateFeature.ts b/server/src/internal/features/featureActions/updateFeature.ts index afb065d8d..159743a34 100644 --- a/server/src/internal/features/featureActions/updateFeature.ts +++ b/server/src/internal/features/featureActions/updateFeature.ts @@ -8,10 +8,10 @@ import { validateCreditSystem, validateMeteredConfig, } from "../featureUtils.js"; -import { getObjectsUsingFeature } from "../handlers/handleUpdateFeature/getObjectsUsingFeature.js"; -import { handleFeatureIdChanged } from "../handlers/handleUpdateFeature/handleFeatureIdChanged.js"; -import { handleFeatureTypeChanged } from "../handlers/handleUpdateFeature/handleFeatureTypeChanged.js"; -import { handleFeatureUsageTypeChanged } from "../handlers/handleUpdateFeature/handleFeatureUsageTypeChanged.js"; +import { getObjectsUsingFeature } from "../utils/updateFeatureUtils/getObjectsUsingFeature.js"; +import { handleFeatureIdChanged } from "../utils/updateFeatureUtils/handleFeatureIdChanged.js"; +import { handleFeatureTypeChanged } from "../utils/updateFeatureUtils/handleFeatureTypeChanged.js"; +import { handleFeatureUsageTypeChanged } from "../utils/updateFeatureUtils/handleFeatureUsageTypeChanged.js"; interface UpdateFeatureParams { ctx: AutumnContext; diff --git a/server/src/internal/features/featureRouter.ts b/server/src/internal/features/featureRouter.ts index 1ba8003cb..61398d400 100644 --- a/server/src/internal/features/featureRouter.ts +++ b/server/src/internal/features/featureRouter.ts @@ -1,177 +1,198 @@ -import { - ApiFeatureSchema, - ApiFeatureType, - ErrCode, - type Feature, - FeatureType, - type FeatureUsageType, - UpdateFeatureParamsSchema, -} from "@autumn/shared"; +// import { +// ApiFeatureType, +// ApiFeatureV0Schema, +// ErrCode, +// type Feature, +// FeatureType, +// type FeatureUsageType, +// UpdateFeatureParamsSchema, +// } from "@autumn/shared"; -import express, { type Router } from "express"; -import { JobName } from "@/queue/JobName.js"; -import { addTaskToQueue } from "@/queue/queueUtils.js"; -import RecaseError from "@/utils/errorUtils.js"; -import { keyToTitle } from "@/utils/genUtils.js"; -import { routeHandler } from "@/utils/routerUtils.js"; -import { FeatureService } from "./FeatureService.js"; -import { validateFeatureId } from "./featureUtils.js"; -import { handleDeleteFeature } from "./handlers/handleDeleteFeature.js"; -import { handleGetFeatureDeletionInfo } from "./handlers/handleGetFeatureDeletionInfo.js"; -import { handleUpdateFeature } from "./handlers/handleUpdateFeature.js"; -import { fromApiFeature, toApiFeature } from "./utils/mapFeatureUtils.js"; +import { Hono } from "hono"; +import type { HonoEnv } from "../../honoUtils/HonoEnv"; +import { handleCreateFeature } from "./handlers/handleCreateFeature"; +import { handleDeleteFeature } from "./handlers/handleDeleteFeature"; +import { handleGetFeature } from "./handlers/handleGetFeature"; +import { handleListFeatures } from "./handlers/handleListFeatures"; +import { handleUpdateFeature } from "./handlers/handleUpdateFeature"; +import { handleGetFeatureDeletionInfo } from "./internalHandlers/handleGetFeatureDeletionInfo"; -export const featureRouter: Router = express.Router(); +// import express, { type Router } from "express"; +// import { JobName } from "@/queue/JobName.js"; +// import { addTaskToQueue } from "@/queue/queueUtils.js"; +// import RecaseError from "@/utils/errorUtils.js"; +// import { keyToTitle } from "@/utils/genUtils.js"; +// import { routeHandler } from "@/utils/routerUtils.js"; +// import { FeatureService } from "./FeatureService.js"; +// import { validateFeatureId } from "./featureUtils.js"; +// import { handleDeleteFeature } from "./handlers/handleDeleteFeature.js"; +// import { handleGetFeatureDeletionInfo } from "./handlers/handleGetFeatureDeletionInfo.js"; +// import { handleUpdateFeature } from "./handlers/handleUpdateFeature.js"; +// import { fromApiFeature, toApiFeature } from "./utils/mapFeatureUtils.js"; -// 1. Get features... -featureRouter.get("", async (req: any, res: any) => - routeHandler({ - req, - res, - action: "list features", - handler: async () => { - const includeArchived = req.query.include_archived === "true"; - const features = await FeatureService.list({ - db: req.db, - orgId: req.orgId, - env: req.env, - archived: includeArchived ? undefined : false, - // showOnlyArchived: includeArchived ? undefined : false, - }); +// export const featureRouter: Router = express.Router(); - res - .status(200) - .json({ list: features.map((feature) => toApiFeature({ feature })) }); - }, - }), +// // 1. Get features... +// featureRouter.get("", async (req: any, res: any) => +// routeHandler({ +// req, +// res, +// action: "list features", +// handler: async () => { +// const includeArchived = req.query.include_archived === "true"; +// const features = await FeatureService.list({ +// db: req.db, +// orgId: req.orgId, +// env: req.env, +// archived: includeArchived ? undefined : false, +// // showOnlyArchived: includeArchived ? undefined : false, +// }); + +// res +// .status(200) +// .json({ list: features.map((feature) => toApiFeature({ feature })) }); +// }, +// }), +// ); + +// featureRouter.get("/:featureId", async (req: any, res: any) => +// routeHandler({ +// req, +// res, +// action: "Get feature", +// handler: async () => { +// const feature = req.features.find( +// (f: Feature) => f.id === req.params.featureId, +// ); + +// if (!feature) { +// throw new RecaseError({ +// message: `Feature with id ${req.params.featureId} not found`, +// code: ErrCode.FeatureNotFound, +// statusCode: 404, +// }); +// } + +// res.status(200).json(toApiFeature({ feature })); +// }, +// }), +// ); + +// featureRouter.post("", async (req: any, res: any) => +// routeHandler({ +// req, +// res, +// action: "Create feature", +// handler: async () => { +// const apiFeature = ApiFeatureV0Schema.parse(req.body); +// if (!apiFeature.name) { +// apiFeature.name = keyToTitle(apiFeature.id); +// } + +// validateFeatureId(apiFeature.id); + +// const feature = fromApiFeature({ +// apiFeature, +// orgId: req.orgId, +// env: req.env, +// }); + +// const { db, logger, features: curFeatures } = req; + +// const curFeature = curFeatures.find((f: Feature) => f.id === feature.id); + +// if (curFeature) { +// throw new RecaseError({ +// message: `Feature with id ${feature.id} already exists`, +// code: ErrCode.DuplicateFeatureId, +// statusCode: 400, +// }); +// } + +// await FeatureService.insert({ db, data: [feature], logger }); + +// await addTaskToQueue({ +// jobName: JobName.GenerateFeatureDisplay, +// payload: { feature }, +// }); + +// res.status(200).json(apiFeature); +// }, +// }), +// ); + +// featureRouter.post("/:feature_id", async (req: any, res: any) => +// routeHandler({ +// req, +// res, +// action: "Update feature", +// handler: async (req: any, res: any) => { +// const { feature_id: featureId } = req.params; +// const { features: curFeatures } = req; +// const apiFeature = UpdateFeatureParamsSchema.parse(req.body); + +// const originalFeature = curFeatures.find( +// (f: Feature) => f.id === featureId, +// ); + +// if (!originalFeature) { +// throw new RecaseError({ +// message: `Feature with id ${featureId} not found`, +// code: ErrCode.FeatureNotFound, +// statusCode: 404, +// }); +// } + +// // Replace body... +// let featureType = apiFeature.type as unknown as FeatureType; +// let usageType: FeatureUsageType | undefined; +// if ( +// apiFeature.type === ApiFeatureType.SingleUsage || +// apiFeature.type === ApiFeatureType.ContinuousUse +// ) { +// featureType = FeatureType.Metered; +// usageType = apiFeature.type as unknown as FeatureUsageType; +// } + +// const newConfig = originalFeature.config; +// if (usageType) { +// newConfig.usage_type = usageType; +// } + +// if (apiFeature.credit_schema) { +// newConfig.schema = apiFeature.credit_schema.map((credit) => ({ +// metered_feature_id: credit.metered_feature_id, +// credit_amount: credit.credit_cost, +// })); +// } + +// const newBody = { +// id: req.body.id || undefined, +// name: req.body.name || undefined, +// type: featureType, +// config: newConfig, +// archived: req.body.archived ?? undefined, +// }; + +// req.body = newBody; + +// await handleUpdateFeature(req, res, true); +// }, +// }), +// ); + +// featureRouter.delete("/:featureId", handleDeleteFeature); + +// featureRouter.get("/:feature_id/deletion_info", handleGetFeatureDeletionInfo); + +export const featureRouter = new Hono(); +featureRouter.get("", ...handleListFeatures); +featureRouter.post("", ...handleCreateFeature); +featureRouter.get("/:feature_id", ...handleGetFeature); +featureRouter.post("/:feature_id", ...handleUpdateFeature); +featureRouter.delete("/:feature_id", ...handleDeleteFeature); + +featureRouter.get( + "/:feature_id/deletion_info", + ...handleGetFeatureDeletionInfo, ); - -featureRouter.get("/:featureId", async (req: any, res: any) => - routeHandler({ - req, - res, - action: "Get feature", - handler: async () => { - const feature = req.features.find( - (f: Feature) => f.id === req.params.featureId, - ); - - if (!feature) { - throw new RecaseError({ - message: `Feature with id ${req.params.featureId} not found`, - code: ErrCode.FeatureNotFound, - statusCode: 404, - }); - } - - res.status(200).json(toApiFeature({ feature })); - }, - }), -); - -featureRouter.post("", async (req: any, res: any) => - routeHandler({ - req, - res, - action: "Create feature", - handler: async () => { - const apiFeature = ApiFeatureSchema.parse(req.body); - if (!apiFeature.name) { - apiFeature.name = keyToTitle(apiFeature.id); - } - - validateFeatureId(apiFeature.id); - - const feature = fromApiFeature({ - apiFeature, - orgId: req.orgId, - env: req.env, - }); - - const { db, logger, features: curFeatures } = req; - - const curFeature = curFeatures.find((f: Feature) => f.id === feature.id); - - if (curFeature) { - throw new RecaseError({ - message: `Feature with id ${feature.id} already exists`, - code: ErrCode.DuplicateFeatureId, - statusCode: 400, - }); - } - - await FeatureService.insert({ db, data: [feature], logger }); - - await addTaskToQueue({ - jobName: JobName.GenerateFeatureDisplay, - payload: { feature }, - }); - - res.status(200).json(apiFeature); - }, - }), -); - -featureRouter.post("/:feature_id", async (req: any, res: any) => - routeHandler({ - req, - res, - action: "Update feature", - handler: async (req: any, res: any) => { - const { feature_id: featureId } = req.params; - const { features: curFeatures } = req; - const apiFeature = UpdateFeatureParamsSchema.parse(req.body); - - const originalFeature = curFeatures.find( - (f: Feature) => f.id === featureId, - ); - - if (!originalFeature) { - throw new RecaseError({ - message: `Feature with id ${featureId} not found`, - code: ErrCode.FeatureNotFound, - statusCode: 404, - }); - } - - // Replace body... - let featureType = apiFeature.type as unknown as FeatureType; - let usageType: FeatureUsageType | undefined; - if ( - apiFeature.type === ApiFeatureType.SingleUsage || - apiFeature.type === ApiFeatureType.ContinuousUse - ) { - featureType = FeatureType.Metered; - usageType = apiFeature.type as unknown as FeatureUsageType; - } - - const newConfig = originalFeature.config; - if (usageType) { - newConfig.usage_type = usageType; - } - - if (apiFeature.credit_schema) { - newConfig.schema = apiFeature.credit_schema.map((credit) => ({ - metered_feature_id: credit.metered_feature_id, - credit_amount: credit.credit_cost, - })); - } - - const newBody = { - id: req.body.id || undefined, - name: req.body.name || undefined, - type: featureType, - config: newConfig, - archived: req.body.archived ?? undefined, - }; - - req.body = newBody; - - await handleUpdateFeature(req, res, true); - }, - }), -); - -featureRouter.delete("/:featureId", handleDeleteFeature); - -featureRouter.get("/:feature_id/deletion_info", handleGetFeatureDeletionInfo); diff --git a/server/src/internal/features/featureUtils.ts b/server/src/internal/features/featureUtils.ts index b721c2da1..2ebceb098 100644 --- a/server/src/internal/features/featureUtils.ts +++ b/server/src/internal/features/featureUtils.ts @@ -69,11 +69,10 @@ export const validateCreditSystem = (config: CreditSystemConfig) => { const newConfig = { ...config, usage_type: FeatureUsageType.Single }; for (let i = 0; i < newConfig.schema.length; i++) { - newConfig.schema[i].feature_amount = 1; - const creditAmount = parseFloat( newConfig.schema[i].credit_amount.toString(), ); + if (Number.isNaN(creditAmount)) { throw new RecaseError({ message: `Credit amount should be a number`, @@ -120,9 +119,10 @@ export const runSaveFeatureDisplayTask = async ({ }, }); } catch (error) { - logger.error("failed to generate feature display", { - error, - feature, + logger.error(`failed to generate feature display, ${error}`, { + data: { + feature, + }, }); } }; diff --git a/server/src/internal/features/handlers/handleCreateFeature.ts b/server/src/internal/features/handlers/handleCreateFeature.ts index bb523ecb5..380c4e76a 100644 --- a/server/src/internal/features/handlers/handleCreateFeature.ts +++ b/server/src/internal/features/handlers/handleCreateFeature.ts @@ -1,18 +1,43 @@ -import { handleFrontendReqError } from "@/utils/errorUtils.js"; -import { createFeature } from "../featureActions/createFeature.js"; +import { + AffectedResource, + ApiVersion, + CreateFeatureV0ParamsSchema, + CreateFeatureV1ParamsSchema, + dbToApiFeatureV1, + featureV1ToDbFeature, + InternalError, +} from "@autumn/shared"; +import { createRoute } from "../../../honoMiddlewares/routeHandler"; +import { createFeature } from "../featureActions/createFeature"; -export const handleCreateFeature = async (req: any, res: any) => { - try { - console.log("Trying to create feature"); - const data = req.body; +export const handleCreateFeature = createRoute({ + versionedBody: { + latest: CreateFeatureV1ParamsSchema, + [ApiVersion.V1_Beta]: CreateFeatureV0ParamsSchema, + }, + resource: AffectedResource.Feature, + handler: async (c) => { + const body = c.req.valid("json"); + const ctx = c.get("ctx"); - const insertedFeature = await createFeature({ - ctx: req, - data, + // Get backend feature + const feature = featureV1ToDbFeature({ + apiFeature: body, + originalFeature: undefined, }); - res.status(200).json(insertedFeature); - } catch (error) { - handleFrontendReqError({ req, error, res, action: "Create feature" }); - } -}; + // Body is now always in the latest V1 format, regardless of API version + const dbFeature = await createFeature({ + ctx, + data: feature, + }); + + if (!dbFeature) { + throw new InternalError({ message: "Insert feature returned null" }); + } + + return c.json( + dbToApiFeatureV1({ dbFeature, targetVersion: ctx.apiVersion }), + ); + }, +}); diff --git a/server/src/internal/features/handlers/handleDeleteFeature.ts b/server/src/internal/features/handlers/handleDeleteFeature.ts index 7e76555c7..63ed4c2ae 100644 --- a/server/src/internal/features/handlers/handleDeleteFeature.ts +++ b/server/src/internal/features/handlers/handleDeleteFeature.ts @@ -1,67 +1,50 @@ -import { ErrCode } from "@autumn/shared"; -import { getCreditSystemsFromFeature } from "@/internal/features/creditSystemUtils.js"; -import { FeatureService } from "@/internal/features/FeatureService.js"; -import { EntitlementService } from "@/internal/products/entitlements/EntitlementService.js"; -import RecaseError from "@/utils/errorUtils.js"; -import type { - ExtendedRequest, - ExtendedResponse, -} from "@/utils/models/Request.js"; -import { routeHandler } from "@/utils/routerUtils.js"; +import { FeatureNotFoundError, RecaseError } from "@autumn/shared"; +import { createRoute } from "../../../honoMiddlewares/routeHandler"; +import { EntitlementService } from "../../products/entitlements/EntitlementService"; +import { getCreditSystemsFromFeature } from "../creditSystemUtils"; +import { FeatureService } from "../FeatureService"; -export const handleDeleteFeature = async (req: any, res: any) => - routeHandler({ - req, - res, - action: "Delete feature", - handler: async (req: ExtendedRequest, res: ExtendedResponse) => { - const { db, orgId } = req; +export const handleDeleteFeature = createRoute({ + handler: async (c) => { + const ctx = c.get("ctx"); + const { db, org, features } = ctx; + const { feature_id } = c.req.param(); - const { featureId } = req.params; - const features = await FeatureService.getFromReq(req); - const feature = features.find((f) => f.id === featureId); - const creditSystems = getCreditSystemsFromFeature({ - featureId, - features, + const feature = features.find((f) => f.id === feature_id); + if (!feature) { + throw new FeatureNotFoundError({ featureId: feature_id }); + } + + const creditSystems = getCreditSystemsFromFeature({ + featureId: feature_id, + features, + }); + + if (creditSystems.length > 0) { + throw new RecaseError({ + message: `Feature ${feature_id} is used by credit system ${creditSystems[0].id}`, }); + } - if (!feature) { - throw new RecaseError({ - message: `Feature ${featureId} not found`, - code: ErrCode.FeatureNotFound, - statusCode: 404, - }); - } + // Get prices that use this feature + const ent = await EntitlementService.getByFeature({ + db, + internalFeatureId: feature.internal_id!, + }); - if (creditSystems.length > 0) { - throw new RecaseError({ - message: `Feature ${featureId} is used by credit system ${creditSystems[0].id}`, - code: ErrCode.InvalidFeature, - statusCode: 400, - }); - } - - // Get prices that use this feature - const ent = await EntitlementService.getByFeature({ - db, - internalFeatureId: feature.internal_id!, + if (ent) { + throw new RecaseError({ + message: `Feature ${feature_id} is used in a product. You must delete the product first, or archive it instead.`, }); + } - if (ent) { - throw new RecaseError({ - message: `Feature ${featureId} is used in a product. You must delete the product first, or archive it instead.`, - code: ErrCode.InvalidFeature, - statusCode: 400, - }); - } + await FeatureService.delete({ + db, + orgId: org.id, + featureId: feature_id, + env: ctx.env, + }); - await FeatureService.delete({ - db: req.db, - orgId, - featureId, - env: req.env, - }); - - res.status(200).json({ success: true }); - }, - }); + return c.json({ success: true }); + }, +}); diff --git a/server/src/internal/features/handlers/handleGetFeature.ts b/server/src/internal/features/handlers/handleGetFeature.ts new file mode 100644 index 000000000..ccef8fcad --- /dev/null +++ b/server/src/internal/features/handlers/handleGetFeature.ts @@ -0,0 +1,42 @@ +import { + AffectedResource, + dbToApiFeatureV1, + ErrCode, + RecaseError, +} from "@autumn/shared"; +import { z } from "zod/v4"; +import { createRoute } from "../../../honoMiddlewares/routeHandler"; +import { FeatureService } from "../FeatureService"; + +export const handleGetFeature = createRoute({ + resource: AffectedResource.Feature, + params: z.object({ + feature_id: z.string(), + }), + handler: async (c) => { + const ctx = c.get("ctx"); + const { feature_id } = c.req.param(); + + const feature = await FeatureService.get({ + db: ctx.db, + id: feature_id, + orgId: ctx.org.id, + env: ctx.env, + }); + + if (!feature) { + throw new RecaseError({ + message: `Feature with id ${feature_id} not found`, + code: ErrCode.FeatureNotFound, + statusCode: 404, + }); + } + + const apiFeature = dbToApiFeatureV1({ + dbFeature: feature, + targetVersion: ctx.apiVersion, + }); + + return c.json(apiFeature); + }, +}); diff --git a/server/src/internal/features/handlers/handleGetFeatureDeletionInfo.ts b/server/src/internal/features/handlers/handleGetFeatureDeletionInfo.ts deleted file mode 100644 index 1ec58fd6a..000000000 --- a/server/src/internal/features/handlers/handleGetFeatureDeletionInfo.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { ExtendedRequest, ExtendedResponse } from "@/utils/models/Request.js"; -import { and, eq, sql } from "drizzle-orm"; -import { FeatureService } from "../FeatureService.js"; -import { entitlements, products } from "@autumn/shared"; -import { routeHandler } from "@/utils/routerUtils.js"; - -export const handleGetFeatureDeletionInfo = async (req: any, res: any) => - routeHandler({ - req, - res, - action: "Get feature deletion info", - handler: async (req: any, res: any) => { - let { db } = req; - let { feature_id } = req.params; - - let feature = await FeatureService.get({ - db, - id: feature_id, - orgId: req.orgId, - env: req.env, - }); - - if (!feature) { - return res.status(404).json({ error: "Feature not found" }); - } - - // Use Drizzle query similar to ProductService.getDeletionText - let res_data = await db - .select({ - productName: sql`CASE WHEN ROW_NUMBER() OVER (ORDER BY ${products.created_at}) = 1 THEN ${products.name ?? "Product name not found"} ELSE NULL END`, - totalCount: sql`COUNT(*) OVER ()`, - }) - .from(products) - .innerJoin( - entitlements, - eq(products.internal_id, entitlements.internal_product_id), - ) - .where( - and( - eq(entitlements.internal_feature_id, feature.internal_id!), - eq(products.env, req.env), - eq(products.org_id, req.orgId), - ), - ) - .limit(1); - - console.log( - `Feature ${feature_id} has ${res_data.length} products. First product name: ${res_data[0]?.productName}`, - ); - - // If no products found, return explicit zero count - if (!res_data || res_data.length === 0) { - res.status(200).json({ - productName: null, - totalCount: 0, - }); - } else { - res.status(200).json({ - productName: res_data[0]?.productName || null, - totalCount: Number(res_data[0]?.totalCount) || 0, - }); - } - }, - }); diff --git a/server/src/internal/features/handlers/handleListFeatures.ts b/server/src/internal/features/handlers/handleListFeatures.ts new file mode 100644 index 000000000..b6b721224 --- /dev/null +++ b/server/src/internal/features/handlers/handleListFeatures.ts @@ -0,0 +1,15 @@ +import { AffectedResource, dbToApiFeatureV1 } from "@autumn/shared"; +import { createRoute } from "../../../honoMiddlewares/routeHandler"; + +export const handleListFeatures = createRoute({ + resource: AffectedResource.Feature, + handler: async (c) => { + const ctx = c.get("ctx"); + + const apiFeatures = ctx.features.map((feature) => + dbToApiFeatureV1({ dbFeature: feature, targetVersion: ctx.apiVersion }), + ); + + return c.json({ list: apiFeatures }); + }, +}); diff --git a/server/src/internal/features/handlers/handleUpdateFeature.ts b/server/src/internal/features/handlers/handleUpdateFeature.ts index 0546ee4fc..e9d079be5 100644 --- a/server/src/internal/features/handlers/handleUpdateFeature.ts +++ b/server/src/internal/features/handlers/handleUpdateFeature.ts @@ -1,33 +1,73 @@ -import { routeHandler } from "@/utils/routerUtils.js"; -import { updateFeature } from "../featureActions/updateFeature.js"; -import { toApiFeature } from "../utils/mapFeatureUtils.js"; +import { + AffectedResource, + ApiVersion, + dbToApiFeatureV1, + FeatureNotFoundError, + FeatureType, + featureV1ToDbFeatureConfig, + InternalError, + nullish, + RecaseError, + UpdateFeatureV0ParamsSchema, + UpdateFeatureV1ParamsSchema, +} from "@autumn/shared"; -export const handleUpdateFeature = async ( - req: any, - res: any, - _fromApi: boolean = false, -) => - routeHandler({ - req, - res, - action: "Update feature", - handler: async (req: any, res: any) => { - const featureId = req.params.feature_id; - const data = req.body; +import { createRoute } from "../../../honoMiddlewares/routeHandler"; +import { updateFeature } from "../featureActions/updateFeature"; - // Use the abstracted updateFeature function - const updatedFeature = await updateFeature({ - ctx: req, - featureId, - updates: data, +export const handleUpdateFeature = createRoute({ + versionedBody: { + latest: UpdateFeatureV1ParamsSchema, + [ApiVersion.V1_Beta]: UpdateFeatureV0ParamsSchema, + }, + resource: AffectedResource.Feature, + handler: async (c) => { + const body = c.req.valid("json"); + const ctx = c.get("ctx"); + + const { feature_id } = c.req.param(); + const originalFeature = ctx.features.find((f) => f.id === feature_id); + if (!originalFeature) { + throw new FeatureNotFoundError({ featureId: feature_id }); + } + + // If changing type and consumable not provided, throw error + if (body.type === FeatureType.Metered && nullish(body.consumable)) { + throw new RecaseError({ + message: "Consumable is required when changing type to metered", }); + } - res - .status(200) - .json( - updatedFeature - ? toApiFeature({ feature: updatedFeature }) - : undefined, - ); - }, - }); + const newConfig = featureV1ToDbFeatureConfig({ + apiFeature: body, + originalFeature, + }); + + const updatedFeature = await updateFeature({ + ctx, + featureId: feature_id, + updates: { + id: body.id, + name: body.name ?? undefined, + type: body.type, + + config: newConfig, + + archived: body.archived, + event_names: body.event_names, + display: body.display, + }, + }); + + if (!updatedFeature) { + throw new InternalError({ message: "Update feature returned null" }); + } + + return c.json( + dbToApiFeatureV1({ + dbFeature: updatedFeature, + targetVersion: ctx.apiVersion, + }), + ); + }, +}); diff --git a/server/src/internal/features/internalFeatureRouter.ts b/server/src/internal/features/internalFeatureRouter.ts deleted file mode 100644 index 3b97292a8..000000000 --- a/server/src/internal/features/internalFeatureRouter.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { CreateFeatureSchema, ErrCode, FeatureType } from "@autumn/shared"; -import express, { type Router } from "express"; -import { formatZodError } from "@/errors/formatZodError.js"; -import { handleDeleteFeature } from "@/internal/features/handlers/handleDeleteFeature.js"; -import { handleUpdateFeature } from "@/internal/features/handlers/handleUpdateFeature.js"; -import RecaseError from "@/utils/errorUtils.js"; -import { generateId } from "@/utils/genUtils.js"; -import { FeatureService } from "./FeatureService.js"; -import { - validateCreditSystem, - validateFeatureId, - validateMeteredConfig, -} from "./featureUtils.js"; -import { handleCreateFeature } from "./handlers/handleCreateFeature.js"; -import { handleGetFeatureDeletionInfo } from "./handlers/handleGetFeatureDeletionInfo.js"; - -export const internalFeatureRouter: Router = express.Router(); - -internalFeatureRouter.get("", async (req: any, res: any) => { - try { - const { showArchived } = req.query; - - if (showArchived !== undefined) { - // If showArchived is specified, use FeatureService.list with the parameter - const features = await FeatureService.list({ - db: req.db, - orgId: req.orgId, - env: req.env, - archived: showArchived === "true", - }); - res.status(200).json({ features }); - } else { - // If no showArchived parameter, use the original getFromReq method - const features = await FeatureService.getFromReq(req); - res.status(200).json({ features }); - } - } catch (error: any) { - console.log("Error fetching features:", error); - res.status(500).json({ error: error.message }); - } -}); - -export const validateFeature = (data: any) => { - const featureType = data.type; - - validateFeatureId(data.id); - - let config = data.config; - if (featureType === FeatureType.Metered) { - config = validateMeteredConfig(config); - } else if (featureType === FeatureType.CreditSystem) { - config = validateCreditSystem(config); - } - - try { - const parsedFeature = CreateFeatureSchema.parse({ ...data, config }); - return parsedFeature; - } catch (error: any) { - throw new RecaseError({ - message: `Invalid feature: ${formatZodError(error)}`, - code: ErrCode.InvalidFeature, - statusCode: 400, - }); - } -}; - -export const initNewFeature = ({ - data, - orgId, - env, -}: { - data: any; - orgId: string; - env: any; -}) => { - return { - ...data, - org_id: orgId, - env, - created_at: Date.now(), - internal_id: generateId("fe"), - }; -}; - -internalFeatureRouter.post("", handleCreateFeature); - -internalFeatureRouter.get( - "/data/deletion_text/:feature_id", - handleGetFeatureDeletionInfo, -); - -internalFeatureRouter.post("/:feature_id", handleUpdateFeature as any); -internalFeatureRouter.delete("/:featureId", handleDeleteFeature); diff --git a/server/src/internal/features/internalHandlers/handleGetFeatureDeletionInfo.ts b/server/src/internal/features/internalHandlers/handleGetFeatureDeletionInfo.ts new file mode 100644 index 000000000..77ecc0c58 --- /dev/null +++ b/server/src/internal/features/internalHandlers/handleGetFeatureDeletionInfo.ts @@ -0,0 +1,70 @@ +import { + AffectedResource, + ErrCode, + entitlements, + products, + RecaseError, +} from "@autumn/shared"; +import { and, eq, sql } from "drizzle-orm"; +import { createRoute } from "../../../honoMiddlewares/routeHandler"; +import { FeatureService } from "../FeatureService.js"; + +export const handleGetFeatureDeletionInfo = createRoute({ + resource: AffectedResource.Feature, + handler: async (c) => { + const ctx = c.get("ctx"); + const { feature_id } = c.req.param(); + + const feature = await FeatureService.get({ + db: ctx.db, + id: feature_id, + orgId: ctx.org.id, + env: ctx.env, + }); + + if (!feature) { + throw new RecaseError({ + message: "Feature not found", + code: ErrCode.FeatureNotFound, + statusCode: 404, + }); + } + + // Use Drizzle query similar to ProductService.getDeletionText + const res_data = await ctx.db + .select({ + productName: sql`CASE WHEN ROW_NUMBER() OVER (ORDER BY ${products.created_at}) = 1 THEN ${products.name ?? "Product name not found"} ELSE NULL END`, + totalCount: sql`COUNT(*) OVER ()`, + }) + .from(products) + .innerJoin( + entitlements, + eq(products.internal_id, entitlements.internal_product_id), + ) + .where( + and( + eq(entitlements.internal_feature_id, feature.internal_id!), + eq(products.env, ctx.env), + eq(products.org_id, ctx.org.id), + ), + ) + .limit(1); + + console.log( + `Feature ${feature_id} has ${res_data.length} products. First product name: ${res_data[0]?.productName}`, + ); + + // If no products found, return explicit zero count + if (!res_data || res_data.length === 0) { + return c.json({ + productName: null, + totalCount: 0, + }); + } + + return c.json({ + productName: res_data[0]?.productName || null, + totalCount: Number(res_data[0]?.totalCount) || 0, + }); + }, +}); diff --git a/server/src/internal/features/utils/mapFeatureUtils.ts b/server/src/internal/features/utils/mapFeatureUtils.ts index 84babf088..b9cc27c42 100644 --- a/server/src/internal/features/utils/mapFeatureUtils.ts +++ b/server/src/internal/features/utils/mapFeatureUtils.ts @@ -1,10 +1,7 @@ import { - type ApiFeature, - ApiFeatureSchema, ApiFeatureType, + type ApiFeatureV0, type AppEnv, - type CreditSchemaItem, - type Feature, FeatureType, type FeatureUsageType, } from "@autumn/shared"; @@ -15,41 +12,12 @@ import { constructMeteredFeature, } from "./constructFeatureUtils.js"; -export const toApiFeature = ({ feature }: { feature: Feature }) => { - // return FeatureResponseSchema.parse(feature); - // 1. Get feature type - let featureType = feature.type; - if (feature.type === FeatureType.Metered) { - featureType = feature.config.usage_type; - } - - let creditSchema; - if (feature.type === FeatureType.CreditSystem) { - creditSchema = feature.config.schema.map((s: CreditSchemaItem) => ({ - metered_feature_id: s.metered_feature_id, - credit_cost: s.credit_amount, - })); - } - - return ApiFeatureSchema.parse({ - id: feature.id, - name: feature.name, - type: featureType, - display: { - singular: feature.display?.singular || feature.name, - plural: feature.display?.plural || feature.name, - }, - credit_schema: creditSchema, - archived: feature.archived, - }); -}; - export const fromApiFeature = ({ apiFeature, orgId, env, }: { - apiFeature: ApiFeature; + apiFeature: ApiFeatureV0; orgId: string; env: AppEnv; }) => { diff --git a/server/src/internal/features/handlers/handleUpdateFeature/getObjectsUsingFeature.ts b/server/src/internal/features/utils/updateFeatureUtils/getObjectsUsingFeature.ts similarity index 100% rename from server/src/internal/features/handlers/handleUpdateFeature/getObjectsUsingFeature.ts rename to server/src/internal/features/utils/updateFeatureUtils/getObjectsUsingFeature.ts diff --git a/server/src/internal/features/handlers/handleUpdateFeature/handleFeatureIdChanged.ts b/server/src/internal/features/utils/updateFeatureUtils/handleFeatureIdChanged.ts similarity index 100% rename from server/src/internal/features/handlers/handleUpdateFeature/handleFeatureIdChanged.ts rename to server/src/internal/features/utils/updateFeatureUtils/handleFeatureIdChanged.ts diff --git a/server/src/internal/features/handlers/handleUpdateFeature/handleFeatureTypeChanged.ts b/server/src/internal/features/utils/updateFeatureUtils/handleFeatureTypeChanged.ts similarity index 100% rename from server/src/internal/features/handlers/handleUpdateFeature/handleFeatureTypeChanged.ts rename to server/src/internal/features/utils/updateFeatureUtils/handleFeatureTypeChanged.ts diff --git a/server/src/internal/features/handlers/handleUpdateFeature/handleFeatureUsageTypeChanged.ts b/server/src/internal/features/utils/updateFeatureUtils/handleFeatureUsageTypeChanged.ts similarity index 100% rename from server/src/internal/features/handlers/handleUpdateFeature/handleFeatureUsageTypeChanged.ts rename to server/src/internal/features/utils/updateFeatureUtils/handleFeatureUsageTypeChanged.ts diff --git a/server/src/internal/mainRouter.ts b/server/src/internal/mainRouter.ts index 426a68793..c8400d410 100644 --- a/server/src/internal/mainRouter.ts +++ b/server/src/internal/mainRouter.ts @@ -12,7 +12,6 @@ import { analyticsRouter } from "./analytics/internalAnalyticsRouter.js"; import { trmnlRouter } from "./api/trmnl/trmnlRouter.js"; import { cusRouter } from "./customers/internalCusRouter.js"; import { devRouter } from "./dev/devRouter.js"; -import { internalFeatureRouter } from "./features/internalFeatureRouter.js"; import { InvoiceService } from "./invoices/InvoiceService.js"; import { handlePostOrg } from "./orgs/handlers/handlePostOrg.js"; import { onboardingRouter } from "./orgs/onboarding/onboardingRouter.js"; @@ -32,7 +31,6 @@ mainRouter.use("/admin", withAdminAuth, adminRouter); mainRouter.use("/users", withAuth, userRouter); mainRouter.use("/onboarding", withOrgAuth, onboardingRouter); mainRouter.use("/organization", withOrgAuth, orgRouter); -mainRouter.use("/features", withOrgAuth, internalFeatureRouter); mainRouter.use("/products", withOrgAuth, expressProductRouter); mainRouter.use("/dev", devRouter); mainRouter.use("/customers", withOrgAuth, cusRouter); diff --git a/server/src/internal/products/entitlements/entitlementUtils.ts b/server/src/internal/products/entitlements/entitlementUtils.ts index 53e694ca3..9cdb2dcdf 100644 --- a/server/src/internal/products/entitlements/entitlementUtils.ts +++ b/server/src/internal/products/entitlements/entitlementUtils.ts @@ -9,6 +9,7 @@ import { type Feature, FeatureType, type FreeTrial, + FreeTrialDuration, type FullEntitlement, type FullProduct, type Price, @@ -44,6 +45,22 @@ export const entIntervalToTrialDuration = ({ } }; +export const trialToDays = (freeTrial: FreeTrial) => { + let days: number; + switch (freeTrial.duration) { + case FreeTrialDuration.Day: + days = freeTrial.length; + break; + case FreeTrialDuration.Month: + days = freeTrial.length * 30; + break; + case FreeTrialDuration.Year: + days = freeTrial.length * 365; + break; + } + return days; +}; + export const applyTrialToEntitlement = ( entitlement: EntitlementWithFeature, freeTrial: FreeTrial | null, @@ -55,13 +72,13 @@ export const applyTrialToEntitlement = ( return false; if (entitlement.allowance_type === AllowanceType.Unlimited) return false; - const trialDays = freeTrial.length; + const trialDays = trialToDays(freeTrial); const entDays = entIntervalToTrialDuration({ interval: entitlement.interval!, intervalCount: entitlement.interval_count || 1, }); - if (entDays && entDays > trialDays) { + if (entDays && trialDays < entDays) { return true; } diff --git a/server/src/internal/products/handlers/handleCopyProduct/handleCopyProductV2.ts b/server/src/internal/products/handlers/handleCopyProduct/handleCopyProductV2.ts index b7b0b554e..f31b99bb5 100644 --- a/server/src/internal/products/handlers/handleCopyProduct/handleCopyProductV2.ts +++ b/server/src/internal/products/handlers/handleCopyProduct/handleCopyProductV2.ts @@ -6,10 +6,29 @@ import { } from "@autumn/shared"; import { createRoute } from "@/honoMiddlewares/routeHandler.js"; import { FeatureService } from "@/internal/features/FeatureService.js"; -import { initNewFeature } from "@/internal/features/internalFeatureRouter.js"; + import { ProductService } from "@/internal/products/ProductService.js"; import { copyProduct } from "@/internal/products/productUtils.js"; import RecaseError from "@/utils/errorUtils.js"; +import { generateId } from "../../../../utils/genUtils"; + +export const initNewFeature = ({ + data, + orgId, + env, +}: { + data: any; + orgId: string; + env: any; +}) => { + return { + ...data, + org_id: orgId, + env, + created_at: Date.now(), + internal_id: generateId("fe"), + }; +}; /** * Route: POST /v1/products/:productId/copy - Copy a product diff --git a/server/src/internal/products/handlers/handleDeleteProduct.ts b/server/src/internal/products/handlers/handleDeleteProduct.ts index e0f3abc7d..86f0c949d 100644 --- a/server/src/internal/products/handlers/handleDeleteProduct.ts +++ b/server/src/internal/products/handlers/handleDeleteProduct.ts @@ -13,10 +13,7 @@ const DeleteProductParamsSchema = z.object({ }); const DeleteProductQuerySchema = z.object({ - all_versions: z - .string() - .optional() - .transform((val) => val === "true"), + all_versions: z.boolean(), }); export const handleDeleteProduct = createRoute({ @@ -24,7 +21,7 @@ export const handleDeleteProduct = createRoute({ query: DeleteProductQuerySchema, resource: AffectedResource.Product, handler: async (c) => { - const { product_id } = c.req.valid("param"); + const { product_id } = c.req.param(); const { all_versions } = c.req.valid("query"); const { db, org, env } = c.get("ctx"); diff --git a/server/src/internal/products/handlers/handleGetPlanDeleteInfo.ts b/server/src/internal/products/handlers/handleGetPlanDeleteInfo.ts new file mode 100644 index 000000000..2f243af1f --- /dev/null +++ b/server/src/internal/products/handlers/handleGetPlanDeleteInfo.ts @@ -0,0 +1,50 @@ +import { AffectedResource, ProductNotFoundError } from "@autumn/shared"; +import { createRoute } from "@/honoMiddlewares/routeHandler.js"; +import { CusProdReadService } from "../../customers/cusProducts/CusProdReadService.js"; +import { ProductService } from "../ProductService.js"; + +export const handleGetPlanDeleteInfo = createRoute({ + resource: AffectedResource.Product, + handler: async (c) => { + const ctx = c.get("ctx"); + const { db, org, env } = ctx; + const { product_id } = c.req.param(); + + const product = await ProductService.get({ + db, + id: product_id, + orgId: org.id, + env, + }); + + if (!product) { + throw new ProductNotFoundError({ productId: product_id }); + } + + const [allVersions, latestVersion, deletionText] = await Promise.all([ + CusProdReadService.existsForProduct({ + db, + productId: product_id, + }), + CusProdReadService.existsForProduct({ + db, + internalProductId: product.internal_id, + }), + ProductService.getDeletionText({ + db, + productId: product_id, + orgId: org.id, + env, + }), + ]); + + return c.json({ + numVersion: product.version, + hasCusProducts: allVersions, + hasCusProductsLatest: latestVersion, + customerName: + deletionText[0]?.name || deletionText[0]?.email || deletionText[0]?.id, + totalCount: deletionText[0]?.totalCount, + }); + }, +}); diff --git a/server/src/internal/products/handlers/handlePlanHasCustomers.ts b/server/src/internal/products/handlers/handlePlanHasCustomers.ts new file mode 100644 index 000000000..a9cb36c92 --- /dev/null +++ b/server/src/internal/products/handlers/handlePlanHasCustomers.ts @@ -0,0 +1,58 @@ +import { + AffectedResource, + ProductNotFoundError, + type ProductV2, + productsAreSame, +} from "@autumn/shared"; +import { z } from "zod/v4"; +import { createRoute } from "@/honoMiddlewares/routeHandler.js"; +import { CusProductService } from "../../customers/cusProducts/CusProductService.js"; +import { ProductService } from "../ProductService.js"; + +const HasCustomersBodySchema = z.object({ + id: z.string().optional(), + items: z.array(z.any()).optional(), + free_trial: z.any().optional(), +}); + +export const handlePlanHasCustomers = createRoute({ + body: HasCustomersBodySchema, + resource: AffectedResource.Product, + handler: async (c) => { + const ctx = c.get("ctx"); + const { db, features, org, env } = ctx; + const { product_id } = c.req.param(); + const body = c.req.valid("json"); + + const product = await ProductService.getFull({ + db, + idOrInternalId: product_id, + orgId: org.id, + env, + }); + + if (!product) { + throw new ProductNotFoundError({ productId: product_id }); + } + + const cusProductsCurVersion = + await CusProductService.getByInternalProductId({ + db, + internalProductId: product.internal_id, + }); + + const { itemsSame, freeTrialsSame } = productsAreSame({ + newProductV2: body as ProductV2, + curProductV1: product, + features, + }); + + const productSame = itemsSame && freeTrialsSame; + + return c.json({ + current_version: product.version, + will_version: !productSame && cusProductsCurVersion.length > 0, + archived: product.archived, + }); + }, +}); diff --git a/server/src/internal/products/handlers/handleUpdateProduct/updateProductDetails.ts b/server/src/internal/products/handlers/handleUpdateProduct/updateProductDetails.ts index 5fb534469..3a7e67712 100644 --- a/server/src/internal/products/handlers/handleUpdateProduct/updateProductDetails.ts +++ b/server/src/internal/products/handlers/handleUpdateProduct/updateProductDetails.ts @@ -223,7 +223,6 @@ export const handleUpdateProductDetails = async ({ } // 2. Update product - console.log("New group:", newProduct.group); await ProductService.updateByInternalId({ db, diff --git a/server/src/internal/products/handlers/handleVersionProduct.ts b/server/src/internal/products/handlers/handleVersionProduct.ts index 3898f90bc..8d74cbfda 100644 --- a/server/src/internal/products/handlers/handleVersionProduct.ts +++ b/server/src/internal/products/handlers/handleVersionProduct.ts @@ -99,12 +99,12 @@ export const handleVersionProductV2 = async ({ data: customPrices, }); - // Handle new free trial + // Handle new free trial (create new) if (newProductV2.free_trial || latestProduct.free_trial) { await handleNewFreeTrial({ db, newFreeTrial: newProductV2.free_trial || null, - curFreeTrial: latestProduct.free_trial, + curFreeTrial: null, internalProductId: newProduct.internal_id, isCustom: false, newVersion: true, // This is a new product version diff --git a/server/src/internal/products/handlers/productActions/updateProduct.ts b/server/src/internal/products/handlers/productActions/updateProduct.ts index d1ec0d734..aae13b272 100644 --- a/server/src/internal/products/handlers/productActions/updateProduct.ts +++ b/server/src/internal/products/handlers/productActions/updateProduct.ts @@ -81,15 +81,15 @@ export const updateProduct = async ({ const newFreeTrial = "free_trial" in updates - ? (updates.free_trial as FreeTrial | undefined) - : curProductV2.free_trial; + ? ((updates.free_trial as FreeTrial | undefined) ?? undefined) + : (curProductV2.free_trial ?? undefined); + const newProductV2: ProductV2 = { ...curProductV2, ...updates, group: updates.group || curProductV2.group || "", items: updates.items || [], - free_trial: - "free_trial" in updates ? newFreeTrial : curProductV2.free_trial, + free_trial: newFreeTrial, }; await disableCurrentDefault({ @@ -101,8 +101,7 @@ export const updateProduct = async ({ db, curProduct: fullProduct, newProduct: UpdateProductSchema.parse(updates), - newFreeTrial: - "free_trial" in updates ? updates.free_trial : curProductV2.free_trial, + newFreeTrial: newFreeTrial, items: updates.items || curProductV2.items, org, rewardPrograms, diff --git a/server/src/internal/products/prices/billingIntervalUtils2.ts b/server/src/internal/products/prices/billingIntervalUtils2.ts index 248c271bd..49dc9d0ea 100644 --- a/server/src/internal/products/prices/billingIntervalUtils2.ts +++ b/server/src/internal/products/prices/billingIntervalUtils2.ts @@ -1,4 +1,6 @@ import type { IntervalConfig } from "@autumn/shared"; +import { UTCDate } from "@date-fns/utc"; +import { toMilliseconds } from "../../../utils/timeUtils.js"; import { addIntervalForProration, subtractIntervalForProration, @@ -31,6 +33,15 @@ export const addIntervalToAnchor = ({ }); }; +const isLessThanEquals = ({ a, b }: { a: UTCDate; b: UTCDate }) => { + // Check if a is <= now. return true if a is ~ same as b (maybe by a couple of hours?) + const aUnix = a.getTime(); + const bUnix = b.getTime(); + if (aUnix < bUnix + toMilliseconds.hours(1)) return true; + + return false; +}; + export const subtractIntervalFromAnchor = ({ anchor, intervalConfig, @@ -45,13 +56,14 @@ export const subtractIntervalFromAnchor = ({ for (let i = 0; i < 50; i++) { const newAnchor = subtractIntervalForProration({ - unixTimestamp: anchor, + unixTimestamp: curAnchor, interval: intervalConfig.interval, intervalCount: intervalConfig.intervalCount ?? 1, }); // Return anchor before it goes below now - if (newAnchor <= now) return curAnchor; + if (isLessThanEquals({ a: new UTCDate(newAnchor), b: new UTCDate(now) })) + return curAnchor; curAnchor = newAnchor; } diff --git a/server/src/internal/products/product-items/productItemUtils/addIdsToProductItems.ts b/server/src/internal/products/product-items/productItemUtils/addIdsToProductItems.ts index c5a5d484f..62c44b05d 100644 --- a/server/src/internal/products/product-items/productItemUtils/addIdsToProductItems.ts +++ b/server/src/internal/products/product-items/productItemUtils/addIdsToProductItems.ts @@ -6,11 +6,12 @@ import { entToPrice, getBillingType, isFixedPrice, + itemToEntInterval, type Price, type ProductItem, UsageModel, } from "@autumn/shared"; -import { itemToEntInterval } from "../itemIntervalUtils.js"; + import { isFeatureItem, isFeaturePriceItem } from "./getItemType.js"; export const addIdsToProductItems = ({ @@ -56,10 +57,10 @@ export const addIdsToProductItems = ({ // Sort by interval const aIntervalValue = entIntervalToValue( - itemToEntInterval(a) as EntInterval, + itemToEntInterval({ item: a }) as EntInterval, ); const bIntervalValue = entIntervalToValue( - itemToEntInterval(b) as EntInterval, + itemToEntInterval({ item: b }) as EntInterval, ); if (!aIntervalValue.eq(bIntervalValue)) { return aIntervalValue.sub(bIntervalValue).toNumber(); diff --git a/server/src/internal/products/productRouter.ts b/server/src/internal/products/productRouter.ts index aebe4b124..b2865e80e 100644 --- a/server/src/internal/products/productRouter.ts +++ b/server/src/internal/products/productRouter.ts @@ -1,140 +1,12 @@ -import { ProductNotFoundError, productsAreSame } from "@autumn/shared"; -import { Router } from "express"; -import { createStripeCli } from "@/external/connect/createStripeCli.js"; -import { createStripePriceIFNotExist } from "@/external/stripe/createStripePrice/createStripePrice.js"; -import { OrgService } from "@/internal/orgs/OrgService.js"; -import { ProductService } from "@/internal/products/ProductService.js"; -import { checkStripeProductExists } from "@/internal/products/productUtils.js"; -import { handleRequestError } from "@/utils/errorUtils.js"; -import { routeHandler } from "@/utils/routerUtils.js"; -import { CusProductService } from "../customers/cusProducts/CusProductService.js"; - -import { handleGetProductDeleteInfo } from "./handlers/handleGetProductDeleteInfo.js"; - -export const productRouter: Router = Router(); - -productRouter.post("/all/init_stripe", async (req: any, res) => { - try { - const { orgId, env, logger, db } = req; - - const [fullProducts, org] = await Promise.all([ - ProductService.listFull({ - db, - orgId, - env, - }), - OrgService.getFromReq(req), - ]); - - console.log( - "fullProducts", - fullProducts.map((p) => p.id), - ); - - const stripeCli = createStripeCli({ - org, - env, - }); - - const productBatchSize = 5; - for (let i = 0; i < fullProducts.length; i += productBatchSize) { - const batch = fullProducts.slice(i, i + productBatchSize); - const batchPromises = batch.map((product) => - checkStripeProductExists({ - db, - org, - env, - product, - logger, - }), - ); - await Promise.all(batchPromises); - } - - const entitlements = fullProducts.flatMap((p) => p.entitlements); - const prices = fullProducts.flatMap((p) => p.prices); - - const batchSize = 3; - for (let i = 0; i < prices.length; i += batchSize) { - const batch = prices.slice(i, i + batchSize); - const batchPriceUpdate = []; - for (const price of batch) { - batchPriceUpdate.push( - createStripePriceIFNotExist({ - db, - org, - stripeCli: stripeCli, - price, - entitlements, - product: fullProducts.find( - (p) => p.internal_id === price.internal_product_id, - )!, - logger, - }), - ); - } - - await Promise.all(batchPriceUpdate); - } - res.status(200).json({ message: "Stripe products initialized" }); - } catch (error) { - handleRequestError({ req, error, res, action: "Init stripe products" }); - } -}); - -productRouter.get("/:productId/has_customers", async (req: any, res: any) => - routeHandler({ - req, - res, - action: "Get product has customers", - handler: async () => { - const { productId } = req.params; - const { db, features } = req; - const { id, items, free_trial } = req.body; - - const product = await ProductService.getFull({ - db, - idOrInternalId: productId, - orgId: req.orgId, - env: req.env, - }); - - if (!product) { - throw new ProductNotFoundError({ productId }); - } - - const cusProductsCurVersion = - await CusProductService.getByInternalProductId({ - db, - internalProductId: product.internal_id, - }); - - const { itemsSame, freeTrialsSame } = productsAreSame({ - newProductV2: req.body, - curProductV1: product, - features, - }); - - const productSame = itemsSame && freeTrialsSame; - - res.status(200).json({ - current_version: product.version, - will_version: !productSame && cusProductsCurVersion.length > 0, - archived: product.archived, - }); - }, - }), -); - -productRouter.get("/:productId/deletion_info", handleGetProductDeleteInfo); - import { Hono } from "hono"; import type { HonoEnv } from "@/honoUtils/HonoEnv.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 { handleGetPlanDeleteInfo } from "./handlers/handleGetPlanDeleteInfo.js"; import { handleListPlans } from "./handlers/handleListPlans.js"; +import { handlePlanHasCustomers } from "./handlers/handlePlanHasCustomers.js"; import { handleUpdatePlan } from "./handlers/handleUpdateProduct/handleUpdatePlan.js"; export const honoProductBetaRouter = new Hono(); @@ -144,11 +16,85 @@ honoProductBetaRouter.get("", ...handleListPlans); export const honoProductRouter = new Hono(); export const migrationRouter = new Hono(); -// POST /products - Create a product +// CRUD honoProductRouter.get("", ...handleListPlans); honoProductRouter.post("", ...handleCreatePlan); - honoProductRouter.get("/:product_id", ...handleGetPlan); honoProductRouter.post("/:product_id", ...handleUpdatePlan); // will be deprecated honoProductRouter.delete("/:product_id", ...handleDeleteProductHono); + +// Others honoProductRouter.post("/:product_id/copy", ...handleCopyProductV2); + +// Info before deleting plan +honoProductRouter.get("/:product_id/has_customers", ...handlePlanHasCustomers); +honoProductRouter.get("/:product_id/deletion_info", ...handleGetPlanDeleteInfo); + +// productRouter.post("/all/init_stripe", async (req: any, res) => { +// try { +// const { orgId, env, logger, db } = req; + +// const [fullProducts, org] = await Promise.all([ +// ProductService.listFull({ +// db, +// orgId, +// env, +// }), +// OrgService.getFromReq(req), +// ]); + +// console.log( +// "fullProducts", +// fullProducts.map((p) => p.id), +// ); + +// const stripeCli = createStripeCli({ +// org, +// env, +// }); + +// const productBatchSize = 5; +// for (let i = 0; i < fullProducts.length; i += productBatchSize) { +// const batch = fullProducts.slice(i, i + productBatchSize); +// const batchPromises = batch.map((product) => +// checkStripeProductExists({ +// db, +// org, +// env, +// product, +// logger, +// }), +// ); +// await Promise.all(batchPromises); +// } + +// const entitlements = fullProducts.flatMap((p) => p.entitlements); +// const prices = fullProducts.flatMap((p) => p.prices); + +// const batchSize = 3; +// for (let i = 0; i < prices.length; i += batchSize) { +// const batch = prices.slice(i, i + batchSize); +// const batchPriceUpdate = []; +// for (const price of batch) { +// batchPriceUpdate.push( +// createStripePriceIFNotExist({ +// db, +// org, +// stripeCli: stripeCli, +// price, +// entitlements, +// product: fullProducts.find( +// (p) => p.internal_id === price.internal_product_id, +// )!, +// logger, +// }), +// ); +// } + +// await Promise.all(batchPriceUpdate); +// } +// res.status(200).json({ message: "Stripe products initialized" }); +// } catch (error) { +// handleRequestError({ req, error, res, action: "Init stripe products" }); +// } +// }); diff --git a/server/src/internal/products/productUtils/productResponseUtils/getProductResponse.ts b/server/src/internal/products/productUtils/productResponseUtils/getProductResponse.ts index cd6885424..859425fe2 100644 --- a/server/src/internal/products/productUtils/productResponseUtils/getProductResponse.ts +++ b/server/src/internal/products/productUtils/productResponseUtils/getProductResponse.ts @@ -12,10 +12,10 @@ import { type FullProduct, type Price, type ProductItem, + toApiFeature, UsageModel, } from "@autumn/shared"; import type { DrizzleCli } from "@/db/initDrizzle.js"; -import { toApiFeature } from "@/internal/features/utils/mapFeatureUtils.js"; import { notNullish } from "@/utils/genUtils.js"; import { getFreeTrialAfterFingerprint } from "../../free-trials/freeTrialUtils.js"; import { sortProductItems } from "../../pricecn/pricecnUtils.js"; diff --git a/server/src/internal/products/productV2Utils.ts b/server/src/internal/products/productV2Utils.ts index 70fe4a680..b55276768 100644 --- a/server/src/internal/products/productV2Utils.ts +++ b/server/src/internal/products/productV2Utils.ts @@ -84,7 +84,6 @@ export const mapToProductV2 = ({ const productV2: ProductV2 = { internal_id: product.internal_id, - env: product.env, id: product.id, name: product.name, is_add_on: product.is_add_on, diff --git a/server/src/queue/createWorkerContext.ts b/server/src/queue/createWorkerContext.ts index f3d1550c3..cab5e5959 100644 --- a/server/src/queue/createWorkerContext.ts +++ b/server/src/queue/createWorkerContext.ts @@ -58,6 +58,7 @@ export const createWorkerContext = async ({ apiVersion: createdAtToVersion({ createdAt: org.created_at! }), clickhouseClient: null as any, expand: [], + skipCache: true, }; return ctx; diff --git a/server/src/trigger/handleThresholdReached.ts b/server/src/trigger/handleThresholdReached.ts index 05dd57192..f33da1fbe 100644 --- a/server/src/trigger/handleThresholdReached.ts +++ b/server/src/trigger/handleThresholdReached.ts @@ -1,10 +1,11 @@ import { + dbToApiFeatureV1, type Feature, type FullCustomer, + toApiFeature, WebhookEventType, } from "@autumn/shared"; import { sendSvixEvent } from "@/external/svix/svixHelpers.js"; -import { toApiFeature } from "@/internal/features/utils/mapFeatureUtils.js"; import type { AutumnContext } from "../honoUtils/HonoEnv.js"; import { apiBalanceToAllowed } from "../internal/api/check/checkUtils/apiBalanceToAllowed.js"; import { getApiCustomerBase } from "../internal/customers/cusUtils/apiCusUtils/getApiCustomerBase.js"; @@ -59,7 +60,10 @@ export const handleAllowanceUsed = async ({ data: { threshold_type: "allowance_used", customer: newApiCustomer, - feature: toApiFeature({ feature }), + feature: dbToApiFeatureV1({ + dbFeature: feature, + targetVersion: ctx.apiVersion, + }), }, }); } diff --git a/server/src/trigger/updateBalanceTask.ts b/server/src/trigger/updateBalanceTask.ts index da808e4cb..641fb9231 100644 --- a/server/src/trigger/updateBalanceTask.ts +++ b/server/src/trigger/updateBalanceTask.ts @@ -44,7 +44,6 @@ import { getMeteredDeduction, performDeduction, } from "./deductUtils.js"; -import { handleThresholdReached } from "./handleThresholdReached.js"; export type DeductParams = { db: DrizzleCli; @@ -265,7 +264,7 @@ export const performDeductionOnCusEnt = ({ deducted: newDeducted, toDeduct: newToDeduct, } = performDeduction({ - cusEntBalance: new Decimal(entityBalance), + cusEntBalance: new Decimal(entityBalance ?? 0), toDeduct: toDeductCursor, allowNegativeBalance, ent: cusEnt.entitlement, @@ -527,7 +526,7 @@ export const deductFromUsageBasedCusEnt = async ({ const cusPrice = getRelatedCusPrice(usageBasedEnt, cusPrices); const billingType = cusPrice?.price - ? getBillingType(cusPrice?.price.config!) + ? getBillingType(cusPrice?.price.config ?? undefined) : undefined; const blockUsageLimit = billingType === BillingType.InArrearProrated ? false : true; @@ -723,18 +722,6 @@ export const updateCustomerBalance = async ({ }, }); } - - handleThresholdReached({ - org, - env, - features: allFeatures, - db, - feature, - cusEnts: originalCusEnts, - newCusEnts: cusEnts, - fullCus: customer, - logger, - }); } return cusEnts; diff --git a/server/src/trigger/updateUsageTask.ts b/server/src/trigger/updateUsageTask.ts index ca13e5e05..aed5aa4ee 100644 --- a/server/src/trigger/updateUsageTask.ts +++ b/server/src/trigger/updateUsageTask.ts @@ -31,7 +31,6 @@ import RecaseError from "@/utils/errorUtils.js"; import { deductFromAdditionalBalance } from "../internal/balances/deductUtils/deductFromAdditionalBalance.js"; import { generateId } from "../utils/genUtils.js"; -import { handleThresholdReached } from "./handleThresholdReached.js"; import { deductAllowanceFromCusEnt, deductFromUsageBasedCusEnt, @@ -475,6 +474,7 @@ export const updateUsage = async ({ apiVersion: new ApiVersionClass(LATEST_VERSION), timestamp: Date.now(), expand: [], + skipCache: true, }, }); @@ -520,18 +520,6 @@ export const updateUsage = async ({ }; await performFeatureDeduction(); - - handleThresholdReached({ - org, - env, - features: allFeatures, - db, - feature, - cusEnts: originalCusEnts, - newCusEnts: cusEnts, - fullCus: customer, - logger, - }); } return cusEnts; diff --git a/server/src/utils/cacheUtils/cacheUtils.ts b/server/src/utils/cacheUtils/cacheUtils.ts index dd4b2c236..994ce01d5 100644 --- a/server/src/utils/cacheUtils/cacheUtils.ts +++ b/server/src/utils/cacheUtils/cacheUtils.ts @@ -90,6 +90,12 @@ export const normalizeCachedBalance = (balance: any): any => { } } + if (balance.feature?.event_names) { + balance.feature.event_names = normalizeArray( + balance.feature.event_names, + ) as typeof balance.feature.event_names; + } + return balance; }; @@ -127,64 +133,39 @@ export const normalizeCachedData = ( // Fix missing credit_schema -> null if (data.balances) { for (const featureId in data.balances) { - const feature = data.balances[featureId]; + const balance = data.balances[featureId]; // if (!feature.reset) { // feature.reset = null; // } if ( - !Array.isArray(feature.breakdown) && - typeof feature.breakdown === "object" + !Array.isArray(balance.breakdown) && + typeof balance.breakdown === "object" ) { - feature.breakdown = undefined; + balance.breakdown = undefined; } if ( - !Array.isArray(feature.rollovers) && - typeof feature.rollovers === "object" + !Array.isArray(balance.rollovers) && + typeof balance.rollovers === "object" ) { - feature.rollovers = undefined; + balance.rollovers = undefined; } - if (feature.breakdown) { - for (const breakdown of feature.breakdown) { + if (balance.feature?.event_names) { + balance.feature.event_names = normalizeArray( + balance.feature.event_names, + ) as typeof balance.feature.event_names; + } + + if (balance.breakdown) { + for (const breakdown of balance.breakdown) { // if (!breakdown.reset) { // breakdown.reset = null; // } } } - - // if (feature.usage_limit === 0 || feature.usage_limit === null) { - // feature.usage_limit = undefined; - // } - - // // Ensure credit_schema is null if undefined (for consistent schema) - // if (feature.credit_schema === null) { - // feature.credit_schema = undefined; - // } - - // if (feature.interval_count === undefined) { - // feature.interval_count = null; - // } - - // // interval should be null if undefined - // if (feature.interval === undefined) { - // feature.interval = null; - // } - - // Fix breakdown usage_limit - // if (feature.breakdown) { - // for (const breakdown of feature.breakdown) { - // if (breakdown.usage_limit === 0) { - // breakdown.usage_limit = undefined; - // } - - // // if (breakdown.next_reset_at === undefined) { - // // breakdown.next_reset_at = null; - // // } - // } - // } } } diff --git a/server/src/utils/checkUtils/checkCustomerCorrect.ts b/server/src/utils/checkUtils/checkCustomerCorrect.ts index 7a9e47b28..55be9a07e 100644 --- a/server/src/utils/checkUtils/checkCustomerCorrect.ts +++ b/server/src/utils/checkUtils/checkCustomerCorrect.ts @@ -9,9 +9,9 @@ import { type FullCustomer, type Organization, } from "@autumn/shared"; +import { defaultApiVersion } from "@tests/constants.js"; +import { cusProductToSubIds } from "@tests/merged/mergeUtils.test.js"; import type Stripe from "stripe"; -import { defaultApiVersion } from "tests/constants.js"; -import { cusProductToSubIds } from "tests/merged/mergeUtils.test.js"; import type { DrizzleCli } from "@/db/initDrizzle.js"; import { priceToStripeItem } from "@/external/stripe/priceToStripeItem/priceToStripeItem.js"; import { subIsCanceled } from "@/external/stripe/stripeSubUtils.js"; diff --git a/server/src/utils/scriptUtils/createTestProducts.ts b/server/src/utils/scriptUtils/createTestProducts.ts index 03a65b2e9..f075deed4 100644 --- a/server/src/utils/scriptUtils/createTestProducts.ts +++ b/server/src/utils/scriptUtils/createTestProducts.ts @@ -6,7 +6,6 @@ import { CreateFreeTrialSchema, type CreateReward, FeatureUsageType, - type FreeTrial, FreeTrialDuration, type ProductItem, type ProductV2, @@ -144,15 +143,7 @@ export const constructProduct = ({ id || (isAnnual ? `${type}-annual` : interval ? `${type}-${interval}` : type); - let free_trial: CreateFreeTrial | null = null; - if (freeTrial) { - free_trial = freeTrial as FreeTrial; - } else if (trial) { - free_trial = CreateFreeTrialSchema.parse({ - length: 7, - duration: FreeTrialDuration.Day, - }); - } + const freeTrialLength = freeTrial?.length || 7; const product: ProductV2 = { id: id_, @@ -169,7 +160,15 @@ export const constructProduct = ({ is_default: (type === "free" && isDefault) || forcePaidDefault, version: 1, group: group || "", - free_trial: free_trial as FreeTrial, + free_trial: + freeTrial || trial + ? (CreateFreeTrialSchema.parse({ + length: freeTrialLength, + duration: FreeTrialDuration.Day, + unique_fingerprint: true, + card_required: true, + }) as any) + : null, created_at: Date.now(), }; diff --git a/server/src/utils/scriptUtils/testUtils/createSharedProduct.ts b/server/src/utils/scriptUtils/testUtils/createSharedProduct.ts index 6c18e89b9..0fcff66db 100644 --- a/server/src/utils/scriptUtils/testUtils/createSharedProduct.ts +++ b/server/src/utils/scriptUtils/testUtils/createSharedProduct.ts @@ -8,6 +8,7 @@ import { createProducts } from "@tests/utils/productUtils.js"; import type { TestContext } from "@tests/utils/testInitUtils/createTestContext.js"; import { and, eq, inArray } from "drizzle-orm"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { deleteCachedApiCustomer } from "../../../internal/customers/cusUtils/apiCusCacheUtils/deleteCachedApiCustomer"; export const createSharedProducts = async ({ products, @@ -33,16 +34,30 @@ export const createSharedProducts = async ({ throw new Error("Too many customers under shared default free product"); } - await ctx.db.delete(customers).where( - and( - inArray( - customers.internal_id, - cusProducts.map((cp) => cp.internal_customer_id), + const deletedCustomers = await ctx.db + .delete(customers) + .where( + and( + inArray( + customers.internal_id, + cusProducts.map((cp) => cp.internal_customer_id), + ), + eq(customers.env, ctx.env), + eq(customers.org_id, ctx.org.id), ), - eq(customers.env, ctx.env), - eq(customers.org_id, ctx.org.id), - ), - ); + ) + .returning(); + const clearCache = []; + for (const customer of deletedCustomers) { + clearCache.push( + deleteCachedApiCustomer({ + customerId: customer.id ?? "", + orgId: ctx.org.id, + env: ctx.env, + }), + ); + } + await Promise.all(clearCache); const autumn = new AutumnInt({ secretKey: ctx.orgSecretKey, diff --git a/server/src/utils/workerUtils/createAutumnContext.ts b/server/src/utils/workerUtils/createAutumnContext.ts index 41a622fcb..298af2e80 100644 --- a/server/src/utils/workerUtils/createAutumnContext.ts +++ b/server/src/utils/workerUtils/createAutumnContext.ts @@ -60,5 +60,6 @@ export const createWorkerAutumnContext = async ({ authType: AuthType.Unknown, apiVersion, timestamp: Date.now(), + skipCache: false, } satisfies AutumnContext; }; diff --git a/server/src/websockets/WsManager.ts b/server/src/websockets/WsManager.ts deleted file mode 100644 index d640e6b4f..000000000 --- a/server/src/websockets/WsManager.ts +++ /dev/null @@ -1,18 +0,0 @@ -export class WebsocketManager { - public channels: Map>; - public subscriptions: WeakMap>; - - constructor() { - this.channels = new Map(); - this.subscriptions = new WeakMap(); - } - - // Initialize a client's subscription tracking - initializeClient(ws: WebSocket) { - // this.subscriptions.set(ws, new Set()); - // this.sendToClient(ws, { - // type: "welcome", - // channels: Array.from(this.channels.keys()), - // }); - } -} diff --git a/server/src/websockets/initWs.ts b/server/src/websockets/initWs.ts deleted file mode 100644 index b05f78715..000000000 --- a/server/src/websockets/initWs.ts +++ /dev/null @@ -1,127 +0,0 @@ -import http from "http"; -import { AppEnv, ErrCode } from "@autumn/shared"; -import { WebSocketServer, WebSocket } from "ws"; - -export enum SbChannelEvent { - BalanceUpdated = "balance_updated", -} - -interface RouteInfo { - pattern: RegExp; - paramNames: string[]; - callback: ( - ws: WebSocket, - req: http.IncomingMessage, - params: Record, - ) => Promise; -} - -const getPkey = async (req: any) => { - const query = req.url.split("?")[1]; - const queryParams = new URLSearchParams(query); - const pkey = req.headers["x-publishable-key"] || queryParams.get("pkey"); - - if (!pkey) { - throw new Error("No publishable key found"); - } - - if (typeof pkey !== "string") { - throw new Error("Invalid publishable key"); - } - - if (!pkey.startsWith("am_pk_test_") && !pkey.startsWith("am_pk_live_")) { - throw new Error("Invalid publishable key"); - } - - const env = pkey.startsWith("am_pk_test_") ? AppEnv.Sandbox : AppEnv.Live; - - return { - error: ErrCode.OrgNotFound, - fallback: false, - statusCode: 401, - }; -}; - -class WebSocketRouter { - private wss: WebSocketServer; - private routes: RouteInfo[] = []; - - public on({ - route, - callback, - }: { - route: string; - callback: ( - ws: WebSocket, - req: any, - params: Record, - ) => Promise; - }) { - const paramNames: string[] = []; - const pattern = route.replace(/:([^/]+)/g, (_, paramName) => { - paramNames.push(paramName); - return "([^/]+)"; - }); - this.routes.push({ - pattern: new RegExp(`^${pattern}$`), - paramNames, - callback, - }); - } - - constructor(server: http.Server) { - this.wss = new WebSocketServer({ server }); - this.wss.on("connection", (ws: WebSocket, req: http.IncomingMessage) => - this.handleConnection(ws as any, req as any), - ); - } - - private async handleConnection(ws: WebSocket, req: any) { - const path = req.url; - - try { - } catch (error) { - console.log("Failed to get org from pkey"); - ws.close(1000, "Invalid publishable key"); - return; - } - - for (const route of this.routes) { - const match = path.match(route.pattern); - if (match) { - // Extract params from match groups - const params: Record = {}; - route.paramNames.forEach((name, index) => { - params[name] = match[index + 1]; - }); - route.callback(ws, req, params); - return; - } - } - - if (!path) { - ws.close(1000, "No path found"); - return; - } - - ws.close(1000, "Route not found"); - } -} - -export const initWs = (server: http.Server) => { - const wsRouter = new WebSocketRouter(server); - - wsRouter.on({ - route: "/:customer_id/entitlements", - callback: async (ws, req, params) => { - console.log("entitlements", params); - }, - }); - - wsRouter.on({ - route: "/:customer_id/entitlements/:feature_id", - callback: async (ws, req, params) => { - console.log("entitlement", params); - }, - }); -}; diff --git a/server/tests/_temp/temp1.test.ts b/server/tests/_temp/temp1.test.ts index 0f4a0ca39..7823b3bd5 100644 --- a/server/tests/_temp/temp1.test.ts +++ b/server/tests/_temp/temp1.test.ts @@ -1,21 +1,10 @@ import { beforeAll, describe, test } from "bun:test"; -import { - type AppEnv, - LegacyVersion, - OnDecrease, - OnIncrease, - type Organization, -} from "@autumn/shared"; +import { FreeTrialDuration, LegacyVersion } from "@autumn/shared"; import { TestFeature } from "@tests/setup/v2Features.js"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; import chalk from "chalk"; -import type { Stripe } from "stripe"; -import type { DrizzleCli } from "@/db/initDrizzle.js"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { - constructArrearProratedItem, - constructFeatureItem, -} from "@/utils/scriptUtils/constructItem.js"; +import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; @@ -25,53 +14,23 @@ const pro = constructProduct({ type: "pro", items: [ - // constructFeatureItem({ featureId: TestFeature.Words, includedUsage: 100 }), - constructArrearProratedItem({ - featureId: TestFeature.Users, - includedUsage: 1, - pricePerUnit: 10, - config: { - on_increase: OnIncrease.BillImmediately, - on_decrease: OnDecrease.Prorate, - }, - }), - ], -}); -const premium = constructProduct({ - type: "premium", - - items: [ - // constructFeatureItem({ featureId: TestFeature.Words, includedUsage: 100 }), - constructArrearProratedItem({ - featureId: TestFeature.Users, - includedUsage: 1, - pricePerUnit: 10, - config: { - on_increase: OnIncrease.BillImmediately, - on_decrease: OnDecrease.Prorate, - }, - }), + constructFeatureItem({ featureId: TestFeature.Words, includedUsage: 100 }), ], + freeTrial: { + length: 7, + duration: FreeTrialDuration.Day, + unique_fingerprint: false, + card_required: true, + }, }); describe(`${chalk.yellowBright("temp: Testing add ons")}`, () => { const customerId = "temp"; const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 }); - let stripeCli: Stripe; let testClockId: string; - let curUnix: number; - let db: DrizzleCli; - let org: Organization; - let env: AppEnv; - beforeAll(async () => { - db = ctx.db; - org = ctx.org; - env = ctx.env; - stripeCli = ctx.stripeCli; - - const { testClockId: testClockId1 } = await initCustomerV3({ + const result = await initCustomerV3({ ctx, customerId, customerData: {}, @@ -81,11 +40,11 @@ describe(`${chalk.yellowBright("temp: Testing add ons")}`, () => { await initProductsV0({ ctx, - products: [pro, premium], + products: [pro], prefix: customerId, }); - testClockId = testClockId1!; + testClockId = result.testClockId!; }); test("should attach pro product", async () => { diff --git a/server/tests/attach/basic/basic1.test.ts b/server/tests/attach/basic/basic1.test.ts index 09fd16568..e07d339c1 100644 --- a/server/tests/attach/basic/basic1.test.ts +++ b/server/tests/attach/basic/basic1.test.ts @@ -10,8 +10,8 @@ import chalk from "chalk"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; +import { initCustomerV3 } from "../../../src/utils/scriptUtils/testUtils/initCustomerV3.js"; import { sharedDefaultFree } from "./sharedProducts.js"; const free2 = constructProduct({ diff --git a/server/tests/attach/basic/basic8.test.ts b/server/tests/attach/basic/basic8.test.ts index 2a7bd5f1c..0f735eb2f 100644 --- a/server/tests/attach/basic/basic8.test.ts +++ b/server/tests/attach/basic/basic8.test.ts @@ -108,6 +108,7 @@ describe(`${chalk.yellowBright("basic8: Testing trial duplicates (same fingerpri }); const customer = await AutumnCli.getCustomer(customerId2); + console.log(JSON.stringify(customer, null, 2)); await expectCustomerV0Correct({ sent: proWithTrial, diff --git a/server/tests/balances/check/basic/check8.test.ts b/server/tests/balances/check/basic/check8.test.ts index 2d8b113f6..1fd3b7b8b 100644 --- a/server/tests/balances/check/basic/check8.test.ts +++ b/server/tests/balances/check/basic/check8.test.ts @@ -1,14 +1,21 @@ import { beforeAll, describe, expect, test } from "bun:test"; -import { ApiVersion, type CheckResponseV1, SuccessCode } from "@autumn/shared"; -import chalk from "chalk"; +import { + ApiVersion, + AppEnv, + type CheckResponseV1, + SuccessCode, +} from "@autumn/shared"; import { TestFeature } from "@tests/setup/v2Features.js"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { timeout } from "@/utils/genUtils.js"; +import { OrgService } from "@/internal/orgs/OrgService.js"; +import { generatePublishableKey } from "@/utils/encryptUtils.js"; import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; +import { timeout } from "../../../utils/genUtils.js"; const messagesFeature = constructFeatureItem({ featureId: TestFeature.Messages, @@ -46,10 +53,30 @@ describe(`${chalk.yellowBright("check8: test public key & send_event")}`, () => product_id: freeProd.id, }); + // Ensure test_pkey is set on the org (needed for public key tests) + if (!ctx.org.test_pkey) { + const testPkey = generatePublishableKey(AppEnv.Sandbox); + await OrgService.update({ + db: ctx.db, + orgId: ctx.org.id, + updates: { + test_pkey: testPkey, + }, + }); + // Update the context org object + ctx.org.test_pkey = testPkey; + } + + if (!ctx.org.test_pkey.startsWith("am_pk")) { + throw new Error( + `test_pkey "${ctx.org.test_pkey}" does not start with "am_pk". Expected format: am_pk_test_...`, + ); + } + // Initialize Autumn client with public key autumnPublic = new AutumnInt({ version: ApiVersion.V1_2, - secretKey: ctx.org.test_pkey!, + secretKey: ctx.org.test_pkey, }); }); diff --git a/server/tests/balances/update/balances-update3.test.ts b/server/tests/balances/update/balances-update3.test.ts index 40537a6c8..e8fda2675 100644 --- a/server/tests/balances/update/balances-update3.test.ts +++ b/server/tests/balances/update/balances-update3.test.ts @@ -1,8 +1,8 @@ import { beforeAll, describe, expect, test } from "bun:test"; import { type ApiCustomer, ApiVersion, type LimitedItem } from "@autumn/shared"; -import chalk from "chalk"; import { TestFeature } from "@tests/setup/v2Features.js"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { CusService } from "@/internal/customers/CusService.js"; import { constructArrearItem } from "@/utils/scriptUtils/constructItem.js"; @@ -113,7 +113,7 @@ describe(`${chalk.yellowBright("balances-update3: Balance decoupling tests")}`, expect(cusEnt?.additional_balance).toBe(10); // Added expect(cusEnt?.additional_granted_balance).toBe(10); // Added - const customer = await autumnV2.customers.get(customerId); + const customer = await autumnV2.customers.get(customerId); const balance = customer.balances[TestFeature.Users]; // current = 0 + 0 + 10 = 10 expect(balance.current_balance).toBe(10); @@ -141,10 +141,10 @@ describe(`${chalk.yellowBright("balances-update3: Balance decoupling tests")}`, expect(cusEnt?.additional_balance).toBe(5); // 10 - 5 expect(cusEnt?.additional_granted_balance).toBe(5); // 10 - 5 - const customer = await autumnV2.customers.get(customerId); - const feature = customer.features[TestFeature.Users]; - // current = 0 + 0 + 5 = 5 - expect(feature.current_balance).toBe(5); + const customer = await autumnV2.customers.get(customerId); + const balance = customer.balances[TestFeature.Users]; + + expect(balance.current_balance).toBe(5); }); test("CASE C: balances.update REMOVE with insufficient additional_balance", async () => { @@ -168,9 +168,9 @@ describe(`${chalk.yellowBright("balances-update3: Balance decoupling tests")}`, expect(cusEnt?.additional_balance).toBe(0); // Floored expect(cusEnt?.additional_granted_balance).toBe(0); // 5 - 5 - const customer = await autumnV2.customers.get(customerId); - const feature = customer.features[TestFeature.Users]; - expect(feature.current_balance).toBe(0); + const customer = await autumnV2.customers.get(customerId); + const balance = customer.balances[TestFeature.Users]; + expect(balance.current_balance).toBe(0); }); test("Track +5 then update REMOVE to trigger main balance deduction", async () => { @@ -269,13 +269,12 @@ describe(`${chalk.yellowBright("balances-update3: Balance decoupling tests")}`, expect(cusEnt?.additional_balance).toBe(0); // Unchanged expect(cusEnt?.additional_granted_balance).toBe(0); // Unchanged - const customer = await autumnV2.customers.get(customerId); - const feature = customer.features[TestFeature.Users]; - // current = Math.max(0, 0) + 0 = 0 - // purchased = 0 - expect(feature.current_balance).toBe(0); - expect(feature.purchased_balance).toBe(0); - expect(feature.granted_balance).toBe(0); - expect(feature.usage).toBe(0); + const customer = await autumnV2.customers.get(customerId); + const balance = customer.balances[TestFeature.Users]; + + expect(balance.current_balance).toBe(0); + expect(balance.purchased_balance).toBe(0); + expect(balance.granted_balance).toBe(0); + expect(balance.usage).toBe(0); }); }); diff --git a/server/tests/contUse/entities/entity4.test.ts b/server/tests/contUse/entities/entity4.test.ts index 193f93149..0058359b8 100644 --- a/server/tests/contUse/entities/entity4.test.ts +++ b/server/tests/contUse/entities/entity4.test.ts @@ -8,11 +8,11 @@ import { OnDecrease, OnIncrease, } from "@autumn/shared"; -import chalk from "chalk"; import { TestFeature } from "@tests/setup/v2Features.js"; import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js"; import { useEntityBalanceAndExpect } from "@tests/utils/expectUtils/expectContUse/expectEntityUtils.js"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { timeout } from "@/utils/genUtils.js"; import { @@ -130,7 +130,7 @@ describe(`${chalk.yellowBright(`contUse/${testCase}: Testing per entity features const entRes = await autumn.check({ customer_id: customerId, feature_id: TestFeature.Messages, - entity_id: entity.id, + entity_id: entity.id ?? "", }); expect(entRes.balance).toBe(perEntityItem.included_usage); diff --git a/server/tests/crud/plan/create-plan-cross-version.test.ts b/server/tests/crud/plan/create-plan-cross-version.test.ts index 62aafaffe..9e2c45769 100644 --- a/server/tests/crud/plan/create-plan-cross-version.test.ts +++ b/server/tests/crud/plan/create-plan-cross-version.test.ts @@ -7,7 +7,6 @@ import { type CreatePlanParams, FreeTrialDuration, Infinite, - ProductItemInterval, ResetInterval, TierInfinite, UsageModel, @@ -115,7 +114,7 @@ describe(chalk.yellowBright("Plan V2 - Cross-Version Consistency"), () => { const v1_2 = (await autumnV1_2.products.get( "trial_transform", )) as ApiProduct; - expect(v1_2.free_trial!.duration).toBe(ProductItemInterval.Day); + expect(v1_2.free_trial!.duration).toBe(FreeTrialDuration.Day); expect(v1_2.free_trial!.length).toBe(7); expect(v1_2.free_trial!.unique_fingerprint).toBe(true); // Always true in V1.2 }); diff --git a/server/tests/testRunner/outputParser.ts b/server/tests/testRunner/outputParser.ts index 0cdadbdf0..893517ba1 100644 --- a/server/tests/testRunner/outputParser.ts +++ b/server/tests/testRunner/outputParser.ts @@ -65,7 +65,7 @@ export function parseTestOutput(output: string): TestSummary { } } - passedFiles = totalFiles - failedFiles; + let passedFiles = totalFiles - failedFiles; // Extract failure details let currentTestFile = ""; diff --git a/server/tests/utils/expectUtils/expectContUseUtils.ts b/server/tests/utils/expectUtils/expectContUseUtils.ts index 38a62b692..3e403d77f 100644 --- a/server/tests/utils/expectUtils/expectContUseUtils.ts +++ b/server/tests/utils/expectUtils/expectContUseUtils.ts @@ -167,10 +167,10 @@ export const calcProrationAndExpectInvoice = async ({ expect(invoices.length).to.equal( numInvoices, - `Should have ${numInvoices} invoices`, + `Should have ${numInvoices} invoices; got ${invoices.length}`, ); expect(invoices[0].total).to.equal( proratedAmount, - "Latest invoice should be equals to calculated prorated amount", + `Latest invoice should be equals to calculated prorated amount; got ${invoices[0].total}`, ); }; diff --git a/shared/api/_openapi/prevVersions/openapi1.2/featuresOpenApi.ts b/shared/api/_openapi/prevVersions/openapi1.2/featuresOpenApi.ts index f0432a7d5..fc6b2fa23 100644 --- a/shared/api/_openapi/prevVersions/openapi1.2/featuresOpenApi.ts +++ b/shared/api/_openapi/prevVersions/openapi1.2/featuresOpenApi.ts @@ -4,15 +4,15 @@ import { SuccessResponseSchema, } from "../../../common/commonResponses.js"; import { - ApiFeatureSchema, + ApiFeatureV0Schema, FEATURE_EXAMPLE, -} from "../../../features/apiFeature.js"; +} from "../../../features/prevVersions/apiFeatureV0.js"; import { CreateFeatureParamsSchema, UpdateFeatureParamsSchema, -} from "../../../features/featureOpModels.js"; +} from "../../../features/prevVersions/featureV0OpModels.js"; -export const ApiFeatureWithMeta = ApiFeatureSchema.extend({ +export const ApiFeatureWithMeta = ApiFeatureV0Schema.extend({ type: z.enum(["boolean", "single_use", "continuous_use", "credit_system"]), }).meta({ id: "Feature", diff --git a/shared/api/balances/prevVersions/legacyUpdateBalanceModels.ts b/shared/api/balances/prevVersions/legacyUpdateBalanceModels.ts index a630714c5..8557d8f4b 100644 --- a/shared/api/balances/prevVersions/legacyUpdateBalanceModels.ts +++ b/shared/api/balances/prevVersions/legacyUpdateBalanceModels.ts @@ -1,25 +1,14 @@ import { z } from "zod/v4"; -export const UpdateBalancesParamsSchema = z - .object({ - balances: z.array( - z.object({ - feature_id: z.string().meta({ - description: "The ID of the feature to update balance for.", - }), - balance: z.number().meta({ - description: "The new balance value.", - }), +export const UpdateBalancesParamsSchema = z.object({ + balances: z.array( + z.object({ + feature_id: z.string().meta({ + description: "The ID of the feature to update balance for.", }), - ), - }) - .meta({ - example: { - balances: [ - { - feature_id: "tokens", - balance: 1000, - }, - ], - }, - }); + balance: z.number().meta({ + description: "The new balance value.", + }), + }), + ), +}); diff --git a/shared/api/core/checkModels.ts b/shared/api/core/checkModels.ts deleted file mode 100644 index 30e929760..000000000 --- a/shared/api/core/checkModels.ts +++ /dev/null @@ -1,130 +0,0 @@ -// Check Feature Schemas -// export const ExtCheckParamsSchema = z.object({ -// customer_id: z.string().meta({ -// description: "The ID of the customer to check", -// example: "cus_123", -// }), -// feature_id: z.string().optional().meta({ -// description: "The ID of the feature to check access for", -// example: "api_calls", -// }), -// product_id: z.string().optional().meta({ -// description: "The ID of the product to check", -// example: "pro_plan", -// }), -// entity_id: z.string().optional().meta({ -// description: "The ID of the entity (optional)", -// example: "entity_123", -// }), - -// customer_data: CustomerDataSchema.optional().meta({ -// description: -// "Customer data to create or update the customer if they don't exist", -// }), - -// required_balance: z.number().optional().meta({ -// description: "The required balance for the check", -// example: 1, -// }), -// send_event: z.boolean().optional().meta({ -// description: "Whether to send a usage event if allowed", -// example: true, -// }), -// with_preview: z.boolean().optional().meta({ -// description: "Whether to include preview information in the response", -// example: true, -// }), -// entity_data: EntityDataSchema.optional().meta({ -// description: "Entity data to create the entity if it doesn't exist", -// }), -// properties: z.record(z.string(), z.any()).optional().meta({ -// description: "Properties to pass to the check", -// internal: true, -// }), -// }); - -// export const CheckParamsSchema = ExtCheckParamsSchema.extend({ -// required_quantity: z.number().optional(), -// }).refine( -// (data) => { -// if (data.product_id && data.feature_id) { -// return false; -// } - -// if (!data.product_id && !data.feature_id) { -// return false; -// } - -// return true; -// }, -// { -// message: "Must provide either product_id or feature_id", -// path: [], -// }, -// ); - -// export const CheckResultSchema = z -// .object({ -// allowed: z.boolean().meta({ -// description: "Whether the customer is allowed to use the feature", -// example: true, -// }), -// customer_id: z.string().meta({ -// description: "The ID of the customer", -// example: "cus_123", -// }), -// feature_id: z.string().meta({ -// description: "The ID of the feature checked", -// example: "api_calls", -// }), -// entity_id: z.string().nullish().meta({ -// description: "The ID of the entity (if provided)", -// example: "entity_123", -// }), -// required_balance: z -// .number() -// .meta({ -// description: "The required balance for this check", -// example: 1, -// }) -// .optional(), // not present for boolean features -// code: z.string().meta({ -// description: "Response code indicating the result", -// example: "allowed", -// }), -// // preview: CheckFeaturePreviewSchema.optional().meta({ -// // description: "Preview information if with_preview was true", -// // }), -// }) -// .extend(CoreCusFeatureSchema.shape); - -// Export Types -// export type ExtCheckParams = z.infer; -// export type CheckParams = z.infer; -// export type CheckResponse = z.infer; -// export type CheckFeatureScenario = z.infer; -// export type CheckResult = z.infer; - -// // Check Feature Preview Schemas -// export const CheckFeaturePreviewSchema = z.object({ -// scenario: CheckFeatureScenarioSchema, -// title: z.string().meta({ -// description: "Title for the preview message", -// example: "Usage Limit Reached", -// }), -// message: z.string().meta({ -// description: "Detailed message explaining the check result", -// example: "You've reached your usage limit. Upgrade to continue.", -// }), -// feature_id: z.string().meta({ -// description: "The ID of the feature", -// example: "api_calls", -// }), -// feature_name: z.string().meta({ -// description: "The name of the feature", -// example: "API Calls", -// }), -// products: z.array(ApiProductSchema).meta({ -// description: "Available products that include this feature", -// }), -// }); diff --git a/shared/api/customers/changes/V1.1_FeaturesArrayToObject.ts b/shared/api/customers/changes/V1.1_FeaturesArrayToObject.ts index 1e3338c07..3375fc7f3 100644 --- a/shared/api/customers/changes/V1.1_FeaturesArrayToObject.ts +++ b/shared/api/customers/changes/V1.1_FeaturesArrayToObject.ts @@ -1,4 +1,4 @@ -import { ApiFeatureType } from "@api/features/apiFeature.js"; +import { ApiFeatureType } from "@api/features/prevVersions/apiFeatureV0.js"; import { ApiVersion } from "@api/versionUtils/ApiVersion.js"; import { AffectedResource, diff --git a/shared/api/customers/cusFeatures/apiBalance.ts b/shared/api/customers/cusFeatures/apiBalance.ts index aefe3cf85..d5bdfb6ea 100644 --- a/shared/api/customers/cusFeatures/apiBalance.ts +++ b/shared/api/customers/cusFeatures/apiBalance.ts @@ -1,6 +1,6 @@ -import { ApiFeatureSchema } from "@api/features/apiFeature.js"; import { ResetInterval } from "@models/productModels/intervals/resetInterval.js"; import { z } from "zod/v4"; +import { ApiFeatureV1Schema } from "../../features/apiFeatureV1.js"; export const ApiBalanceResetSchema = z.object({ interval: z.enum(ResetInterval).or(z.literal("multiple")), @@ -26,7 +26,7 @@ export const ApiBalanceBreakdownSchema = z.object({ export const ApiBalanceSchema = z.object({ feature_id: z.string(), - feature: ApiFeatureSchema.optional(), + feature: ApiFeatureV1Schema.optional(), unlimited: z.boolean(), granted_balance: z.number(), diff --git a/shared/api/customers/cusFeatures/changes/V1.2_CusFeatureChange.ts b/shared/api/customers/cusFeatures/changes/V1.2_CusFeatureChange.ts index c786cf35e..acaf10c8e 100644 --- a/shared/api/customers/cusFeatures/changes/V1.2_CusFeatureChange.ts +++ b/shared/api/customers/cusFeatures/changes/V1.2_CusFeatureChange.ts @@ -1,4 +1,4 @@ -import { type ApiFeature, ApiFeatureType } from "@api/features/apiFeature.js"; +import { ApiFeatureType } from "@api/features/prevVersions/apiFeatureV0.js"; import { ApiVersion } from "@api/versionUtils/ApiVersion.js"; import { AffectedResource, @@ -7,7 +7,9 @@ import { import { EntInterval } from "@models/productModels/intervals/entitlementInterval.js"; import { Decimal } from "decimal.js"; import type { z } from "zod/v4"; +import { FeatureType } from "../../../../models/featureModels/featureEnums.js"; import { resetIntvToEntIntv } from "../../../../utils/planFeatureUtils/planFeatureIntervals.js"; +import type { ApiFeatureV1 } from "../../../features/apiFeatureV1.js"; import { type ApiBalance, type ApiBalanceBreakdown, @@ -38,14 +40,14 @@ const resetToV3IntervalParams = ({ unlimited, }: { input: ApiBalance | ApiBalanceBreakdown; - feature?: ApiFeature; + feature?: ApiFeatureV1; unlimited: boolean; }): { interval: EntInterval | "multiple" | null; interval_count: number | null; next_reset_at: number | null; } => { - const isBoolean = feature?.type === ApiFeatureType.Boolean; + const isBoolean = feature?.type === FeatureType.Boolean; // 1. No reset if (!input.reset) @@ -72,10 +74,20 @@ const resetToV3IntervalParams = ({ }; }; -const toV3Type = ({ feature }: { feature?: ApiFeature }) => { - if (feature?.type === ApiFeatureType.Boolean) { +const toV3Type = ({ feature }: { feature?: ApiFeatureV1 }) => { + if (feature?.type === FeatureType.Boolean) { return ApiFeatureType.Static; - } else return feature?.type ?? ApiFeatureType.SingleUsage; + } else if (feature?.type === FeatureType.Metered) { + if (feature.consumable) { + return ApiFeatureType.SingleUsage; + } else { + return ApiFeatureType.ContinuousUse; + } + } else if (feature?.type === FeatureType.CreditSystem) { + return ApiFeatureType.CreditSystem; + } else { + return ApiFeatureType.Static; + } }; const toV3BalanceParams = ({ @@ -85,11 +97,11 @@ const toV3BalanceParams = ({ legacyData, }: { input: ApiBalance | ApiBalanceBreakdown; - feature?: ApiFeature; + feature?: ApiFeatureV1; unlimited: boolean; legacyData?: CusFeatureLegacyData; }) => { - const isBoolean = feature?.type === ApiFeatureType.Boolean; + const isBoolean = feature?.type === FeatureType.Boolean; if (isBoolean || unlimited) { return { diff --git a/shared/api/customers/cusFeatures/previousVersions/apiCusFeatureV3.ts b/shared/api/customers/cusFeatures/previousVersions/apiCusFeatureV3.ts index a074464e4..44f0433e9 100644 --- a/shared/api/customers/cusFeatures/previousVersions/apiCusFeatureV3.ts +++ b/shared/api/customers/cusFeatures/previousVersions/apiCusFeatureV3.ts @@ -1,4 +1,4 @@ -import { ApiFeatureType } from "@api/features/apiFeature.js"; +import { ApiFeatureType } from "@api/features/prevVersions/apiFeatureV0.js"; import { EntInterval } from "@models/productModels/intervals/entitlementInterval.js"; import { z } from "zod/v4"; diff --git a/shared/api/customers/customerOpModels.ts b/shared/api/customers/customerOpModels.ts index 394a08da3..e0eb0939c 100644 --- a/shared/api/customers/customerOpModels.ts +++ b/shared/api/customers/customerOpModels.ts @@ -102,14 +102,9 @@ export const UpdateCustomerParamsSchema = z.object({ name: z.string().nullish().meta({ description: "The customer's name.", }), - email: z - .string() - .email({ message: "not a valid email address" }) - .or(z.literal("")) - .nullish() - .meta({ - description: "The customer's email address.", - }), + email: z.email({ message: "not a valid email address" }).nullish().meta({ + description: "Customer's email address", + }), fingerprint: z.string().nullish().meta({ description: "Unique identifier (eg, serial number) to detect duplicate customers.", diff --git a/shared/api/errors/classes/cusErrClasses.ts b/shared/api/errors/classes/cusErrClasses.ts index b1ea172ac..bb5ae95fc 100644 --- a/shared/api/errors/classes/cusErrClasses.ts +++ b/shared/api/errors/classes/cusErrClasses.ts @@ -14,3 +14,14 @@ export class CustomerNotFoundError extends RecaseError { this.name = "CustomerNotFoundError"; } } + +export class CustomerAlreadyExistsError extends RecaseError { + constructor(opts: { message?: string; customerId: string }) { + super({ + message: opts.message || `Customer ${opts.customerId} already exists`, + code: CusErrorCode.CustomerAlreadyExists, + statusCode: 409, + }); + this.name = "CustomerAlreadyExistsError"; + } +} diff --git a/shared/api/errors/codes/cusErrCodes.ts b/shared/api/errors/codes/cusErrCodes.ts index 7c4b12cd1..78b2d99d8 100644 --- a/shared/api/errors/codes/cusErrCodes.ts +++ b/shared/api/errors/codes/cusErrCodes.ts @@ -3,6 +3,7 @@ */ export const CusErrorCode = { CustomerNotFound: "customer_not_found", + CustomerAlreadyExists: "customer_already_exists", } as const; export type CusErrorCode = (typeof CusErrorCode)[keyof typeof CusErrorCode]; diff --git a/shared/api/features/apiFeatureV1.ts b/shared/api/features/apiFeatureV1.ts new file mode 100644 index 000000000..08a06fd3f --- /dev/null +++ b/shared/api/features/apiFeatureV1.ts @@ -0,0 +1,31 @@ +import { z } from "zod/v4"; +import { FeatureType } from "../../models/featureModels/featureEnums.js"; + +export const ApiFeatureV1Schema = z.object({ + id: z.string(), + name: z.string(), + type: z.enum(FeatureType), + + consumable: z.boolean(), + + event_names: z.array(z.string()).optional(), + credit_schema: z + .array( + z.object({ + metered_feature_id: z.string(), + credit_cost: z.number(), + }), + ) + .optional(), + + display: z + .object({ + singular: z.string().nullish(), + plural: z.string().nullish(), + }) + .optional(), + + archived: z.boolean(), +}); + +export type ApiFeatureV1 = z.infer; diff --git a/shared/api/features/changes/V1.2_FeatureChange.ts b/shared/api/features/changes/V1.2_FeatureChange.ts new file mode 100644 index 000000000..e08a1e2d1 --- /dev/null +++ b/shared/api/features/changes/V1.2_FeatureChange.ts @@ -0,0 +1,90 @@ +import { ApiVersion } from "@api/versionUtils/ApiVersion.js"; +import { + AffectedResource, + defineVersionChange, +} from "@api/versionUtils/versionChangeUtils/VersionChange.js"; +import { FeatureType } from "@models/featureModels/featureEnums.js"; +import type { z } from "zod/v4"; +import { ApiFeatureV1Schema } from "../apiFeatureV1.js"; +import { + ApiFeatureType, + ApiFeatureV0Schema, +} from "../prevVersions/apiFeatureV0.js"; + +/** + * V1_2_FeatureChange: Transforms feature response between V1 and V0 + * + * Applied when: targetVersion <= V1_Beta + * + * Breaking changes introduced in V2.0: + * + * 1. Type enum changes: + * - V1: Uses FeatureType (boolean, metered, credit_system) + * - V0: Uses ApiFeatureType (static, boolean, single_use, continuous_use, credit_system) + * + * 2. Fields in V1 not in V0: + * - `consumable`: Boolean flag for metered features + * - `event_names`: Optional array of event names + * + * 3. Type mapping (V1 → V0): + * - boolean → boolean + * - credit_system → credit_system + * - metered (consumable: true) → single_use + * - metered (consumable: false) → continuous_use + * + * Input: ApiFeatureV1 (V2.0+ format) + * Output: ApiFeatureV0 (V1.2 format) + */ + +export const V1_2_FeatureChange = defineVersionChange({ + name: "V1_2 Feature Change", + newVersion: ApiVersion.V2_0, + oldVersion: ApiVersion.V1_Beta, + description: [ + "Feature type enum changed from FeatureType to ApiFeatureType", + "Removed consumable and event_names fields", + ], + affectedResources: [AffectedResource.Feature], + newSchema: ApiFeatureV1Schema, + oldSchema: ApiFeatureV0Schema, + affectsResponse: true, + affectsRequest: false, + + // Response: V1 → V0 (new format to old) + transformResponse: ({ + input, + }: { + input: z.infer; + }): z.infer => { + // Map V1 type to V0 type + let v0Type: ApiFeatureType; + + if (input.type === FeatureType.Boolean) { + v0Type = ApiFeatureType.Boolean; + } else if (input.type === FeatureType.CreditSystem) { + v0Type = ApiFeatureType.CreditSystem; + } else if (input.type === FeatureType.Metered) { + // Use consumable flag to determine single_use vs continuous_use + v0Type = input.consumable + ? ApiFeatureType.SingleUsage + : ApiFeatureType.ContinuousUse; + } else { + // Fallback (should never happen) + v0Type = ApiFeatureType.Boolean; + } + + return { + id: input.id, + name: input.name, + type: v0Type, + display: input.display + ? { + singular: input.display.singular || "", + plural: input.display.plural || "", + } + : null, + credit_schema: input.credit_schema || null, + archived: input.archived, + } satisfies z.infer; + }, +}); diff --git a/shared/api/features/changes/V1.2_FeatureParamsChange.ts b/shared/api/features/changes/V1.2_FeatureParamsChange.ts new file mode 100644 index 000000000..b5965434e --- /dev/null +++ b/shared/api/features/changes/V1.2_FeatureParamsChange.ts @@ -0,0 +1,71 @@ +import { ApiVersion } from "@api/versionUtils/ApiVersion.js"; +import { + AffectedResource, + defineVersionChange, +} from "@api/versionUtils/versionChangeUtils/VersionChange.js"; +import type { z } from "zod/v4"; +import { featureV0ToV1Type } from "../../../utils/featureUtils/convertFeatureUtils.js"; +import { CreateFeatureV1ParamsSchema } from "../featureV1OpModels.js"; +import { CreateFeatureV0ParamsSchema } from "../prevVersions/featureV0OpModels.js"; + +/** + * V1_2_FeatureParamsChange: Transforms feature params between V0 and V1 + * + * Applied when: targetVersion <= V1_Beta + * + * Breaking changes introduced in V1_2: + * + * 1. Type enum changes: + * - V0: Uses ApiFeatureType (static, boolean, single_use, continuous_use, credit_system) + * - V1: Uses FeatureType (boolean, metered, credit_system) + * + * 2. New fields in V1: + * - `consumable`: Required for metered features + * - `event_names`: Optional array of event names + * + * 3. Type mapping: + * - V0 single_use → V1 metered (consumable: true) + * - V0 continuous_use → V1 metered (consumable: false) + * - V0 static (legacy) → V1 boolean + */ + +// Create Feature Change +export const V1_2_CreateFeatureChange = defineVersionChange({ + name: "V1_2 Create Feature Change", + newVersion: ApiVersion.V2_0, + oldVersion: ApiVersion.V1_Beta, + description: [ + "Feature type enum changed from ApiFeatureType to FeatureType", + "Added consumable and event_names fields", + ], + affectedResources: [AffectedResource.Feature], + newSchema: CreateFeatureV1ParamsSchema, + oldSchema: CreateFeatureV0ParamsSchema, + affectsRequest: true, + affectsResponse: false, + + // Request: V0 → V1 (old format to new) + transformRequest: ({ + input, + }: { + input: z.infer; + }): z.infer => { + const { type, consumable } = featureV0ToV1Type({ type: input.type }); + + const result: z.infer = { + id: input.id, + name: input.name, + type, + consumable, + display: input.display + ? { + singular: input.display.singular, + plural: input.display.plural, + } + : undefined, + credit_schema: input.credit_schema || undefined, + }; + + return result; + }, +}); diff --git a/shared/api/features/featureV1OpModels.ts b/shared/api/features/featureV1OpModels.ts new file mode 100644 index 000000000..6eed3a1c8 --- /dev/null +++ b/shared/api/features/featureV1OpModels.ts @@ -0,0 +1,94 @@ +import { z } from "zod/v4"; +import { FeatureType } from "../../models/featureModels/featureEnums.js"; +import { nullish } from "../../utils/utils.js"; + +const featureDescriptions = { + id: "The ID of the feature. This is used to refer to it in other API calls like /track or /check.", + name: "The name of the feature.", + type: "The type of the feature. 'single_use' features are consumed, like API calls, tokens, or messages. 'continuous_use' features are allocated, like seats, workspaces, or projects. 'credit_system' features are schemas that unify multiple 'single_use' features into a single credit system.", + display: + "Singular and plural display names for the feature in your user interface.", + + credit_schema: + "A schema that maps 'single_use' feature IDs to credit costs. Applicable only for 'credit_system' features.", + + archived: + "Whether the feature is archived. Archived features are hidden from the dashboard and list features endpoint.", +}; + +// Create Feature Params +export const CreateFeatureV1ParamsSchema = z + .object({ + id: z.string().meta({ description: featureDescriptions.id }), + name: z.string().nullish().meta({ description: featureDescriptions.name }), + type: z.enum(FeatureType).meta({ description: featureDescriptions.type }), + consumable: z.boolean().optional(), + + display: z + .object({ + singular: z.string(), + plural: z.string(), + }) + .optional() + .meta({ description: featureDescriptions.display }), + + credit_schema: z + .array( + z.object({ + metered_feature_id: z.string(), + credit_cost: z.number(), + }), + ) + .optional() + .meta({ description: featureDescriptions.credit_schema }), + + event_names: z.array(z.string()).optional(), + }) + .refine( + (data) => { + if (data.type === FeatureType.Metered && nullish(data.consumable)) { + return false; + } + return true; + }, + { + message: + "Please specify whether the feature is consumable (eg. API tokens, credits, etc.) or not.", + path: ["consumable"], + }, + ) + .refine( + (data) => { + if ( + data.type === FeatureType.CreditSystem && + nullish(data.credit_schema) + ) { + return false; + } + return true; + }, + { + message: "Please specify the credit schema for the feature.", + path: ["credit_schema"], + }, + ) + .refine( + (data) => { + if (data.type === FeatureType.CreditSystem && data.consumable === false) { + return false; + } + return true; + }, + { + message: "Credit system features must be consumable.", + path: ["consumable"], + }, + ); + +export const UpdateFeatureV1ParamsSchema = + CreateFeatureV1ParamsSchema.partial().extend({ + archived: z.boolean().optional(), + }); + +export type CreateFeatureV1Params = z.infer; +export type UpdateFeatureV1Params = z.infer; diff --git a/shared/api/features/featuresOpenApi.ts b/shared/api/features/featuresOpenApi.ts index 342966e4b..83a29ed68 100644 --- a/shared/api/features/featuresOpenApi.ts +++ b/shared/api/features/featuresOpenApi.ts @@ -3,14 +3,17 @@ import { getListResponseSchema, SuccessResponseSchema, } from "../common/commonResponses.js"; -import { ApiFeatureSchema, FEATURE_EXAMPLE } from "./apiFeature.js"; +import { + ApiFeatureV0Schema, + FEATURE_EXAMPLE, +} from "./prevVersions/apiFeatureV0.js"; import { CreateFeatureParamsSchema, UpdateFeatureParamsSchema, -} from "./featureOpModels.js"; +} from "./prevVersions/featureV0OpModels.js"; // Register the schema with .meta() for OpenAPI spec generation -export const ApiFeatureWithMeta = ApiFeatureSchema.extend({ +export const ApiFeatureWithMeta = ApiFeatureV0Schema.extend({ type: z.enum(["boolean", "single_use", "continuous_use", "credit_system"]), }).meta({ id: "Feature", diff --git a/shared/api/features/apiFeature.ts b/shared/api/features/prevVersions/apiFeatureV0.ts similarity index 93% rename from shared/api/features/apiFeature.ts rename to shared/api/features/prevVersions/apiFeatureV0.ts index 5e3f5ba2d..1ac487994 100644 --- a/shared/api/features/apiFeature.ts +++ b/shared/api/features/prevVersions/apiFeatureV0.ts @@ -22,7 +22,7 @@ export const FEATURE_EXAMPLE = { }; // Base schema without .meta() to avoid side effects during imports -export const ApiFeatureSchema = z.object({ +export const ApiFeatureV0Schema = z.object({ id: z.string().meta({ description: "The ID of the feature, used to refer to it in other API calls like /track or /check.", @@ -69,4 +69,4 @@ export const ApiFeatureSchema = z.object({ }), }); -export type ApiFeature = z.infer; +export type ApiFeatureV0 = z.infer; diff --git a/shared/api/features/featureOpModels.ts b/shared/api/features/prevVersions/featureV0OpModels.ts similarity index 86% rename from shared/api/features/featureOpModels.ts rename to shared/api/features/prevVersions/featureV0OpModels.ts index d2221be42..04fb267a4 100644 --- a/shared/api/features/featureOpModels.ts +++ b/shared/api/features/prevVersions/featureV0OpModels.ts @@ -1,5 +1,5 @@ import { z } from "zod/v4"; -import { ApiFeatureType } from "./apiFeature.js"; +import { ApiFeatureType } from "./apiFeatureV0.js"; const featureDescriptions = { id: "The ID of the feature. This is used to refer to it in other API calls like /track or /check.", @@ -16,7 +16,7 @@ const featureDescriptions = { }; // Create Feature Params -export const CreateFeatureParamsSchema = z.object({ +export const CreateFeatureV0ParamsSchema = z.object({ id: z.string().meta({ description: featureDescriptions.id }), name: z.string().nullish().meta({ description: featureDescriptions.name }), type: z.enum(ApiFeatureType).meta({ description: featureDescriptions.type }), @@ -40,7 +40,7 @@ export const CreateFeatureParamsSchema = z.object({ }); // Update Feature Params -export const UpdateFeatureParamsSchema = z.object({ +export const UpdateFeatureV0ParamsSchema = z.object({ id: z.string().optional().meta({ description: featureDescriptions.id }), name: z.string().optional().meta({ description: featureDescriptions.name }), type: z @@ -70,5 +70,5 @@ export const UpdateFeatureParamsSchema = z.object({ .meta({ description: featureDescriptions.archived }), }); -export type CreateFeatureParams = z.infer; -export type UpdateFeatureParams = z.infer; +export type CreateFeatureV0Params = z.infer; +export type UpdateFeatureV0Params = z.infer; diff --git a/shared/api/features/updateFeatureParams.ts b/shared/api/features/updateFeatureParams.ts index a683e7c3c..7b69a8e10 100644 --- a/shared/api/features/updateFeatureParams.ts +++ b/shared/api/features/updateFeatureParams.ts @@ -1,5 +1,5 @@ import { z } from "zod/v4"; -import { ApiFeatureType } from "./apiFeature.js"; +import { ApiFeatureType } from "./prevVersions/apiFeatureV0.js"; export const UpdateFeatureParamsSchema = z.object({ id: z.string().optional(), diff --git a/shared/api/models.ts b/shared/api/models.ts index 621aaa6f1..cead3c70b 100644 --- a/shared/api/models.ts +++ b/shared/api/models.ts @@ -1,6 +1,5 @@ // Core export * from "./core/attachModels.js"; -export * from "./core/checkModels.js"; export * from "./core/checkoutModels.js"; // NOTE: coreOpenApi.js is NOT exported here - it's only imported by openapi.ts for spec generation export * from "./core/coreOpModels.js"; @@ -35,8 +34,8 @@ export * from "./errors/classes/featureErrClasses.js"; export * from "./errors/codes/featureErrCodes.js"; // Features -export * from "./features/apiFeature.js"; -export * from "./features/featureOpModels.js"; +export * from "./features/prevVersions/apiFeatureV0.js"; +export * from "./features/prevVersions/featureV0OpModels.js"; // Others export * from "./others/apiDiscount.js"; @@ -65,12 +64,12 @@ export * from "./balances/check/checkResponseV2.js"; export * from "./balances/check/enums/CheckExpand.js"; export * from "./balances/check/prevVersions/CheckResponseV0.js"; export * from "./balances/check/prevVersions/CheckResponseV1.js"; +export * from "./balances/prevVersions/legacyUpdateBalanceModels.js"; export * from "./balances/track/prevVersions/trackResponseV1.js"; export * from "./balances/track/trackParams.js"; export * from "./balances/track/trackResponseV2.js"; export * from "./balances/track/trackTypes/pgDeductionUpdate.js"; export * from "./balances/usageModels.js"; - export * from "./common/customerData.js"; export * from "./common/entityData.js"; export * from "./customers/cusFeatures/cusFeatureLegacyData.js"; @@ -78,9 +77,10 @@ export * from "./customers/cusPlans/previousVersions/apiCusProductV3.js"; export * from "./entities/apiBaseEntity.js"; // Errors export * from "./errors/index.js"; +export * from "./features/apiFeatureV1.js"; +export * from "./features/featureV1OpModels.js"; +export * from "./features/prevVersions/apiFeatureV0.js"; +export * from "./features/prevVersions/featureV0OpModels.js"; // Models export * from "./platform/platformModels.js"; - -export * from "./products/planExpand.js"; - export * from "./products/planLegacyData.js"; diff --git a/shared/api/products/planExpand.ts b/shared/api/products/planExpand.ts deleted file mode 100644 index e69de29bb..000000000 diff --git a/shared/api/products/planFeature/apiPlanFeature.ts b/shared/api/products/planFeature/apiPlanFeature.ts index 41bd85eb9..b06352c8e 100644 --- a/shared/api/products/planFeature/apiPlanFeature.ts +++ b/shared/api/products/planFeature/apiPlanFeature.ts @@ -8,13 +8,13 @@ import { OnDecrease, OnIncrease, } from "../../../models/productV2Models/productItemModels/productItemEnums.js"; -import { ApiFeatureSchema } from "../../features/apiFeature.js"; +import { ApiFeatureV0Schema } from "../../features/prevVersions/apiFeatureV0.js"; import { DisplaySchema } from "../components/display.js"; export const ApiPlanFeatureSchema = z .object({ feature_id: z.string(), - feature: ApiFeatureSchema.optional(), + feature: ApiFeatureV0Schema.optional(), granted_balance: z.number(), unlimited: z.boolean(), diff --git a/shared/api/products/planFeature/previousVersions/apiProductItem.ts b/shared/api/products/planFeature/previousVersions/apiProductItem.ts index 070b3135a..3fe60d79d 100644 --- a/shared/api/products/planFeature/previousVersions/apiProductItem.ts +++ b/shared/api/products/planFeature/previousVersions/apiProductItem.ts @@ -1,4 +1,4 @@ -import { ApiFeatureSchema } from "@api/features/apiFeature.js"; +import { ApiFeatureV0Schema } from "@api/features/prevVersions/apiFeatureV0.js"; import { ProductItemInterval } from "@models/productModels/intervals/productItemInterval.js"; import { Infinite } from "@models/productModels/productEnums.js"; import { @@ -35,7 +35,7 @@ export const ApiProductItemSchema = z }), // Feature response - feature: ApiFeatureSchema.nullish().meta({ + feature: ApiFeatureV0Schema.nullish().meta({ internal: true, }), diff --git a/shared/api/versionUtils/versionChangeUtils/VersionChange.ts b/shared/api/versionUtils/versionChangeUtils/VersionChange.ts index 5d7bd3457..a731139d0 100644 --- a/shared/api/versionUtils/versionChangeUtils/VersionChange.ts +++ b/shared/api/versionUtils/versionChangeUtils/VersionChange.ts @@ -12,6 +12,7 @@ export enum AffectedResource { CusBalance = "cus_balance", Invoice = "invoice", Product = "product", + Feature = "feature", Check = "check", Track = "track", // Add more as needed diff --git a/shared/api/versionUtils/versionChangeUtils/versionChangeRegistry.ts b/shared/api/versionUtils/versionChangeUtils/versionChangeRegistry.ts index eeeea50f8..8e56c1732 100644 --- a/shared/api/versionUtils/versionChangeUtils/versionChangeRegistry.ts +++ b/shared/api/versionUtils/versionChangeUtils/versionChangeRegistry.ts @@ -12,6 +12,9 @@ import { V1_2_CustomerQueryChange } from "@api/customers/requestChanges/V1.2_Cus // Import entity changes import { V1_2_EntityChange } from "@api/entities/changes/V1.2_EntityChange.js"; import { V1_2_EntityQueryChange } from "@api/entities/requestChanges/V1.2_EntityQueryChange.js"; +// Import feature changes +import { V1_2_FeatureChange } from "@api/features/changes/V1.2_FeatureChange.js"; +import { V1_2_CreateFeatureChange } from "@api/features/changes/V1.2_FeatureParamsChange.js"; // Import product changes import { V1_2_ProductChanges } from "@api/products/changes/V1.2_ProductChanges.js"; import { V0_2_CheckChange } from "../../balances/check/changes/V0.2_CheckChange.js"; @@ -29,6 +32,9 @@ export const V2_CHANGES: VersionChangeConstructor[] = [ V1_2_ProductChanges, // Transforms Product TO V1.2 format from V2 Plan format V1_2_CheckChange, // Transforms Check TO V1.2 format from V0.2 format V1_2_CheckQueryChange, // Transforms Check Query TO V2.0 format (adds expand options) + + V1_2_FeatureChange, // Transforms Feature TO V1_Beta format (V0) from V2 format (V1) + V1_2_CreateFeatureChange, // Transforms Create Feature params TO V1_Beta ]; export const V1_4_CHANGES: VersionChangeConstructor[] = [ diff --git a/shared/index.ts b/shared/index.ts index c81f5bf1a..81f4ea341 100644 --- a/shared/index.ts +++ b/shared/index.ts @@ -99,7 +99,6 @@ export * from "./models/otherModels/metadataModels.js"; export * from "./models/otherModels/metadataTable.js"; // Duration Types export * from "./models/productModels/durationTypes/rolloverExpiryDurationType.js"; -export * from "./models/productModels/entModels/entEnums.js"; export * from "./models/productModels/entModels/entModels.js"; // 3. Entitlement Models export * from "./models/productModels/entModels/entTable.js"; diff --git a/shared/models/featureModels/featureConfig/creditConfig.ts b/shared/models/featureModels/featureConfig/creditConfig.ts index ab38663c1..0edcaa1de 100644 --- a/shared/models/featureModels/featureConfig/creditConfig.ts +++ b/shared/models/featureModels/featureConfig/creditConfig.ts @@ -11,7 +11,7 @@ export const CreditSystemConfigSchema = z.object({ schema: z.array( z.object({ metered_feature_id: z.string(), - feature_amount: z.number(), + // feature_amount: z.number(), credit_amount: z.number(), }), ), diff --git a/shared/models/productModels/entModels/entEnums.ts b/shared/models/productModels/entModels/entEnums.ts deleted file mode 100644 index e69de29bb..000000000 diff --git a/shared/utils/featureUtils.ts b/shared/utils/featureUtils.ts index 9b23f5aa3..de6d0db2e 100644 --- a/shared/utils/featureUtils.ts +++ b/shared/utils/featureUtils.ts @@ -1,4 +1,4 @@ -import { ApiFeatureSchema } from "@api/features/apiFeature.js"; +import { ApiFeatureV0Schema } from "@api/features/prevVersions/apiFeatureV0.js"; import type { CreditSchemaItem } from "../models/featureModels/featureConfig/creditConfig.js"; import { FeatureType } from "../models/featureModels/featureEnums.js"; import type { Feature } from "../models/featureModels/featureModels.js"; @@ -25,7 +25,7 @@ export const toApiFeature = ({ feature }: { feature: Feature }) => { })); } - return ApiFeatureSchema.parse({ + return ApiFeatureV0Schema.parse({ id: feature.id, name: feature.name, type: featureType, diff --git a/shared/utils/featureUtils/apiFeatureToDbFeature.ts b/shared/utils/featureUtils/apiFeatureToDbFeature.ts index 1aa7b3cd6..d776a1430 100644 --- a/shared/utils/featureUtils/apiFeatureToDbFeature.ts +++ b/shared/utils/featureUtils/apiFeatureToDbFeature.ts @@ -1,17 +1,33 @@ -import { type ApiFeature, ApiFeatureType } from "@api/features/apiFeature.js"; +import { + ApiFeatureType, + type ApiFeatureV0, +} from "@api/features/prevVersions/apiFeatureV0.js"; import type { UpdateFeatureParams } from "@api/features/updateFeatureParams.js"; import { FeatureType, - type FeatureUsageType, + FeatureUsageType, } from "@models/featureModels/featureEnums.js"; import type { Feature } from "@models/featureModels/featureModels.js"; import { AppEnv } from "@models/genModels/genEnums.js"; +import type { ApiFeatureV1 } from "../../api/features/apiFeatureV1.js"; +import type { + CreateFeatureV1Params, + UpdateFeatureV1Params, +} from "../../api/models.js"; +import { + AffectedResource, + ApiVersionClass, + applyResponseVersionChanges, + LATEST_VERSION, +} from "../../api/versionUtils/versionUtils.js"; +import type { CreditSchemaItem } from "../../models/featureModels/featureConfig/creditConfig.js"; +import { notNullish, nullish } from "../utils.js"; export const apiFeatureToDbFeature = ({ apiFeature, originalFeature, }: { - apiFeature: ApiFeature | UpdateFeatureParams; + apiFeature: ApiFeatureV0 | UpdateFeatureParams; originalFeature?: Feature; }) => { // Replace body... @@ -35,10 +51,12 @@ export const apiFeatureToDbFeature = ({ } if (apiFeature.credit_schema) { - newConfig.schema = apiFeature.credit_schema.map((credit: { metered_feature_id: string; credit_cost: number }) => ({ - metered_feature_id: credit.metered_feature_id, - credit_amount: credit.credit_cost, - })); + newConfig.schema = apiFeature.credit_schema.map( + (credit: { metered_feature_id: string; credit_cost: number }) => ({ + metered_feature_id: credit.metered_feature_id, + credit_amount: credit.credit_cost, + }), + ); } return { @@ -56,12 +74,153 @@ export const apiFeatureToDbFeature = ({ } satisfies Feature; }; +export const featureV1ToDbFeatureConfig = ({ + apiFeature, + originalFeature, +}: { + apiFeature: UpdateFeatureV1Params; + originalFeature: Feature; +}) => { + const type = apiFeature.type || originalFeature.type; + + if (nullish(apiFeature.consumable) && nullish(apiFeature.credit_schema)) + return; + + if (type === FeatureType.Boolean) return; + + if (type === FeatureType.Metered) { + const newUsageType = notNullish(apiFeature.consumable) + ? apiFeature.consumable + ? FeatureUsageType.Single + : FeatureUsageType.Continuous + : originalFeature.config?.usage_type; + return { + usage_type: newUsageType, + }; + } + + if (type === FeatureType.CreditSystem) { + const newSchema = notNullish(apiFeature.credit_schema) + ? apiFeature.credit_schema.map( + (credit: { metered_feature_id: string; credit_cost: number }) => ({ + metered_feature_id: credit.metered_feature_id, + credit_amount: credit.credit_cost, + }), + ) + : originalFeature.config?.schema; + return { + schema: newSchema, + usage_type: FeatureUsageType.Single, + }; + } + + return undefined; +}; + +export const featureV1ToDbFeature = ({ + apiFeature, + originalFeature, +}: { + apiFeature: ApiFeatureV1 | CreateFeatureV1Params; + originalFeature?: Feature; +}) => { + // Replace body... + const featureType = apiFeature.type; + const eventNames = apiFeature.event_names; + + const newConfig = + featureType === FeatureType.Boolean + ? undefined + : originalFeature?.config || {}; + + if (apiFeature.type === FeatureType.Metered) { + newConfig.usage_type = apiFeature.consumable + ? FeatureUsageType.Single + : FeatureUsageType.Continuous; + } + + if (apiFeature.credit_schema) { + newConfig.usage_type = FeatureUsageType.Single; + newConfig.schema = apiFeature.credit_schema.map( + (credit: { metered_feature_id: string; credit_cost: number }) => ({ + metered_feature_id: credit.metered_feature_id, + credit_amount: credit.credit_cost, + }), + ); + } + + return { + internal_id: originalFeature?.internal_id ?? "", + org_id: originalFeature?.org_id ?? "", + created_at: originalFeature?.created_at ?? Date.now(), + env: originalFeature?.env ?? AppEnv.Sandbox, + + id: apiFeature.id ?? originalFeature?.id ?? "", + name: apiFeature.name ?? originalFeature?.name ?? "", + type: featureType, + config: newConfig, + archived: + "archived" in apiFeature + ? apiFeature.archived + : (originalFeature?.archived ?? false), + event_names: eventNames ?? [], + } satisfies Feature; +}; + +/** + * Converts a database feature to the V1 API format (latest format). + * + * Version handling: + * - This function always returns ApiFeatureV1 (V2.0+ format) + * - Automatic version transformation to older formats (V0) happens via V1.2_FeatureChange + * - The transformation is applied by the middleware when handlers use resource: AffectedResource.Feature + * - For API version V1_Beta and older, responses are automatically converted to ApiFeatureV0 format + */ +export const dbToApiFeatureV1 = ({ + dbFeature, + targetVersion, +}: { + dbFeature: Feature; + targetVersion?: ApiVersionClass; +}) => { + const result = { + id: dbFeature.id, + name: dbFeature.name, + type: dbFeature.type, + consumable: + dbFeature.type === FeatureType.CreditSystem || + dbFeature.config?.usage_type === FeatureUsageType.Single, + + credit_schema: dbFeature.config?.schema?.map( + (schema: CreditSchemaItem) => ({ + metered_feature_id: schema.metered_feature_id, + credit_cost: schema.credit_amount, + }), + ), + event_names: dbFeature.event_names, + archived: dbFeature.archived, + + display: dbFeature.display + ? { + singular: dbFeature.display.singular, + plural: dbFeature.display.plural, + } + : undefined, + } satisfies ApiFeatureV1; + + return applyResponseVersionChanges({ + input: result, + targetVersion: targetVersion ?? new ApiVersionClass(LATEST_VERSION), + resource: AffectedResource.Feature, + }); +}; + // export const fromApiFeature = ({ // apiFeature, // orgId, // env, // }: { -// apiFeature: ApiFeature; +// apiFeature: ApiFeatureV0; // orgId: string; // env: AppEnv; // }) => { diff --git a/shared/utils/featureUtils/convertFeatureUtils.ts b/shared/utils/featureUtils/convertFeatureUtils.ts index 29234a075..1e6dde991 100644 --- a/shared/utils/featureUtils/convertFeatureUtils.ts +++ b/shared/utils/featureUtils/convertFeatureUtils.ts @@ -4,6 +4,7 @@ import { } from "@models/featureModels/featureEnums.js"; import type { Feature } from "@models/featureModels/featureModels.js"; import { ProductItemFeatureType } from "@models/productV2Models/productItemModels/productItemModels.js"; +import { ApiFeatureType } from "../../api/models.js"; export const featureToItemFeatureType = ({ feature }: { feature: Feature }) => { let featureType: ProductItemFeatureType; @@ -26,6 +27,30 @@ export const featureToItemFeatureType = ({ feature }: { feature: Feature }) => { return featureType; }; +export const featureV0ToV1Type = ({ + type, +}: { + type: ApiFeatureType; +}): { type: FeatureType; consumable?: boolean } => { + let featureType = type as unknown as FeatureType; + let consumable: boolean = false; + if ( + type === ApiFeatureType.SingleUsage || + type === ApiFeatureType.ContinuousUse + ) { + featureType = FeatureType.Metered; + consumable = type === ApiFeatureType.SingleUsage; + } else if (type === ApiFeatureType.CreditSystem) { + featureType = FeatureType.CreditSystem; + consumable = true; + } else if (type === ApiFeatureType.Static) { + featureType = FeatureType.Boolean; + consumable = false; + } + + return { type: featureType, consumable }; +}; + export const isContUseFeature = ({ feature }: { feature: Feature }) => { return feature.config?.usage_type === FeatureUsageType.Continuous; }; diff --git a/vite/src/hooks/queries/useGeneralQuery.tsx b/vite/src/hooks/queries/useGeneralQuery.tsx index c424205b4..d1b2e6da7 100644 --- a/vite/src/hooks/queries/useGeneralQuery.tsx +++ b/vite/src/hooks/queries/useGeneralQuery.tsx @@ -19,6 +19,7 @@ export const useGeneralQuery = ({ method, url, }); + return data; }; diff --git a/vite/src/hooks/stores/useProductStore.ts b/vite/src/hooks/stores/useProductStore.ts index 437c002da..dd898d79c 100644 --- a/vite/src/hooks/stores/useProductStore.ts +++ b/vite/src/hooks/stores/useProductStore.ts @@ -1,4 +1,8 @@ -import { type ProductV2, productsAreSame } from "@autumn/shared"; +import { + type ProductV2, + productsAreSame, + productV2ToBasePrice, +} from "@autumn/shared"; import { useMemo } from "react"; import { create } from "zustand"; import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery"; @@ -99,6 +103,18 @@ export const useHasDetailsChanged = () => { features, }); - return !comparison.detailsSame; + const basePrice1 = productV2ToBasePrice({ + product: product as unknown as ProductV2, + }); + const basePrice2 = productV2ToBasePrice({ + product: baseProduct as unknown as ProductV2, + }); + + const basePricesSame = + basePrice1?.price === basePrice2?.price && + basePrice1?.interval === basePrice2?.interval && + basePrice1?.interval_count === basePrice2?.interval_count; + + return !(comparison.detailsSame && basePricesSame); }, [product, baseProduct, features]); }; diff --git a/vite/src/services/FeatureService.tsx b/vite/src/services/FeatureService.tsx index 7db5772ff..067a53f5f 100644 --- a/vite/src/services/FeatureService.tsx +++ b/vite/src/services/FeatureService.tsx @@ -1,23 +1,36 @@ -import type { CreateFeature } from "@autumn/shared"; +import type { + ApiFeatureV1, + CreateFeatureV1Params, + UpdateFeatureV1Params, +} from "@autumn/shared"; import type { AxiosInstance } from "axios"; +const featureHeaders = { + "x-api-version": "2.0.0", +}; export class FeatureService { static async createFeature( axiosInstance: AxiosInstance, - data: CreateFeature, + data: CreateFeatureV1Params, ) { - return await axiosInstance.post("/features", data); + return await axiosInstance.post("/v1/features", data, { + headers: featureHeaders, + }); } static async updateFeature( axiosInstance: AxiosInstance, featureId: string, - data: any, + data: UpdateFeatureV1Params, ) { - return await axiosInstance.post(`/features/${featureId}`, data); + return await axiosInstance.post(`/v1/features/${featureId}`, data, { + headers: featureHeaders, + }); } static async deleteFeature(axiosInstance: AxiosInstance, featureId: string) { - return await axiosInstance.delete(`/features/${featureId}`); + return await axiosInstance.delete(`/v1/features/${featureId}`, { + headers: featureHeaders, + }); } } diff --git a/vite/src/services/useAxiosInstance.tsx b/vite/src/services/useAxiosInstance.tsx index cea6f67c3..76d194f87 100644 --- a/vite/src/services/useAxiosInstance.tsx +++ b/vite/src/services/useAxiosInstance.tsx @@ -1,5 +1,5 @@ // import { endpoint } from "@/utils/constants/constants"; -import { AppEnv } from "@autumn/shared"; +import { type ApiVersion, AppEnv } from "@autumn/shared"; import axios from "axios"; import { authClient } from "@/lib/auth-client"; import { useEnv } from "@/utils/envUtils"; @@ -8,7 +8,11 @@ const defaultParams = { isAuth: true, }; -export function useAxiosInstance(params?: { env?: AppEnv; isAuth?: boolean }) { +export function useAxiosInstance(params?: { + version?: ApiVersion; + env?: AppEnv; + isAuth?: boolean; +}) { const currentEnv = useEnv(); const envToUse = params?.env ?? currentEnv; @@ -20,7 +24,10 @@ export function useAxiosInstance(params?: { env?: AppEnv; isAuth?: boolean }) { axiosInstance.interceptors.request.use( async (config: any) => { config.headers.app_env = envToUse; - config.headers["x-api-version"] = "1.2"; + // Only set x-api-version if not already set by the request + if (!config.headers["x-api-version"]) { + config.headers["x-api-version"] = params?.version ?? "1.2"; + } // config.headers["Autumn-Version"] = "0.2.0"; config.headers["x-client-type"] = "dashboard"; diff --git a/vite/src/views/onboarding3/hooks/actions/useFeatureCreationActions.tsx b/vite/src/views/onboarding3/hooks/actions/useFeatureCreationActions.tsx index 5cb7955d3..62c25ac63 100644 --- a/vite/src/views/onboarding3/hooks/actions/useFeatureCreationActions.tsx +++ b/vite/src/views/onboarding3/hooks/actions/useFeatureCreationActions.tsx @@ -1,5 +1,9 @@ -import type { CreateFeature, ProductV2 } from "@autumn/shared"; -import { apiFeatureToDbFeature, CreateFeatureSchema } from "@autumn/shared"; +import type { CreateFeature, Feature, ProductV2 } from "@autumn/shared"; +import { + CreateFeatureSchema, + FeatureUsageType, + featureV1ToDbFeature, +} from "@autumn/shared"; import type { AxiosError } from "axios"; import { useCallback } from "react"; import { toast } from "sonner"; @@ -57,12 +61,12 @@ export const useFeatureCreationActions = () => { name: feature.name, id: feature.id, type: feature.type, - config: feature.config, + consumable: feature.config?.usage_type === FeatureUsageType.Single, event_names: feature.event_names, }, ); - updatedFeature = apiFeatureToDbFeature({ apiFeature: data }); + updatedFeature = featureV1ToDbFeature({ apiFeature: data }); toast.success(`Feature "${feature.name}" updated successfully!`); } else { @@ -71,10 +75,10 @@ export const useFeatureCreationActions = () => { name: feature.name, id: feature.id, type: feature.type, - config: feature.config, + consumable: feature.config?.usage_type === FeatureUsageType.Single, event_names: feature.event_names, }); - updatedFeature = apiFeatureToDbFeature({ apiFeature: data }); + updatedFeature = featureV1ToDbFeature({ apiFeature: data }); toast.success(`Feature "${feature.name}" created successfully!`); // Track feature creation in onboarding @@ -89,7 +93,7 @@ export const useFeatureCreationActions = () => { await refetchFeatures(); // Refresh features list // Update both base and working copy after successful save - setBaseFeature(updatedFeature); + setBaseFeature(updatedFeature as Feature); setFeature(updatedFeature); // Refetch product from backend to sync changes (e.g., entitlement updates when feature type changes) diff --git a/vite/src/views/onboarding3/hooks/useOnboardingSteps.tsx b/vite/src/views/onboarding3/hooks/useOnboardingSteps.tsx index 9991884e9..1fc23b831 100644 --- a/vite/src/views/onboarding3/hooks/useOnboardingSteps.tsx +++ b/vite/src/views/onboarding3/hooks/useOnboardingSteps.tsx @@ -42,8 +42,8 @@ export const useOnboardingSteps = () => { // If base price is unchecked (no price item), that's valid if (!hasBasePriceItem) return true; - // If base price is checked, it needs a valid amount - return basePrice?.amount != null && basePrice.amount > 0; + // If base price is checked, it needs a valid price amount + return basePrice?.price != null && basePrice.price > 0; } case OnboardingStep.FeatureCreation: return ( diff --git a/vite/src/views/products/features/components/CreateFeatureSheet.tsx b/vite/src/views/products/features/components/CreateFeatureSheet.tsx index 4d2608de8..39b1ee79e 100644 --- a/vite/src/views/products/features/components/CreateFeatureSheet.tsx +++ b/vite/src/views/products/features/components/CreateFeatureSheet.tsx @@ -1,4 +1,8 @@ -import { CreateFeatureSchema } from "@autumn/shared"; +import { + CreateFeatureSchema, + type CreditSchemaItem, + FeatureUsageType, +} from "@autumn/shared"; import type { AxiosError } from "axios"; import { useEffect, useState } from "react"; import { toast } from "sonner"; @@ -46,6 +50,7 @@ function CreateFeatureSheet({ const reset = useFeatureStore((s) => s.reset); const axiosInstance = useAxiosInstance(); + const { refetch } = useFeaturesQuery(); const handleCreateFeature = async () => { @@ -65,7 +70,13 @@ function CreateFeatureSheet({ name: feature.name, id: feature.id, type: feature.type, - config: feature.config, + consumable: feature.config?.usage_type === FeatureUsageType.Single, + credit_schema: feature.config?.schema?.map( + (x: CreditSchemaItem) => ({ + metered_feature_id: x.metered_feature_id, + credit_cost: x.credit_amount, + }), + ), event_names: feature.event_names, }, ); @@ -78,6 +89,7 @@ function CreateFeatureSheet({ onSuccess(createdFeature.id); } } catch (error: unknown) { + console.error("Error creating feature", error); toast.error( getBackendErr(error as AxiosError, "Failed to create feature"), ); diff --git a/vite/src/views/products/features/components/UpdateFeatureSheet.tsx b/vite/src/views/products/features/components/UpdateFeatureSheet.tsx index 90382cf88..b335045b9 100644 --- a/vite/src/views/products/features/components/UpdateFeatureSheet.tsx +++ b/vite/src/views/products/features/components/UpdateFeatureSheet.tsx @@ -1,4 +1,4 @@ -import type { Feature } from "@autumn/shared"; +import { type Feature, FeatureUsageType } from "@autumn/shared"; import type { AxiosError } from "axios"; import { useEffect, useState } from "react"; import { toast } from "sonner"; @@ -54,10 +54,11 @@ function UpdateFeatureSheet({ await FeatureService.updateFeature(axiosInstance, selectedFeature.id, { ...feature, id: feature.id, - type: feature.type, name: feature.name, - config: feature.config, + type: feature.type, + consumable: feature.config?.usage_type === FeatureUsageType.Single, event_names: feature.event_names, + display: undefined, }); await refetch(); diff --git a/vite/src/views/products/features/credit-systems/archives/CreateCreditSystem.tsx b/vite/src/views/products/features/credit-systems/archives/CreateCreditSystem.tsx deleted file mode 100644 index 8873ea84b..000000000 --- a/vite/src/views/products/features/credit-systems/archives/CreateCreditSystem.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import { - type CreateFeature, - FeatureType, - FeatureUsageType, -} from "@autumn/shared"; -import { useEffect, useState } from "react"; -import { toast } from "sonner"; -import { - CustomDialogBody, - CustomDialogContent, - CustomDialogFooter, -} from "@/components/general/modal-components/DialogContentWrapper"; -import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog"; -import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery"; -import { FeatureService } from "@/services/FeatureService"; -import { useAxiosInstance } from "@/services/useAxiosInstance"; -import { getBackendErr } from "@/utils/genUtils"; -import { validateCreditSystem } from "../utils/validateCreditSystem"; -import CreditSystemConfig from "./CreditSystemConfig"; - -const defaultCreditSystem = { - name: "", - id: "", - type: FeatureType.CreditSystem, - config: { - schema: [{ metered_feature_id: "", feature_amount: 1, credit_amount: 0 }], - usage_type: FeatureUsageType.Single, - }, - event_names: [], -}; - -function CreateCreditSystem() { - const { refetch } = useFeaturesQuery(); - const axiosInstance = useAxiosInstance(); - - const [isLoading, setIsLoading] = useState(false); - const [open, setOpen] = useState(false); - - const [creditSystem, setCreditSystem] = - useState(defaultCreditSystem); - - useEffect(() => { - if (open) { - setCreditSystem(defaultCreditSystem); - } - }, [open]); - - const handleCreateCreditSystem = async () => { - const validationError = validateCreditSystem(creditSystem); - if (validationError) { - toast.error(validationError); - return; - } - - setIsLoading(true); - try { - await FeatureService.createFeature(axiosInstance, { - name: creditSystem.name, - id: creditSystem.id, - type: FeatureType.CreditSystem, - config: creditSystem.config, - event_names: creditSystem.event_names, - }); - await refetch(); - setOpen(false); - } catch (error) { - toast.error(getBackendErr(error, "Failed to create credit system")); - } - setIsLoading(false); - }; - - return ( - - - - - - - - Create Credit System - - - - - - - - - - ); -} - -export default CreateCreditSystem; diff --git a/vite/src/views/products/features/credit-systems/archives/CreditSystemConfig.tsx b/vite/src/views/products/features/credit-systems/archives/CreditSystemConfig.tsx deleted file mode 100644 index b01b72d9d..000000000 --- a/vite/src/views/products/features/credit-systems/archives/CreditSystemConfig.tsx +++ /dev/null @@ -1,220 +0,0 @@ -import FieldLabel from "@/components/general/modal-components/FieldLabel"; -import { slugify } from "@/utils/formatUtils/formatTextUtils"; -import { - CreateFeature, - CreditSchemaItem, - Feature, - FeatureType, -} from "@autumn/shared"; - -import { - Select, - SelectItem, - SelectTrigger, - SelectValue, - SelectContent, -} from "@/components/ui/select"; -import { Input } from "@/components/ui/input"; - -import { useEffect, useState } from "react"; -import { Button } from "@/components/ui/button"; -import { X } from "lucide-react"; -import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery"; -import { toast } from "sonner"; - -function CreditSystemConfig({ - creditSystem, - setCreditSystem, -}: { - creditSystem: CreateFeature; - setCreditSystem: (creditSystem: CreateFeature) => void; -}) { - const { features } = useFeaturesQuery(); - const [fields, setFields] = useState( - creditSystem.name - ? { - name: creditSystem.name, - id: creditSystem.id, - } - : { - name: "", - id: "", - }, - ); - const [idChanged, setIdChanged] = useState(creditSystem.name !== ""); - const [creditSystemConfig, setCreditSystemConfig] = useState( - creditSystem.type === FeatureType.CreditSystem - ? creditSystem.config - : { - schema: [ - { - metered_feature_id: "", - feature_amount: 1, - credit_amount: 0, - }, - ], - }, - ); - - const handleSchemaChange = (index: number, key: string, value: any) => { - const newSchema = [...creditSystemConfig.schema]; - newSchema[index][key] = value; - setCreditSystemConfig({ ...creditSystemConfig, schema: newSchema }); - }; - - const addSchemaItem = () => { - const newSchema = [...creditSystemConfig.schema]; - newSchema.push({ - metered_feature_id: "", - feature_amount: 1, - credit_amount: 0, - }); - - setCreditSystemConfig({ ...creditSystemConfig, schema: newSchema }); - }; - - const removeSchemaItem = (index: number) => { - const newSchema = [...creditSystemConfig.schema]; - if (newSchema.length == 1) { - toast.error("There must be at least one feature in the credit system"); - return; - } - newSchema.splice(index, 1); - setCreditSystemConfig({ ...creditSystemConfig, schema: newSchema }); - }; - - useEffect(() => { - setCreditSystem({ - ...creditSystem, - name: fields.name, - id: fields.id, - type: FeatureType.CreditSystem, - config: creditSystemConfig, - }); - }, [fields, creditSystemConfig]); - - return ( -
-
-
- Name - { - const newFields: any = { ...fields, name: e.target.value }; - if (!idChanged) { - newFields.id = slugify(e.target.value); - } - setFields(newFields); - }} - /> -
-
- ID - { - setFields({ ...fields, id: e.target.value }); - setIdChanged(true); - }} - /> -
-
- -
-
-
- Metered Feature - Credit Cost -
- -
- {creditSystemConfig.schema.map((item: any, index: number) => ( -
-
-
- -
- -
- - handleSchemaChange( - index, - "credit_amount", - e.target.value, - ) - } - /> -
- -
-
-
-
- ))} -
-
- - -
-
- ); -} - -export default CreditSystemConfig; diff --git a/vite/src/views/products/features/credit-systems/archives/UpdateCreditSystem.tsx b/vite/src/views/products/features/credit-systems/archives/UpdateCreditSystem.tsx deleted file mode 100644 index c7975fa2c..000000000 --- a/vite/src/views/products/features/credit-systems/archives/UpdateCreditSystem.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { useState } from "react"; -import CreditSystemConfig from "./CreditSystemConfig"; -import { CreateFeature } from "@autumn/shared"; -import { Dialog, DialogTitle } from "@/components/ui/dialog"; -import { Button } from "@/components/ui/button"; -import { FeatureService } from "@/services/FeatureService"; -import { useAxiosInstance } from "@/services/useAxiosInstance"; -import { toast } from "sonner"; -import { - CustomDialogBody, - CustomDialogContent, - CustomDialogFooter, -} from "@/components/general/modal-components/DialogContentWrapper"; -import { getBackendErr } from "@/utils/genUtils"; -import { validateCreditSystem } from "./utils/validateCreditSystem"; -import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery"; - -function UpdateCreditSystem({ - open, - setOpen, - selectedCreditSystem, - setSelectedCreditSystem, -}: { - open: boolean; - setOpen: (open: boolean) => void; - selectedCreditSystem: CreateFeature; - setSelectedCreditSystem: (creditSystem: CreateFeature) => void; -}) { - const [updateLoading, setUpdateLoading] = useState(false); - const { refetch } = useFeaturesQuery(); - const axiosInstance = useAxiosInstance(); - - const handleUpdateCreditSystem = async () => { - const validationError = validateCreditSystem(selectedCreditSystem); - if (validationError) { - toast.error(validationError); - return; - } - - setUpdateLoading(true); - try { - await FeatureService.updateFeature( - axiosInstance, - selectedCreditSystem.id, - { - ...selectedCreditSystem, - }, - ); - await refetch(); - setOpen(false); - } catch (error) { - toast.error(getBackendErr(error, "Failed to update credit system")); - } - setUpdateLoading(false); - }; - - return ( - - - - Update Credit System - - - - - - - - - - ); -} - -export default UpdateCreditSystem; diff --git a/vite/src/views/products/features/credit-systems/components/CreateCreditSystemSheet.tsx b/vite/src/views/products/features/credit-systems/components/CreateCreditSystemSheet.tsx index 22e0ffc83..0217329db 100644 --- a/vite/src/views/products/features/credit-systems/components/CreateCreditSystemSheet.tsx +++ b/vite/src/views/products/features/credit-systems/components/CreateCreditSystemSheet.tsx @@ -1,4 +1,4 @@ -import type { CreateFeature } from "@autumn/shared"; +import type { CreateFeature, CreditSchemaItem } from "@autumn/shared"; import { FeatureType, FeatureUsageType } from "@autumn/shared"; import type { AxiosError } from "axios"; import { useEffect, useState } from "react"; @@ -61,7 +61,12 @@ export function CreateCreditSystemSheet() { name: creditSystem.name, id: creditSystem.id, type: FeatureType.CreditSystem, - config: creditSystem.config, + credit_schema: creditSystem.config?.schema?.map( + (x: CreditSchemaItem) => ({ + metered_feature_id: x.metered_feature_id, + credit_cost: x.credit_amount, + }), + ), event_names: creditSystem.event_names, }); await refetch(); diff --git a/vite/src/views/products/features/credit-systems/components/UpdateCreditSystemSheet.tsx b/vite/src/views/products/features/credit-systems/components/UpdateCreditSystemSheet.tsx index 32f681593..847f208db 100644 --- a/vite/src/views/products/features/credit-systems/components/UpdateCreditSystemSheet.tsx +++ b/vite/src/views/products/features/credit-systems/components/UpdateCreditSystemSheet.tsx @@ -1,4 +1,4 @@ -import type { CreateFeature, Feature } from "@autumn/shared"; +import type { CreateFeature, CreditSchemaItem, Feature } from "@autumn/shared"; import { FeatureType } from "@autumn/shared"; import type { AxiosError } from "axios"; import { useEffect, useState } from "react"; @@ -42,6 +42,7 @@ function UpdateCreditSystemSheet({ }, ], }, + event_names: [], }); const axiosInstance = useAxiosInstance(); @@ -55,6 +56,7 @@ function UpdateCreditSystemSheet({ id: selectedCreditSystem.id, type: selectedCreditSystem.type, config: selectedCreditSystem.config, + event_names: selectedCreditSystem.event_names, }); } }, [open, selectedCreditSystem]); @@ -70,9 +72,23 @@ function UpdateCreditSystemSheet({ setLoading(true); try { - await FeatureService.updateFeature(axiosInstance, selectedCreditSystem.id, { - ...creditSystem, - }); + await FeatureService.updateFeature( + axiosInstance, + selectedCreditSystem.id, + { + id: creditSystem.id, + name: creditSystem.name, + type: creditSystem.type, + credit_schema: creditSystem.config?.schema?.map( + (x: CreditSchemaItem) => ({ + metered_feature_id: x.metered_feature_id, + credit_cost: Number(x.credit_amount), + }), + ), + event_names: creditSystem.event_names, + display: undefined, + }, + ); await refetch(); toast.success("Credit system updated successfully"); diff --git a/vite/src/views/products/features/feature-row-toolbar/DeleteFeatureDialog.tsx b/vite/src/views/products/features/feature-row-toolbar/DeleteFeatureDialog.tsx index 6e4749b44..6f8c6fbe5 100644 --- a/vite/src/views/products/features/feature-row-toolbar/DeleteFeatureDialog.tsx +++ b/vite/src/views/products/features/feature-row-toolbar/DeleteFeatureDialog.tsx @@ -37,9 +37,10 @@ export const DeleteFeatureDialog = ({ isLoading, refetch: refetchFeatureInfo, } = useGeneralQuery({ - url: `/features/data/deletion_text/${feature.id}`, + url: `/v1/features/${feature.id}/deletion_info`, queryKey: ["featureInfo", feature.id], enabled: dropdownOpen, + method: "GET", }); useEffect(() => { @@ -81,9 +82,7 @@ export const DeleteFeatureDialog = ({ toast.success("Feature deleted successfully"); setOpen(false); } catch (error: unknown) { - toast.error( - getBackendErr(error as AxiosError, "Error deleting feature"), - ); + toast.error(getBackendErr(error as AxiosError, "Error deleting feature")); } finally { setLoading(false); } @@ -120,7 +119,11 @@ export const DeleteFeatureDialog = ({ e.stopPropagation()}> - {feature.archived ? "Unarchive" : hasProducts ? "Archive" : "Delete"}{" "} + {feature.archived + ? "Unarchive" + : hasProducts + ? "Archive" + : "Delete"}{" "} {feature.name} @@ -149,7 +152,11 @@ export const DeleteFeatureDialog = ({ )} {hasProducts && !feature.archived && ( - )} diff --git a/vite/src/views/products/features/feature-row-toolbar/FeatureRowToolbar.tsx b/vite/src/views/products/features/feature-row-toolbar/FeatureRowToolbar.tsx index b3233e140..051ef0eef 100644 --- a/vite/src/views/products/features/feature-row-toolbar/FeatureRowToolbar.tsx +++ b/vite/src/views/products/features/feature-row-toolbar/FeatureRowToolbar.tsx @@ -1,13 +1,13 @@ +import type { Feature } from "@autumn/shared"; +import { ArchiveRestore, Delete } from "lucide-react"; +import { useState } from "react"; +import { ToolbarButton } from "@/components/general/table-components/ToolbarButton"; import { DropdownMenu, - DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, + DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { useState } from "react"; -import { Feature } from "@autumn/shared"; -import { ToolbarButton } from "@/components/general/table-components/ToolbarButton"; -import { Delete, ArchiveRestore } from "lucide-react"; import { DeleteFeatureDialog } from "./DeleteFeatureDialog"; export const FeatureRowToolbar = ({ feature }: { feature: Feature }) => { @@ -24,7 +24,7 @@ export const FeatureRowToolbar = ({ feature }: { feature: Feature }) => { /> - + void; - dropdownOpen: boolean; -}) => { - const { refetch } = useFeaturesQuery(); - const axiosInstance = useAxiosInstance(); - const [deleteLoading, setDeleteLoading] = useState(false); - const [archiveLoading, setArchiveLoading] = useState(false); - - const { - data: deletionText, - isLoading: isFeatureInfoLoading, - refetch: refetchFeatureInfo, - } = useGeneralQuery({ - url: `/features/data/deletion_text/${feature.id}`, - queryKey: ["featureInfo", feature.id], - enabled: dropdownOpen, - }); - - useEffect(() => { - if (open) { - refetchFeatureInfo(); - } - }, [open, feature.id]); - - const hasProducts = deletionText?.totalCount > 0; - - const getDeleteMessage = () => { - if (feature.archived) { - return "This feature is currently archived and hidden from the UI. Would you like to unarchive it to make it visible again?"; - } - - if (hasProducts) { - if (deletionText?.productName && deletionText?.totalCount) { - if (deletionText.totalCount === 1) { - return `${deletionText.productName} is using this feature. You must remove this feature from the product first, or archive it instead.`; - } else { - const otherCount = deletionText.totalCount - 1; - return `${deletionText.productName} and ${otherCount} other product${otherCount > 1 ? "s" : ""} are using this feature. You must remove this feature from the products first, or archive it instead.`; - } - } else { - return "There are products using this feature. You must remove this feature from the products first, or archive it instead."; - } - } else { - return "Are you sure you want to delete this feature? This action cannot be undone."; - } - }; - - const handleDelete = async () => { - if (archiveLoading) return; - setDeleteLoading(true); - try { - await FeatureService.deleteFeature(axiosInstance, feature.id); - await refetch(); - setOpen(false); - } catch (error) { - console.error("Error deleting feature:", error); - toast.error(getBackendErr(error, "Error deleting feature")); - } finally { - setDeleteLoading(false); - } - }; - - const handleArchive = async () => { - setArchiveLoading(true); - const newArchivedState = !feature.archived; - try { - await FeatureService.updateFeature(axiosInstance, feature.id, { - archived: newArchivedState, - }); - await refetch(); - toast.success( - `Feature ${feature.name} ${newArchivedState ? "archived" : "unarchived"} successfully`, - ); - setOpen(false); - } catch (error) { - console.error( - `Error ${newArchivedState ? "archiving" : "unarchiving"} feature:`, - error, - ); - toast.error( - getBackendErr( - error, - `Error ${newArchivedState ? "archiving" : "unarchiving"} feature`, - ), - ); - } finally { - setArchiveLoading(false); - } - }; - - if (isFeatureInfoLoading) return <>; - - return ( - - e.stopPropagation()}> - - - {feature.archived ? "Unarchive" : "Delete"} {feature.name} - - - -
-

{getDeleteMessage()}

-
- - {hasProducts && !feature.archived && ( - - )} - {feature.archived && ( - - )} - {!hasProducts && !feature.archived && ( - - )} - -
-
- ); -};