feat: created api product and api feature zod schemas

This commit is contained in:
John Yeo
2025-09-30 19:08:50 +01:00
parent 16dfca67e8
commit 96e0e8c2ce
40 changed files with 1079 additions and 2077 deletions

View File

@@ -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"
}
}
},

View File

@@ -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";

View File

@@ -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 }));
},
}),
);

View File

@@ -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 || [],

View File

@@ -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(

View File

@@ -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 })),
);

View File

@@ -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,

View File

@@ -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,
};
};

View File

@@ -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,
});
};

View File

@@ -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()}`;
}
}
return body;
};
// Clean up common Zod error messages
let message = issue.message;
const logRequestBody = (logger: any, req: any, level: "warn" | "error") => {
// Handle common patterns and make them more user-friendly
if (
req.body &&
typeof req.body === "object" &&
Object.keys(req.body).length > 0
message.includes("Too small") &&
message.includes("expected string to have >=1 characters")
) {
logger[level]("Request body:");
logger[level](getJsonBody(req.body));
}
};
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;
logger[level](`Event type: ${body.type}, ID: ${body.id}`);
} catch (error) {
logger[level](`Invalid JSON body`);
}
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 {
logger[level](`${req.method} ${req.originalUrl}`);
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 `${path}: ${message}`;
};
const formattedIssues = error.issues.map(formatMessage);
// If there are multiple issues, format them nicely
if (formattedIssues.length === 1) {
return formattedIssues[0];
} else {
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`);

View File

@@ -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,7 +263,6 @@ export const routeHandler = async <TLoad = undefined>({
});
}
} catch (error) {
try {
if (error instanceof RecaseError) {
if (error.code === ErrCode.EntityNotFound) {
req.logtail.warn(
@@ -276,9 +274,8 @@ export const routeHandler = async <TLoad = undefined>({
});
}
}
} catch (error) {}
let originalUrl = req.originalUrl;
const originalUrl = req.originalUrl;
if (error instanceof Stripe.errors.StripeError) {
if (
originalUrl.includes("/exchange") &&

View File

@@ -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);
// }
// }

View File

@@ -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(),

View File

@@ -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(),

View File

@@ -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<typeof UpdateFeatureParamsSchema>;

View File

@@ -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";

68
shared/api/openapi.ts Normal file
View File

@@ -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);
}
}

1
shared/api/operations.ts Normal file
View File

@@ -0,0 +1 @@
export * from "./features/updateFeatureParams.js";

View File

@@ -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<typeof APIFreeTrialSchema>;

View File

@@ -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: "<string>",
feature_type: "single_use",
included_usage: 123,
interval: "<string>",
usage_model: "prepaid",
price: 123,
billing_units: 123,
entity_feature_id: "<string>",
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: "<string>",
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<typeof APIProductSchema>;
export type APIProductProperties = z.infer<typeof APIProductPropertiesSchema>;
// export type ProductResponse = z.infer<typeof ProductResponseSchema>;

View File

@@ -9,7 +9,8 @@ import {
} from "@models/productV2Models/productItemModels/productItemModels.js";
import { z } from "zod/v4";
export const APIProductItemSchema = z.object({
export const APIProductItemSchema = z
.object({
// Feature stuff
type: z.enum(ProductItemType).nullish(),
feature_id: z.string().nullish(),
@@ -38,6 +39,17 @@ export const APIProductItemSchema = z.object({
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<typeof APIProductItemSchema>;

View File

@@ -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(""),
});

View File

@@ -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 },
},
},
},
},
},
};

View File

@@ -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";

View File

@@ -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(),

View File

@@ -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[];
}

View File

@@ -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(

View File

@@ -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<typeof APIFeatureSchema>;
export type UpdateAPIFeature = z.infer<typeof UpdateAPIFeatureSchema>;

View File

@@ -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<typeof FreeTrialSchema>;
export type CreateFreeTrial = z.infer<typeof CreateFreeTrialSchema>;
export type FreeTrialResponse = z.infer<typeof FreeTrialResponseSchema>;

View File

@@ -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<typeof ProductItemResponseSchema>;

View File

@@ -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<typeof ProductResponseSchema>;
export type ProductProperties = z.infer<typeof ProductPropertiesSchema>;

File diff suppressed because it is too large Load Diff

View File

@@ -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",

View File

@@ -13,7 +13,8 @@
"rootDir": ".",
"paths": {
"@api/*": ["./api/*"],
"@models/*": ["./models/*"]
"@models/*": ["./models/*"],
"@utils/*": ["./utils/*"]
}
},
"include": ["./**/*"],

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,
});

View File

@@ -6,3 +6,5 @@ export const nullish = <T>(
export const notNullish = <T>(value: T | null | undefined): value is T =>
value !== null && value !== undefined;
export const idRegex = /^[a-zA-Z0-9_-]+$/;

View File

@@ -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 = ({

View File

@@ -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,