feat: moved update product to new router

This commit is contained in:
John Yeo
2025-10-02 10:41:17 +01:00
parent dfa2e203fd
commit e5435c48a2
18 changed files with 810 additions and 454 deletions

View File

@@ -1,5 +1,6 @@
import type { Context, Env, Handler, MiddlewareHandler } from "hono";
import type { ZodType, z } from "zod/v4";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
import { validator } from "./validatorMiddleware.js";
@@ -59,6 +60,7 @@ export function createRoute<
>(opts: {
body?: Body;
query?: Query;
withTx?: boolean;
handler: (
c: ValidatedContext<HonoEnv, Body, Query>,
) => Response | Promise<Response>;
@@ -73,7 +75,19 @@ export function createRoute<
}
const wrappedHandler = async (c: ValidatedContext<HonoEnv, Body, Query>) => {
return await opts.handler(c);
if (opts.withTx) {
const db = c.get("ctx").db;
return await db.transaction(async (tx) => {
c.set("ctx", {
...c.get("ctx"),
db: tx as unknown as DrizzleCli,
});
return await opts.handler(c);
});
} else {
return await opts.handler(c);
}
};
return [...middlewares, wrappedHandler as Handler] as const;

View File

@@ -8,7 +8,7 @@ import { orgConfigMiddleware } from "./honoMiddlewares/orgConfigMiddleware.js";
import { secretKeyMiddleware } from "./honoMiddlewares/secretKeyMiddleware.js";
import { traceMiddleware } from "./honoMiddlewares/traceMiddleware.js";
import type { HonoEnv } from "./honoUtils/HonoEnv.js";
import { honoProductRouter } from "./internal/products/honoProductRouter.js";
import { honoProductRouter } from "./internal/products/productRouter.js";
import { auth } from "./utils/auth.js";
const ALLOWED_ORIGINS = [

View File

@@ -1,16 +1,16 @@
import { DrizzleCli } from "@/db/initDrizzle.js";
import { handleNewProductItems } from "@/internal/products/product-items/productItemUtils/handleNewProductItems.js";
import { constructProduct } from "@/internal/products/productUtils.js";
import {
AppEnv,
CreateProductSchema,
EntInsertSchema,
Entitlement,
Feature,
Price,
Product,
ProductV2,
type Entitlement,
type Feature,
type Price,
type Product,
type ProductV2,
} from "@autumn/shared";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { handleNewProductItems } from "@/internal/products/product-items/productItemUtils/handleNewProductItems.js";
import { constructProduct } from "@/internal/products/productUtils.js";
export const parseChatProducts = async ({
db,
@@ -25,13 +25,13 @@ export const parseChatProducts = async ({
orgId: string;
chatProducts: ProductV2[];
}) => {
let products: Product[] = [];
const products: Product[] = [];
let allPrices: Price[] = [];
let allEnts: Entitlement[] = [];
const allPrices: Price[] = [];
const allEnts: Entitlement[] = [];
for (const product of chatProducts) {
let backendProduct: Product = constructProduct({
const backendProduct: Product = constructProduct({
productData: CreateProductSchema.parse({
...product,
}),
@@ -39,7 +39,7 @@ export const parseChatProducts = async ({
env: AppEnv.Sandbox,
});
let { prices, entitlements } = await handleNewProductItems({
const { prices, entitlements } = await handleNewProductItems({
db,
curPrices: [],
curEnts: [],

View File

@@ -19,7 +19,7 @@ export const validateOneOffTrial = async ({
freeTrial,
}: {
prices: Price[];
freeTrial: FreeTrial | null;
freeTrial: FreeTrial | CreateFreeTrial | null;
}) => {
if (isOneOff(prices) && freeTrial) {
throw new RecaseError({
@@ -194,5 +194,5 @@ export const handleNewFreeTrial = async ({
});
}
return createdFreeTrial;
return createdFreeTrial as FreeTrial;
};

View File

@@ -1,95 +1,45 @@
import {
type CreateProductParams,
CreateProductParamsSchema,
type CreateProductV2Params,
CreateProductV2ParamsSchema,
type Entitlement,
type FreeTrial,
type Product,
type FullProduct,
type Price,
ProductAlreadyExistsError,
type ProductItem,
} from "@autumn/shared";
import { createRoute } from "@/honoMiddlewares/routeHandler.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { JobName } from "@/queue/JobName.js";
import { addTaskToQueue } from "@/queue/queueUtils.js";
import { getEntsWithFeature } from "../entitlements/entitlementUtils.js";
import {
handleNewFreeTrial,
validateOneOffTrial,
} from "../free-trials/freeTrialUtils.js";
import { ProductService } from "../ProductService.js";
import { handleNewProductItems } from "../product-items/productItemUtils/handleNewProductItems.js";
import { isDefaultTrial } from "../productUtils/classifyProduct.js";
import { getGroupToDefaults } from "../productUtils.js";
const validateCreateProduct = async ({
ctx,
body,
}: {
ctx: AutumnContext;
body: CreateProductParams;
}) => {
// const { free_trial, items } = body;
const { org, env, db, features } = ctx;
// const productData = CreateProductSchema.parse(req.body);
// validateId("Product", productData.id);
// if (nullish(req.body.name)) {
// productData.name = keyToTitle(productData.id);
// }
const existing = await ProductService.get({
db,
orgId: org.id,
env,
id: body.id,
});
// 1. If existing product, throw error
if (existing) {
throw new ProductAlreadyExistsError({ productId: body.id });
}
// 2. Validate items if exist
// if (items && !Array.isArray(items)) {
// throw new RecaseError({
// message: "Items must be an array",
// code: ErrCode.InvalidRequest,
// });
// } else if (items) {
// validateProductItems({
// newItems: items,
// features,
// orgId: req.orgId,
// env: req.env,
// });
// }
// 3. Validate free trial if exist
// let freeTrial: FreeTrial | null = null;
// if (notNullish(free_trial)) {
// // console.log("Free trial before:", free_trial);
// freeTrial = validateAndInitFreeTrial({
// freeTrial: free_trial,
// internalProductId: productData.id,
// isCustom: false,
// });
// // console.log("Free trial after:", freeTrial);
// }
return {
features,
// freeTrial,
// productData,
};
};
import { getProductResponse } from "../productUtils/productResponseUtils/getProductResponse.js";
import {
constructProduct,
getGroupToDefaults,
initProductInStripe,
} from "../productUtils.js";
export const disableCurrentDefault = async ({
req,
newProduct,
items,
freeTrial,
// items,
// freeTrial,
}: {
req: AutumnContext;
newProduct: Product;
items: ProductItem[];
freeTrial: FreeTrial;
newProduct: CreateProductV2Params;
// items: ProductItem[];
// freeTrial: FreeTrial;
}) => {
const { db, org, env, logger } = req;
let defaultProds = await ProductService.listDefault({
db,
orgId: org.id,
@@ -104,13 +54,7 @@ export const disableCurrentDefault = async ({
defaultProds,
})?.[newProduct.group];
const willBeDefaultTrial = isDefaultTrial({
product: {
...newProduct,
free_trial: freeTrial,
items: items || [],
},
});
const willBeDefaultTrial = isDefaultTrial({ product: newProduct });
if (willBeDefaultTrial) {
// Disable current default trial
@@ -142,100 +86,102 @@ export const disableCurrentDefault = async ({
* Route: POST /products - Create a product
*/
export const createProduct = createRoute({
body: CreateProductParamsSchema,
body: CreateProductV2ParamsSchema,
handler: async (c) => {
const body = c.req.valid("json");
const query = c.req.valid("query");
const ctx = c.get("ctx");
// const query = c.req.valid("query");
const { logger, org, features, env, db } = ctx;
const { items } = body;
// const { features, freeTrial, productData } = await validateCreateProduct({
// ctx,
// body,
// });
const existing = await ProductService.get({
db,
orgId: org.id,
env,
id: body.id,
});
// const newProduct = constructProduct({
// productData,
// orgId: org.id,
// env,
// });
// 1. If existing product, throw error
if (existing) throw new ProductAlreadyExistsError({ productId: body.id });
return c.json({});
await disableCurrentDefault({
req: ctx,
newProduct: body,
});
// await disableCurrentDefault({
// req,
// newProduct,
// items,
// freeTrial: freeTrial || null,
// });
const product = await ProductService.insert({
db,
product: constructProduct({
productData: body,
orgId: org.id,
env,
}),
});
// const product = await ProductService.insert({ db, product: newProduct });
const { items, free_trial } = body;
// let prices: Price[] = [];
// let entitlements: Entitlement[] = [];
// if (notNullish(items)) {
// const res = await handleNewProductItems({
// db,
// product,
// features,
// curPrices: [],
// curEnts: [],
// newItems: items,
// logger,
// isCustom: false,
// newVersion: false,
// });
// prices = res.prices;
// entitlements = res.entitlements;
// }
let prices: Price[] = [];
let entitlements: Entitlement[] = [];
if (items) {
const res = await handleNewProductItems({
db,
product,
features,
curPrices: [],
curEnts: [],
newItems: items,
logger,
isCustom: false,
newVersion: false,
});
prices = res.prices;
entitlements = res.entitlements;
}
// await validateOneOffTrial({
// prices,
// freeTrial: freeTrial || null,
// });
await validateOneOffTrial({
prices,
freeTrial: free_trial || null,
});
// await initProductInStripe({
// db,
// product: {
// ...product,
// prices,
// entitlements,
// } as FullProduct,
// org,
// env,
// logger,
// });
let newFreeTrial: FreeTrial | null = null;
if (free_trial) {
newFreeTrial =
(await handleNewFreeTrial({
db,
newFreeTrial: free_trial,
curFreeTrial: null,
internalProductId: product.internal_id,
isCustom: false,
})) || null;
}
// if (notNullish(freeTrial)) {
// await handleNewFreeTrial({
// db,
// newFreeTrial: freeTrial,
// curFreeTrial: null,
// internalProductId: product.internal_id,
// isCustom: false,
// });
// }
const newFullProduct: FullProduct = {
...product,
prices,
entitlements: getEntsWithFeature({ ents: entitlements, features }),
free_trial: newFreeTrial,
};
// await addTaskToQueue({
// jobName: JobName.DetectBaseVariant,
// payload: {
// curProduct: {
// ...product,
// prices,
// entitlements: [],
// },
// },
// });
await initProductInStripe({
db,
product: newFullProduct,
org,
env,
logger,
});
// res.status(200).json(
// APIProductSchema.parse({
// ...product,
// autumn_id: product.internal_id,
// items: items || [],
// free_trial: freeTrial,
// }),
// );
await addTaskToQueue({
jobName: JobName.DetectBaseVariant,
payload: {
curProduct: newFullProduct,
},
});
const productResponse = await getProductResponse({
product: newFullProduct,
features,
});
return c.json(productResponse);
},
});

View File

@@ -1,214 +1,214 @@
import {
ErrCode,
CreateProductV2ParamsSchema,
mapToProductV2,
notNullish,
ProductNotFoundError,
ProductV2Schema,
RecaseError,
UpdateProductQuerySchema,
UpdateProductSchema,
UpdateProductV2ParamsSchema,
} from "@autumn/shared";
import { createRoute } from "@/honoMiddlewares/routeHandler.js";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
import { FeatureService } from "@/internal/features/FeatureService.js";
import { OrgService } from "@/internal/orgs/OrgService.js";
import { handleNewFreeTrial } from "@/internal/products/free-trials/freeTrialUtils.js";
import { handleNewProductItems } from "@/internal/products/product-items/productItemUtils/handleNewProductItems.js";
import { RewardProgramService } from "@/internal/rewards/RewardProgramService.js";
import { JobName } from "@/queue/JobName.js";
import { addTaskToQueue } from "@/queue/queueUtils.js";
import RecaseError from "@/utils/errorUtils.js";
import { notNullish } from "@/utils/genUtils.js";
import { routeHandler } from "@/utils/routerUtils.js";
import { validateOneOffTrial } from "../../free-trials/freeTrialUtils.js";
import { ProductService } from "../../ProductService.js";
import { productsAreSame } from "../../productUtils/compareProductUtils.js";
import { initProductInStripe } from "../../productUtils.js";
import { mapToProductItems } from "../../productV2Utils.js";
import {
disableCurrentDefault,
handleCreateProduct,
} from "../handleCreateProduct_old.js";
handleNewFreeTrial,
validateOneOffTrial,
} from "../../free-trials/freeTrialUtils.js";
import { ProductService } from "../../ProductService.js";
import { handleNewProductItems } from "../../product-items/productItemUtils/handleNewProductItems.js";
import { productsAreSame } from "../../productUtils/compareProductUtils.js";
import { getProductResponse } from "../../productUtils/productResponseUtils/getProductResponse.js";
import { initProductInStripe } from "../../productUtils.js";
import { disableCurrentDefault } from "../handleCreateProduct.js";
import { handleVersionProductV2 } from "../handleVersionProduct.js";
import { handleUpdateProductDetails } from "./updateProductDetails.js";
export const handleUpdateProductV2 = async (req: any, res: any) =>
routeHandler({
req,
res,
action: "Update product",
handler: async () => {
const { productId } = req.params;
const { version, upsert, disable_version } = req.query;
const { orgId, env, logger, db } = req;
export const handleUpdateProductV2 = createRoute({
body: UpdateProductV2ParamsSchema,
query: UpdateProductQuerySchema,
handler: async (c) => {
const body = c.req.valid("json");
const ctx = c.get("ctx");
const productId = c.req.param("productId");
const [features, org, fullProduct, rewardPrograms, _defaultProds] =
await Promise.all([
FeatureService.getFromReq(req),
OrgService.getFromReq(req),
ProductService.getFull({
db,
idOrInternalId: productId,
orgId,
env,
version: version ? parseInt(version) : undefined,
allowNotFound: upsert === "true",
}),
RewardProgramService.getByProductId({
db,
productIds: [productId],
orgId,
env,
}),
ProductService.listDefault({
db,
orgId,
env,
}),
]);
const { db, org, env, features, logger } = ctx;
const { version, upsert, disable_version } = c.req.valid("query");
// const { productId } = req.params;
// const { orgId, env, logger, db } = req;
if (!fullProduct) {
if (upsert === "true") {
await handleCreateProduct(req, res);
return;
}
throw new ProductNotFoundError({ productId: productId });
}
const cusProductsCurVersion =
await CusProductService.getByInternalProductId({
db,
internalProductId: fullProduct.internal_id,
});
const cusProductExists = cusProductsCurVersion.length > 0;
await disableCurrentDefault({
req,
newProduct: {
...fullProduct,
...req.body,
},
items:
req.body.items ||
mapToProductItems({
prices: fullProduct.prices,
entitlements: fullProduct.entitlements,
features,
}),
freeTrial: req.body.free_trial || fullProduct.free_trial || null,
});
await handleUpdateProductDetails({
const [fullProduct, rewardPrograms, _defaultProds] = await Promise.all([
ProductService.getFull({
db,
curProduct: fullProduct,
newProduct: UpdateProductSchema.parse(req.body),
newFreeTrial: req.body.free_trial,
items: req.body.items,
org,
rewardPrograms,
logger,
idOrInternalId: productId,
orgId: org.id,
env,
version: version ? parseInt(version) : undefined,
allowNotFound: upsert === "true",
}),
RewardProgramService.getByProductId({
db,
productIds: [productId],
orgId: org.id,
env,
}),
ProductService.listDefault({
db,
orgId: org.id,
env,
}),
]);
if (!fullProduct) throw new ProductNotFoundError({ productId: productId });
// // How to go into another route handler?
// if (upsert === "true") await handleCreateProduct(c);
// Start a transaction?
const cusProductsCurVersion =
await CusProductService.getByInternalProductId({
db,
internalProductId: fullProduct.internal_id,
});
const itemsExist = notNullish(req.body.items);
if (cusProductExists && itemsExist) {
if (disable_version === "true") {
throw new RecaseError({
message: "Cannot auto save product as there are existing customers",
code: ErrCode.InvalidRequest,
statusCode: 400,
});
}
const curProductV2 = mapToProductV2({
product: fullProduct,
features,
});
const { itemsSame, freeTrialsSame } = productsAreSame({
newProductV2: req.body,
curProductV1: fullProduct,
features,
});
const productSame = itemsSame && freeTrialsSame;
const cusProductExists = cusProductsCurVersion.length > 0;
if (!productSame) {
await handleVersionProductV2({
req,
res,
latestProduct: fullProduct,
org,
env,
items: req.body.items,
freeTrial: req.body.free_trial,
});
return;
}
res.status(200).send(fullProduct);
return;
}
await disableCurrentDefault({
req: ctx,
newProduct: CreateProductV2ParamsSchema.parse({
...fullProduct,
...body,
}),
});
const { items, free_trial } = req.body;
await handleUpdateProductDetails({
db,
curProduct: fullProduct,
newProduct: UpdateProductSchema.parse(body),
newFreeTrial: body.free_trial || curProductV2.free_trial || undefined,
items: body.items || curProductV2.items,
org,
rewardPrograms,
logger: ctx.logger,
});
if (free_trial !== undefined) {
await validateOneOffTrial({
prices: fullProduct.prices,
freeTrial: free_trial,
const itemsExist = notNullish(body.items);
if (cusProductExists && itemsExist) {
const newProductV2 = ProductV2Schema.parse({
...body,
items: body.items || [],
});
if (disable_version === "true") {
throw new RecaseError({
message: "Cannot auto save product as there are existing customers",
});
}
const { itemsSame, freeTrialsSame } = productsAreSame({
newProductV2,
curProductV1: fullProduct,
features,
});
const productSame = itemsSame && freeTrialsSame;
if (!productSame) {
const newProduct = await handleVersionProductV2({
ctx,
newProductV2,
latestProduct: fullProduct,
org,
env,
});
return c.json(newProduct);
}
return c.json(fullProduct);
}
const { free_trial } = body;
if (body.items) {
await handleNewProductItems({
db,
curPrices: fullProduct.prices,
curEnts: fullProduct.entitlements,
newItems: items,
newItems: body.items,
features,
product: fullProduct,
logger,
logger: ctx.logger,
isCustom: false,
});
}
// New full product
const newFullProduct = await ProductService.getFull({
db,
idOrInternalId: fullProduct.id,
orgId,
env,
// New full product
const newFullProduct = await ProductService.getFull({
db,
idOrInternalId: fullProduct.id,
orgId: org.id,
env,
});
if (free_trial !== undefined) {
await validateOneOffTrial({
prices: newFullProduct.prices,
freeTrial: free_trial,
});
if (free_trial !== undefined) {
await validateOneOffTrial({
prices: newFullProduct.prices,
freeTrial: free_trial,
});
await handleNewFreeTrial({
db,
curFreeTrial: fullProduct.free_trial,
newFreeTrial: free_trial,
internalProductId: fullProduct.internal_id,
isCustom: false,
product: fullProduct,
});
}
// New full product
await initProductInStripe({
await handleNewFreeTrial({
db,
curFreeTrial: fullProduct.free_trial,
newFreeTrial: free_trial,
internalProductId: fullProduct.internal_id,
isCustom: false,
product: fullProduct,
});
}
// New full product
await initProductInStripe({
db,
product: newFullProduct,
org,
env,
logger,
});
logger.info("Adding task to queue to detect base variant");
await addTaskToQueue({
jobName: JobName.DetectBaseVariant,
payload: {
curProduct: newFullProduct,
},
});
await addTaskToQueue({
jobName: JobName.RewardMigration,
payload: {
oldPrices: fullProduct.prices,
productId: fullProduct.id,
orgId: org.id,
env,
},
});
return c.json(
getProductResponse({
product: newFullProduct,
org,
env,
logger,
});
logger.info("Adding task to queue to detect base variant");
await addTaskToQueue({
jobName: JobName.DetectBaseVariant,
payload: {
curProduct: newFullProduct,
},
});
await addTaskToQueue({
jobName: JobName.RewardMigration,
payload: {
oldPrices: fullProduct.prices,
productId: fullProduct.id,
orgId: org.id,
env,
},
});
res.status(200).send({ message: "Product updated" });
return;
},
});
features,
}),
);
},
});

View File

@@ -0,0 +1,214 @@
import {
ErrCode,
ProductNotFoundError,
UpdateProductSchema,
} from "@autumn/shared";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
import { FeatureService } from "@/internal/features/FeatureService.js";
import { OrgService } from "@/internal/orgs/OrgService.js";
import { handleNewFreeTrial } from "@/internal/products/free-trials/freeTrialUtils.js";
import { handleNewProductItems } from "@/internal/products/product-items/productItemUtils/handleNewProductItems.js";
import { RewardProgramService } from "@/internal/rewards/RewardProgramService.js";
import { JobName } from "@/queue/JobName.js";
import { addTaskToQueue } from "@/queue/queueUtils.js";
import RecaseError from "@/utils/errorUtils.js";
import { notNullish } from "@/utils/genUtils.js";
import { routeHandler } from "@/utils/routerUtils.js";
import { validateOneOffTrial } from "../../free-trials/freeTrialUtils.js";
import { ProductService } from "../../ProductService.js";
import { productsAreSame } from "../../productUtils/compareProductUtils.js";
import { initProductInStripe } from "../../productUtils.js";
import { mapToProductItems } from "../../productV2Utils.js";
import {
disableCurrentDefault,
handleCreateProduct,
} from "../handleCreateProduct_old.js";
import { handleVersionProductV2 } from "../handleVersionProduct.js";
import { handleUpdateProductDetails } from "./updateProductDetails.js";
export const handleUpdateProductV2 = async (req: any, res: any) =>
routeHandler({
req,
res,
action: "Update product",
handler: async () => {
const { productId } = req.params;
const { version, upsert, disable_version } = req.query;
const { orgId, env, logger, db } = req;
const [features, org, fullProduct, rewardPrograms, _defaultProds] =
await Promise.all([
FeatureService.getFromReq(req),
OrgService.getFromReq(req),
ProductService.getFull({
db,
idOrInternalId: productId,
orgId,
env,
version: version ? parseInt(version) : undefined,
allowNotFound: upsert === "true",
}),
RewardProgramService.getByProductId({
db,
productIds: [productId],
orgId,
env,
}),
ProductService.listDefault({
db,
orgId,
env,
}),
]);
if (!fullProduct) {
if (upsert === "true") {
await handleCreateProduct(req, res);
return;
}
throw new ProductNotFoundError({ productId: productId });
}
const cusProductsCurVersion =
await CusProductService.getByInternalProductId({
db,
internalProductId: fullProduct.internal_id,
});
const cusProductExists = cusProductsCurVersion.length > 0;
await disableCurrentDefault({
req,
newProduct: {
...fullProduct,
...req.body,
},
items:
req.body.items ||
mapToProductItems({
prices: fullProduct.prices,
entitlements: fullProduct.entitlements,
features,
}),
freeTrial: req.body.free_trial || fullProduct.free_trial || null,
});
await handleUpdateProductDetails({
db,
curProduct: fullProduct,
newProduct: UpdateProductSchema.parse(req.body),
newFreeTrial: req.body.free_trial,
items: req.body.items,
org,
rewardPrograms,
logger,
});
const itemsExist = notNullish(req.body.items);
if (cusProductExists && itemsExist) {
if (disable_version === "true") {
throw new RecaseError({
message: "Cannot auto save product as there are existing customers",
code: ErrCode.InvalidRequest,
statusCode: 400,
});
}
const { itemsSame, freeTrialsSame } = productsAreSame({
newProductV2: req.body,
curProductV1: fullProduct,
features,
});
const productSame = itemsSame && freeTrialsSame;
if (!productSame) {
await handleVersionProductV2({
req,
res,
latestProduct: fullProduct,
org,
env,
items: req.body.items,
freeTrial: req.body.free_trial,
});
return;
}
res.status(200).send(fullProduct);
return;
}
const { items, free_trial } = req.body;
if (free_trial !== undefined) {
await validateOneOffTrial({
prices: fullProduct.prices,
freeTrial: free_trial,
});
}
await handleNewProductItems({
db,
curPrices: fullProduct.prices,
curEnts: fullProduct.entitlements,
newItems: items,
features,
product: fullProduct,
logger,
isCustom: false,
});
// New full product
const newFullProduct = await ProductService.getFull({
db,
idOrInternalId: fullProduct.id,
orgId,
env,
});
if (free_trial !== undefined) {
await validateOneOffTrial({
prices: newFullProduct.prices,
freeTrial: free_trial,
});
await handleNewFreeTrial({
db,
curFreeTrial: fullProduct.free_trial,
newFreeTrial: free_trial,
internalProductId: fullProduct.internal_id,
isCustom: false,
product: fullProduct,
});
}
// New full product
await initProductInStripe({
db,
product: newFullProduct,
org,
env,
logger,
});
logger.info("Adding task to queue to detect base variant");
await addTaskToQueue({
jobName: JobName.DetectBaseVariant,
payload: {
curProduct: newFullProduct,
},
});
await addTaskToQueue({
jobName: JobName.RewardMigration,
payload: {
oldPrices: fullProduct.prices,
productId: fullProduct.id,
orgId: org.id,
env,
},
});
res.status(200).send({ message: "Product updated" });
return;
},
});

View File

@@ -1,5 +1,6 @@
import {
type AppEnv,
type CreateFreeTrial,
type FreeTrial,
type FullProduct,
isFreeProductV2,
@@ -15,7 +16,6 @@ import { createStripeCli } from "@/external/stripe/utils.js";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
import { isStripeConnected } from "@/internal/orgs/orgUtils.js";
import { notNullish } from "@/utils/genUtils.js";
import { FreeTrialService } from "../../free-trials/FreeTrialService.js";
import { ProductService } from "../../ProductService.js";
import { usagePriceToProductName } from "../../prices/priceUtils/usagePriceUtils/convertUsagePrice.js";
import {
@@ -125,7 +125,7 @@ const willBeDefaultTrial = ({
}: {
newProduct: UpdateProduct;
curProduct: FullProduct;
newFreeTrial: FreeTrial;
newFreeTrial?: FreeTrial | CreateFreeTrial;
newItems: ProductItem[];
}) => {
// 1. Get final default
@@ -157,7 +157,7 @@ export const handleUpdateProductDetails = async ({
db: DrizzleCli;
curProduct: FullProduct;
newProduct: UpdateProduct;
newFreeTrial: FreeTrial;
newFreeTrial?: FreeTrial | CreateFreeTrial;
items: ProductItem[];
org: Organization;
rewardPrograms: RewardProgram[];
@@ -170,11 +170,6 @@ export const handleUpdateProductDetails = async ({
env: curProduct.env as AppEnv,
});
const trialConfig = await FreeTrialService.getByProductId({
db,
productId: curProduct.internal_id,
});
// Should error if:
// - New product is a default product
// - Org is not allowed to have paid default products

View File

@@ -1,12 +1,11 @@
import {
type AppEnv,
CreateProductSchema,
type FreeTrial,
CreateProductV2ParamsSchema,
type FullProduct,
type Organization,
type ProductItem,
type ProductV2,
} from "@autumn/shared";
import { FeatureService } from "@/internal/features/FeatureService.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { EntitlementService } from "@/internal/products/entitlements/EntitlementService.js";
import { handleNewFreeTrial } from "@/internal/products/free-trials/freeTrialUtils.js";
import { ProductService } from "@/internal/products/ProductService.js";
@@ -22,48 +21,45 @@ import { addTaskToQueue } from "@/queue/queueUtils.js";
import { getEntsWithFeature } from "../entitlements/entitlementUtils.js";
export const handleVersionProductV2 = async ({
req,
res,
ctx,
newProductV2,
latestProduct,
org,
env,
items,
freeTrial,
// items,
// freeTrial,
}: {
req: any;
res: any;
ctx: AutumnContext;
newProductV2: ProductV2;
latestProduct: FullProduct;
org: Organization;
env: AppEnv;
items: ProductItem[];
freeTrial: FreeTrial;
// items: ProductItem[];
// freeTrial: FreeTrial;
}) => {
const { db } = req;
const { db, features } = ctx;
const curVersion = latestProduct.version;
const newVersion = curVersion + 1;
const features = await FeatureService.getFromReq(req);
console.log(
`Updating product ${latestProduct.id} version from ${curVersion} to ${newVersion}`,
);
const newProduct = constructProduct({
productData: CreateProductSchema.parse({
productData: CreateProductV2ParamsSchema.parse({
...latestProduct,
...req.body,
...newProductV2,
version: newVersion,
}),
orgId: org.id,
env: latestProduct.env as AppEnv,
processor: latestProduct.processor,
baseVariantId: latestProduct.base_variant_id,
processor: latestProduct.processor || undefined,
});
// Validate product items...
validateProductItems({
newItems: items,
newItems: newProductV2.items,
features,
orgId: org.id,
env,
@@ -85,7 +81,7 @@ export const handleVersionProductV2 = async ({
db,
curPrices: latestProduct.prices,
curEnts: latestProduct.entitlements,
newItems: items,
newItems: newProductV2.items,
features,
product: newProduct,
logger: console,
@@ -104,27 +100,16 @@ export const handleVersionProductV2 = async ({
});
// Handle new free trial
if (freeTrial) {
if (newProductV2.free_trial) {
await handleNewFreeTrial({
db,
newFreeTrial: freeTrial,
newFreeTrial: newProductV2.free_trial,
curFreeTrial: null,
internalProductId: newProduct.internal_id,
isCustom: false,
});
}
// await addTaskToQueue({
// jobName: JobName.DetectBaseVariant,
// payload: {
// curProduct: {
// ...newProduct,
// // prices: customPrices,
// // entitlements: getEntsWithFeature({ ents: customEnts, features }),
// },
// },
// });
await initProductInStripe({
db,
product: {
@@ -142,16 +127,10 @@ export const handleVersionProductV2 = async ({
payload: {
oldPrices: latestProduct.prices,
productId: latestProduct.id,
// newPrices: customPrices,
// product: {
// ...newProduct,
// prices: customPrices,
// entitlements: getEntsWithFeature({ ents: customEnts, features }),
// },
orgId: org.id,
env,
},
});
res.status(200).send(newProduct);
return newProduct;
};

View File

@@ -1,9 +0,0 @@
import { Hono } from "hono";
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
import { createProduct } from "./handlers/handleCreateProduct.js";
// Create a Hono app for products
export const honoProductRouter = new Hono<HonoEnv>();
// POST /products - Create a product
honoProductRouter.post("", ...createProduct);

View File

@@ -14,7 +14,6 @@ import { handleDeleteProduct } from "./handlers/handleDeleteProduct.js";
import { handleGetProduct } from "./handlers/handleGetProduct.js";
import { handleGetProductDeleteInfo } from "./handlers/handleGetProductDeleteInfo.js";
import { handleListProductsBeta } from "./handlers/handleListProductsBeta.js";
import { handleUpdateProductV2 } from "./handlers/handleUpdateProduct/handleUpdateProduct.js";
import { productsAreSame } from "./productUtils/compareProductUtils.js";
export const productBetaRouter: Router = Router();
@@ -24,11 +23,9 @@ export const productRouter: Router = Router();
productRouter.get("", handleListProductsBeta);
// productRouter.post("", handleCreateProduct);
productRouter.get("/:productId", handleGetProduct);
productRouter.post("/:productId", handleUpdateProductV2);
// productRouter.post("/:productId", handleUpdateProductV2);
productRouter.delete("/:productId", handleDeleteProduct);
@@ -148,3 +145,15 @@ productRouter.get("/:productId/has_customers", async (req: any, res: any) =>
);
productRouter.get("/:productId/deletion_info", handleGetProductDeleteInfo);
import { Hono } from "hono";
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
import { createProduct } from "./handlers/handleCreateProduct.js";
import { handleUpdateProductV2 } from "./handlers/handleUpdateProduct/handleUpdateProduct.js";
// Create a Hono app for products
export const honoProductRouter = new Hono<HonoEnv>();
// POST /products - Create a product
honoProductRouter.post("", ...createProduct);
honoProductRouter.post("/:productId", ...handleUpdateProductV2);

View File

@@ -2,7 +2,7 @@ import {
type AppEnv,
BillingInterval,
BillingType,
type CreateProduct,
type CreateProductV2Params,
EntInterval,
type Entitlement,
EntitlementSchema,
@@ -75,21 +75,28 @@ export const constructProduct = ({
orgId,
env,
processor,
baseVariantId,
}: {
productData: CreateProduct;
productData: CreateProductV2Params;
orgId: string;
env: AppEnv;
processor?: any;
baseVariantId?: string | null;
processor?: {
id: string;
type: string;
};
}) => {
const newProduct: Product = {
...productData,
org_id: orgId,
id: productData.id,
name: productData.name,
is_add_on: productData.is_add_on,
is_default: productData.is_default,
version: productData.version || 1,
group: productData.group,
env,
processor,
internal_id: generateId("prod"),
org_id: orgId,
created_at: Date.now(),
processor,
base_variant_id: null,
archived: false,
};

View File

@@ -1,4 +1,9 @@
import type { FullProduct, Price, ProductV2 } from "@autumn/shared";
import type {
CreateProductParams,
FullProduct,
Price,
ProductV2,
} from "@autumn/shared";
import { pricesOnlyOneOff } from "../prices/priceUtils.js";
import { isFeatureItem } from "../product-items/productItemUtils/getItemType.js";
import { isFreeProduct } from "../productUtils.js";
@@ -31,15 +36,19 @@ export const isMainProduct = ({
return !prodIsAddOn({ product }) && !oneOffOrAddOn({ product, prices });
};
export const isFreeProductV2 = ({ product }: { product: ProductV2 }) => {
return product.items.every((item) => isFeatureItem(item));
export const isFreeProductV2 = ({
product,
}: {
product: ProductV2 | CreateProductParams;
}) => {
return (product.items || []).every((item) => isFeatureItem(item));
};
export const isDefaultTrial = ({
product,
skipDefault = false,
}: {
product: ProductV2;
product: ProductV2 | CreateProductParams;
skipDefault?: boolean;
}) => {
return (

View File

@@ -1,23 +1,22 @@
import {
constructBooleanFeature,
constructMeteredFeature,
} from "@/internal/features/utils/constructFeatureUtils.js";
import {
AppEnv,
BillingInterval,
CouponDurationType,
CreateFreeTrial,
type CreateFreeTrial,
CreateFreeTrialSchema,
CreateReward,
type CreateReward,
FeatureUsageType,
FreeTrial,
FreeTrialDuration,
ProductItem,
ProductV2,
type ProductItem,
type ProductV2,
RewardType,
} from "@autumn/shared";
import { keyToTitle } from "../genUtils.js";
import {
constructBooleanFeature,
constructMeteredFeature,
} from "@/internal/features/utils/constructFeatureUtils.js";
import { constructPriceItem } from "@/internal/products/product-items/productItemUtils.js";
import { keyToTitle } from "../genUtils.js";
export enum TestFeatureType {
Boolean = "boolean",
@@ -139,11 +138,11 @@ export const constructProduct = ({
);
}
let id_ =
const id_ =
id ||
(isAnnual ? `${type}-annual` : interval ? `${type}-${interval}` : type);
let product: ProductV2 = {
const product: ProductV2 = {
id: id_,
name: id
? keyToTitle(id)

View File

@@ -5,7 +5,9 @@ import { z } from "zod/v4";
export const CreateProductItemParamsSchema = ProductItemSchema;
export const CreateProductParamsSchema = z.object({
// Base product params
export const CreateProductV2ParamsSchema = z.object({
id: z.string().nonempty().regex(idRegex),
name: z.string().refine((val) => val.length > 0, {
@@ -18,7 +20,32 @@ export const CreateProductParamsSchema = z.object({
group: z.string().default(""),
items: z.array(CreateProductItemParamsSchema).optional(),
free_trial: CreateFreeTrialSchema.optional(),
free_trial: CreateFreeTrialSchema.nullish().default(null),
});
export type CreateProductParams = z.infer<typeof CreateProductParamsSchema>;
export const UpdateProductV2ParamsSchema = CreateProductV2ParamsSchema.extend({
id: z.string().nonempty().regex(idRegex).optional(),
name: z
.string()
.refine((val) => val.length > 0, {
message: "name must be a non-empty string",
})
.optional(),
is_add_on: z.boolean().optional(),
is_default: z.boolean().optional(),
version: z.number().optional(),
group: z.string().optional(),
// items: z.array(CreateProductItemParamsSchema).optional(),
free_trial: CreateFreeTrialSchema.nullish(),
});
export const UpdateProductQuerySchema = z.object({
version: z.string().optional(),
upsert: z.string().optional(),
disable_version: z.string().optional(),
});
export type CreateProductV2Params = z.infer<typeof CreateProductV2ParamsSchema>;
export type UpdateProductV2Params = z.infer<typeof UpdateProductV2ParamsSchema>;

View File

@@ -13,7 +13,7 @@ export const ProductSchema = z.object({
version: z.number(),
group: z.string(),
env: z.nativeEnum(AppEnv),
env: z.enum(AppEnv),
internal_id: z.string(),
org_id: z.string(),
created_at: z.number(),

View File

@@ -36,6 +36,174 @@ paths:
group:
default: ""
type: string
items:
type: array
items:
type: object
properties:
feature_id:
anyOf:
- type: string
- type: "null"
feature_type:
anyOf:
- type: string
enum:
- single_use
- continuous_use
- boolean
- static
- type: "null"
included_usage:
anyOf:
- anyOf:
- type: number
- type: string
const: inf
- type: "null"
interval:
anyOf:
- type: string
enum:
- minute
- hour
- day
- week
- month
- quarter
- semi_annual
- year
- type: "null"
interval_count:
anyOf:
- type: number
- type: "null"
entity_feature_id:
anyOf:
- type: string
- type: "null"
usage_model:
anyOf:
- type: string
enum:
- prepaid
- pay_per_use
- type: "null"
price:
anyOf:
- type: number
- type: "null"
tiers:
anyOf:
- type: array
items:
type: object
properties:
to:
anyOf:
- type: number
- type: string
const: inf
amount:
type: number
required:
- to
- amount
- type: "null"
billing_units:
anyOf:
- type: number
- type: "null"
usage_limit:
anyOf:
- type: number
- type: "null"
reset_usage_when_enabled:
anyOf:
- type: boolean
- type: "null"
config:
anyOf:
- type: object
properties:
on_increase:
anyOf:
- type: string
enum:
- bill_immediately
- prorate_immediately
- prorate_next_cycle
- bill_next_cycle
- type: "null"
on_decrease:
anyOf:
- type: string
enum:
- prorate
- prorate_immediately
- prorate_next_cycle
- none
- no_prorations
- type: "null"
rollover:
anyOf:
- type: object
properties:
max:
anyOf:
- type: number
- type: "null"
duration:
default: month
type: string
enum:
- month
- forever
length:
type: number
required:
- max
- length
- type: "null"
- type: "null"
created_at:
anyOf:
- type: number
- type: "null"
entitlement_id:
anyOf:
- type: string
- type: "null"
price_id:
anyOf:
- type: string
- type: "null"
price_config:
anyOf:
- {}
- type: "null"
free_trial:
type: object
properties:
length:
anyOf:
- type: string
- type: number
unique_fingerprint:
default: false
type: boolean
duration:
default: day
type: string
enum:
- day
- month
- year
card_required:
default: true
type: boolean
required:
- length
required:
- id
- name

View File

@@ -1,3 +1,9 @@
import type { ProductV2 } from "@autumn/shared";
import { useEffect, useState } from "react";
import { useNavigate } from "react-router";
import { toast } from "sonner";
import { WarningBox } from "@/components/general/modal-components/WarningBox";
import { ToggleButton } from "@/components/general/ToggleButton";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -6,19 +12,11 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { useProductsQuery } from "@/hooks/queries/useProductsQuery";
import { ProductService } from "@/services/products/ProductService";
import { useNavigate } from "react-router";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { getBackendErr, navigateTo } from "@/utils/genUtils";
import { ProductConfig } from "../../ProductConfig";
import { ProductV2 } from "@autumn/shared";
import { ToggleButton } from "@/components/general/ToggleButton";
import { WarningBox } from "@/components/general/modal-components/WarningBox";
import { useProductsQuery } from "@/hooks/queries/useProductsQuery";
const defaultProduct = {
name: "",