From 96e0e8c2ce47f2c3e99004c73c9bca0f92fd299a Mon Sep 17 00:00:00 2001 From: John Yeo Date: Tue, 30 Sep 2025 19:08:50 +0100 Subject: [PATCH] feat: created api product and api feature zod schemas --- server/biome.json | 14 +- server/src/index.ts | 6 +- server/src/internal/features/featureRouter.ts | 62 +- .../products/handlers/handleCreateProduct.ts | 70 +- .../products/handlers/handleGetProduct.ts | 17 +- .../products/handlers/handleListProducts.ts | 9 +- .../products/internalProductRouter.ts | 61 +- .../internal/products/pricecn/pricecnUtils.ts | 130 +- .../getProductResponse.ts | 25 +- server/src/utils/errorUtils.ts | 79 +- server/src/utils/routerUtils.ts | 39 +- shared/api/customer.ts | 280 +-- .../api/customers/components/cusProductRes.ts | 44 - shared/api/features/apiFeature.ts | 4 +- shared/api/features/updateFeatureParams.ts | 6 + shared/api/models.ts | 8 + shared/api/openapi.ts | 68 + shared/api/operations.ts | 1 + shared/api/products/apiFreeTrial.ts | 4 +- shared/api/products/apiProduct.ts | 158 +- shared/api/products/apiProductItem.ts | 64 +- .../operations/createProductParams.ts | 15 + shared/api/products/productsOpenApi.ts | 24 + shared/index.ts | 7 +- shared/models/attachModels/checkoutModels.ts | 11 +- .../models/checkModels/checkPreviewModels.ts | 6 +- .../cusResModels/cusProductResponse.ts | 4 +- .../models/featureModels/featureResModels.ts | 36 - .../freeTrialModels/freeTrialModels.ts | 10 - .../prodItemResponseModels.ts | 43 - .../productV2Models/productResponseModels.ts | 35 - shared/openapi.yaml | 1739 ++++------------- shared/package.json | 2 +- shared/tsconfig.json | 3 +- shared/utils/featureUtils.ts | 12 +- .../productDisplayUtils/getProductItemRes.ts | 28 +- shared/utils/productUtils.ts | 17 +- shared/utils/utils.ts | 2 + .../features/components/SelectFeatureType.tsx | 9 +- .../components/SelectFeatureUsageType.tsx | 4 +- 40 files changed, 1079 insertions(+), 2077 deletions(-) create mode 100644 shared/api/features/updateFeatureParams.ts create mode 100644 shared/api/openapi.ts create mode 100644 shared/api/operations.ts create mode 100644 shared/api/products/operations/createProductParams.ts create mode 100644 shared/api/products/productsOpenApi.ts delete mode 100644 shared/models/featureModels/featureResModels.ts delete mode 100644 shared/models/productV2Models/productItemModels/prodItemResponseModels.ts delete mode 100644 shared/models/productV2Models/productResponseModels.ts diff --git a/server/biome.json b/server/biome.json index 5b4e7f7d5..463566616 100644 --- a/server/biome.json +++ b/server/biome.json @@ -9,8 +9,7 @@ "files": { "ignoreUnknown": false, "experimentalScannerIgnores": ["dist/**"], - "includes": ["src/**/*.ts", "tests/**/*.ts"], - "ignore": ["dist/**"] + "includes": ["src/**/*.ts", "tests/**/*.ts"] }, "formatter": { "enabled": true, @@ -20,9 +19,6 @@ "enabled": true, "rules": { "recommended": true, - "style": { - "noNonNullAssertion": "off" - }, "suspicious": { "noExportsInTest": "off", "noExplicitAny": "off", @@ -30,6 +26,12 @@ }, "complexity": { "noStaticOnlyClass": "off" + }, + "correctness": { + "useParseIntRadix": "off" + }, + "style": { + "noNonNullAssertion": "off" } } }, @@ -58,4 +60,4 @@ } } ] -} \ No newline at end of file +} diff --git a/server/src/index.ts b/server/src/index.ts index 4e46facf8..53b06d06e 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -3,14 +3,14 @@ import { config } from "dotenv"; config(); import "./instrumentation.js"; +import cluster from "node:cluster"; +import http from "node:http"; +import os from "node:os"; import { AppEnv } from "@autumn/shared"; import { context, trace } from "@opentelemetry/api"; import { toNodeHandler } from "better-auth/node"; -import cluster from "cluster"; import cors from "cors"; import express from "express"; -import http from "http"; -import os from "os"; import { client, db } from "./db/initDrizzle.js"; import { CacheManager } from "./external/caching/CacheManager.js"; import { ClickHouseManager } from "./external/clickhouse/ClickHouseManager.js"; diff --git a/server/src/internal/features/featureRouter.ts b/server/src/internal/features/featureRouter.ts index 5e1498697..88557d622 100644 --- a/server/src/internal/features/featureRouter.ts +++ b/server/src/internal/features/featureRouter.ts @@ -1,23 +1,25 @@ -import express, { Router } from "express"; -import { FeatureService } from "./FeatureService.js"; -import { fromAPIFeature, toAPIFeature } from "./utils/mapFeatureUtils.js"; import { APIFeatureSchema, APIFeatureType, ErrCode, - Feature, + type Feature, FeatureType, - UpdateAPIFeatureSchema, + type FeatureUsageType, + UpdateFeatureParamsSchema, } from "@autumn/shared"; -import { addTaskToQueue } from "@/queue/queueUtils.js"; + +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 { handleUpdateFeature } from "./handlers/handleUpdateFeature.js"; -import { handleDeleteFeature } from "./handlers/handleDeleteFeature.js"; -import { keyToTitle, notNullOrUndefined } from "@/utils/genUtils.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"; export const featureRouter: Router = express.Router(); @@ -29,7 +31,7 @@ featureRouter.get("", async (req: any, res: any) => action: "list features", handler: async () => { const includeArchived = req.query.include_archived === "true"; - let features = await FeatureService.list({ + const features = await FeatureService.list({ db: req.db, orgId: req.orgId, env: req.env, @@ -51,7 +53,7 @@ featureRouter.get("/:featureId", async (req: any, res: any) => action: "Get feature", handler: async () => { const feature = req.features.find( - (f: Feature) => f.id == req.params.featureId, + (f: Feature) => f.id === req.params.featureId, ); if (!feature) { @@ -73,14 +75,14 @@ featureRouter.post("", async (req: any, res: any) => res, action: "Create feature", handler: async () => { - let apiFeature = APIFeatureSchema.parse(req.body); + const apiFeature = APIFeatureSchema.parse(req.body); if (!apiFeature.name) { apiFeature.name = keyToTitle(apiFeature.id); } validateFeatureId(apiFeature.id); - let feature = fromAPIFeature({ + const feature = fromAPIFeature({ apiFeature, orgId: req.orgId, env: req.env, @@ -88,7 +90,7 @@ featureRouter.post("", async (req: any, res: any) => const { db, logger, features: curFeatures } = req; - let curFeature = curFeatures.find((f: Feature) => f.id == feature.id); + const curFeature = curFeatures.find((f: Feature) => f.id === feature.id); if (curFeature) { throw new RecaseError({ @@ -116,11 +118,14 @@ featureRouter.post("/:feature_id", async (req: any, res: any) => res, action: "Update feature", handler: async (req: any, res: any) => { - let { feature_id: featureId } = req.params; - let { features: curFeatures } = req; - let apiFeature = UpdateAPIFeatureSchema.parse(req.body); + 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, + ); - let originalFeature = curFeatures.find((f: Feature) => f.id == featureId); if (!originalFeature) { throw new RecaseError({ message: `Feature with id ${featureId} not found`, @@ -131,16 +136,16 @@ featureRouter.post("/:feature_id", async (req: any, res: any) => // Replace body... let featureType = apiFeature.type as unknown as FeatureType; - let usageType = undefined; + let usageType: FeatureUsageType | undefined; if ( - apiFeature.type == APIFeatureType.SingleUsage || - apiFeature.type == APIFeatureType.ContinuousUse + apiFeature.type === APIFeatureType.SingleUsage || + apiFeature.type === APIFeatureType.ContinuousUse ) { featureType = FeatureType.Metered; - usageType = apiFeature.type; + usageType = apiFeature.type as unknown as FeatureUsageType; } - let newConfig = originalFeature.config; + const newConfig = originalFeature.config; if (usageType) { newConfig.usage_type = usageType; } @@ -152,7 +157,7 @@ featureRouter.post("/:feature_id", async (req: any, res: any) => })); } - let newBody = { + const newBody = { id: req.body.id || undefined, name: req.body.name || undefined, type: featureType, @@ -163,15 +168,6 @@ featureRouter.post("/:feature_id", async (req: any, res: any) => req.body = newBody; await handleUpdateFeature(req, res, true); - - // let newFeature = await FeatureService.get({ - // db: req.db, - // id: featureId, - // orgId: req.orgId, - // env: req.env, - // }); - - // res.status(200).json(toAPIFeature({ feature: newFeature })); }, }), ); diff --git a/server/src/internal/products/handlers/handleCreateProduct.ts b/server/src/internal/products/handlers/handleCreateProduct.ts index 7ea695900..5f944ebe4 100644 --- a/server/src/internal/products/handlers/handleCreateProduct.ts +++ b/server/src/internal/products/handlers/handleCreateProduct.ts @@ -1,48 +1,45 @@ -import { validateProductItems } from "@/internal/products/product-items/validateProductItems.js"; +import { + APIProductSchema, + CreateProductSchema, + type Entitlement, + ErrCode, + type FreeTrial, + type FullProduct, + type Price, + type Product, + type ProductItem, +} from "@autumn/shared"; import { handleNewFreeTrial, validateAndInitFreeTrial, } from "@/internal/products/free-trials/freeTrialUtils.js"; - -import RecaseError from "@/utils/errorUtils.js"; -import { routeHandler } from "@/utils/routerUtils.js"; +import { ProductService } from "@/internal/products/ProductService.js"; +import { handleNewProductItems } from "@/internal/products/product-items/productItemUtils/handleNewProductItems.js"; +import { validateProductItems } from "@/internal/products/product-items/validateProductItems.js"; import { - CreateProductSchema, - Entitlement, - ErrCode, - FreeTrial, - FullProduct, - Price, - Product, - ProductItem, - ProductResponseSchema, - ProductV2, -} from "@autumn/shared"; + constructProduct, + getGroupToDefaults, + initProductInStripe, +} from "@/internal/products/productUtils.js"; +import { JobName } from "@/queue/JobName.js"; +import { addTaskToQueue } from "@/queue/queueUtils.js"; +import RecaseError from "@/utils/errorUtils.js"; import { keyToTitle, notNullish, nullish, validateId, } from "@/utils/genUtils.js"; - -import { ProductService } from "@/internal/products/ProductService.js"; -import { - constructProduct, - getGroupToDefaults, - initProductInStripe, -} from "@/internal/products/productUtils.js"; -import { handleNewProductItems } from "@/internal/products/product-items/productItemUtils/handleNewProductItems.js"; -import { ExtendedRequest } from "@/utils/models/Request.js"; -import { addTaskToQueue } from "@/queue/queueUtils.js"; -import { JobName } from "@/queue/JobName.js"; -import { isDefaultTrial } from "../productUtils/classifyProduct.js"; +import type { ExtendedRequest } from "@/utils/models/Request.js"; +import { routeHandler } from "@/utils/routerUtils.js"; import { validateOneOffTrial } from "../free-trials/freeTrialUtils.js"; +import { isDefaultTrial } from "../productUtils/classifyProduct.js"; const validateCreateProduct = async ({ req }: { req: ExtendedRequest }) => { - let { free_trial, items } = req.body; - let { orgId, env, db, features } = req; + const { free_trial, items } = req.body; + const { orgId, env, db, features } = req; - let productData = CreateProductSchema.parse(req.body); + const productData = CreateProductSchema.parse(req.body); validateId("Product", productData.id); @@ -169,14 +166,15 @@ export const handleCreateProduct = async (req: Request, res: any) => res, action: "POST /products", handler: async (req, res) => { - let { items } = req.body; - let { logtail: logger, org, features, env, db } = req; + const { items } = req.body; - let { freeTrial, productData } = await validateCreateProduct({ + const { logtail: logger, org, features, env, db } = req; + + const { freeTrial, productData } = await validateCreateProduct({ req, }); - let newProduct = constructProduct({ + const newProduct = constructProduct({ productData, orgId: org.id, env, @@ -189,7 +187,7 @@ export const handleCreateProduct = async (req: Request, res: any) => freeTrial: freeTrial || null, }); - let product = await ProductService.insert({ db, product: newProduct }); + const product = await ProductService.insert({ db, product: newProduct }); let prices: Price[] = []; let entitlements: Entitlement[] = []; @@ -248,7 +246,7 @@ export const handleCreateProduct = async (req: Request, res: any) => }); res.status(200).json( - ProductResponseSchema.parse({ + APIProductSchema.parse({ ...product, autumn_id: product.internal_id, items: items || [], diff --git a/server/src/internal/products/handlers/handleGetProduct.ts b/server/src/internal/products/handlers/handleGetProduct.ts index 47689885a..6452b2cc8 100644 --- a/server/src/internal/products/handlers/handleGetProduct.ts +++ b/server/src/internal/products/handlers/handleGetProduct.ts @@ -1,11 +1,14 @@ +import { ErrCode } from "@autumn/shared"; +import { StatusCodes } from "http-status-codes"; import { FeatureService } from "@/internal/features/FeatureService.js"; import { ProductService } from "@/internal/products/ProductService.js"; import { getProductResponse } from "@/internal/products/productUtils/productResponseUtils/getProductResponse.js"; import RecaseError from "@/utils/errorUtils.js"; -import { ExtendedRequest, ExtendedResponse } from "@/utils/models/Request.js"; +import type { + ExtendedRequest, + ExtendedResponse, +} from "@/utils/models/Request.js"; import { routeHandler } from "@/utils/routerUtils.js"; -import { ErrCode } from "@autumn/shared"; -import { StatusCodes } from "http-status-codes"; export const handleGetProduct = async (req: any, res: any) => routeHandler({ @@ -14,7 +17,7 @@ export const handleGetProduct = async (req: any, res: any) => action: "get product", handler: async (req: ExtendedRequest, res: ExtendedResponse) => { const { productId } = req.params; - let { schemaVersion } = req.query as { schemaVersion: string }; + const { schemaVersion } = req.query as { schemaVersion: string }; const { db, orgId, env } = req; @@ -25,7 +28,7 @@ export const handleGetProduct = async (req: any, res: any) => }); } - let [product, features] = await Promise.all([ + const [product, features] = await Promise.all([ ProductService.getFull({ db, orgId, @@ -43,9 +46,9 @@ export const handleGetProduct = async (req: any, res: any) => }); } - let schemaVersionInt = schemaVersion ? parseInt(schemaVersion) : 2; + const schemaVersionInt = schemaVersion ? parseInt(schemaVersion) : 2; - if (schemaVersionInt == 1) { + if (schemaVersionInt === 1) { res.status(200).json(product); } else { res.status(200).json( diff --git a/server/src/internal/products/handlers/handleListProducts.ts b/server/src/internal/products/handlers/handleListProducts.ts index a042d2d14..a96fd10aa 100644 --- a/server/src/internal/products/handlers/handleListProducts.ts +++ b/server/src/internal/products/handlers/handleListProducts.ts @@ -1,7 +1,10 @@ -import { routeHandler } from "@/utils/routerUtils.js"; import { FeatureService } from "@/internal/features/FeatureService.js"; import { ProductService } from "@/internal/products/ProductService.js"; -import { ExtendedRequest, ExtendedResponse } from "@/utils/models/Request.js"; +import type { + ExtendedRequest, + ExtendedResponse, +} from "@/utils/models/Request.js"; +import { routeHandler } from "@/utils/routerUtils.js"; import { getProductResponse } from "../productUtils/productResponseUtils/getProductResponse.js"; export const handleListProducts = async (req: any, res: any) => @@ -21,7 +24,7 @@ export const handleListProducts = async (req: any, res: any) => }), ]); - let prods = await Promise.all( + const prods = await Promise.all( products.map((p) => getProductResponse({ product: p, features })), ); diff --git a/server/src/internal/products/internalProductRouter.ts b/server/src/internal/products/internalProductRouter.ts index 6be0d048c..185948f29 100644 --- a/server/src/internal/products/internalProductRouter.ts +++ b/server/src/internal/products/internalProductRouter.ts @@ -1,27 +1,24 @@ +import { ErrCode, type FeatureOptions, UsageModel } from "@autumn/shared"; import { Router } from "express"; -import { FeatureService } from "../features/FeatureService.js"; import { StatusCodes } from "http-status-codes"; -import { ProductService } from "./ProductService.js"; -import { ErrCode, UsageModel } from "@autumn/shared"; -import { FeatureOptions } from "@autumn/shared"; -import { OrgService } from "../orgs/OrgService.js"; -import { RewardService } from "../rewards/RewardService.js"; -import { getGroupToDefaults, getProductVersionCounts } from "./productUtils.js"; -import { getLatestProducts } from "./productUtils.js"; -import { CusProdReadService } from "../customers/cusProducts/CusProdReadService.js"; -import { MigrationService } from "../migrations/MigrationService.js"; -import { RewardProgramService } from "../rewards/RewardProgramService.js"; -import { mapToProductV2 } from "./productV2Utils.js"; -import { isFeaturePriceItem } from "./product-items/productItemUtils/getItemType.js"; - import RecaseError, { handleFrontendReqError } from "@/utils/errorUtils.js"; - +import { CusProdReadService } from "../customers/cusProducts/CusProdReadService.js"; +import { FeatureService } from "../features/FeatureService.js"; +import { MigrationService } from "../migrations/MigrationService.js"; +import { OrgService } from "../orgs/OrgService.js"; import { createOrgResponse } from "../orgs/orgUtils.js"; -import { - sortFullProducts, - sortProductsByPrice, -} from "./productUtils/sortProductUtils.js"; +import { RewardProgramService } from "../rewards/RewardProgramService.js"; +import { RewardService } from "../rewards/RewardService.js"; import { handleGetProductDeleteInfo } from "./handlers/handleGetProductDeleteInfo.js"; +import { ProductService } from "./ProductService.js"; +import { isFeaturePriceItem } from "./product-items/productItemUtils/getItemType.js"; +import { sortFullProducts } from "./productUtils/sortProductUtils.js"; +import { + getGroupToDefaults, + getLatestProducts, + getProductVersionCounts, +} from "./productUtils.js"; +import { mapToProductV2 } from "./productV2Utils.js"; export const productRouter: Router = Router({ mergeParams: true }); @@ -56,14 +53,14 @@ productRouter.get("/products", async (req: any, res) => { // Get counts for all products productRouter.get("/product_counts", async (req: any, res) => { try { - let { db } = req; - let products = await ProductService.listFull({ + const { db } = req; + const products = await ProductService.listFull({ db, orgId: req.orgId, env: req.env, }); - let counts = await Promise.all( + const counts = await Promise.all( products.map(async (product) => { // if (latestVersion) { // return CusProdReadService.getCounts({ @@ -81,7 +78,7 @@ productRouter.get("/product_counts", async (req: any, res) => { }), ); - let result: { [key: string]: any } = {}; + const result: { [key: string]: any } = {}; for (let i = 0; i < products.length; i++) { if (!result[products[i].id]) { result[products[i].id] = counts[i]; @@ -159,7 +156,7 @@ productRouter.get("/:productId/data2", async (req: any, res) => { }); } - let productV2 = mapToProductV2({ + const productV2 = mapToProductV2({ product: product, features: req.features, }); @@ -237,7 +234,7 @@ productRouter.get("/migrations", async (req: any, res) => { productRouter.get("/data", async (req: any, res) => { try { - let { db } = req; + const { db } = req; const allVersions = req.query.all_versions === "true"; @@ -292,8 +289,8 @@ productRouter.get("/data", async (req: any, res) => { productRouter.post("/data", async (req: any, res) => { try { - let { db } = req; - let { showArchived } = req.body; + const { db } = req; + const { showArchived } = req.body; const [products, defaultProds, features, org, coupons, rewardPrograms] = await Promise.all([ @@ -343,8 +340,8 @@ productRouter.post("/data", async (req: any, res) => { productRouter.get("/counts", async (req: any, res) => { try { - let { db } = req; - let products = await ProductService.listFull({ + const { db } = req; + const products = await ProductService.listFull({ db, orgId: req.orgId, env: req.env, @@ -353,7 +350,7 @@ productRouter.get("/counts", async (req: any, res) => { const latestVersion = req.query.latest_version === "true"; - let counts = await Promise.all( + const counts = await Promise.all( products.map(async (product) => { if (latestVersion) { return CusProdReadService.getCounts({ @@ -371,7 +368,7 @@ productRouter.get("/counts", async (req: any, res) => { }), ); - let result: { [key: string]: any } = {}; + const result: { [key: string]: any } = {}; for (let i = 0; i < products.length; i++) { if (!result[products[i].id]) { result[products[i].id] = counts[i]; @@ -447,7 +444,7 @@ productRouter.get("/:productId/data", async (req: any, res) => { return b.id.localeCompare(a.id); }); - let productV2 = mapToProductV2({ product, features }); + const productV2 = mapToProductV2({ product, features }); res.status(200).send({ product: productV2, diff --git a/server/src/internal/products/pricecn/pricecnUtils.ts b/server/src/internal/products/pricecn/pricecnUtils.ts index 27891df07..059af32cd 100644 --- a/server/src/internal/products/pricecn/pricecnUtils.ts +++ b/server/src/internal/products/pricecn/pricecnUtils.ts @@ -1,34 +1,34 @@ -import RecaseError from "@/utils/errorUtils.js"; - -import { notNullish, nullish } from "@/utils/genUtils.js"; -import { getFeatureName } from "@/internal/features/utils/displayUtils.js"; - import { - ProductV2, - Feature, - ProductItem, - Organization, - ProductItemFeatureType, - ErrCode, - Infinite, - FullCusProduct, - numberWithCommas, AttachScenario, - FullProduct, - FullCustomer, + cusProductToProduct, + ErrCode, + type Feature, + type FullCusProduct, + type FullCustomer, + type FullProduct, + Infinite, + numberWithCommas, + type Organization, + type ProductItem, + ProductItemFeatureType, + type ProductV2, } from "@autumn/shared"; -import { isPriceItem } from "../product-items/productItemUtils/getItemType.js"; -import { isFeaturePriceItem } from "../product-items/productItemUtils/getItemType.js"; -import { cusProductToProduct } from "@autumn/shared"; -import { isProductUpgrade } from "../productUtils.js"; -import { getLargestInterval } from "../prices/priceUtils/priceIntervalUtils.js"; -import { DrizzleCli } from "@/db/initDrizzle.js"; +import type { DrizzleCli } from "@/db/initDrizzle.js"; +import { getFeatureName } from "@/internal/features/utils/displayUtils.js"; +import RecaseError from "@/utils/errorUtils.js"; +import { notNullish, nullish } from "@/utils/genUtils.js"; import { getFreeTrialAfterFingerprint } from "../free-trials/freeTrialUtils.js"; +import { getLargestInterval } from "../prices/priceUtils/priceIntervalUtils.js"; +import { + isFeaturePriceItem, + isPriceItem, +} from "../product-items/productItemUtils/getItemType.js"; +import { isProductUpgrade } from "../productUtils.js"; export const sortProductItems = (items: ProductItem[], features: Feature[]) => { items.sort((a, b) => { - let aIsPriceItem = isPriceItem(a); - let bIsPriceItem = isPriceItem(b); + const aIsPriceItem = isPriceItem(a); + const bIsPriceItem = isPriceItem(b); if (aIsPriceItem && bIsPriceItem) { return 0; @@ -43,8 +43,8 @@ export const sortProductItems = (items: ProductItem[], features: Feature[]) => { } // 2. Put feature price next - let aIsFeatureItem = isFeaturePriceItem(a); - let bIsFeatureItem = isFeaturePriceItem(b); + const aIsFeatureItem = isFeaturePriceItem(a); + const bIsFeatureItem = isFeaturePriceItem(b); if (aIsFeatureItem && !bIsFeatureItem) { return -1; @@ -55,9 +55,9 @@ export const sortProductItems = (items: ProductItem[], features: Feature[]) => { } // 3. Put feature price items in alphabetical order - let feature = features.find((f) => f.id == a.feature_id); - let aFeatureName = feature?.name; - let bFeatureName = features.find((f) => f.id == b.feature_id)?.name; + const feature = features.find((f) => f.id == a.feature_id); + const aFeatureName = feature?.name; + const bFeatureName = features.find((f) => f.id == b.feature_id)?.name; if (!aFeatureName || !bFeatureName) { return 0; @@ -101,14 +101,14 @@ export const getPriceText = ({ return formatAmount(item.price as number); } - let tiers = item.tiers; + const tiers = item.tiers; if (tiers) { if (tiers.length == 1) { return formatAmount(tiers[0].amount); } - let firstPrice = tiers[0].amount; - let lastPrice = tiers[tiers.length - 1].amount; + const firstPrice = tiers[0].amount; + const lastPrice = tiers[tiers.length - 1].amount; return `${formatAmount(firstPrice)} - ${formatAmount(lastPrice)}`; } @@ -126,7 +126,9 @@ export const getPricecnPrice = ({ items: ProductItem[]; isMainPrice?: boolean; }) => { - let priceExists = items.some((i) => isPriceItem(i) || isFeaturePriceItem(i)); + const priceExists = items.some( + (i) => isPriceItem(i) || isFeaturePriceItem(i), + ); if (!priceExists) { return { @@ -135,7 +137,7 @@ export const getPricecnPrice = ({ }; } - let priceItem = items[0]; + const priceItem = items[0]; if (isPriceItem(priceItem)) { return { @@ -144,8 +146,8 @@ export const getPricecnPrice = ({ secondaryText: priceItem.interval ? `per ${priceItem.interval}` : " ", }; } else { - let feature = features.find((f) => f.id == priceItem.feature_id); - let texts = featurePricetoPricecnItem({ + const feature = features.find((f) => f.id == priceItem.feature_id); + const texts = featurePricetoPricecnItem({ feature, item: priceItem, org, @@ -180,12 +182,12 @@ export const featureToPricecnItem = ({ }; } - let featureName = getIncludedFeatureName({ + const featureName = getIncludedFeatureName({ feature, item, }); - let includedUsageTxt = + const includedUsageTxt = item.included_usage == Infinite ? "Unlimited " : nullish(item.included_usage) || item.included_usage == 0 @@ -219,14 +221,14 @@ export const featurePricetoPricecnItem = ({ } // 1. Get included usage - let includedFeatureName = getIncludedFeatureName({ + const includedFeatureName = getIncludedFeatureName({ feature, item, }); let includedUsageStr = ""; if (notNullish(item.included_usage) && (item.included_usage as number) > 0) { - let includedUsage = numberWithCommas(item.included_usage as number); + const includedUsage = numberWithCommas(item.included_usage as number); if (withNameAfterIncluded) { includedUsageStr = `${includedUsage} ${includedFeatureName}`; } else { @@ -234,8 +236,8 @@ export const featurePricetoPricecnItem = ({ } } - let priceStr = getPriceText({ item, org }); - let billingFeatureName = getFeatureName({ + const priceStr = getPriceText({ item, org }); + const billingFeatureName = getFeatureName({ feature, plural: typeof item.billing_units == "number" && item.billing_units > 1, }); @@ -247,7 +249,8 @@ export const featurePricetoPricecnItem = ({ priceStr2 = `${billingFeatureName}`; } - let intervalStr = isMainPrice && item.interval ? ` per ${item.interval}` : ""; + const intervalStr = + isMainPrice && item.interval ? ` per ${item.interval}` : ""; if (includedUsageStr) { return { @@ -288,9 +291,9 @@ export const getAttachScenario = ({ return AttachScenario.Scheduled; } - let curFullProduct = cusProductToProduct({ cusProduct: curMainProduct }); + const curFullProduct = cusProductToProduct({ cusProduct: curMainProduct }); - let isUpgrade = isProductUpgrade({ + const isUpgrade = isProductUpgrade({ prices1: curFullProduct.prices, prices2: fullProduct.prices, }); @@ -319,29 +322,31 @@ export const toPricecnProduct = async ({ curScheduledProduct?: FullCusProduct | null; fullCus?: FullCustomer; }) => { - let items = structuredClone(product.items); + const items = structuredClone(product.items); sortProductItems(items, features); - let price = getPricecnPrice({ org, items, features }); - let priceExists = items.some((i) => isPriceItem(i) || isFeaturePriceItem(i)); - let itemsWithoutPrice = priceExists ? items.slice(1) : items; + const price = getPricecnPrice({ org, items, features }); + const priceExists = items.some( + (i) => isPriceItem(i) || isFeaturePriceItem(i), + ); + const itemsWithoutPrice = priceExists ? items.slice(1) : items; - let pricecnItems = itemsWithoutPrice.map((i) => { + const pricecnItems = itemsWithoutPrice.map((i) => { let data: { primaryText?: string; secondaryText?: string; }; if (isPriceItem(i)) { - let priceTxt = getPriceText({ item: i, org }); + const priceTxt = getPriceText({ item: i, org }); data = { primaryText: priceTxt, secondaryText: i.interval ? `per ${i.interval}` : undefined, }; } - let feature = features.find((f) => f.id == i.feature_id); + const feature = features.find((f) => f.id == i.feature_id); if (isFeaturePriceItem(i)) { data = featurePricetoPricecnItem({ feature, @@ -364,25 +369,25 @@ export const toPricecnProduct = async ({ }; }); - let isCurrent = curMainProduct?.product.id == product.id; - let isScheduled = curScheduledProduct?.product.id == product.id; + const isCurrent = curMainProduct?.product.id == product.id; + const isScheduled = curScheduledProduct?.product.id == product.id; let buttonText = "Get Started"; if (isCurrent) { - let isCanceled = curMainProduct!.canceled_at != null; + const isCanceled = curMainProduct!.canceled_at != null; buttonText = isCanceled ? "Renew" : "Current Plan"; } else if (isScheduled) { buttonText = "Scheduled"; } - let scenario = getAttachScenario({ + const scenario = getAttachScenario({ curMainProduct, curScheduledProduct, fullProduct, }); - let freeTrial = fullProduct.free_trial; + const freeTrial = fullProduct.free_trial; let baseVariant = null; if (fullProduct.base_variant_id) { @@ -401,7 +406,7 @@ export const toPricecnProduct = async ({ baseVariant || otherProducts.some((p) => p.base_variant_id == product.id) ) { - let intervalSet = getLargestInterval({ prices: fullProduct.prices }); + const intervalSet = getLargestInterval({ prices: fullProduct.prices }); intervalGroup = intervalSet?.interval; } @@ -435,16 +440,7 @@ export const toPricecnProduct = async ({ : null, items: pricecnItems, scenario, - // free_trial: freeTrial - // ? FreeTrialResponseSchema.parse({ - // ...freeTrial, - // trial_available: trialAvailable, - // }) - // : null, - // interval_group: intervalGroup, - - // To deprecate buttonText, }; }; diff --git a/server/src/internal/products/productUtils/productResponseUtils/getProductResponse.ts b/server/src/internal/products/productUtils/productResponseUtils/getProductResponse.ts index ebed16c7c..ceb431654 100644 --- a/server/src/internal/products/productUtils/productResponseUtils/getProductResponse.ts +++ b/server/src/internal/products/productUtils/productResponseUtils/getProductResponse.ts @@ -1,17 +1,17 @@ import { + type APIFreeTrial, + APIFreeTrialSchema, + APIProductItemSchema, + APIProductPropertiesSchema, + APIProductSchema, AttachScenario, BillingInterval, type Feature, type FeatureOptions, - type FreeTrialResponse, - FreeTrialResponseSchema, type FullCustomer, type FullProduct, type Price, type ProductItem, - ProductItemResponseSchema, - ProductPropertiesSchema, - ProductResponseSchema, UsageModel, } from "@autumn/shared"; import type { DrizzleCli } from "@/db/initDrizzle.js"; @@ -71,7 +71,7 @@ export const getProductItemResponse = ({ } const feature = features.find((f) => f.id === item.feature_id); - return ProductItemResponseSchema.parse({ + return APIProductItemSchema.parse({ type, ...item, feature: feature ? toAPIFeature({ feature }) : null, @@ -106,7 +106,7 @@ export const getFreeTrialResponse = async ({ }); if (attachScenario === AttachScenario.Downgrade) trial = null; - return FreeTrialResponseSchema.parse({ + return APIFreeTrialSchema.parse({ duration: product.free_trial?.duration, length: product.free_trial?.length, unique_fingerprint: product.free_trial?.unique_fingerprint, @@ -116,7 +116,7 @@ export const getFreeTrialResponse = async ({ } if (product.free_trial) { - return FreeTrialResponseSchema.parse({ + return APIFreeTrialSchema.parse({ duration: product.free_trial?.duration, length: product.free_trial?.length, unique_fingerprint: product.free_trial?.unique_fingerprint, @@ -132,7 +132,7 @@ export const getProductProperties = ({ freeTrial, }: { product: FullProduct; - freeTrial?: FreeTrialResponse | null; + freeTrial?: APIFreeTrial | null; }) => { const largestInterval = getLargestInterval({ prices: product.prices, @@ -142,7 +142,7 @@ export const getProductProperties = ({ const hasFreeTrial = notNullish(freeTrial) && freeTrial?.trial_available !== false; - return ProductPropertiesSchema.parse({ + return APIProductPropertiesSchema.parse({ is_free: isFreeProduct(product.prices) || false, is_one_off: isOneOff(product.prices) || false, interval_group: largestInterval?.interval, @@ -205,9 +205,9 @@ export const getProductResponse = async ({ product, fullCus, attachScenario, - })) as FreeTrialResponse; + })) as APIFreeTrial; - return ProductResponseSchema.parse({ + return APIProductSchema.parse({ ...product, name: product.name || null, group: product.group || null, @@ -215,6 +215,5 @@ export const getProductResponse = async ({ free_trial: freeTrial || null, scenario: attachScenario, properties: getProductProperties({ product, freeTrial }), - archived: product.archived ? true : undefined, }); }; diff --git a/server/src/utils/errorUtils.ts b/server/src/utils/errorUtils.ts index 11deb9864..2a3105d20 100644 --- a/server/src/utils/errorUtils.ts +++ b/server/src/utils/errorUtils.ts @@ -2,7 +2,7 @@ import { ErrCode } from "@autumn/shared"; import chalk from "chalk"; import { StatusCodes } from "http-status-codes"; import Stripe from "stripe"; -import { ZodError } from "zod"; +import { ZodError, type ZodIssue } from "zod/v4"; export const isPaymentDeclined = (error: any) => { return ( @@ -47,52 +47,49 @@ export default class RecaseError extends Error { } export function formatZodError(error: ZodError): string { - return error.errors - .map((err) => - err.path.length ? `${err.path.join(".")}: ${err.message}` : err.message, - ) - .join(", "); -} + const formatMessage = (issue: ZodIssue): string => { + const path = issue.path.length ? issue.path.join(".") : "input"; -const getJsonBody = (body: any) => { - if (Buffer.isBuffer(body)) { - try { - return JSON.parse(body.toString()); - } catch (e) { - return `[Invalid JSON] Raw body: ${body.toString()}`; + // Clean up common Zod error messages + let message = issue.message; + + // Handle common patterns and make them more user-friendly + if ( + message.includes("Too small") && + message.includes("expected string to have >=1 characters") + ) { + message = "cannot be empty"; + } else if (message.includes("Invalid string: must match pattern")) { + // Extract the pattern and make it more readable + if (message.includes("/^[a-zA-Z0-9_-]+$/")) { + message = + "must contain only letters, numbers, underscores, and hyphens"; + } else { + message = "has invalid format"; + } + } else if (message.includes("Invalid input: expected string, received")) { + const receivedType = message.split("received ")[1]; + message = `must be a string (received ${receivedType})`; + } else if (message.includes("Invalid input: expected number, received")) { + const receivedType = message.split("received ")[1]; + message = `must be a number (received ${receivedType})`; + } else if (message.includes("Invalid input: expected boolean, received")) { + const receivedType = message.split("received ")[1]; + message = `must be a boolean (received ${receivedType})`; } - } - return body; -}; -const logRequestBody = (logger: any, req: any, level: "warn" | "error") => { - if ( - req.body && - typeof req.body === "object" && - Object.keys(req.body).length > 0 - ) { - logger[level]("Request body:"); - logger[level](getJsonBody(req.body)); - } -}; + return `${path}: ${message}`; + }; -const logReqUrl = (logger: any, req: any, level: "warn" | "error") => { - if (req.originalUrl.includes("/webhooks/stripe")) { - logger[level](`Stripe webhook: ${req.originalUrl}`); - let body = req.body; - try { - body = Buffer.isBuffer(req.body) - ? JSON.parse(req.body.toString()) - : req.body; + const formattedIssues = error.issues.map(formatMessage); - logger[level](`Event type: ${body.type}, ID: ${body.id}`); - } catch (error) { - logger[level](`Invalid JSON body`); - } + // If there are multiple issues, format them nicely + if (formattedIssues.length === 1) { + return formattedIssues[0]; } else { - logger[level](`${req.method} ${req.originalUrl}`); + return `[Validation Errors] ${formattedIssues.join("; ")}`; } -}; +} export const handleRequestError = ({ error, @@ -194,7 +191,7 @@ export const handleFrontendReqError = ({ const logger = req.logger; if ( error instanceof RecaseError && - error.statusCode == StatusCodes.NOT_FOUND + error.statusCode === StatusCodes.NOT_FOUND ) { // Temporarily disable logger to prevent thread-stream crashes console.log(`(frontend) ${req.method} ${req.originalUrl}: not found`); diff --git a/server/src/utils/routerUtils.ts b/server/src/utils/routerUtils.ts index 02d7525bb..8217a80d1 100644 --- a/server/src/utils/routerUtils.ts +++ b/server/src/utils/routerUtils.ts @@ -1,15 +1,14 @@ -import { AppEnv, ErrCode, Organization } from "@autumn/shared"; +import { ErrCode } from "@autumn/shared"; +import { StatusCodes } from "http-status-codes"; +import qs from "qs"; +import Stripe from "stripe"; +import { ZodAny, ZodError, ZodObject } from "zod"; +import { withSpan as withSpanTracer } from "@/internal/analytics/tracer/spanUtils.js"; import RecaseError, { formatZodError, handleRequestError, } from "./errorUtils.js"; -import { ZodAny, ZodError, ZodObject } from "zod"; -import { StatusCodes } from "http-status-codes"; -import Stripe from "stripe"; -import { DrizzleCli } from "@/db/initDrizzle.js"; -import { ExtendedRequest } from "./models/Request.js"; -import { withSpan as withSpanTracer } from "@/internal/analytics/tracer/spanUtils.js"; -import qs from "qs"; +import type { ExtendedRequest } from "./models/Request.js"; /** * Parses query parameters with proper type coercion for validation @@ -264,21 +263,19 @@ export const routeHandler = async ({ }); } } catch (error) { - try { - if (error instanceof RecaseError) { - if (error.code === ErrCode.EntityNotFound) { - req.logtail.warn( - `${error.message}, org: ${req.org?.slug || req.orgId}`, - ); - return res.status(404).json({ - message: error.message, - code: error.code, - }); - } + if (error instanceof RecaseError) { + if (error.code === ErrCode.EntityNotFound) { + req.logtail.warn( + `${error.message}, org: ${req.org?.slug || req.orgId}`, + ); + return res.status(404).json({ + message: error.message, + code: error.code, + }); } - } catch (error) {} + } - let originalUrl = req.originalUrl; + const originalUrl = req.originalUrl; if (error instanceof Stripe.errors.StripeError) { if ( originalUrl.includes("/exchange") && diff --git a/shared/api/customer.ts b/shared/api/customer.ts index 5eb415410..a9ea33d3c 100644 --- a/shared/api/customer.ts +++ b/shared/api/customer.ts @@ -1,148 +1,148 @@ -import { writeFileSync } from "node:fs"; -import yaml from "yaml"; -import { z } from "zod/v4"; -import { createDocument } from "zod-openapi"; -import { AttachBodySchema } from "../models/attachModels/attachBody.js"; -import { CusResponseSchema } from "../models/cusModels/cusResponseModels.js"; +// import { writeFileSync } from "node:fs"; +// import yaml from "yaml"; +// import { z } from "zod/v4"; +// import { createDocument } from "zod-openapi"; +// import { AttachBodySchema } from "../models/attachModels/attachBody.js"; +// import { CusResponseSchema } from "../models/cusModels/cusResponseModels.js"; -const customerId = z.string().meta({ - description: "Your internal ID for the customer", - example: "cus_123", - id: "customer_id", -}); +// const customerId = z.string().meta({ +// description: "Your internal ID for the customer", +// example: "cus_123", +// id: "customer_id", +// }); -const featureId = z.string().meta({ - description: "Feature ID as defined in the dashboard (eg. 'messages')", - example: "messages", - id: "feature_id", -}); +// const featureId = z.string().meta({ +// description: "Feature ID as defined in the dashboard (eg. 'messages')", +// example: "messages", +// id: "feature_id", +// }); -const AttachResult = z - .object({ - message: z.string().meta({ - description: "A short description on the result of the operation", - example: "Successfully downgraded from Product A to Product B", - id: "message", - }), - product_ids: z.array(z.string()).meta({ - description: "The IDs of the products that were attached", - example: ["pro", "one_off"], - id: "product_ids", - }), - customer_id: customerId, - }) - .meta({ id: "AttachResult" }); +// const AttachResult = z +// .object({ +// message: z.string().meta({ +// description: "A short description on the result of the operation", +// example: "Successfully downgraded from Product A to Product B", +// id: "message", +// }), +// product_ids: z.array(z.string()).meta({ +// description: "The IDs of the products that were attached", +// example: ["pro", "one_off"], +// id: "product_ids", +// }), +// customer_id: customerId, +// }) +// .meta({ id: "AttachResult" }); -const attachDefinition = { - summary: "Attach Product", - tags: ["core"], - "x-speakeasy-name-override": "attach", - requestBody: { - content: { - "application/json": { schema: AttachBodySchema }, - }, - }, - responses: { - "200": { - description: "200 OK", - content: { - "application/json": { schema: AttachResult }, - }, - }, - }, -}; +// const attachDefinition = { +// summary: "Attach Product", +// tags: ["core"], +// "x-speakeasy-name-override": "attach", +// requestBody: { +// content: { +// "application/json": { schema: AttachBodySchema }, +// }, +// }, +// responses: { +// "200": { +// description: "200 OK", +// content: { +// "application/json": { schema: AttachResult }, +// }, +// }, +// }, +// }; -// Define Customer schema as a reusable component -const CustomerV1 = CusResponseSchema.meta({ id: "Customer" }); +// // Define Customer schema as a reusable component +// const CustomerV1 = CusResponseSchema.meta({ id: "Customer" }); -const document = createDocument({ - openapi: "3.1.0", - info: { - title: "My API", - version: "1.0.0", - }, - servers: [ - { - url: "https://api.useautumn.com", - description: "Production server", - }, - ], - security: [ - { - secretKey: [], - }, - ], - components: { - securitySchemes: { - secretKey: { - type: "http", - scheme: "bearer", - bearerFormat: "JWT", - }, - }, - }, +// const document = createDocument({ +// openapi: "3.1.0", +// info: { +// title: "My API", +// version: "1.0.0", +// }, +// servers: [ +// { +// url: "https://api.useautumn.com", +// description: "Production server", +// }, +// ], +// security: [ +// { +// secretKey: [], +// }, +// ], +// components: { +// securitySchemes: { +// secretKey: { +// type: "http", +// scheme: "bearer", +// bearerFormat: "JWT", +// }, +// }, +// }, - paths: { - "/core/attach": { - post: attachDefinition, - }, - "/customers/{customer_id}": { - get: { - summary: "Get customer", - "x-speakeasy-name-override": "get", - tags: ["customers"], - requestParams: { path: z.object({ customer_id: customerId }) }, - responses: { - "200": { - description: "200 OK", - content: { - "application/json": { schema: CustomerV1 }, - }, - }, - }, - }, - }, - "/customers/{customer_id}/features/{feature_id}": { - patch: { - summary: "Update customer feature", - tags: ["customer.features"], - "x-speakeasy-name-override": "update", - requestParams: { - path: z.object({ customer_id: customerId, feature_id: featureId }), - }, - requestBody: { - content: { - "application/json": { - schema: z.object({ - usage: z.number(), - }), - }, - }, - }, - responses: { - "200": { - description: "200 OK", - content: { - "application/json": { - schema: z.object({ - usage: z.number(), - }), - }, - }, - }, - }, - }, - }, - }, -}); +// paths: { +// "/core/attach": { +// post: attachDefinition, +// }, +// "/customers/{customer_id}": { +// get: { +// summary: "Get customer", +// "x-speakeasy-name-override": "get", +// tags: ["customers"], +// requestParams: { path: z.object({ customer_id: customerId }) }, +// responses: { +// "200": { +// description: "200 OK", +// content: { +// "application/json": { schema: CustomerV1 }, +// }, +// }, +// }, +// }, +// }, +// "/customers/{customer_id}/features/{feature_id}": { +// patch: { +// summary: "Update customer feature", +// tags: ["customer.features"], +// "x-speakeasy-name-override": "update", +// requestParams: { +// path: z.object({ customer_id: customerId, feature_id: featureId }), +// }, +// requestBody: { +// content: { +// "application/json": { +// schema: z.object({ +// usage: z.number(), +// }), +// }, +// }, +// }, +// responses: { +// "200": { +// description: "200 OK", +// content: { +// "application/json": { +// schema: z.object({ +// usage: z.number(), +// }), +// }, +// }, +// }, +// }, +// }, +// }, +// }, +// }); -// Export to YAML file during build -if (process.env.NODE_ENV !== "production") { - try { - const yamlContent = yaml.stringify(document); - writeFileSync("./openapi.yaml", yamlContent, "utf8"); - console.log("OpenAPI document exported to openapi-customer.yaml"); - } catch (error) { - console.error("Failed to export OpenAPI document:", error); - } -} +// // Export to YAML file during build +// if (process.env.NODE_ENV !== "production") { +// try { +// const yamlContent = yaml.stringify(document); +// writeFileSync("./openapi.yaml", yamlContent, "utf8"); +// console.log("OpenAPI document exported to openapi-customer.yaml"); +// } catch (error) { +// console.error("Failed to export OpenAPI document:", error); +// } +// } diff --git a/shared/api/customers/components/cusProductRes.ts b/shared/api/customers/components/cusProductRes.ts index ceadd0596..e69de29bb 100644 --- a/shared/api/customers/components/cusProductRes.ts +++ b/shared/api/customers/components/cusProductRes.ts @@ -1,44 +0,0 @@ -// import { ProductResV2Schema } from "@api/products/prodResV2/prodResV2.js"; -// import { z } from "zod/v4"; - -// // import { CusProductStatus } from "../../../models/cusProductModels/cusProductEnums.js"; -// // import { ProductItemResponseSchema } from "../../../models/productV2Models/productItemModels/prodItemResponseModels.js"; - -// const PartialProduct = ProductResV2Schema.pick({ -// id: true, -// name: true, -// is_default: true, -// is_add_on: true, -// version: true, -// group: true, -// items: true, -// }); - -// export const CusProductResSchema = z.object({ -// ...PartialProduct.shape, -// group: z.string().nullable(), -// status: z.enum(["active", "past_due", "expired"]), - -// canceled_at: z.number().nullable(), -// started_at: z.number(), -// current_period_start: z.number().nullish(), -// current_period_end: z.number().nullish(), -// entity_id: z.string().nullish(), - -// quantity: z.number().optional(), -// }); - -// // Product related fields -// // id: z.string(), -// // name: z.string().nullable(), -// // is_default: z.boolean(), -// // is_add_on: z.boolean(), -// // version: z.number().nullish(), -// // items: z.array(ProductItemResponseSchema).nullish(), -// // product: { - -// // }, - -// // entity_id: z.string().nullish(), - -// // stripe_subscription_ids: z.array(z.string()).nullish(), diff --git a/shared/api/features/apiFeature.ts b/shared/api/features/apiFeature.ts index a634ec053..77907982f 100644 --- a/shared/api/features/apiFeature.ts +++ b/shared/api/features/apiFeature.ts @@ -1,6 +1,6 @@ import { z } from "zod/v4"; -export enum FeatureResType { +export enum APIFeatureType { Boolean = "boolean", SingleUsage = "single_use", ContinuousUse = "continuous_use", @@ -10,7 +10,7 @@ export enum FeatureResType { export const APIFeatureSchema = z.object({ id: z.string(), name: z.string().nullish(), - type: z.enum(FeatureResType), + type: z.enum(APIFeatureType), display: z .object({ singular: z.string(), diff --git a/shared/api/features/updateFeatureParams.ts b/shared/api/features/updateFeatureParams.ts new file mode 100644 index 000000000..c050d8172 --- /dev/null +++ b/shared/api/features/updateFeatureParams.ts @@ -0,0 +1,6 @@ +import { APIFeatureSchema } from "@api/features/apiFeature.js"; +import type { z } from "zod/v4"; + +export const UpdateFeatureParamsSchema = APIFeatureSchema.partial(); + +export type UpdateFeatureParams = z.infer; diff --git a/shared/api/models.ts b/shared/api/models.ts index 453d9297c..d86018803 100644 --- a/shared/api/models.ts +++ b/shared/api/models.ts @@ -1,3 +1,11 @@ +export * from "./features/apiFeature.js"; + +// Product export * from "./products/apiFreeTrial.js"; export * from "./products/apiProduct.js"; export * from "./products/apiProductItem.js"; +export * from "./products/operations/createProductParams.js"; + +// export * from "./products/apiFreeTrial.js"; +// export * from "./products/apiProduct.js"; +// export * from "./products/apiProductItem.js"; diff --git a/shared/api/openapi.ts b/shared/api/openapi.ts new file mode 100644 index 000000000..4d5318265 --- /dev/null +++ b/shared/api/openapi.ts @@ -0,0 +1,68 @@ +import { writeFileSync } from "node:fs"; +import yaml from "yaml"; +import { createDocument } from "zod-openapi"; +import { APIProductSchema } from "./products/apiProduct.js"; +import { CreateProductParamsSchema } from "./products/operations/createProductParams.js"; + +const API_VERSION = "1.2.0"; + +const document = createDocument({ + openapi: "3.1.0", + info: { + title: "Autumn API", + version: API_VERSION, + }, + servers: [ + { + url: "https://api.useautumn.com", + description: "Production server", + }, + ], + security: [ + { + secretKey: [], + }, + ], + components: { + securitySchemes: { + secretKey: { + type: "http", + scheme: "bearer", + bearerFormat: "JWT", + }, + }, + }, + + paths: { + "/products": { + post: { + summary: "Create Product", + tags: ["products"], + requestBody: { + content: { + "application/json": { schema: CreateProductParamsSchema }, + }, + }, + responses: { + "200": { + description: "200 OK", + content: { + "application/json": { schema: APIProductSchema }, + }, + }, + }, + }, + }, + }, +}); + +// Export to YAML file during build +if (process.env.NODE_ENV !== "production") { + try { + const yamlContent = yaml.stringify(document); + writeFileSync("./openapi.yaml", yamlContent, "utf8"); + console.log("OpenAPI document exported to openapi-customer.yaml"); + } catch (error) { + console.error("Failed to export OpenAPI document:", error); + } +} diff --git a/shared/api/operations.ts b/shared/api/operations.ts new file mode 100644 index 000000000..8ded0962f --- /dev/null +++ b/shared/api/operations.ts @@ -0,0 +1 @@ +export * from "./features/updateFeatureParams.js"; diff --git a/shared/api/products/apiFreeTrial.ts b/shared/api/products/apiFreeTrial.ts index b73027dde..aebec3169 100644 --- a/shared/api/products/apiFreeTrial.ts +++ b/shared/api/products/apiFreeTrial.ts @@ -1,7 +1,7 @@ import { FreeTrialDuration } from "@models/productModels/freeTrialModels/freeTrialEnums.js"; import { z } from "zod/v4"; -export const APIFreeTrial = z.object({ +export const APIFreeTrialSchema = z.object({ duration: z.enum(FreeTrialDuration), length: z.number(), unique_fingerprint: z.boolean(), @@ -10,3 +10,5 @@ export const APIFreeTrial = z.object({ // For Cus Product trial_available: z.boolean().nullish().default(true), }); + +export type APIFreeTrial = z.infer; diff --git a/shared/api/products/apiProduct.ts b/shared/api/products/apiProduct.ts index 714bcd86b..75672becd 100644 --- a/shared/api/products/apiProduct.ts +++ b/shared/api/products/apiProduct.ts @@ -1,41 +1,143 @@ import { AttachScenario } from "@models/checkModels/checkPreviewModels.js"; import { AppEnv } from "@models/genModels/genEnums.js"; -import { ProductItemResponseSchema } from "@models/productV2Models/productItemModels/prodItemResponseModels.js"; import { z } from "zod/v4"; -import { APIFreeTrial } from "./apiFreeTrial.js"; -// import { AttachScenario } from "../checkModels/checkPreviewModels.js"; -// import { AppEnv } from "../genModels/genEnums.js"; -// import { FreeTrialResponseSchema } from "../productModels/freeTrialModels/freeTrialModels.js"; -// import { ProductItemResponseSchema } from "./productItemModels/prodItemResponseModels.js"; +import { APIFreeTrialSchema } from "./apiFreeTrial.js"; +import { APIProductItemSchema } from "./apiProductItem.js"; export const APIProductPropertiesSchema = z.object({ - is_free: z.boolean(), - is_one_off: z.boolean(), - interval_group: z.string().nullish(), - has_trial: z.boolean().nullish(), - updateable: z.boolean().nullish(), + is_free: z.boolean().meta({ + description: "True if the product has no base price or usage prices", + example: false, + }), + is_one_off: z.boolean().meta({ + description: "True if the product only contains a one-time price", + example: false, + }), + interval_group: z.string().nullish().meta({ + description: + "The billing interval group for recurring products (e.g., 'monthly', 'yearly')", + example: "monthly", + }), + has_trial: z.boolean().nullish().meta({ + description: "True if the product includes a free trial", + example: true, + }), + updateable: z.boolean().nullish().meta({ + description: + "True if the product can be updated after creation (only applicable if there are prepaid recurring prices)", + example: true, + }), }); -export const APIProductSchema = z.object({ - id: z.string(), - name: z.string(), - group: z.string().nullable(), +export const APIProductSchema = z + .object({ + id: z.string().meta({ + description: "The ID of the product you set when creating the product", + example: "pro_plan", + }), - env: z.enum(AppEnv), - is_add_on: z.boolean(), - is_default: z.boolean(), - version: z.number(), - created_at: z.number(), + name: z.string().meta({ + description: "The name of the product", + example: "Pro Plan", + }), - items: z.array(ProductItemResponseSchema), - free_trial: APIFreeTrial.nullable(), - base_variant_id: z.string().nullable(), + group: z.string().nullable().meta({ + description: "The group of the product", + example: "product_set_1", + }), - scenario: z.enum(AttachScenario).optional(), - properties: APIProductPropertiesSchema.optional(), - archived: z.boolean().optional(), -}); + env: z.enum(AppEnv).meta({ + description: "The environment of the product", + example: "production", + }), + + is_add_on: z.boolean().meta({ + description: + "Whether the product is an add-on and can be purchased alongside other products", + example: true, + }), + + is_default: z.boolean().meta({ + description: "Whether the product is the default product", + example: true, + }), + + archived: z.boolean({ message: "archived should be a boolean" }).meta({ + description: + "Whether this product has been archived and is no longer available", + example: false, + }), + + version: z.number().meta({ + description: "The version of the product", + example: 1, + }), + + created_at: z.number().meta({ + description: + "The timestamp of when the product was created in milliseconds since epoch", + example: 1759247877000, + }), + + items: z.array(APIProductItemSchema).meta({ + description: + "Array of product items that define the features and pricing", + example: [ + { + feature_id: "", + feature_type: "single_use", + included_usage: 123, + interval: "", + usage_model: "prepaid", + price: 123, + billing_units: 123, + entity_feature_id: "", + reset_usage_when_enabled: true, + tiers: [ + { + to: 123, + amount: 123, + }, + ], + }, + ], + }), + + free_trial: APIFreeTrialSchema.nullable().meta({ + description: "Free trial configuration for this product, if available", + example: { + duration: "", + length: 123, + unique_fingerprint: true, + }, + }), + + base_variant_id: z.string().nullable().meta({ + description: "ID of the base variant this product is derived from", + example: "var_1234567890abcdef", + }), + + scenario: z.enum(AttachScenario).optional().meta({ + description: + "Scenario context for when this product is used in attach flows", + example: "upgrade", + }), + + properties: APIProductPropertiesSchema.optional().meta({ + description: "Additional properties and metadata for the product", + example: { + is_free: false, + is_one_off: false, + interval_group: "monthly", + has_trial: true, + updateable: true, + }, + }), + }) + .meta({ + description: "A product", + id: "Product", + }); export type APIProduct = z.infer; export type APIProductProperties = z.infer; -// export type ProductResponse = z.infer; diff --git a/shared/api/products/apiProductItem.ts b/shared/api/products/apiProductItem.ts index 73ee1948e..b9dc122e3 100644 --- a/shared/api/products/apiProductItem.ts +++ b/shared/api/products/apiProductItem.ts @@ -9,35 +9,47 @@ import { } from "@models/productV2Models/productItemModels/productItemModels.js"; import { z } from "zod/v4"; -export const APIProductItemSchema = z.object({ - // Feature stuff - type: z.enum(ProductItemType).nullish(), - feature_id: z.string().nullish(), - feature_type: z.enum(ProductItemFeatureType).nullish(), +export const APIProductItemSchema = z + .object({ + // Feature stuff + type: z.enum(ProductItemType).nullish(), + feature_id: z.string().nullish(), + feature_type: z.enum(ProductItemFeatureType).nullish(), - // Feature response - feature: APIFeatureSchema.nullish(), + // Feature response + feature: APIFeatureSchema.nullish(), - included_usage: z.number().or(z.literal(Infinite)).nullish(), - interval: z.enum(ProductItemInterval).nullish(), - interval_count: z.number().nullish(), + included_usage: z.number().or(z.literal(Infinite)).nullish(), + interval: z.enum(ProductItemInterval).nullish(), + interval_count: z.number().nullish(), - // Price config - price: z.number().nullish(), - tiers: z.array(PriceTierSchema).nullish(), - usage_model: z.enum(UsageModel).nullish(), - billing_units: z.number().nullish(), // amount per billing unit (eg. $9 / 250 units) - reset_usage_when_enabled: z.boolean().nullish(), - quantity: z.number().nullish(), - next_cycle_quantity: z.number().nullish(), - entity_feature_id: z.string().nullish(), + // Price config + price: z.number().nullish(), + tiers: z.array(PriceTierSchema).nullish(), + usage_model: z.enum(UsageModel).nullish(), + billing_units: z.number().nullish(), // amount per billing unit (eg. $9 / 250 units) + reset_usage_when_enabled: z.boolean().nullish(), + quantity: z.number().nullish(), + next_cycle_quantity: z.number().nullish(), + entity_feature_id: z.string().nullish(), - display: z - .object({ - primary_text: z.string(), - secondary_text: z.string().nullish(), - }) - .nullish(), -}); + display: z + .object({ + primary_text: z.string(), + secondary_text: z.string().nullish(), + }) + .nullish(), + }) + .meta({ + id: "ProductItem", + description: "A product item that defines a feature", + example: { + feature_id: "feature_1", + feature_type: "single_use", + included_usage: 123, + interval: "monthly", + usage_model: "prepaid", + }, + }); export type APIProductItem = z.infer; diff --git a/shared/api/products/operations/createProductParams.ts b/shared/api/products/operations/createProductParams.ts new file mode 100644 index 000000000..a98339a87 --- /dev/null +++ b/shared/api/products/operations/createProductParams.ts @@ -0,0 +1,15 @@ +import { idRegex } from "@utils/utils.js"; +import { z } from "zod/v4"; + +export const CreateProductParamsSchema = z.object({ + id: z.string().nonempty().regex(idRegex), + + name: z.string().refine((val) => val.length > 0, { + message: "name must be a non-empty string", + }), + + is_add_on: z.boolean().default(false), + is_default: z.boolean().default(false), + version: z.number().optional(), + group: z.string().default(""), +}); diff --git a/shared/api/products/productsOpenApi.ts b/shared/api/products/productsOpenApi.ts new file mode 100644 index 000000000..825bf2590 --- /dev/null +++ b/shared/api/products/productsOpenApi.ts @@ -0,0 +1,24 @@ +import { APIProductSchema } from "./apiProduct.js"; +import { CreateProductParamsSchema } from "./operations/createProductParams.js"; + +export const productOps = { + "/products": { + post: { + summary: "Create Product", + tags: ["products"], + requestBody: { + content: { + "application/json": { schema: CreateProductParamsSchema }, + }, + }, + responses: { + "200": { + description: "200 OK", + content: { + "application/json": { schema: APIProductSchema }, + }, + }, + }, + }, + }, +}; diff --git a/shared/index.ts b/shared/index.ts index 2856d6c8d..e8a1ff866 100644 --- a/shared/index.ts +++ b/shared/index.ts @@ -4,6 +4,7 @@ export { schemas }; // API MODELS export * from "./api/models.js"; +export * from "./api/operations.js"; // Auth Models export * from "./db/auth-schema.js"; @@ -38,9 +39,9 @@ export * from "./models/cusModels/cusExpand.js"; // 8. Customer Models export * from "./models/cusModels/cusModels.js"; +export * from "./models/cusModels/cusResModels/cusFeatureResponse.js"; export * from "./models/cusModels/cusResModels/cusProductResponse.js"; export * from "./models/cusModels/cusResModels/cusReferralsResponse.js"; - // Cus response export * from "./models/cusModels/cusResponseModels.js"; export * from "./models/cusModels/cusTable.js"; @@ -115,10 +116,10 @@ export * from "./models/productModels/productTable.js"; export * from "./models/productV2Models/productItemModels/featureItem.js"; export * from "./models/productV2Models/productItemModels/featurePriceItem.js"; export * from "./models/productV2Models/productItemModels/priceItem.js"; -export * from "./models/productV2Models/productItemModels/prodItemResponseModels.js"; + export * from "./models/productV2Models/productItemModels/productItemEnums.js"; export * from "./models/productV2Models/productItemModels/productItemModels.js"; -export * from "./models/productV2Models/productResponseModels.js"; + // 6. Product V2 Models export * from "./models/productV2Models/productV2Models.js"; export * from "./models/rewardModels/referralModels/referralCodeTable.js"; diff --git a/shared/models/attachModels/checkoutModels.ts b/shared/models/attachModels/checkoutModels.ts index ac016ca3b..6ca89d0a5 100644 --- a/shared/models/attachModels/checkoutModels.ts +++ b/shared/models/attachModels/checkoutModels.ts @@ -1,20 +1,21 @@ +import { APIProductSchema } from "@api/products/apiProduct.js"; +import { APIProductItemSchema } from "@api/products/apiProductItem.js"; + import { z } from "zod/v4"; import { FeatureOptionsSchema } from "../cusProductModels/cusProductModels.js"; -import { ProductItemResponseSchema } from "../productV2Models/productItemModels/prodItemResponseModels.js"; -import { ProductResponseSchema } from "../productV2Models/productResponseModels.js"; export const CheckoutLineSchema = z.object({ description: z.string(), amount: z.number(), - item: ProductItemResponseSchema.nullish(), + item: APIProductItemSchema.nullish(), }); export const CheckoutResponseSchema = z.object({ url: z.string().nullish(), customer_id: z.string(), lines: z.array(CheckoutLineSchema), - product: ProductResponseSchema.nullish(), - current_product: ProductResponseSchema.nullish(), + product: APIProductSchema.nullish(), + current_product: APIProductSchema.nullish(), options: z.array(FeatureOptionsSchema).nullish(), total: z.number().nullish(), currency: z.string().nullish(), diff --git a/shared/models/checkModels/checkPreviewModels.ts b/shared/models/checkModels/checkPreviewModels.ts index 3a030b009..13f082f86 100644 --- a/shared/models/checkModels/checkPreviewModels.ts +++ b/shared/models/checkModels/checkPreviewModels.ts @@ -1,6 +1,6 @@ +import type { APIProduct } from "@api/products/apiProduct.js"; import type { Infinite } from "../productModels/productEnums.js"; import type { UsageModel } from "../productV2Models/productItemModels/productItemModels.js"; -import type { ProductResponse } from "../productV2Models/productResponseModels.js"; export enum AttachScenario { Scheduled = "scheduled", @@ -59,7 +59,7 @@ export interface CheckProductPreview { currency: string; }; - product?: ProductResponse; + product?: APIProduct; payment_method?: any; } @@ -76,5 +76,5 @@ export interface CheckFeaturePreview { feature_id: string; feature_name: string; - products: ProductResponse[]; + products: APIProduct[]; } diff --git a/shared/models/cusModels/cusResModels/cusProductResponse.ts b/shared/models/cusModels/cusResModels/cusProductResponse.ts index cdb396199..4d19af496 100644 --- a/shared/models/cusModels/cusResModels/cusProductResponse.ts +++ b/shared/models/cusModels/cusResModels/cusProductResponse.ts @@ -1,6 +1,6 @@ +import { APIProductItemSchema } from "@api/models.js"; import { z } from "zod/v4"; import { CusProductStatus } from "../../cusProductModels/cusProductEnums.js"; -import { ProductItemResponseSchema } from "../../productV2Models/productItemModels/prodItemResponseModels.js"; export const CusProductResponseSchema = z.object({ id: z.string(), @@ -22,7 +22,7 @@ export const CusProductResponseSchema = z.object({ current_period_start: z.number().nullish(), current_period_end: z.number().nullish(), entity_id: z.string().nullish(), - items: z.array(ProductItemResponseSchema).nullish(), + items: z.array(APIProductItemSchema).nullish(), quantity: z.number().optional(), prepaid_quantities: z .array( diff --git a/shared/models/featureModels/featureResModels.ts b/shared/models/featureModels/featureResModels.ts deleted file mode 100644 index 55524d461..000000000 --- a/shared/models/featureModels/featureResModels.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { z } from "zod/v4"; - -export enum APIFeatureType { - Boolean = "boolean", - SingleUsage = "single_use", - ContinuousUse = "continuous_use", - CreditSystem = "credit_system", -} -export const APIFeatureSchema = z.object({ - id: z.string(), - name: z.string().nullish(), - type: z.nativeEnum(APIFeatureType), - display: z - .object({ - singular: z.string(), - plural: z.string(), - }) - .nullish(), - - credit_schema: z - .array( - z.object({ - metered_feature_id: z.string(), - credit_cost: z.number(), - }), - ) - .nullish(), - archived: z.boolean().nullish(), -}); - -export const UpdateAPIFeatureSchema = APIFeatureSchema.extend({ - archived: z.boolean().optional(), -}).partial(); - -export type APIFeature = z.infer; -export type UpdateAPIFeature = z.infer; diff --git a/shared/models/productModels/freeTrialModels/freeTrialModels.ts b/shared/models/productModels/freeTrialModels/freeTrialModels.ts index f07f8a985..1cf1f601f 100644 --- a/shared/models/productModels/freeTrialModels/freeTrialModels.ts +++ b/shared/models/productModels/freeTrialModels/freeTrialModels.ts @@ -23,15 +23,5 @@ export const CreateFreeTrialSchema = z.object({ card_required: z.boolean().default(true), }); -export const FreeTrialResponseSchema = z.object({ - // id: z.string(), - duration: z.nativeEnum(FreeTrialDuration), - length: z.number(), - unique_fingerprint: z.boolean(), - trial_available: z.boolean().nullish().default(true), - card_required: z.boolean().nullish(), -}); - export type FreeTrial = z.infer; export type CreateFreeTrial = z.infer; -export type FreeTrialResponse = z.infer; diff --git a/shared/models/productV2Models/productItemModels/prodItemResponseModels.ts b/shared/models/productV2Models/productItemModels/prodItemResponseModels.ts deleted file mode 100644 index 9dc0365d3..000000000 --- a/shared/models/productV2Models/productItemModels/prodItemResponseModels.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { z } from "zod/v4"; -import { APIFeatureSchema } from "../../featureModels/featureResModels.js"; -import { Infinite } from "../../productModels/productEnums.js"; -import { - PriceTierSchema, - ProductItemFeatureType, - ProductItemInterval, - ProductItemType, - UsageModel, -} from "./productItemModels.js"; - -export const ProductItemResponseSchema = z.object({ - // Feature stuff - type: z.nativeEnum(ProductItemType).nullish(), - feature_id: z.string().nullish(), - feature_type: z.nativeEnum(ProductItemFeatureType).nullish(), - - // Feature response - feature: APIFeatureSchema.nullish(), - - included_usage: z.number().or(z.literal(Infinite)).nullish(), - interval: z.nativeEnum(ProductItemInterval).nullish(), - interval_count: z.number().nullish(), - - // Price config - price: z.number().nullish(), - tiers: z.array(PriceTierSchema).nullish(), - usage_model: z.nativeEnum(UsageModel).nullish(), - billing_units: z.number().nullish(), // amount per billing unit (eg. $9 / 250 units) - reset_usage_when_enabled: z.boolean().nullish(), - quantity: z.number().nullish(), - next_cycle_quantity: z.number().nullish(), - entity_feature_id: z.string().nullish(), - - display: z - .object({ - primary_text: z.string(), - secondary_text: z.string().nullish(), - }) - .nullish(), -}); - -export type ProductItemResponse = z.infer; diff --git a/shared/models/productV2Models/productResponseModels.ts b/shared/models/productV2Models/productResponseModels.ts deleted file mode 100644 index db286c361..000000000 --- a/shared/models/productV2Models/productResponseModels.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { z } from "zod/v4"; -import { AttachScenario } from "../checkModels/checkPreviewModels.js"; -import { AppEnv } from "../genModels/genEnums.js"; -import { FreeTrialResponseSchema } from "../productModels/freeTrialModels/freeTrialModels.js"; -import { ProductItemResponseSchema } from "./productItemModels/prodItemResponseModels.js"; - -export const ProductPropertiesSchema = z.object({ - is_free: z.boolean(), - is_one_off: z.boolean(), - interval_group: z.string().nullish(), - has_trial: z.boolean().nullish(), - updateable: z.boolean().nullish(), -}); - -export const ProductResponseSchema = z.object({ - id: z.string(), - name: z.string().nullable(), - group: z.string().nullable(), - env: z.nativeEnum(AppEnv), - is_add_on: z.boolean(), - is_default: z.boolean(), - version: z.number(), - created_at: z.number(), - - items: z.array(ProductItemResponseSchema), - free_trial: FreeTrialResponseSchema.nullable(), - base_variant_id: z.string().nullable(), - - scenario: z.nativeEnum(AttachScenario).optional(), - properties: ProductPropertiesSchema.optional(), - archived: z.boolean().optional(), -}); - -export type ProductResponse = z.infer; -export type ProductProperties = z.infer; diff --git a/shared/openapi.yaml b/shared/openapi.yaml index 0640ee7ed..17e82320a 100644 --- a/shared/openapi.yaml +++ b/shared/openapi.yaml @@ -1,1454 +1,401 @@ openapi: 3.1.0 info: - title: My API - version: 1.0.0 + title: Autumn API + version: 1.2.0 servers: - url: https://api.useautumn.com description: Production server security: - secretKey: [] paths: - /core/attach: + /products: post: - summary: Attach Product + summary: Create Product tags: - - core - x-speakeasy-name-override: attach + - products requestBody: content: application/json: schema: type: object properties: - customer_id: - description: ID of the customer to attach the product to + id: type: string - customer_data: - description: Customer data if using attach to auto create customer - anyOf: - - {} - - type: "null" - entity_id: - anyOf: - - type: string - - type: "null" - entity_data: - anyOf: - - {} - - type: "null" - product_id: - anyOf: - - type: string - - type: "null" - product_ids: - anyOf: - - minItems: 1 - type: array - items: - type: string - - type: "null" - options: - anyOf: - - type: array - items: - type: object - properties: - feature_id: - type: string - quantity: - type: number - upcoming_quantity: - anyOf: - - type: number - - type: "null" - adjustable_quantity: - anyOf: - - type: boolean - - type: "null" - internal_feature_id: - anyOf: - - type: string - - type: "null" - required: - - feature_id - - quantity - - type: "null" - products: - anyOf: - - type: array - items: - type: object - properties: - product_id: - type: string - quantity: - anyOf: - - type: number - - type: "null" - entity_id: - anyOf: - - type: string - - type: "null" - options: - anyOf: - - type: array - items: - type: object - properties: - feature_id: - type: string - quantity: - type: number - upcoming_quantity: - anyOf: - - type: number - - type: "null" - adjustable_quantity: - anyOf: - - type: boolean - - type: "null" - internal_feature_id: - anyOf: - - type: string - - type: "null" - required: - - feature_id - - quantity - - type: "null" - required: - - product_id - - type: "null" - is_custom: + minLength: 1 + pattern: ^[a-z0-9]+$ + name: + type: string + minLength: 1 + is_add_on: + default: false + type: boolean + is_default: + default: false type: boolean - items: - type: array - items: - type: object - properties: - feature_id: - anyOf: - - type: string - - type: "null" - feature_type: - anyOf: - - type: string - enum: - - single_use - - continuous_use - - boolean - - static - - type: "null" - included_usage: - anyOf: - - anyOf: - - type: number - - type: string - const: inf - - type: "null" - interval: - anyOf: - - type: string - enum: - - minute - - hour - - day - - week - - month - - quarter - - semi_annual - - year - - type: "null" - interval_count: - anyOf: - - type: number - - type: "null" - entity_feature_id: - anyOf: - - type: string - - type: "null" - usage_model: - anyOf: - - type: string - enum: - - prepaid - - pay_per_use - - type: "null" - price: - anyOf: - - type: number - - type: "null" - tiers: - anyOf: - - type: array - items: - type: object - properties: - to: - anyOf: - - type: number - - type: string - const: inf - amount: - type: number - required: - - to - - amount - - type: "null" - billing_units: - anyOf: - - type: number - - type: "null" - usage_limit: - anyOf: - - type: number - - type: "null" - reset_usage_when_enabled: - anyOf: - - type: boolean - - type: "null" - config: - anyOf: - - type: object - properties: - on_increase: - anyOf: - - type: string - enum: - - bill_immediately - - prorate_immediately - - prorate_next_cycle - - bill_next_cycle - - type: "null" - on_decrease: - anyOf: - - type: string - enum: - - prorate - - prorate_immediately - - prorate_next_cycle - - none - - no_prorations - - type: "null" - rollover: - anyOf: - - type: object - properties: - max: - anyOf: - - type: number - - type: "null" - duration: - default: month - type: string - enum: - - month - - forever - length: - type: number - required: - - max - - length - - type: "null" - - type: "null" - created_at: - anyOf: - - type: number - - type: "null" - entitlement_id: - anyOf: - - type: string - - type: "null" - price_id: - anyOf: - - type: string - - type: "null" - price_config: - anyOf: - - {} - - type: "null" - free_trial: - anyOf: - - type: object - properties: - length: - anyOf: - - type: string - - type: number - unique_fingerprint: - default: false - type: boolean - duration: - default: day - type: string - enum: - - day - - month - - year - card_required: - default: true - type: boolean - required: - - length - - type: boolean version: + default: 1 type: number - success_url: + group: + default: "" type: string - force_checkout: - type: boolean - invoice_only: - type: boolean - metadata: {} - billing_cycle_anchor: - type: number - checkout_session_params: {} - reward: - anyOf: - - type: string - - type: array - items: - type: string - invoice: - type: boolean - enable_product_immediately: - type: boolean - finalize_invoice: - type: boolean - skip_checkout: - type: boolean - setup_payment: - type: boolean required: - - customer_id + - id + - name responses: "200": description: 200 OK content: application/json: schema: - $ref: "#/components/schemas/AttachResult" - /customers/{customer_id}: - get: - summary: Get customer - x-speakeasy-name-override: get - tags: - - customers - parameters: - - in: path - name: customer_id - schema: - $ref: "#/components/schemas/customer_id" - required: true - description: Your internal ID for the customer - responses: - "200": - description: 200 OK - content: - application/json: - schema: - $ref: "#/components/schemas/Customer" - /customers/{customer_id}/features/{feature_id}: - patch: - summary: Update customer feature - tags: - - customer.features - x-speakeasy-name-override: update - parameters: - - in: path - name: customer_id - schema: - $ref: "#/components/schemas/customer_id" - required: true - description: Your internal ID for the customer - - in: path - name: feature_id - schema: - $ref: "#/components/schemas/feature_id" - required: true - description: Feature ID as defined in the dashboard (eg. 'messages') - requestBody: - content: - application/json: - schema: - type: object - properties: - usage: - type: number - required: - - usage - responses: - "200": - description: 200 OK - content: - application/json: - schema: - type: object - properties: - usage: - type: number - required: - - usage - additionalProperties: false + $ref: "#/components/schemas/Product" components: schemas: - customer_id: - description: Your internal ID for the customer - example: cus_123 - type: string - feature_id: - description: Feature ID as defined in the dashboard (eg. 'messages') - example: messages - type: string - AttachResult: + Product: + description: A product type: object properties: - message: - $ref: "#/components/schemas/message" - product_ids: - $ref: "#/components/schemas/product_ids" - customer_id: - $ref: "#/components/schemas/customer_id" - required: - - message - - product_ids - - customer_id - additionalProperties: false - message: - description: A short description on the result of the operation - example: Successfully downgraded from Product A to Product B - type: string - product_ids: - description: The IDs of the products that were attached - example: - - pro - - one_off - type: array - items: - type: string - Customer: - type: object - properties: - autumn_id: - anyOf: - - type: string - - type: "null" id: - description: Your internal ID for the customer - example: cus_123 - anyOf: - - type: string - - type: "null" - created_at: - description: The date and time the customer was created in milliseconds since - epoch - example: 1717000000 - type: number + description: The ID of the product you set when creating the product + example: pro_plan + type: string name: - anyOf: - - type: string - - type: "null" - email: - anyOf: - - type: string - - type: "null" - fingerprint: - anyOf: - - type: string - - type: "null" - stripe_id: - default: null + description: The name of the product + example: Pro Plan + type: string + group: + description: The group of the product + example: product_set_1 anyOf: - type: string - type: "null" env: + description: The environment of the product + example: production type: string enum: - sandbox - live - products: + is_add_on: + description: Whether the product is an add-on and can be purchased alongside + other products + example: true + type: boolean + is_default: + description: Whether the product is the default product + example: true + type: boolean + archived: + description: Whether this product has been archived and is no longer available + example: false + type: boolean + version: + description: The version of the product + example: 1 + type: number + created_at: + description: The timestamp of when the product was created in milliseconds since + epoch + example: 1759247877000 + type: number + items: + description: Array of product items that define the features and pricing + example: + - feature_id: + feature_type: single_use + included_usage: 123 + interval: + usage_model: prepaid + price: 123 + billing_units: 123 + entity_feature_id: + reset_usage_when_enabled: true + tiers: + - to: 123 + amount: 123 type: array items: - type: object - properties: - id: - type: string - name: - anyOf: - - type: string - - type: "null" - group: - anyOf: - - type: string - - type: "null" - status: - type: string - enum: - - scheduled - - active - - past_due - - expired - - unknown - - trialing - canceled: - anyOf: - - type: boolean - - type: "null" - trialing: - anyOf: - - type: boolean - - type: "null" - canceled_at: - anyOf: - - type: number - - type: "null" - started_at: - type: number - is_default: - type: boolean - is_add_on: - type: boolean - version: - anyOf: - - type: number - - type: "null" - stripe_subscription_ids: - anyOf: - - type: array - items: - type: string - - type: "null" - current_period_start: - anyOf: - - type: number - - type: "null" - current_period_end: - anyOf: - - type: number - - type: "null" - entity_id: - anyOf: - - type: string - - type: "null" - items: - anyOf: - - type: array - items: - type: object - properties: - type: - anyOf: - - type: string - enum: - - feature - - priced_feature - - price - - type: "null" - feature_id: - anyOf: - - type: string - - type: "null" - feature_type: - anyOf: - - type: string - enum: - - single_use - - continuous_use - - boolean - - static - - type: "null" - feature: - anyOf: - - type: object - properties: - id: - type: string - name: - anyOf: - - type: string - - type: "null" - type: - type: string - enum: - - boolean - - single_use - - continuous_use - - credit_system - display: - anyOf: - - type: object - properties: - singular: - type: string - plural: - type: string - required: - - singular - - plural - additionalProperties: false - - type: "null" - credit_schema: - anyOf: - - type: array - items: - type: object - properties: - metered_feature_id: - type: string - credit_cost: - type: number - required: - - metered_feature_id - - credit_cost - additionalProperties: false - - type: "null" - archived: - anyOf: - - type: boolean - - type: "null" - required: - - id - - type - additionalProperties: false - - type: "null" - included_usage: - anyOf: - - anyOf: - - type: number - - type: string - const: inf - - type: "null" - interval: - anyOf: - - type: string - enum: - - minute - - hour - - day - - week - - month - - quarter - - semi_annual - - year - - type: "null" - interval_count: - anyOf: - - type: number - - type: "null" - price: - anyOf: - - type: number - - type: "null" - tiers: - anyOf: - - type: array - items: - type: object - properties: - to: - anyOf: - - type: number - - type: string - const: inf - amount: - type: number - required: - - to - - amount - additionalProperties: false - - type: "null" - usage_model: - anyOf: - - type: string - enum: - - prepaid - - pay_per_use - - type: "null" - billing_units: - anyOf: - - type: number - - type: "null" - reset_usage_when_enabled: - anyOf: - - type: boolean - - type: "null" - quantity: - anyOf: - - type: number - - type: "null" - next_cycle_quantity: - anyOf: - - type: number - - type: "null" - entity_feature_id: - anyOf: - - type: string - - type: "null" - display: - anyOf: - - type: object - properties: - primary_text: - type: string - secondary_text: - anyOf: - - type: string - - type: "null" - required: - - primary_text - additionalProperties: false - - type: "null" - additionalProperties: false - - type: "null" - quantity: - type: number - prepaid_quantities: - anyOf: - - type: array - items: - type: object - properties: - quantity: - type: number - feature_id: - type: string - required: - - quantity - - feature_id - additionalProperties: false - - type: "null" - required: - - id - - name - - group - - status - - started_at - - is_default - - is_add_on - additionalProperties: false - features: {} - invoices: - type: array - items: - type: object - properties: - product_ids: - type: array - items: - type: string - stripe_id: - type: string - status: - type: string - total: - type: number - currency: - type: string - created_at: - type: number - hosted_invoice_url: - anyOf: - - type: string - - type: "null" - required: - - product_ids - - stripe_id - - status - - total - - currency - - created_at - additionalProperties: false - trials_used: - type: array - items: - type: object - properties: - product_id: - type: string - customer_id: - type: string - fingerprint: - anyOf: - - type: string - - type: "null" - required: - - product_id - - customer_id - additionalProperties: false - rewards: + $ref: "#/components/schemas/ProductItem" + free_trial: + description: Free trial configuration for this product, if available + example: + duration: + length: 123 + unique_fingerprint: true anyOf: - type: object properties: - discounts: - type: array - items: - type: object - properties: - id: - type: string - name: - type: string - type: - type: string - enum: - - percentage_discount - - fixed_discount - - free_product - - invoice_credits - discount_value: - type: number - duration_type: - type: string - enum: - - one_off - - months - - forever - duration_value: - anyOf: - - type: number - - type: "null" - currency: - anyOf: - - type: string - - type: "null" - start: - anyOf: - - type: number - - type: "null" - end: - anyOf: - - type: number - - type: "null" - subscription_id: - anyOf: - - type: string - - type: "null" - total_discount_amount: - anyOf: - - type: number - - type: "null" - required: - - id - - name - - type - - discount_value - - duration_type - additionalProperties: false + duration: + type: string + enum: + - day + - month + - year + length: + type: number + unique_fingerprint: + type: boolean + card_required: + type: boolean + trial_available: + default: true + anyOf: + - type: boolean + - type: "null" required: - - discounts + - duration + - length + - unique_fingerprint + - card_required + - trial_available additionalProperties: false - type: "null" - metadata: - default: {} + base_variant_id: + description: ID of the base variant this product is derived from + example: var_1234567890abcdef + anyOf: + - type: string + - type: "null" + scenario: + description: Scenario context for when this product is used in attach flows + example: upgrade + type: string + enum: + - scheduled + - active + - new + - renew + - upgrade + - downgrade + - cancel + - expired + properties: + description: Additional properties and metadata for the product + example: + is_free: false + is_one_off: false + interval_group: monthly + has_trial: true + updateable: true type: object - propertyNames: {} - additionalProperties: {} - entities: - type: array - items: - type: object - properties: - id: - anyOf: - - type: string - - type: "null" - name: - anyOf: - - type: string - - type: "null" - customer_id: - type: string - feature_id: - anyOf: - - type: string - - type: "null" - created_at: - type: number - env: - type: string - enum: - - sandbox - - live - products: - type: array - items: - type: object - properties: - id: - type: string - name: - anyOf: - - type: string - - type: "null" - group: - anyOf: - - type: string - - type: "null" - status: - type: string - enum: - - scheduled - - active - - past_due - - expired - - unknown - - trialing - canceled: - anyOf: - - type: boolean - - type: "null" - trialing: - anyOf: - - type: boolean - - type: "null" - canceled_at: - anyOf: - - type: number - - type: "null" - started_at: - type: number - is_default: - type: boolean - is_add_on: - type: boolean - version: - anyOf: - - type: number - - type: "null" - stripe_subscription_ids: - anyOf: - - type: array - items: - type: string - - type: "null" - current_period_start: - anyOf: - - type: number - - type: "null" - current_period_end: - anyOf: - - type: number - - type: "null" - entity_id: - anyOf: - - type: string - - type: "null" - items: - anyOf: - - type: array - items: - type: object - properties: - type: - anyOf: - - type: string - enum: - - feature - - priced_feature - - price - - type: "null" - feature_id: - anyOf: - - type: string - - type: "null" - feature_type: - anyOf: - - type: string - enum: - - single_use - - continuous_use - - boolean - - static - - type: "null" - feature: - anyOf: - - type: object - properties: - id: - type: string - name: - anyOf: - - type: string - - type: "null" - type: - type: string - enum: - - boolean - - single_use - - continuous_use - - credit_system - display: - anyOf: - - type: object - properties: - singular: - type: string - plural: - type: string - required: - - singular - - plural - additionalProperties: false - - type: "null" - credit_schema: - anyOf: - - type: array - items: - type: object - properties: - metered_feature_id: - type: string - credit_cost: - type: number - required: - - metered_feature_id - - credit_cost - additionalProperties: false - - type: "null" - archived: - anyOf: - - type: boolean - - type: "null" - required: - - id - - type - additionalProperties: false - - type: "null" - included_usage: - anyOf: - - anyOf: - - type: number - - type: string - const: inf - - type: "null" - interval: - anyOf: - - type: string - enum: - - minute - - hour - - day - - week - - month - - quarter - - semi_annual - - year - - type: "null" - interval_count: - anyOf: - - type: number - - type: "null" - price: - anyOf: - - type: number - - type: "null" - tiers: - anyOf: - - type: array - items: - type: object - properties: - to: - anyOf: - - type: number - - type: string - const: inf - amount: - type: number - required: - - to - - amount - additionalProperties: false - - type: "null" - usage_model: - anyOf: - - type: string - enum: - - prepaid - - pay_per_use - - type: "null" - billing_units: - anyOf: - - type: number - - type: "null" - reset_usage_when_enabled: - anyOf: - - type: boolean - - type: "null" - quantity: - anyOf: - - type: number - - type: "null" - next_cycle_quantity: - anyOf: - - type: number - - type: "null" - entity_feature_id: - anyOf: - - type: string - - type: "null" - display: - anyOf: - - type: object - properties: - primary_text: - type: string - secondary_text: - anyOf: - - type: string - - type: "null" - required: - - primary_text - additionalProperties: false - - type: "null" - additionalProperties: false - - type: "null" - quantity: - type: number - prepaid_quantities: - anyOf: - - type: array - items: - type: object - properties: - quantity: - type: number - feature_id: - type: string - required: - - quantity - - feature_id - additionalProperties: false - - type: "null" - required: - - id - - name - - group - - status - - started_at - - is_default - - is_add_on - additionalProperties: false - features: - type: object - propertyNames: - type: string - additionalProperties: - type: object - properties: - id: - type: string - type: - type: string - enum: - - single_use - - continuous_use - - boolean - - static - name: - anyOf: - - type: string - - type: "null" - interval: - anyOf: - - anyOf: - - type: string - enum: - - lifetime - - minute - - hour - - day - - week - - month - - quarter - - semi_annual - - year - - type: string - const: multiple - - type: "null" - interval_count: - anyOf: - - type: number - - type: "null" - unlimited: - anyOf: - - type: boolean - - type: "null" - balance: - anyOf: - - type: number - - type: "null" - usage: - anyOf: - - type: number - - type: "null" - included_usage: - anyOf: - - type: number - - type: "null" - next_reset_at: - anyOf: - - type: number - - type: "null" - overage_allowed: - anyOf: - - type: boolean - - type: "null" - breakdown: - anyOf: - - type: array - items: - type: object - properties: - interval: - type: string - enum: - - lifetime - - minute - - hour - - day - - week - - month - - quarter - - semi_annual - - year - interval_count: - anyOf: - - type: number - - type: "null" - balance: - anyOf: - - type: number - - type: "null" - usage: - anyOf: - - type: number - - type: "null" - included_usage: - anyOf: - - type: number - - type: "null" - next_reset_at: - anyOf: - - type: number - - type: "null" - required: - - interval - additionalProperties: false - - type: "null" - credit_schema: - anyOf: - - type: array - items: - type: object - properties: - feature_id: - type: string - credit_amount: - type: number - required: - - feature_id - - credit_amount - additionalProperties: false - - type: "null" - usage_limit: - anyOf: - - type: number - - type: "null" - rollovers: - anyOf: - - type: array - items: - type: object - properties: - balance: - type: number - expires_at: - type: number - required: - - balance - - expires_at - additionalProperties: false - - type: "null" - required: - - id - - type - additionalProperties: false - invoices: - type: array - items: - type: object - properties: - product_ids: - type: array - items: - type: string - stripe_id: - type: string - status: - type: string - total: - type: number - currency: - type: string - created_at: - type: number - hosted_invoice_url: - anyOf: - - type: string - - type: "null" - required: - - product_ids - - stripe_id - - status - - total - - currency - - created_at - additionalProperties: false - required: - - id - - name - - customer_id - - created_at - - env - additionalProperties: false - referrals: - type: array - items: - type: object - properties: - program_id: - type: string - customer: - type: object - properties: - id: - type: string - name: - anyOf: - - type: string - - type: "null" - email: - anyOf: - - type: string - - type: "null" - required: - - id - additionalProperties: false - reward_applied: - type: boolean - created_at: - type: number - required: - - program_id - - customer - - reward_applied - - created_at - additionalProperties: false - payment_method: - anyOf: - - {} - - type: "null" - upcoming_invoice: - anyOf: - - type: object - properties: - lines: - type: array - items: - type: object - properties: - product_id: - anyOf: - - type: string - - type: "null" - description: - type: string - amount: - type: number - required: - - description - - amount - additionalProperties: false - discounts: - type: array - items: - type: object - properties: - id: - type: string - name: - type: string - type: - type: string - enum: - - percentage_discount - - fixed_discount - - free_product - - invoice_credits - discount_value: - type: number - duration_type: - type: string - enum: - - one_off - - months - - forever - duration_value: - anyOf: - - type: number - - type: "null" - currency: - anyOf: - - type: string - - type: "null" - start: - anyOf: - - type: number - - type: "null" - end: - anyOf: - - type: number - - type: "null" - subscription_id: - anyOf: - - type: string - - type: "null" - total_discount_amount: - anyOf: - - type: number - - type: "null" - required: - - id - - name - - type - - discount_value - - duration_type - additionalProperties: false - subtotal: - type: number - total: - type: number - currency: - type: string - required: - - lines - - discounts - - subtotal - - total - - currency - additionalProperties: false - - type: "null" + properties: + is_free: + description: True if the product has no base price or usage prices + example: false + type: boolean + is_one_off: + description: True if the product only contains a one-time price + example: false + type: boolean + interval_group: + description: The billing interval group for recurring products (e.g., 'monthly', + 'yearly') + example: monthly + anyOf: + - type: string + - type: "null" + has_trial: + description: True if the product includes a free trial + example: true + anyOf: + - type: boolean + - type: "null" + updateable: + description: True if the product can be updated after creation (only applicable + if there are prepaid recurring prices) + example: true + anyOf: + - type: boolean + - type: "null" + required: + - is_free + - is_one_off + additionalProperties: false required: - id - - created_at - name - - email - - fingerprint - - stripe_id + - group - env - - products - - features - - metadata + - is_add_on + - is_default + - archived + - version + - created_at + - items + - free_trial + - base_variant_id + additionalProperties: false + ProductItem: + description: A product item that defines a feature + example: + feature_id: feature_1 + feature_type: single_use + included_usage: 123 + interval: monthly + usage_model: prepaid + type: object + properties: + type: + anyOf: + - type: string + enum: + - feature + - priced_feature + - price + - type: "null" + feature_id: + anyOf: + - type: string + - type: "null" + feature_type: + anyOf: + - type: string + enum: + - single_use + - continuous_use + - boolean + - static + - type: "null" + feature: + anyOf: + - type: object + properties: + id: + type: string + name: + anyOf: + - type: string + - type: "null" + type: + type: string + enum: + - boolean + - single_use + - continuous_use + - credit_system + display: + anyOf: + - type: object + properties: + singular: + type: string + plural: + type: string + required: + - singular + - plural + additionalProperties: false + - type: "null" + credit_schema: + anyOf: + - type: array + items: + type: object + properties: + metered_feature_id: + type: string + credit_cost: + type: number + required: + - metered_feature_id + - credit_cost + additionalProperties: false + - type: "null" + archived: + anyOf: + - type: boolean + - type: "null" + required: + - id + - type + additionalProperties: false + - type: "null" + included_usage: + anyOf: + - anyOf: + - type: number + - type: string + const: inf + - type: "null" + interval: + anyOf: + - type: string + enum: + - minute + - hour + - day + - week + - month + - quarter + - semi_annual + - year + - type: "null" + interval_count: + anyOf: + - type: number + - type: "null" + price: + anyOf: + - type: number + - type: "null" + tiers: + anyOf: + - type: array + items: + type: object + properties: + to: + anyOf: + - type: number + - type: string + const: inf + amount: + type: number + required: + - to + - amount + additionalProperties: false + - type: "null" + usage_model: + anyOf: + - type: string + enum: + - prepaid + - pay_per_use + - type: "null" + billing_units: + anyOf: + - type: number + - type: "null" + reset_usage_when_enabled: + anyOf: + - type: boolean + - type: "null" + quantity: + anyOf: + - type: number + - type: "null" + next_cycle_quantity: + anyOf: + - type: number + - type: "null" + entity_feature_id: + anyOf: + - type: string + - type: "null" + display: + anyOf: + - type: object + properties: + primary_text: + type: string + secondary_text: + anyOf: + - type: string + - type: "null" + required: + - primary_text + additionalProperties: false + - type: "null" additionalProperties: false securitySchemes: secretKey: diff --git a/shared/package.json b/shared/package.json index 30d788825..08c4e6d10 100644 --- a/shared/package.json +++ b/shared/package.json @@ -13,7 +13,7 @@ "author": "Recase Inc.", "license": "Apache-2.0", "scripts": { - "build:tsc": "tsc && bun api/customer.ts", + "build:tsc": "tsc && bun api/openapi.ts", "build": "bun build ./index.ts --outdir dist --format esm --target bun --external zod", "dev": "bunx nodemon --ext ts --ignore dist --exec \"bun run build && bun run dev:dts\"", "dev:dts": "tsc --emitDeclarationOnly --outDir dist --project tsconfig.json", diff --git a/shared/tsconfig.json b/shared/tsconfig.json index 4b409fd89..3a762c748 100644 --- a/shared/tsconfig.json +++ b/shared/tsconfig.json @@ -13,7 +13,8 @@ "rootDir": ".", "paths": { "@api/*": ["./api/*"], - "@models/*": ["./models/*"] + "@models/*": ["./models/*"], + "@utils/*": ["./utils/*"] } }, "include": ["./**/*"], diff --git a/shared/utils/featureUtils.ts b/shared/utils/featureUtils.ts index 3d80677a3..13f440437 100644 --- a/shared/utils/featureUtils.ts +++ b/shared/utils/featureUtils.ts @@ -1,18 +1,18 @@ -import { CreditSchemaItem } from "../models/featureModels/featureConfig/creditConfig.js"; +import { APIFeatureSchema } from "@api/features/apiFeature.js"; +import type { CreditSchemaItem } from "../models/featureModels/featureConfig/creditConfig.js"; import { FeatureType } from "../models/featureModels/featureEnums.js"; -import { Feature } from "../models/featureModels/featureModels.js"; -import { APIFeatureSchema } from "../models/featureModels/featureResModels.js"; +import type { Feature } from "../models/featureModels/featureModels.js"; export const toAPIFeature = ({ feature }: { feature: Feature }) => { // return FeatureResponseSchema.parse(feature); // 1. Get feature type let featureType = feature.type; - if (feature.type == FeatureType.Metered) { + if (feature.type === FeatureType.Metered) { featureType = feature.config.usage_type; } - let creditSchema = undefined; - if (feature.type == FeatureType.CreditSystem) { + let creditSchema: CreditSchemaItem[] | undefined; + if (feature.type === FeatureType.CreditSystem) { creditSchema = feature.config.schema.map((s: CreditSchemaItem) => ({ metered_feature_id: s.metered_feature_id, credit_cost: s.credit_amount, diff --git a/shared/utils/productDisplayUtils/getProductItemRes.ts b/shared/utils/productDisplayUtils/getProductItemRes.ts index 77edb6f5e..d528351fd 100644 --- a/shared/utils/productDisplayUtils/getProductItemRes.ts +++ b/shared/utils/productDisplayUtils/getProductItemRes.ts @@ -1,15 +1,15 @@ -import { FeatureOptions } from "../../models/cusProductModels/cusProductModels.js"; -import { Feature } from "../../models/featureModels/featureModels.js"; -import { ProductItemResponseSchema } from "../../models/productV2Models/productItemModels/prodItemResponseModels.js"; +import { APIProductItemSchema } from "@api/products/apiProductItem.js"; +import { Decimal } from "decimal.js"; +import type { FeatureOptions } from "../../models/cusProductModels/cusProductModels.js"; +import type { Feature } from "../../models/featureModels/featureModels.js"; import { - ProductItem, + type ProductItem, UsageModel, } from "../../models/productV2Models/productItemModels/productItemModels.js"; import { toAPIFeature } from "../featureUtils.js"; import { getProductItemDisplay } from "../productDisplayUtils.js"; import { notNullish } from "../utils.js"; import { getItemType } from "./getItemType.js"; -import { Decimal } from "decimal.js"; export const calculateProrationAmount = ({ periodEnd, @@ -109,22 +109,22 @@ export const getProductItemResponse = ({ options?: FeatureOptions[]; }) => { // 1. Get item type - let type = getItemType(item); + const type = getItemType(item); // 2. Get display - let display = getProductItemDisplay({ + const display = getProductItemDisplay({ item, features, currency, }); - let priceData = itemToPriceOrTiers({ item }); + const priceData = itemToPriceOrTiers({ item }); - let quantity = undefined; - let upcomingQuantity = undefined; + let quantity: number | undefined; + let upcomingQuantity: number | undefined; - if (item.usage_model == UsageModel.Prepaid && notNullish(options)) { - let option = options!.find((o) => o.feature_id == item.feature_id); + if (item.usage_model === UsageModel.Prepaid && notNullish(options)) { + const option = options!.find((o) => o.feature_id === item.feature_id); quantity = option?.quantity ? option?.quantity * (item.billing_units ?? 1) : undefined; @@ -134,8 +134,8 @@ export const getProductItemResponse = ({ : undefined; } - let feature = features.find((f) => f.id == item.feature_id); - return ProductItemResponseSchema.parse({ + const feature = features.find((f) => f.id === item.feature_id); + return APIProductItemSchema.parse({ type, ...item, feature: feature ? toAPIFeature({ feature }) : null, diff --git a/shared/utils/productUtils.ts b/shared/utils/productUtils.ts index aa0aeb1a2..9f38f1a97 100644 --- a/shared/utils/productUtils.ts +++ b/shared/utils/productUtils.ts @@ -1,17 +1,10 @@ +import { Decimal } from "decimal.js"; +import { type ProductItem, type ProductV2 } from "../index.js"; import { - BillingType, - Price, - ProductItem, - ProductResponse, - ProductV2, -} from "../index.js"; -import { nullish } from "./utils.js"; -import { - isFeatureItem, isFeaturePriceItem, isPriceItem, } from "./productDisplayUtils/getItemType.js"; -import { Decimal } from "decimal.js"; +import { nullish } from "./utils.js"; export const isFreeProductV2 = ({ items }: { items: ProductItem[] }) => { return items.every((item) => nullish(item.price) && nullish(item.tiers)); @@ -44,7 +37,7 @@ export const isProductUpgradeV2 = ({ for (const item of items) { if (item.price) totalPrice = totalPrice.plus(item.price); if (item.tiers) { - let tierTotal = item.tiers.reduce( + const tierTotal = item.tiers.reduce( (acc, tier) => acc.plus(tier.amount), new Decimal(0), ); @@ -75,7 +68,7 @@ export const sortProductsV2 = ({ products }: { products: ProductV2[] }) => { } // Primary sort: by price (using upgrade logic) - let isUpgrade = isProductUpgradeV2({ + const isUpgrade = isProductUpgradeV2({ items1: a.items, items2: b.items, }); diff --git a/shared/utils/utils.ts b/shared/utils/utils.ts index 79b840d35..55709f648 100644 --- a/shared/utils/utils.ts +++ b/shared/utils/utils.ts @@ -6,3 +6,5 @@ export const nullish = ( export const notNullish = (value: T | null | undefined): value is T => value !== null && value !== undefined; + +export const idRegex = /^[a-zA-Z0-9_-]+$/; diff --git a/vite/src/views/products/features/components/SelectFeatureType.tsx b/vite/src/views/products/features/components/SelectFeatureType.tsx index 343480933..c815eb83d 100644 --- a/vite/src/views/products/features/components/SelectFeatureType.tsx +++ b/vite/src/views/products/features/components/SelectFeatureType.tsx @@ -1,12 +1,11 @@ -import FieldLabel from "@/components/general/modal-components/FieldLabel"; -import { SelectType } from "@/components/general/SelectType"; import { APIFeatureType, - CreateFeature, + type CreateFeature, FeatureType, - FeatureUsageType, } from "@autumn/shared"; -import { Zap, Clock, ArrowUp01, Flag } from "lucide-react"; +import { ArrowUp01, Flag } from "lucide-react"; +import FieldLabel from "@/components/general/modal-components/FieldLabel"; +import { SelectType } from "@/components/general/SelectType"; import { defaultMeteredConfig } from "../utils/defaultFeatureConfig"; export const SelectFeatureType = ({ diff --git a/vite/src/views/products/features/components/SelectFeatureUsageType.tsx b/vite/src/views/products/features/components/SelectFeatureUsageType.tsx index e9cae4632..711927b35 100644 --- a/vite/src/views/products/features/components/SelectFeatureUsageType.tsx +++ b/vite/src/views/products/features/components/SelectFeatureUsageType.tsx @@ -1,13 +1,13 @@ import { APIFeatureType, - CreateFeature, + type CreateFeature, FeatureType, FeatureUsageType, } from "@autumn/shared"; import { Clock, Zap } from "lucide-react"; -import { defaultMeteredConfig } from "../utils/defaultFeatureConfig"; import FieldLabel from "@/components/general/modal-components/FieldLabel"; import { SelectType } from "@/components/general/SelectType"; +import { defaultMeteredConfig } from "../utils/defaultFeatureConfig"; export const SelectFeatureUsageType = ({ feature,