reworking create product

This commit is contained in:
John Yeo
2025-10-01 19:50:17 +01:00
parent 5fb28db4bc
commit dfa2e203fd
46 changed files with 934 additions and 590 deletions

View File

@@ -31,6 +31,7 @@
"@date-fns/tz": "^1.2.0",
"@date-fns/utc": "^2.1.0",
"@hono/node-server": "^1.19.5",
"@hono/zod-validator": "^0.7.3",
"@hyperbrowser/sdk": "^0.54.0",
"@hyperdx/node-opentelemetry": "^0.8.2",
"@logtail/node": "^0.5.2",
@@ -450,6 +451,8 @@
"@hono/node-server": ["@hono/node-server@1.19.5", "", { "peerDependencies": { "hono": "^4" } }, "sha512-iBuhh+uaaggeAuf+TftcjZyWh2GEgZcVGXkNtskLVoWaXhnJtC5HLHrU8W1KHDoucqO1MswwglmkWLFyiDn4WQ=="],
"@hono/zod-validator": ["@hono/zod-validator@0.7.3", "", { "peerDependencies": { "hono": ">=3.9.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-uYGdgVib3RlGD698WR5dVM0zB3UuPY5vHKXffGUbUh7r4xY+mFIhF3/v4AcQVLrU5CQdBso8BJr4wuVoCrjTuQ=="],
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
"@humanfs/node": ["@humanfs/node@0.16.6", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.3.0" } }, "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw=="],

View File

@@ -21,11 +21,9 @@
"dev:wsl": "concurrently \"cd server && npm run dev\" \"cd vite && npm run dev\" \"cd shared && npm run dev\"",
"setup": "node setup.js",
"setupci": "node setupci",
"db:push": " bun -F @autumn/shared db:push",
"db:generate": "bun -F @autumn/shared db:generate",
"db:migrate": " bun -F @autumn/shared db:migrate",
"docker:up": "docker compose -f docker-compose.dev.yml up --build",
"docker:up:unix": "docker compose -f docker-compose.unix.yml up --build",
"docker:up:ci": "docker compose -f docker-compose.ci.yml up --build",

View File

@@ -33,6 +33,7 @@
"@date-fns/tz": "^1.2.0",
"@date-fns/utc": "^2.1.0",
"@hono/node-server": "^1.19.5",
"@hono/zod-validator": "^0.7.3",
"@hyperbrowser/sdk": "^0.54.0",
"@hyperdx/node-opentelemetry": "^0.8.2",
"@logtail/node": "^0.5.2",

View File

@@ -3,7 +3,7 @@ import type { Context, Next } from "hono";
import { db } from "@/db/initDrizzle.js";
import { ClickHouseManager } from "@/external/clickhouse/ClickHouseManager.js";
import { logger } from "@/external/logtail/logtailUtils.js";
import type { HonoEnv } from "@/initHono.js";
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
import { generateId } from "@/utils/genUtils.js";
/**

View File

@@ -1,4 +1,4 @@
import { AutumnError, ErrCode } from "@autumn/shared";
import { ErrCode, RecaseError as SharedRecaseError } from "@autumn/shared";
import type { Context } from "hono";
import type { ContentfulStatusCode } from "hono/utils/http-status";
import Stripe from "stripe";
@@ -127,7 +127,7 @@ export const errorMiddleware = (err: Error, c: Context<HonoEnv>) => {
if (specialCaseResponse) return specialCaseResponse;
// 1. Handle RecaseError (our custom errors)
if (err instanceof RecaseError || err instanceof AutumnError) {
if (err instanceof RecaseError || err instanceof SharedRecaseError) {
logger.warn(
`RECASE WARNING (${ctx.org?.slug || "unknown"}): ${err.message} [${err.code}]`,
{

View File

@@ -0,0 +1,80 @@
import type { Context, Env, Handler, MiddlewareHandler } from "hono";
import type { ZodType, z } from "zod/v4";
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
import { validator } from "./validatorMiddleware.js";
/**
* Extended context type that includes validated input
* This tells TypeScript what data is available after validation
*/
type ValidatedContext<
E extends Env,
Body extends ZodType | undefined = undefined,
Query extends ZodType | undefined = undefined,
> = Context<
E,
any,
{
in: {
json: Body extends ZodType ? z.infer<Body> : unknown;
query: Query extends ZodType ? z.infer<Query> : unknown;
};
out: {
json: Body extends ZodType ? z.infer<Body> : unknown;
query: Query extends ZodType ? z.infer<Query> : unknown;
};
}
>;
/**
* Create a type-safe route with validation that preserves full type inference!
* By typing the context parameter, TypeScript knows what's available on c.req.valid()
*
* @example
* ```ts
* export const createProduct = createRoute({
* body: CreateProductParamsSchema,
* handler: async (c) => {
* const body = c.req.valid("json"); // ✅ Fully typed from schema!
* return c.json({ success: true });
* }
* });
*
* // With query validation too:
* export const listProducts = createRoute({
* query: ListProductsQuerySchema,
* handler: async (c) => {
* const query = c.req.valid("query"); // ✅ Fully typed!
* return c.json({ products: [] });
* }
* });
*
* // In router:
* honoProductRouter.post("", ...createProduct);
* ```
*/
export function createRoute<
Body extends ZodType | undefined = undefined,
Query extends ZodType | undefined = undefined,
>(opts: {
body?: Body;
query?: Query;
handler: (
c: ValidatedContext<HonoEnv, Body, Query>,
) => Response | Promise<Response>;
}) {
const middlewares: MiddlewareHandler[] = [];
if (opts.body) {
middlewares.push(validator("json", opts.body));
}
if (opts.query) {
middlewares.push(validator("query", opts.query));
}
const wrappedHandler = async (c: ValidatedContext<HonoEnv, Body, Query>) => {
return await opts.handler(c);
};
return [...middlewares, wrappedHandler as Handler] as const;
}

View File

@@ -0,0 +1,30 @@
import { zValidator } from "@hono/zod-validator";
import type { ZodType } from "zod/v4";
/**
* Custom validator that wraps @hono/zod-validator with error throwing behavior
* This allows the errorMiddleware to handle validation errors consistently
* Maintains full type inference from zValidator
*
* Usage:
* ```ts
* router.post(
* "/products",
* validator("json", CreateProductSchema),
* async (c) => {
* const body = c.req.valid("json"); // Fully typed!
* // ... handler logic
* }
* );
* ```
*/
export const validator = <T extends ZodType>(
target: "json" | "query" | "param" | "header" | "form",
schema: T,
) => {
return zValidator(target, schema, (result, _c) => {
if (!result.success) {
throw result.error;
}
});
};

View File

@@ -0,0 +1,30 @@
import type { AppEnv, AuthType, Feature, Organization } from "@autumn/shared";
import type { ClickHouseClient } from "@clickhouse/client";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import type { Logger } from "@/external/logtail/logtailUtils.js";
export type RequestContext = {
// Variables
org: Organization;
env: AppEnv;
features: Feature[];
userId?: string;
// Objects
db: DrizzleCli;
logger: Logger;
clickhouseClient: ClickHouseClient;
// Info
id: string;
isPublic: boolean;
authType: AuthType;
apiVersion: string;
timestamp: number;
};
export type AutumnContext = RequestContext;
export type HonoEnv = {
Variables: { ctx: AutumnContext };
};

View File

@@ -1,43 +1,16 @@
import type { AppEnv, AuthType, Feature, Organization } from "@autumn/shared";
import type { ClickHouseClient } from "@clickhouse/client";
import { getRequestListener } from "@hono/node-server";
import { Hono } from "hono";
import { cors } from "hono/cors";
import type { DrizzleCli } from "./db/initDrizzle.js";
import type { Logger } from "./external/logtail/logtailUtils.js";
import { apiVersionMiddleware } from "./honoMiddlewares/apiVersionMiddleware.js";
import { baseMiddleware } from "./honoMiddlewares/baseMiddleware.js";
import { errorMiddleware } from "./honoMiddlewares/errorMiddleware.js";
import { orgConfigMiddleware } from "./honoMiddlewares/orgConfigMiddleware.js";
import { secretKeyMiddleware } from "./honoMiddlewares/secretKeyMiddleware.js";
import { traceMiddleware } from "./honoMiddlewares/traceMiddleware.js";
import { handleCreateProduct } from "./internal/products/honoProductRouter.js";
import type { HonoEnv } from "./honoUtils/HonoEnv.js";
import { honoProductRouter } from "./internal/products/honoProductRouter.js";
import { auth } from "./utils/auth.js";
type RequestContext = {
// Variables
org: Organization;
env: AppEnv;
features: Feature[];
userId?: string;
// Objects
db: DrizzleCli;
logger: Logger;
clickhouseClient: ClickHouseClient;
// Info
id: string;
isPublic: boolean;
authType: AuthType;
apiVersion: string;
timestamp: number;
};
export type HonoEnv = {
Variables: { ctx: RequestContext };
};
const ALLOWED_ORIGINS = [
"http://localhost:3000",
"http://localhost:5173",
@@ -103,8 +76,7 @@ export const createHonoApp = () => {
app.use("/v1/*", orgConfigMiddleware);
// Step 6: Add pricing middleware, analytics middleware, etc.
app.post("/v1/products", handleCreateProduct);
app.route("v1/products", honoProductRouter);
// Error handler - must be defined after all routes and middleware
app.onError(errorMiddleware);

View File

@@ -70,7 +70,7 @@ checkRouter.post("", async (req: any, res: any) => {
if (notNullish(requiredBalance)) {
const floatQuantity = parseFloat(requiredBalance);
if (isNaN(floatQuantity)) {
if (Number.isNaN(floatQuantity)) {
throw new RecaseError({
message: "Invalid required_balance",
code: ErrCode.InvalidRequest,

View File

@@ -5,6 +5,7 @@ import {
type Customer,
customers,
type EntityExpand,
ErrCode,
type FullCusProduct,
type FullCustomer,
type Organization,
@@ -13,7 +14,6 @@ import { trace } from "@opentelemetry/api";
import { and, eq, ilike, or, sql } from "drizzle-orm";
import { StatusCodes } from "http-status-codes";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { ErrCode } from "@/errors/errCodes.js";
import RecaseError from "@/utils/errorUtils.js";
import { withSpan } from "../analytics/tracer/spanUtils.js";
import { RELEVANT_STATUSES } from "./cusProducts/CusProductService.js";

View File

@@ -1,7 +1,7 @@
import {
APIVersion,
type AttachConfig,
AutumnError,
RecaseError,
SuccessCode,
} from "@autumn/shared";
import type Stripe from "stripe";
@@ -47,9 +47,8 @@ export const handleCreateCheckout = async ({
});
if (itemSets.length === 0) {
throw new AutumnError({
throw new RecaseError({
message: `Product ${attachParams.products.map((p) => p.name).join(", ")} has no prices, can't create checkout`,
statusCode: 400,
});
}

View File

@@ -1,41 +1,46 @@
import { ExtendedRequest } from "@/utils/models/Request.js";
import { AttachBody } from "@autumn/shared";
import { AttachParams } from "../../cusProducts/AttachParams.js";
import { notNullish } from "@/utils/genUtils.js";
import { AttachBranch, AttachErrCode, BillingInterval } from "@autumn/shared";
import { getExistingCusProducts } from "../../cusProducts/cusProductUtils/getExistingCusProducts.js";
import {
type AttachBody,
AttachBranch,
AttachErrCode,
BillingInterval,
cusProductToPrices,
cusProductToProduct,
ErrCode,
type FeatureOptions,
type FullCusProduct,
} from "@autumn/shared";
import { findPrepaidPrice } from "@/internal/products/prices/priceUtils/findPriceUtils.js";
import { hasPrepaidPrice } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js";
import { pricesOnlyOneOff } from "@/internal/products/prices/priceUtils.js";
import { ErrCode } from "@/errors/errCodes.js";
import RecaseError from "@/utils/errorUtils.js";
import { productsAreSame } from "@/internal/products/productUtils/compareProductUtils.js";
import {
isFreeProduct,
isProductUpgrade,
} from "@/internal/products/productUtils.js";
import { cusProductToPrices, cusProductToProduct } from "@autumn/shared";
import { FeatureOptions, FullCusProduct } from "@autumn/shared";
import { productsAreSame } from "@/internal/products/productUtils/compareProductUtils.js";
import { hasPrepaidPrice } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js";
import RecaseError from "@/utils/errorUtils.js";
import { notNullish } from "@/utils/genUtils.js";
import type { ExtendedRequest } from "@/utils/models/Request.js";
import type { AttachParams } from "../../cusProducts/AttachParams.js";
import { getExistingCusProducts } from "../../cusProducts/cusProductUtils/getExistingCusProducts.js";
import { isMainTrialBranch } from "./attachUtils.js";
import {
attachParamToCusProducts,
getCustomerSub,
} from "./convertAttachParams.js";
import { findPrepaidPrice } from "@/internal/products/prices/priceUtils/findPriceUtils.js";
import { isMainTrialBranch } from "./attachUtils.js";
const handleMultiProductErrors = async ({
attachParams,
}: {
attachParams: AttachParams;
}) => {
let { products } = attachParams;
const { products } = attachParams;
if (pricesOnlyOneOff(attachParams.prices)) {
return true;
}
for (const product of products) {
let { curMainProduct, curSameProduct, curScheduledProduct } =
const { curMainProduct, curSameProduct, curScheduledProduct } =
getExistingCusProducts({
product,
cusProducts: attachParams.cusProducts!,
@@ -55,7 +60,7 @@ const handleMultiProductErrors = async ({
});
}
let curPaidProduct =
const curPaidProduct =
curMainProduct &&
!isFreeProduct(cusProductToPrices({ cusProduct: curMainProduct }));
@@ -85,21 +90,21 @@ const getOptionsToUpdate = ({
newOptionsList: FeatureOptions[];
curSameProduct: FullCusProduct;
}) => {
let optionsToUpdate: { new: FeatureOptions; old: FeatureOptions }[] = [];
const optionsToUpdate: { new: FeatureOptions; old: FeatureOptions }[] = [];
const prices = cusProductToPrices({ cusProduct: curSameProduct });
for (const newOptions of newOptionsList) {
let internalFeatureId = newOptions.internal_feature_id;
let existingOptions = oldOptionsList.find(
const internalFeatureId = newOptions.internal_feature_id;
const existingOptions = oldOptionsList.find(
(o) => o.internal_feature_id === internalFeatureId,
);
let price = findPrepaidPrice({
const price = findPrepaidPrice({
prices,
internalFeatureId: internalFeatureId!,
});
if (price?.config.interval == BillingInterval.OneOff) continue;
if (price?.config.interval === BillingInterval.OneOff) continue;
if (existingOptions && existingOptions.quantity !== newOptions.quantity) {
optionsToUpdate.push({
@@ -123,9 +128,9 @@ export const checkSameCustom = async ({
fromPreview?: boolean;
optionsToUpdate: { new: FeatureOptions; old: FeatureOptions }[];
}) => {
let product = attachParams.products[0];
const product = attachParams.products[0];
let { itemsSame, freeTrialsSame, onlyEntsChanged } = productsAreSame({
const { itemsSame, freeTrialsSame, onlyEntsChanged } = productsAreSame({
newProductV1: {
...product,
prices: attachParams.prices,
@@ -176,7 +181,7 @@ const getSameProductBranch = async ({
attachParams: AttachParams;
fromPreview?: boolean;
}) => {
let product = attachParams.products[0];
const product = attachParams.products[0];
let { curSameProduct, curScheduledProduct } = attachParamToCusProducts({
attachParams,
@@ -190,7 +195,7 @@ const getSameProductBranch = async ({
return AttachBranch.NewVersion;
}
let optionsToUpdate = getOptionsToUpdate({
const optionsToUpdate = getOptionsToUpdate({
oldOptionsList: curSameProduct.options,
newOptionsList: attachParams.optionsList,
curSameProduct,
@@ -214,7 +219,7 @@ const getSameProductBranch = async ({
// 3. If main product
if (curScheduledProduct && !product.is_add_on) {
if (curScheduledProduct.product.id == product.id) {
if (curScheduledProduct.product.id === product.id) {
throw new RecaseError({
message: `Product ${product.name} is already scheduled, can't attach again`,
code: ErrCode.InvalidRequest,
@@ -257,16 +262,19 @@ const getChangeProductBranch = async ({
// 1. If main product is free, it's the same as adding a new product
let mainProduct = cusProductToProduct({ cusProduct: curMainProduct! });
const mainProduct = cusProductToProduct({ cusProduct: curMainProduct! });
if (isFreeProduct(mainProduct.prices)) {
return AttachBranch.MainIsFree;
}
// 2. If main product is paid, check if upgrade or downgrade
let curPrices = cusProductToPrices({ cusProduct: curMainProduct! });
let newPrices = attachParams.prices;
const curPrices = cusProductToPrices({ cusProduct: curMainProduct! });
const newPrices = attachParams.prices;
let isUpgrade = isProductUpgrade({ prices1: curPrices, prices2: newPrices });
const isUpgrade = isProductUpgrade({
prices1: curPrices,
prices2: newPrices,
});
if (isUpgrade) {
if (isMainTrialBranch({ attachParams })) {
@@ -309,7 +317,7 @@ export const getAttachBranch = async ({
return AttachBranch.MultiProduct;
}
let { curSameProduct, curMainProduct } = attachParamToCusProducts({
const { curSameProduct, curMainProduct } = attachParamToCusProducts({
attachParams,
});
@@ -318,7 +326,7 @@ export const getAttachBranch = async ({
return await getSameProductBranch({ attachParams, fromPreview });
}
let product = attachParams.products[0];
const product = attachParams.products[0];
if (product.is_add_on) {
return AttachBranch.AddOn;
}

View File

@@ -6,12 +6,12 @@ import {
BillingType,
cusProductsToCusEnts,
cusProductToPrices,
ErrCode,
type FullCusProduct,
type UsagePriceConfig,
} from "@autumn/shared";
import { Decimal } from "decimal.js";
import { StatusCodes } from "http-status-codes";
import { ErrCode } from "@/errors/errCodes.js";
import { findPriceForFeature } from "@/internal/products/prices/priceUtils/findPriceUtils.js";
import {
getBillingType,

View File

@@ -1,12 +1,15 @@
import RecaseError from "@/utils/errorUtils.js";
import { ErrCode } from "@/errors/errCodes.js";
import { findPrepaidPrice } from "@/internal/products/prices/priceUtils/findPriceUtils.js";
import { FullCusProduct, Price, UsagePriceConfig } from "@autumn/shared";
import { Feature } from "@autumn/shared";
import { FeatureOptions } from "@autumn/shared";
import {
ErrCode,
type Feature,
type FeatureOptions,
type FullCusProduct,
type Price,
type UsagePriceConfig,
} from "@autumn/shared";
import { Decimal } from "decimal.js";
import { findPrepaidPrice } from "@/internal/products/prices/priceUtils/findPriceUtils.js";
import { isFreeProduct, isOneOff } from "@/internal/products/productUtils.js";
import { getPriceEntitlement } from "@/internal/products/prices/priceUtils.js";
import RecaseError from "@/utils/errorUtils.js";
export const mapOptionsList = ({
optionsInput,
@@ -19,7 +22,7 @@ export const mapOptionsList = ({
prices: Price[];
curCusProduct?: FullCusProduct;
}) => {
let newOptionsList: FeatureOptions[] = [];
const newOptionsList: FeatureOptions[] = [];
for (const options of optionsInput || []) {
const feature = features.find(
@@ -47,9 +50,9 @@ export const mapOptionsList = ({
// const ent = getPriceEntitlement(prepaidPrice, entitlements)
let config = prepaidPrice.config as UsagePriceConfig;
const config = prepaidPrice.config as UsagePriceConfig;
let dividedQuantity = new Decimal(options.quantity!)
const dividedQuantity = new Decimal(options.quantity!)
.div(config.billing_units || 1)
.ceil()
.toNumber();

View File

@@ -1,11 +1,11 @@
import { routeHandler } from "@/utils/routerUtils.js";
import { getOrCreateCustomer } from "../cusUtils/getOrCreateCustomer.js";
import { ExtendedRequest } from "@/utils/models/Request.js";
import { ErrCode } from "@autumn/shared";
import { createStripeCusIfNotExists } from "@/external/stripe/stripeCusUtils.js";
import { createStripeCli } from "@/external/stripe/utils.js";
import RecaseError from "@/utils/errorUtils.js";
import { ErrCode } from "@/errors/errCodes.js";
import { toSuccessUrl } from "@/internal/orgs/orgUtils/convertOrgUtils.js";
import RecaseError from "@/utils/errorUtils.js";
import type { ExtendedRequest } from "@/utils/models/Request.js";
import { routeHandler } from "@/utils/routerUtils.js";
import { getOrCreateCustomer } from "../cusUtils/getOrCreateCustomer.js";
export const handleSetupPayment = async (req: any, res: any) =>
routeHandler({
@@ -16,10 +16,14 @@ export const handleSetupPayment = async (req: any, res: any) =>
const { db, env, org } = req;
const logger = req.logger;
let { customer_id, customer_data, success_url, checkout_session_params } =
req.body;
const {
customer_id,
customer_data,
success_url,
checkout_session_params,
} = req.body;
let customer = await getOrCreateCustomer({
const customer = await getOrCreateCustomer({
req,
customerId: customer_id,
customerData: customer_data as any,

View File

@@ -1,4 +1,8 @@
import { ErrCode, type FullCusProduct } from "@autumn/shared";
import {
CusProductNotFoundError,
ErrCode,
type FullCusProduct,
} from "@autumn/shared";
import { Router } from "express";
import { CusService } from "@/internal/customers/CusService.js";
import RecaseError from "@/utils/errorUtils.js";
@@ -57,9 +61,10 @@ cancelRouter.post("", async (req, res) =>
);
if (!cusProduct) {
throw new RecaseError({
code: ErrCode.ProductNotFound,
message: `Product ${product_id} not found for customer ${customer_id}`,
throw new CusProductNotFoundError({
customerId: customer_id,
productId: product_id,
entityId: entity_id,
});
}

View File

@@ -1,42 +1,38 @@
import {
AppEnv,
type AppEnv,
BillingType,
CusProductStatus,
Customer,
CustomerData,
Entitlement,
EntitlementWithFeature,
Entity,
EntityData,
Feature,
FeatureOptions,
FreeTrial,
FullCusProduct,
Organization,
Price,
ProductItem,
UsagePriceConfig,
type Customer,
type CustomerData,
type Entitlement,
type EntitlementWithFeature,
type EntityData,
ErrCode,
type Feature,
type FeatureOptions,
type FreeTrial,
type FullCusProduct,
type Organization,
type Price,
type ProductItem,
type UsagePriceConfig,
} from "@autumn/shared";
import { ErrCode } from "@/errors/errCodes.js";
import RecaseError from "@/utils/errorUtils.js";
import { ProductService } from "@/internal/products/ProductService.js";
import { notNullish, nullish } from "@/utils/genUtils.js";
import { Decimal } from "decimal.js";
import { StatusCodes } from "http-status-codes";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { getOrCreateCustomer } from "@/internal/customers/cusUtils/getOrCreateCustomer.js";
import {
getFreeTrialAfterFingerprint,
handleNewFreeTrial,
} from "@/internal/products/free-trials/freeTrialUtils.js";
import { StatusCodes } from "http-status-codes";
import { getExistingCusProducts } from "./cusProductUtils/getExistingCusProducts.js";
import { getPricesForCusProduct } from "../change-product/scheduleUtils.js";
import { getOrCreateCustomer } from "@/internal/customers/cusUtils/getOrCreateCustomer.js";
import { handleNewProductItems } from "@/internal/products/product-items/productItemUtils/handleNewProductItems.js";
import { ProductService } from "@/internal/products/ProductService.js";
import { getBillingType } from "@/internal/products/prices/priceUtils.js";
import { Decimal } from "decimal.js";
import { DrizzleCli } from "@/db/initDrizzle.js";
import { ExtendedRequest } from "@/utils/models/Request.js";
import { handleNewProductItems } from "@/internal/products/product-items/productItemUtils/handleNewProductItems.js";
import RecaseError from "@/utils/errorUtils.js";
import { notNullish, nullish } from "@/utils/genUtils.js";
import type { ExtendedRequest } from "@/utils/models/Request.js";
import { getPricesForCusProduct } from "../change-product/scheduleUtils.js";
import { getExistingCusProducts } from "./cusProductUtils/getExistingCusProducts.js";
const getProducts = async ({
db,
@@ -107,7 +103,7 @@ const getProducts = async ({
});
}
if (products.length != productIds.length) {
if (products.length !== productIds.length) {
// Get product ids that were not found
throw new RecaseError({
message:
@@ -205,7 +201,7 @@ const getCustomerAndProducts = async ({
}),
]);
let cusProducts = customer.customer_products;
const cusProducts = customer.customer_products;
return { customer, cusProducts, products };
};
@@ -228,7 +224,7 @@ const mapOptionsList = ({
features: Feature[];
prices: Price[];
}) => {
let newOptionsList: FeatureOptions[] = [];
const newOptionsList: FeatureOptions[] = [];
for (const options of optionsListInput) {
const feature = features.find(
(feature) => feature.id === options.feature_id,
@@ -246,8 +242,8 @@ const mapOptionsList = ({
if (!nullish(quantity)) {
const prepaidPrice = prices.find(
(p) =>
getBillingType(p.config!) == BillingType.UsageInAdvance &&
feature.internal_id ==
getBillingType(p.config!) === BillingType.UsageInAdvance &&
feature.internal_id ===
(p.config as UsagePriceConfig).internal_feature_id,
);
@@ -259,9 +255,9 @@ const mapOptionsList = ({
});
}
let config = prepaidPrice.config as UsagePriceConfig;
const config = prepaidPrice.config as UsagePriceConfig;
let dividedQuantity = new Decimal(options.quantity!)
const dividedQuantity = new Decimal(options.quantity!)
.div(config.billing_units || 1)
.ceil()
.toNumber();
@@ -336,7 +332,7 @@ export const getFullCusProductData = async ({
if (!isCustom) {
let freeTrial = null;
let freeTrialProduct = products.find((p) => notNullish(p.free_trial));
const freeTrialProduct = products.find((p) => notNullish(p.free_trial));
if (freeTrialProduct) {
freeTrial = await getFreeTrialAfterFingerprint({
@@ -357,12 +353,12 @@ export const getFullCusProductData = async ({
optionsList: mapOptionsList({
optionsListInput,
features,
prices: products.map((p) => p.prices).flat() as Price[],
prices: products.flatMap((p) => p.prices) as Price[],
}),
prices: products.map((p) => p.prices).flat() as Price[],
entitlements: products
.map((p) => getEntsWithFeature(p.entitlements, features))
.flat() as EntitlementWithFeature[],
prices: products.flatMap((p) => p.prices) as Price[],
entitlements: products.flatMap((p) =>
getEntsWithFeature(p.entitlements, features),
) as EntitlementWithFeature[],
freeTrial,
cusProducts,
entities: customer.entities,
@@ -407,7 +403,7 @@ export const getFullCusProductData = async ({
curEnts = curMainProduct!.customer_entitlements.map((e) => e.entitlement);
}
let { prices, entitlements } = await handleNewProductItems({
const { prices, entitlements } = await handleNewProductItems({
db,
curPrices,
curEnts,

View File

@@ -1,8 +1,9 @@
import { AttachScenario, ErrCode } from "@autumn/shared";
import {
AttachScenario,
CusProductAlreadyExistsError,
CusProductNotFoundError,
} from "@shared/api/errors/classes/cusProductErrClasses.js";
ErrCode,
} from "@autumn/shared";
import { z } from "zod";
import { addProductsUpdatedWebhookTask } from "@/internal/analytics/handlers/handleProductsUpdated.js";
import { ProductService } from "@/internal/products/ProductService.js";

View File

@@ -1,10 +1,9 @@
import RecaseError from "@/utils/errorUtils.js";
import { AppEnv, Feature, features } from "@autumn/shared";
import { ErrCode } from "@/errors/errCodes.js";
import { clearOrgCache } from "../orgs/orgUtils/clearOrgCache.js";
import { DrizzleCli } from "@/db/initDrizzle.js";
import { type AppEnv, ErrCode, type Feature, features } from "@autumn/shared";
import { and, eq } from "drizzle-orm";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import RecaseError from "@/utils/errorUtils.js";
import { notNullish } from "@/utils/genUtils.js";
import { clearOrgCache } from "../orgs/orgUtils/clearOrgCache.js";
export class FeatureService {
static async list({
@@ -136,13 +135,13 @@ export class FeatureService {
logger: any;
}) {
try {
let insertedData = await db
const insertedData = await db
.insert(features)
.values(data as any) // DRIZZLE TYPE REFACTOR
.returning();
if (insertedData && insertedData.length > 0) {
let orgId = insertedData[0].org_id;
const orgId = insertedData[0].org_id;
await clearOrgCache({
db,
orgId: orgId!,
@@ -152,7 +151,7 @@ export class FeatureService {
return insertedData as Feature[]; // DRIZZLE TYPE REFACTOR
} catch (error: any) {
if (error.code === "23505") {
let id = Array.isArray(data) ? data.map((f) => f.id) : data.id;
const id = Array.isArray(data) ? data.map((f) => f.id) : data.id;
throw new RecaseError({
message: `Feature ${id} already exists`,
code: ErrCode.DuplicateFeatureId,
@@ -173,7 +172,7 @@ export class FeatureService {
orgId: string;
env: AppEnv;
}) {
let deletedFeatures = await db
const deletedFeatures = await db
.delete(features)
.where(
and(

View File

@@ -1,4 +1,4 @@
import { ErrCode } from "@/errors/errCodes.js";
import { ErrCode } from "@autumn/shared";
import { getCreditSystemsFromFeature } from "@/internal/features/creditSystemUtils.js";
import { FeatureService } from "@/internal/features/FeatureService.js";
import { EntitlementService } from "@/internal/products/entitlements/EntitlementService.js";
@@ -17,10 +17,10 @@ export const handleDeleteFeature = async (req: any, res: any) =>
handler: async (req: ExtendedRequest, res: ExtendedResponse) => {
const { db, orgId } = req;
let { featureId } = req.params;
let features = await FeatureService.getFromReq(req);
let feature = features.find((f) => f.id === featureId);
let creditSystems = getCreditSystemsFromFeature({
const { featureId } = req.params;
const features = await FeatureService.getFromReq(req);
const feature = features.find((f) => f.id === featureId);
const creditSystems = getCreditSystemsFromFeature({
featureId,
features,
});

View File

@@ -1,33 +1,31 @@
import { DrizzleCli } from "@/db/initDrizzle.js";
import { ErrCode } from "@/errors/errCodes.js";
import {
type AppEnv,
EntInterval,
type Entitlement,
type EntitlementWithFeature,
ErrCode,
type Feature,
FeatureType,
FeatureUsageType,
notNullish,
type Price,
type UsagePriceConfig,
} from "@autumn/shared";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js";
import { FeatureService } from "@/internal/features/FeatureService.js";
import {
getObjectsUsingFeature,
validateCreditSystem,
validateMeteredConfig,
} from "@/internal/features/featureUtils.js";
import { validateMeteredConfig } from "@/internal/features/featureUtils.js";
import { PriceService } from "@/internal/products/prices/PriceService.js";
import { EntitlementService } from "@/internal/products/entitlements/EntitlementService.js";
import { PriceService } from "@/internal/products/prices/PriceService.js";
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 {
Entitlement,
Feature,
FeatureType,
Price,
UsagePriceConfig,
AppEnv,
EntitlementWithFeature,
EntInterval,
FeatureUsageType,
notNullish,
} from "@autumn/shared";
import { ExtendedRequest, ExtendedResponse } from "@/utils/models/Request.js";
import { toAPIFeature } from "../utils/mapFeatureUtils.js";
const handleFeatureIdChanged = async ({
@@ -54,7 +52,7 @@ const handleFeatureIdChanged = async ({
logger: any;
}) => {
// 1. Check if any customer entitlement linked to this feature
let cusEnts = await CusEntService.getByFeature({
const cusEnts = await CusEntService.getByFeature({
db,
internalFeatureId: feature.internal_id!,
});
@@ -68,9 +66,9 @@ const handleFeatureIdChanged = async ({
}
// 2. Update all linked objects
let batchUpdate = [];
const batchUpdate = [];
for (let entitlement of linkedEntitlements) {
for (const entitlement of linkedEntitlements) {
batchUpdate.push(
EntitlementService.update({
db,
@@ -85,8 +83,8 @@ const handleFeatureIdChanged = async ({
await Promise.all(batchUpdate);
// 3. Update all linked prices
let priceUpdate = [];
for (let price of prices) {
const priceUpdate = [];
for (const price of prices) {
priceUpdate.push(
PriceService.update({
db,
@@ -104,9 +102,9 @@ const handleFeatureIdChanged = async ({
await Promise.all(priceUpdate);
// 4. Update all linked credit systems
let creditSystemUpdate = [];
for (let creditSystem of creditSystems) {
let newSchema = structuredClone(creditSystem.config.schema);
const creditSystemUpdate = [];
for (const creditSystem of creditSystems) {
const newSchema = structuredClone(creditSystem.config.schema);
for (let i = 0; i < newSchema.length; i++) {
if (newSchema[i].metered_feature_id === feature.id) {
newSchema[i].metered_feature_id = newId;
@@ -131,9 +129,9 @@ const handleFeatureIdChanged = async ({
await Promise.all(creditSystemUpdate);
// 5. Update all linked entitlements
let entitlementUpdate = [];
const entitlementUpdate = [];
for (let entitlement of entitlements) {
for (const entitlement of entitlements) {
entitlementUpdate.push(
EntitlementService.update({
db,
@@ -165,7 +163,7 @@ const handleFeatureUsageTypeChanged = async ({
prices: Price[];
creditSystems: Feature[];
}) => {
let usageTypeTitle = keyToTitle(newUsageType).toLowerCase();
const usageTypeTitle = keyToTitle(newUsageType).toLowerCase();
if (creditSystems.length > 0) {
throw new RecaseError({
message: `Cannot set to ${usageTypeTitle} because it is used in credit system ${creditSystems[0].id}`,
@@ -183,7 +181,7 @@ const handleFeatureUsageTypeChanged = async ({
}
// Get cus product using feature...
let cusEnts = await CusEntService.getByFeature({
const cusEnts = await CusEntService.getByFeature({
db,
internalFeatureId: feature.internal_id!,
});
@@ -201,8 +199,8 @@ const handleFeatureUsageTypeChanged = async ({
`Feature usage type changed to ${newUsageType}, updating entitlements and prices`,
);
if (newUsageType == FeatureUsageType.Continuous) {
let batchEntUpdate = [];
for (let entitlement of entitlements) {
const batchEntUpdate = [];
for (const entitlement of entitlements) {
batchEntUpdate.push(
EntitlementService.update({
db,
@@ -220,9 +218,9 @@ const handleFeatureUsageTypeChanged = async ({
}
if (prices.length > 0) {
let batchPriceUpdate = [];
for (let price of prices) {
let priceConfig = price.config as UsagePriceConfig;
const batchPriceUpdate = [];
for (const price of prices) {
const priceConfig = price.config as UsagePriceConfig;
batchPriceUpdate.push(
PriceService.update({
@@ -259,13 +257,13 @@ export const handleUpdateFeature = async (
res,
action: "Update feature",
handler: async (req: any, res: any) => {
let featureId = req.params.feature_id;
let data = req.body;
let { db, orgId, env, logtail: logger } = req;
const featureId = req.params.feature_id;
const data = req.body;
const { db, orgId, env, logtail: logger } = req;
// 1. Get feature by ID
let features = await FeatureService.getFromReq(req);
let feature = features.find((f) => f.id == featureId);
const features = await FeatureService.getFromReq(req);
const feature = features.find((f) => f.id == featureId);
if (!feature) {
throw new RecaseError({
@@ -278,7 +276,7 @@ export const handleUpdateFeature = async (
// If only archiving, skip other checks and just update
if (data.archived !== undefined && Object.keys(data).length === 1) {
console.log("Updating feature archived to: ", data.archived);
let updatedFeature = await FeatureService.update({
const updatedFeature = await FeatureService.update({
db: req.db,
id: featureId,
orgId: req.orgId,
@@ -301,19 +299,20 @@ export const handleUpdateFeature = async (
}
// 1. Check if changing type...
let isChangingType = notNullish(data.type) && feature.type !== data.type;
const isChangingType =
notNullish(data.type) && feature.type !== data.type;
let isChangingId = notNullish(data.id) && feature.id !== data.id;
const isChangingId = notNullish(data.id) && feature.id !== data.id;
let isChangingUsageType =
const isChangingUsageType =
feature.type != FeatureType.Boolean &&
data.type != FeatureType.Boolean &&
feature.config?.usage_type != data.config?.usage_type;
let isChangingName = feature.name !== data.name;
const isChangingName = feature.name !== data.name;
if (isChangingType || isChangingId || isChangingUsageType) {
let { entitlements, prices, creditSystems, linkedEntitlements } =
const { entitlements, prices, creditSystems, linkedEntitlements } =
await getObjectsUsingFeature({
db,
orgId: req.orgId,
@@ -374,7 +373,7 @@ export const handleUpdateFeature = async (
: data.config
: feature.config;
let updatedFeature = await FeatureService.update({
const updatedFeature = await FeatureService.update({
db: req.db,
id: featureId,
orgId: req.orgId,

View File

@@ -1,26 +1,19 @@
import { routeHandler } from "@/utils/routerUtils.js";
import { AppEnv, ErrCode } from "@autumn/shared";
import Stripe from "stripe";
import RecaseError from "@/utils/errorUtils.js";
import { encryptData } from "@/utils/encryptUtils.js";
import { ErrCode } from "@/errors/errCodes.js";
import { z } from "zod";
import { ensureStripeProductsWithEnv } from "@/external/stripe/stripeEnsureUtils.js";
import {
checkKeyValid,
createWebhookEndpoint,
} from "@/external/stripe/stripeOnboardingUtils.js";
import { encryptData } from "@/utils/encryptUtils.js";
import RecaseError from "@/utils/errorUtils.js";
import { nullish } from "@/utils/genUtils.js";
import { routeHandler } from "@/utils/routerUtils.js";
import { OrgService } from "../OrgService.js";
import { AppEnv } from "@autumn/shared";
import { notNullish, nullish } from "@/utils/genUtils.js";
import { clearOrgCache } from "../orgUtils/clearOrgCache.js";
import { z } from "zod";
import { isStripeConnected } from "../orgUtils.js";
import {
ensureStripeProducts,
ensureStripeProductsWithEnv,
} from "@/external/stripe/stripeEnsureUtils.js";
import { toSuccessUrl } from "../orgUtils/convertOrgUtils.js";
export const connectStripe = async ({
orgId,
@@ -34,9 +27,9 @@ export const connectStripe = async ({
// 1. Check if key is valid
await checkKeyValid(apiKey);
let stripe = new Stripe(apiKey);
const stripe = new Stripe(apiKey);
let account = await stripe.accounts.retrieve();
const account = await stripe.accounts.retrieve();
// 2. Disconnect existing webhook endpoints
const curWebhooks = await stripe.webhookEndpoints.list();
@@ -47,7 +40,7 @@ export const connectStripe = async ({
}
// 3. Create new webhook endpoint
let webhook = await createWebhookEndpoint(apiKey, env, orgId);
const webhook = await createWebhookEndpoint(apiKey, env, orgId);
// 3. Return encrypted
if (env === AppEnv.Sandbox) {
@@ -96,9 +89,9 @@ export const connectAllStripe = async ({
await checkKeyValid(liveApiKey);
// Get default currency from Stripe
let stripe = new Stripe(testApiKey);
const stripe = new Stripe(testApiKey);
let account = await stripe.accounts.retrieve();
const account = await stripe.accounts.retrieve();
if (nullish(defaultCurrency) && nullish(account.default_currency)) {
throw new RecaseError({

View File

@@ -1,99 +1,80 @@
import {
APIProductSchema,
CreateProductSchema,
type Entitlement,
ErrCode,
type CreateProductParams,
CreateProductParamsSchema,
type FreeTrial,
type FullProduct,
type Price,
type Product,
ProductAlreadyExistsError,
type ProductItem,
} from "@autumn/shared";
import {
handleNewFreeTrial,
validateAndInitFreeTrial,
} from "@/internal/products/free-trials/freeTrialUtils.js";
import { ProductService } from "@/internal/products/ProductService.js";
import { handleNewProductItems } from "@/internal/products/product-items/productItemUtils/handleNewProductItems.js";
import { validateProductItems } from "@/internal/products/product-items/validateProductItems.js";
import {
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 type { ExtendedRequest } from "@/utils/models/Request.js";
import { routeHandler } from "@/utils/routerUtils.js";
import { validateOneOffTrial } from "../free-trials/freeTrialUtils.js";
import { createRoute } from "@/honoMiddlewares/routeHandler.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { ProductService } from "../ProductService.js";
import { isDefaultTrial } from "../productUtils/classifyProduct.js";
import { getGroupToDefaults } from "../productUtils.js";
const validateCreateProduct = async ({ req }: { req: ExtendedRequest }) => {
const { free_trial, items } = req.body;
const { orgId, env, db, features } = req;
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);
// const productData = CreateProductSchema.parse(req.body);
validateId("Product", productData.id);
// validateId("Product", productData.id);
if (nullish(req.body.name)) {
productData.name = keyToTitle(productData.id);
}
// if (nullish(req.body.name)) {
// productData.name = keyToTitle(productData.id);
// }
const existing = await ProductService.get({
db,
orgId,
orgId: org.id,
env,
id: productData.id,
id: body.id,
});
// 1. If existing product, throw error
if (existing) {
throw new ProductAlreadyExistsError({
productId: productData.id,
});
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,
});
}
// 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);
}
// 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,
// freeTrial,
// productData,
};
};
@@ -103,11 +84,10 @@ export const disableCurrentDefault = async ({
items,
freeTrial,
}: {
req: ExtendedRequest;
req: AutumnContext;
newProduct: Product;
items: ProductItem[];
freeTrial: FreeTrial | null;
freeTrial: FreeTrial;
}) => {
const { db, org, env, logger } = req;
let defaultProds = await ProductService.listDefault({
@@ -158,98 +138,104 @@ export const disableCurrentDefault = async ({
}
};
export const handleCreateProduct = async (req: Request, res: any) =>
routeHandler({
req,
res,
action: "POST /products",
handler: async (req, res) => {
const { items } = req.body;
/**
* Route: POST /products - Create a product
*/
export const createProduct = createRoute({
body: CreateProductParamsSchema,
handler: async (c) => {
const body = c.req.valid("json");
const query = c.req.valid("query");
const ctx = c.get("ctx");
const { logtail: logger, org, features, env, db } = req;
const { logger, org, features, env, db } = ctx;
const { items } = body;
const { freeTrial, productData } = await validateCreateProduct({
req,
});
// const { features, freeTrial, productData } = await validateCreateProduct({
// ctx,
// body,
// });
const newProduct = constructProduct({
productData,
orgId: org.id,
env,
});
// const newProduct = constructProduct({
// productData,
// orgId: org.id,
// env,
// });
await disableCurrentDefault({
req,
newProduct,
items,
freeTrial: freeTrial || null,
});
return c.json({});
const product = await ProductService.insert({ db, product: newProduct });
// await disableCurrentDefault({
// req,
// newProduct,
// items,
// freeTrial: freeTrial || null,
// });
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;
}
// const product = await ProductService.insert({ db, product: newProduct });
await validateOneOffTrial({
prices,
freeTrial: freeTrial || null,
});
// 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;
// }
await initProductInStripe({
db,
product: {
...product,
prices,
entitlements,
} as FullProduct,
org,
env,
logger,
});
// await validateOneOffTrial({
// prices,
// freeTrial: freeTrial || null,
// });
if (notNullish(freeTrial)) {
await handleNewFreeTrial({
db,
newFreeTrial: freeTrial,
curFreeTrial: null,
internalProductId: product.internal_id,
isCustom: false,
});
}
// await initProductInStripe({
// db,
// product: {
// ...product,
// prices,
// entitlements,
// } as FullProduct,
// org,
// env,
// logger,
// });
await addTaskToQueue({
jobName: JobName.DetectBaseVariant,
payload: {
curProduct: {
...product,
prices,
entitlements: [],
},
},
});
// if (notNullish(freeTrial)) {
// await handleNewFreeTrial({
// db,
// newFreeTrial: freeTrial,
// curFreeTrial: null,
// internalProductId: product.internal_id,
// isCustom: false,
// });
// }
res.status(200).json(
APIProductSchema.parse({
...product,
autumn_id: product.internal_id,
items: items || [],
free_trial: freeTrial,
}),
);
},
});
// await addTaskToQueue({
// jobName: JobName.DetectBaseVariant,
// payload: {
// curProduct: {
// ...product,
// prices,
// entitlements: [],
// },
// },
// });
// res.status(200).json(
// APIProductSchema.parse({
// ...product,
// autumn_id: product.internal_id,
// items: items || [],
// free_trial: freeTrial,
// }),
// );
},
});

View File

@@ -0,0 +1,255 @@
import {
APIProductSchema,
CreateProductSchema,
type Entitlement,
ErrCode,
type FreeTrial,
type FullProduct,
type Price,
type Product,
ProductAlreadyExistsError,
type ProductItem,
} from "@autumn/shared";
import {
handleNewFreeTrial,
validateAndInitFreeTrial,
} from "@/internal/products/free-trials/freeTrialUtils.js";
import { ProductService } from "@/internal/products/ProductService.js";
import { handleNewProductItems } from "@/internal/products/product-items/productItemUtils/handleNewProductItems.js";
import { validateProductItems } from "@/internal/products/product-items/validateProductItems.js";
import {
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 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 }) => {
const { free_trial, items } = req.body;
const { orgId, env, db, features } = req;
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,
env,
id: productData.id,
});
// 1. If existing product, throw error
if (existing) {
throw new ProductAlreadyExistsError({
productId: productData.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,
};
};
export const disableCurrentDefault = async ({
req,
newProduct,
items,
freeTrial,
}: {
req: ExtendedRequest;
newProduct: Product;
items: ProductItem[];
freeTrial: FreeTrial | null;
}) => {
const { db, org, env, logger } = req;
let defaultProds = await ProductService.listDefault({
db,
orgId: org.id,
env,
});
defaultProds = defaultProds.filter((prod) => prod.id !== newProduct.id);
if (defaultProds.length === 0) return;
const defaults = getGroupToDefaults({
defaultProds,
})?.[newProduct.group];
const willBeDefaultTrial = isDefaultTrial({
product: {
...newProduct,
free_trial: freeTrial,
items: items || [],
},
});
if (willBeDefaultTrial) {
// Disable current default trial
const curDefault = defaults?.defaultTrial;
if (curDefault) {
logger.info(
`Disabling trial on cur default trial product: ${curDefault.id}`,
);
await ProductService.updateByInternalId({
db,
internalId: curDefault.internal_id,
update: { is_default: false },
});
}
} else if (newProduct.is_default) {
const curDefault = defaults?.free;
if (curDefault) {
logger.info(`Disabling trial on cur default product: ${curDefault.id}`);
await ProductService.updateByInternalId({
db,
internalId: curDefault.internal_id,
update: { is_default: false },
});
}
}
};
export const handleCreateProduct = async (req: Request, res: any) =>
routeHandler({
req,
res,
action: "POST /products",
handler: async (req, res) => {
const { items } = req.body;
const { logtail: logger, org, features, env, db } = req;
const { freeTrial, productData } = await validateCreateProduct({
req,
});
const newProduct = constructProduct({
productData,
orgId: org.id,
env,
});
await disableCurrentDefault({
req,
newProduct,
items,
freeTrial: freeTrial || null,
});
const product = await ProductService.insert({ db, product: newProduct });
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;
}
await validateOneOffTrial({
prices,
freeTrial: freeTrial || null,
});
await initProductInStripe({
db,
product: {
...product,
prices,
entitlements,
} as FullProduct,
org,
env,
logger,
});
if (notNullish(freeTrial)) {
await handleNewFreeTrial({
db,
newFreeTrial: freeTrial,
curFreeTrial: null,
internalProductId: product.internal_id,
isCustom: false,
});
}
await addTaskToQueue({
jobName: JobName.DetectBaseVariant,
payload: {
curProduct: {
...product,
prices,
entitlements: [],
},
},
});
res.status(200).json(
APIProductSchema.parse({
...product,
autumn_id: product.internal_id,
items: items || [],
free_trial: freeTrial,
}),
);
},
});

View File

@@ -1,4 +1,4 @@
import { AutumnError, ProductNotFoundError } from "@autumn/shared";
import { ProductNotFoundError, RecaseError } from "@autumn/shared";
import { CusProdReadService } from "@/internal/customers/cusProducts/CusProdReadService.js";
import { ProductService } from "@/internal/products/ProductService.js";
import { routeHandler } from "@/utils/routerUtils.js";
@@ -41,9 +41,8 @@ export const handleDeleteProduct = (req: any, res: any) =>
const cusProdCount = deleteAllVersions ? allCounts.all : latestCounts.all;
if (cusProdCount > 0) {
throw new AutumnError({
throw new RecaseError({
message: `Product ${productId} has ${cusProdCount} customers (expired or active) on it and therefore cannot be deleted`,
statusCode: 400,
});
}

View File

@@ -22,7 +22,7 @@ import { mapToProductItems } from "../../productV2Utils.js";
import {
disableCurrentDefault,
handleCreateProduct,
} from "../handleCreateProduct.js";
} from "../handleCreateProduct_old.js";
import { handleVersionProductV2 } from "../handleVersionProduct.js";
import { handleUpdateProductDetails } from "./updateProductDetails.js";

View File

@@ -1,12 +1,12 @@
import {
type AppEnv,
AutumnError,
type FreeTrial,
type FullProduct,
isFreeProductV2,
type Organization,
type Product,
type ProductItem,
RecaseError,
type RewardProgram,
type UpdateProduct,
} from "@autumn/shared";
@@ -25,23 +25,23 @@ import {
import { isFreeProduct } from "../../productUtils.js";
const productDetailsSame = (prod1: Product, prod2: UpdateProduct) => {
if (notNullish(prod2.id) && prod1.id != prod2.id) {
if (notNullish(prod2.id) && prod1.id !== prod2.id) {
return false;
}
if (notNullish(prod2.name) && prod1.name != prod2.name) {
if (notNullish(prod2.name) && prod1.name !== prod2.name) {
return false;
}
if (notNullish(prod2.group) && prod1.group != prod2.group) {
if (notNullish(prod2.group) && prod1.group !== prod2.group) {
return false;
}
if (notNullish(prod2.is_add_on) && prod1.is_add_on != prod2.is_add_on) {
if (notNullish(prod2.is_add_on) && prod1.is_add_on !== prod2.is_add_on) {
return false;
}
if (notNullish(prod2.is_default) && prod1.is_default != prod2.is_default) {
if (notNullish(prod2.is_default) && prod1.is_default !== prod2.is_default) {
return false;
}
@@ -195,14 +195,14 @@ export const handleUpdateProductDetails = async ({
// 1. Check if there are items
if (items) {
if (items.some((item) => isFeaturePriceItem(item) || isPriceItem(item))) {
throw new AutumnError({
throw new RecaseError({
message:
"Cannot make a product default if it has fixed prices or paid features",
});
}
} else {
if (!isFreeProduct(curProduct.prices)) {
throw new AutumnError({
throw new RecaseError({
message:
"Cannot make a product default if it has fixed prices or paid features",
});
@@ -216,13 +216,13 @@ export const handleUpdateProductDetails = async ({
if (notNullish(newProduct.id) && newProduct.id !== curProduct.id) {
if (customersOnAllVersions.length > 0) {
throw new AutumnError({
throw new RecaseError({
message: "Cannot change product ID because it has existing customers",
});
}
if (rewardPrograms.length > 0) {
throw new AutumnError({
throw new RecaseError({
message:
"Cannot change product ID because existing reward programs are linked to it",
});

View File

@@ -1,33 +1,9 @@
// import { zValidator } from "@hono/zod-validator";
import { Hono } from "hono";
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
import { createProduct } from "./handlers/handleCreateProduct.js";
import { ProductNotFoundError } from "@autumn/shared";
import type { Context } from "hono";
import { createStripeCli } from "@/external/stripe/utils.js";
import type { HonoEnv } from "@/initHono.js";
// Create a Hono app for products
export const honoProductRouter = new Hono<HonoEnv>();
export const handleCreateProduct = async (c: Context<HonoEnv>) => {
const body1 = await c.req.json();
const body2 = await c.req.json();
const ctx = c.get("ctx");
const { org, env } = ctx;
const stripe = createStripeCli({ org, env });
try {
const product = await stripe.products.retrieve("123");
} catch (error: any) {
console.log(error.message, error.code);
}
throw new ProductNotFoundError({ productId: "123" });
// return c.json({ message: "Hello from Hono!" });
// throw new ProductNotFoundError({ productId: "123" });
// Get parsed body from context (already parsed by wrapExpressMiddleware)
// const body = c.get("parsedBody");
// console.log("Body:", body);
// return c.json({ message: "Hello from Hono!", receivedBody: body });
};
// POST /products - Create a product
honoProductRouter.post("", ...createProduct);

View File

@@ -65,13 +65,6 @@ productRouter.get("/product_counts", async (req: any, res) => {
const counts = await Promise.all(
products.map(async (product) => {
// if (latestVersion) {
// return CusProdReadService.getCounts({
// db,
// internalProductId: product.internal_id,
// });
// }
return CusProdReadService.getCountsForAllVersions({
db,
productId: product.id,
@@ -461,11 +454,10 @@ productRouter.post("/product_options", async (req: any, res: any) => {
try {
const { items } = req.body;
const features = await FeatureService.getFromReq(req);
const featureToOptions: { [key: string]: FeatureOptions } = {};
for (const item of items) {
if (isFeaturePriceItem(item) && item.usage_model == UsageModel.Prepaid) {
if (isFeaturePriceItem(item) && item.usage_model === UsageModel.Prepaid) {
featureToOptions[item.feature_id] = {
feature_id: item.feature_id,
quantity: 0,

View File

@@ -35,6 +35,10 @@ export const subItemToFixedPrice = ({
interval,
interval_count: intervalCount,
stripe_price_id: price.id,
stripe_product_id: undefined,
feature_id: undefined,
internal_feature_id: undefined,
},
});
};

View File

@@ -1,42 +1,41 @@
import RecaseError from "@/utils/errorUtils.js";
import {
AllowanceType,
BillingInterval,
BillingType,
BillWhen,
EntInterval,
Entitlement,
type Entitlement,
ErrCode,
Feature,
type Feature,
FeatureType,
FixedPriceConfig,
Infinite,
Price,
PriceType,
ProductItem,
UsageModel,
TierInfinite,
UsagePriceConfig,
OnIncrease,
OnDecrease,
FeatureUsageType,
type FixedPriceConfig,
Infinite,
OnDecrease,
OnIncrease,
type Price,
PriceType,
type ProductItem,
TierInfinite,
UsageModel,
type UsagePriceConfig,
} from "@autumn/shared";
import { generateId, notNullish, nullish } from "@/utils/genUtils.js";
import { pricesAreSame } from "@/internal/products/prices/priceInitUtils.js";
import { entsAreSame } from "../../entitlements/entitlementUtils.js";
import { getBillingType } from "@/internal/products/prices/priceUtils.js";
import {
isFeatureItem,
isFeaturePriceItem,
isPriceItem,
} from "./getItemType.js";
import RecaseError from "@/utils/errorUtils.js";
import { generateId, notNullish, nullish } from "@/utils/genUtils.js";
import { entsAreSame } from "../../entitlements/entitlementUtils.js";
import { shouldProrate } from "../../prices/priceUtils/prorationConfigUtils.js";
import {
itemToBillingInterval,
itemToEntInterval,
} from "../itemIntervalUtils.js";
import { itemCanBeProrated } from "./classifyItem.js";
import { shouldProrate } from "../../prices/priceUtils/prorationConfigUtils.js";
import {
isFeatureItem,
isFeaturePriceItem,
isPriceItem,
} from "./getItemType.js";
export const getResetUsage = ({
item,
@@ -53,7 +52,7 @@ export const getResetUsage = ({
(isFeatureItem(item) || isFeaturePriceItem(item)) &&
feature
) {
return feature?.config?.usage_type == FeatureUsageType.Single;
return feature?.config?.usage_type === FeatureUsageType.Single;
}
return item.reset_usage_when_enabled;
};
@@ -71,11 +70,14 @@ export const toPrice = ({
isCustom: boolean;
newVersion?: boolean;
}) => {
let config: FixedPriceConfig = {
const config: FixedPriceConfig = {
type: PriceType.Fixed,
amount: notNullish(item.price) ? item.price! : item.tiers![0].amount!,
interval: itemToBillingInterval(item) as BillingInterval,
interval_count: item.interval_count || 1,
stripe_product_id: null,
feature_id: null,
internal_feature_id: null,
};
let price: Price = {
@@ -116,9 +118,9 @@ export const toFeature = ({
newVersion?: boolean;
feature?: Feature;
}) => {
let isBoolean = feature?.type == FeatureType.Boolean;
const isBoolean = feature?.type == FeatureType.Boolean;
let resetUsage = getResetUsage({ item, feature });
const resetUsage = getResetUsage({ item, feature });
let ent: Entitlement = {
id: item.entitlement_id || generateId("ent"),
@@ -178,7 +180,7 @@ export const toFeatureAndPrice = ({
newVersion?: boolean;
features: Feature[];
}) => {
let resetUsage = getResetUsage({
const resetUsage = getResetUsage({
item,
feature: features.find((f) => f.id == item.feature_id),
});
@@ -206,7 +208,7 @@ export const toFeatureAndPrice = ({
};
// Will only create new ent id if
let newEnt = !curEnt || (isCustom && !entsAreSame(curEnt, ent));
const newEnt = !curEnt || (isCustom && !entsAreSame(curEnt, ent));
if (newEnt || newVersion) {
ent = {
...ent,
@@ -215,9 +217,9 @@ export const toFeatureAndPrice = ({
};
}
let entInterval = itemToEntInterval(item);
const entInterval = itemToEntInterval(item);
let config: UsagePriceConfig = {
const config: UsagePriceConfig = {
type: PriceType.Usage,
bill_when:
@@ -244,7 +246,8 @@ export const toFeatureAndPrice = ({
let prorationConfig = null;
if (itemCanBeProrated({ item, features })) {
let onIncrease = item.config?.on_increase || OnIncrease.ProrateImmediately;
const onIncrease =
item.config?.on_increase || OnIncrease.ProrateImmediately;
let onDecrease = item.config?.on_decrease || OnDecrease.Prorate;
// console.log("Item config:", item.config);
@@ -274,7 +277,7 @@ export const toFeatureAndPrice = ({
proration_config: prorationConfig,
};
let billingType = getBillingType(price.config!);
const billingType = getBillingType(price.config!);
if (
(billingType == BillingType.UsageInArrear ||
billingType == BillingType.InArrearProrated) &&
@@ -287,13 +290,13 @@ export const toFeatureAndPrice = ({
});
}
let priceOrEntDifferent =
const priceOrEntDifferent =
(curPrice && !pricesAreSame(curPrice, price, true)) ||
(curEnt && !entsAreSame(curEnt, ent));
if (curPrice && (priceOrEntDifferent || newVersion)) {
let newConfig = price.config as UsagePriceConfig;
let curConfig = curPrice.config as UsagePriceConfig;
const newConfig = price.config as UsagePriceConfig;
const curConfig = curPrice.config as UsagePriceConfig;
newConfig.stripe_meter_id = curConfig.stripe_meter_id;
newConfig.stripe_product_id = curConfig.stripe_product_id;
price.config = newConfig;
@@ -341,7 +344,7 @@ export const itemToPriceAndEnt = ({
let sameEnt: Entitlement | null = null;
if (isPriceItem(item)) {
let { price } = toPrice({
const { price } = toPrice({
item,
orgId,
internalProductId,
@@ -363,9 +366,9 @@ export const itemToPriceAndEnt = ({
code: ErrCode.InvalidRequest,
});
}
let isBoolean = feature?.type == FeatureType.Boolean;
const isBoolean = feature?.type == FeatureType.Boolean;
let { ent } = toFeature({
const { ent } = toFeature({
item,
orgId,
internalFeatureId: feature!.internal_id!,
@@ -393,7 +396,7 @@ export const itemToPriceAndEnt = ({
});
}
let { price, ent } = toFeatureAndPrice({
const { price, ent } = toFeatureAndPrice({
item,
orgId,
internalFeatureId: feature!.internal_id!,
@@ -405,7 +408,7 @@ export const itemToPriceAndEnt = ({
features,
});
let entSame = curEnt && entsAreSame(curEnt, ent);
const entSame = curEnt && entsAreSame(curEnt, ent);
// 1. If no curPrice, price is new
if (!curPrice || newVersion) {

View File

@@ -1,4 +1,5 @@
import { expect } from "bun:test";
import assert from "node:assert";
import {
type AppEnv,
CusProductStatus,
@@ -8,7 +9,6 @@ import {
type FullCustomer,
type Organization,
} from "@autumn/shared";
import assert from "assert";
import type Stripe from "stripe";
import { defaultApiVersion } from "tests/constants.js";
import { cusProductToSubIds } from "tests/merged/mergeUtils.test.js";
@@ -187,10 +187,10 @@ const checkAllFreeProducts = async ({
const sub = subs.find(
(sub) =>
sub.customer === fullCus.processor?.id &&
(sub.status == "active" || sub.status == "past_due"),
(sub.status === "active" || sub.status === "past_due"),
);
if (fullCus.org_id == "6bWdIqEuRHBrReXbTb30l9beMFVZ3Ts3") return true;
if (fullCus.org_id === "6bWdIqEuRHBrReXbTb30l9beMFVZ3Ts3") return true;
assert(
!sub,
@@ -289,7 +289,8 @@ export const checkCusSubCorrect = async ({
cusProduct.status === CusProductStatus.Scheduled &&
cusProductInPhase({ phaseStartMillis: unix, cusProduct })
) {
return scheduleIndexes.push(index);
scheduleIndexes.push(index);
return;
}
if (cusProduct.status === CusProductStatus.Scheduled) return;
@@ -311,7 +312,7 @@ export const checkCusSubCorrect = async ({
cp.product.group === product.group &&
cp.status === CusProductStatus.Scheduled &&
(cp.internal_entity_id
? cp.internal_entity_id == cusProduct.internal_entity_id
? cp.internal_entity_id === cusProduct.internal_entity_id
: nullish(cp.internal_entity_id)),
);
@@ -394,7 +395,6 @@ export const checkCusSubCorrect = async ({
);
if (existingIndex !== -1) {
// @ts-expect-error
supposedSubItems[existingIndex].quantity += lineItem.quantity;
} else {
supposedSubItems.push({

View File

@@ -1,7 +1,7 @@
import { ErrCode } from "@autumn/shared";
import { UTCDate } from "@date-fns/utc";
import { format } from "date-fns";
import KSUID from "ksuid";
import { ErrCode } from "@/errors/errCodes.js";
import RecaseError from "./errorUtils.js";
export const generateId = (prefix: string) => {
@@ -99,7 +99,7 @@ export const validateId = (type: string, id: string) => {
if (!id.match(/^[a-zA-Z0-9_-]+$/)) {
throw new RecaseError({
message: `${type} ID can only contain alphanumeric characters, underscores, and hyphens`,
code: ErrCode.InvalidId,
code: ErrCode.InvalidInputs,
statusCode: 400,
});
}

View File

@@ -318,7 +318,7 @@ export const routeHandler = async <TLoad = undefined>({
if (error instanceof ZodError && req.originalUrl.includes("/attach")) {
error = new RecaseError({
message: formatZodError(error),
message: formatZodError(error as any),
code: ErrCode.InvalidInputs,
statusCode: StatusCodes.BAD_REQUEST,
});

View File

@@ -1,8 +1,8 @@
import "dotenv/config";
import fs from "node:fs";
import type { AppEnv } from "@autumn/shared";
import { UTCDate } from "@date-fns/utc";
import { subHours } from "date-fns";
import fs from "fs";
import type { Stripe } from "stripe";
import { db } from "@/db/initDrizzle.js";
import { createLogger } from "@/external/logtail/logtailUtils.js";

View File

@@ -1,31 +1,28 @@
import { products, referralPrograms } from "../../global.js";
import {
type AppEnv,
ErrCode,
type Organization,
type ReferralCode,
type RewardRedemption,
} from "@autumn/shared";
import { assert } from "chai";
import chalk from "chalk";
import AutumnError, { AutumnInt } from "@/external/autumn/autumnCli.js";
import { setupBefore } from "tests/before.js";
import {
AppEnv,
Customer,
ErrCode,
Organization,
ReferralCode,
RewardRedemption,
} from "@autumn/shared";
import { timeout } from "tests/utils/genUtils.js";
import { advanceTestClock } from "tests/utils/stripeUtils.js";
import { addDays } from "date-fns";
import { Stripe } from "stripe";
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
import type { Stripe } from "stripe";
import { addPrefixToProducts } from "tests/attach/utils.js";
import { setupBefore } from "tests/before.js";
import { TestFeature } from "tests/setup/v2Features.js";
import { timeout } from "tests/utils/genUtils.js";
import { createProducts } from "tests/utils/productUtils.js";
import { advanceTestClock } from "tests/utils/stripeUtils.js";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import AutumnError, { AutumnInt } from "@/external/autumn/autumnCli.js";
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { initCustomer } from "@/utils/scriptUtils/initCustomer.js";
import { addPrefixToProducts } from "tests/attach/utils.js";
import { createProducts } from "tests/utils/productUtils.js";
import { DrizzleCli } from "@/db/initDrizzle.js";
import { products, referralPrograms } from "../../global.js";
let pro = constructProduct({
const pro = constructProduct({
id: "pro",
items: [constructFeatureItem({ featureId: TestFeature.Words })],
type: "pro",
@@ -36,15 +33,15 @@ let pro = constructProduct({
describe(`${chalk.yellowBright(
"referrals1: Testing referrals (on checkout)",
)}`, () => {
let mainCustomerId = "main-referral-1";
let alternateCustomerId = "alternate-referral-1";
let redeemers = ["referral1-r1", "referral1-r2", "referral1-r3"];
let autumn: AutumnInt = new AutumnInt();
const mainCustomerId = "main-referral-1";
const alternateCustomerId = "alternate-referral-1";
const redeemers = ["referral1-r1", "referral1-r2", "referral1-r3"];
const autumn: AutumnInt = new AutumnInt();
let stripeCli: Stripe;
let testClockId: string;
let referralCode: ReferralCode;
let redemptions: RewardRedemption[] = [];
const redemptions: RewardRedemption[] = [];
let mainCustomer: any;
let db: DrizzleCli;
let org: Organization;
@@ -89,8 +86,8 @@ describe(`${chalk.yellowBright(
product_id: pro.id,
});
let batchCreate = [];
for (let redeemer of redeemers) {
const batchCreate = [];
for (const redeemer of redeemers) {
batchCreate.push(
initCustomer({
autumn: this.autumnJs,
@@ -117,7 +114,7 @@ describe(`${chalk.yellowBright(
await Promise.all(batchCreate);
});
it("should create code once", async function () {
it("should create code once", async () => {
referralCode = await autumn.referrals.createCode({
customerId: mainCustomerId,
referralId: referralPrograms.onCheckout.id,
@@ -126,7 +123,7 @@ describe(`${chalk.yellowBright(
assert.exists(referralCode.code);
// Get referral code again
let referralCode2 = await autumn.referrals.createCode({
const referralCode2 = await autumn.referrals.createCode({
customerId: mainCustomerId,
referralId: referralPrograms.onCheckout.id,
});
@@ -134,7 +131,7 @@ describe(`${chalk.yellowBright(
assert.equal(referralCode2.code, referralCode.code);
});
it("should fail if same customer tries to redeem code again", async function () {
it("should fail if same customer tries to redeem code again", async () => {
try {
await autumn.referrals.redeem({
customerId: mainCustomerId,
@@ -160,9 +157,9 @@ describe(`${chalk.yellowBright(
}
});
it("should create redemption for each redeemer and fail if redeemed again", async function () {
for (let redeemer of redeemers) {
let redemption: RewardRedemption = await autumn.referrals.redeem({
it("should create redemption for each redeemer and fail if redeemed again", async () => {
for (const redeemer of redeemers) {
const redemption: RewardRedemption = await autumn.referrals.redeem({
customerId: redeemer,
code: referralCode.code,
});
@@ -172,7 +169,7 @@ describe(`${chalk.yellowBright(
// Try redeem for redeemer1 again
try {
let redemption1 = await autumn.referrals.redeem({
const redemption1 = await autumn.referrals.redeem({
customerId: redeemers[0],
code: referralCode.code,
});
@@ -185,9 +182,9 @@ describe(`${chalk.yellowBright(
// return;
it("should be triggered (and applied) when redeemers check out", async function () {
it("should be triggered (and applied) when redeemers check out", async () => {
for (let i = 0; i < redeemers.length; i++) {
let redeemer = redeemers[i];
const redeemer = redeemers[i];
await autumn.attach({
customer_id: redeemer,
@@ -197,21 +194,21 @@ describe(`${chalk.yellowBright(
await timeout(3000);
// Get redemption object
let redemption = await autumn.redemptions.get(redemptions[i].id);
const redemption = await autumn.redemptions.get(redemptions[i].id);
// Check if redemption is triggered
let count = i + 1;
const count = i + 1;
if (count > referralPrograms.onCheckout.max_redemptions) {
assert.equal(redemption.triggered, false);
assert.equal(redemption.applied, false);
} else {
assert.equal(redemption.triggered, true);
assert.equal(redemption.applied, i == 0);
assert.equal(redemption.applied, i === 0);
}
// Check stripe customer
let stripeCus = (await stripeCli.customers.retrieve(
const stripeCus = (await stripeCli.customers.retrieve(
mainCustomer.processor?.id,
)) as Stripe.Customer;
@@ -220,7 +217,7 @@ describe(`${chalk.yellowBright(
});
let curTime = new Date();
it("customer should have discount for first purchase", async function () {
it("customer should have discount for first purchase", async () => {
curTime = addDays(addDays(curTime, 7), 4);
await advanceTestClock({
testClockId,
@@ -229,7 +226,7 @@ describe(`${chalk.yellowBright(
});
// 1. Get invoice
let { invoices } = await autumn.customers.get(mainCustomerId);
const { invoices } = await autumn.customers.get(mainCustomerId);
assert.equal(invoices.length, 2);
assert.equal(invoices[0].total, 0);
});

View File

@@ -1,33 +1,33 @@
import { products, referralPrograms } from "../../global.js";
import {
type Customer,
ErrCode,
type ReferralCode,
type RewardRedemption,
} from "@autumn/shared";
import { assert } from "chai";
import chalk from "chalk";
import AutumnError, { AutumnInt } from "@/external/autumn/autumnCli.js";
import { addDays } from "date-fns";
import type { Stripe } from "stripe";
import { setupBefore } from "tests/before.js";
import {
Customer,
ErrCode,
ReferralCode,
RewardRedemption,
} from "@autumn/shared";
import { timeout } from "tests/utils/genUtils.js";
import { initCustomerWithTestClock } from "tests/utils/testInitUtils.js";
import { advanceTestClock } from "tests/utils/stripeUtils.js";
import { addDays, addHours, addMonths } from "date-fns";
import { Stripe } from "stripe";
import { initCustomer } from "tests/utils/init.js";
import { advanceTestClock } from "tests/utils/stripeUtils.js";
import { initCustomerWithTestClock } from "tests/utils/testInitUtils.js";
import AutumnError, { AutumnInt } from "@/external/autumn/autumnCli.js";
import { products, referralPrograms } from "../../global.js";
// UNCOMMENT FROM HERE
describe(`${chalk.yellowBright(
"referrals2: Testing referrals (immediate redemption)",
)}`, () => {
let mainCustomerId = "main-referral-2";
let redeemers = ["referral2-r1", "referral2-r2", "referral2-r3"];
let autumn: AutumnInt = new AutumnInt();
const mainCustomerId = "main-referral-2";
const redeemers = ["referral2-r1", "referral2-r2", "referral2-r3"];
const autumn: AutumnInt = new AutumnInt();
let stripeCli: Stripe;
let testClockId: string;
let referralCode: ReferralCode;
let redemptions: RewardRedemption[] = [];
const redemptions: RewardRedemption[] = [];
let mainCustomer: Customer;
before(async function () {
@@ -44,8 +44,8 @@ describe(`${chalk.yellowBright(
testClockId = testClockId1;
mainCustomer = customer;
let batchCreate = [];
for (let redeemer of redeemers) {
const batchCreate = [];
for (const redeemer of redeemers) {
batchCreate.push(
initCustomer({
customerId: redeemer,
@@ -60,7 +60,7 @@ describe(`${chalk.yellowBright(
await Promise.all(batchCreate);
});
it("should create code once", async function () {
it("should create code once", async () => {
referralCode = await autumn.referrals.createCode({
customerId: mainCustomerId,
referralId: referralPrograms.immediate.id,
@@ -69,12 +69,12 @@ describe(`${chalk.yellowBright(
assert.exists(referralCode.code);
});
it("should create redemption for each redeemer and fail if redeemed again", async function () {
it("should create redemption for each redeemer and fail if redeemed again", async () => {
for (let i = 0; i < redeemers.length; i++) {
let redeemer = redeemers[i];
let count = i + 1;
const redeemer = redeemers[i];
const count = i + 1;
try {
let redemption: RewardRedemption = await autumn.referrals.redeem({
const redemption: RewardRedemption = await autumn.referrals.redeem({
customerId: redeemer,
code: referralCode.code,
});
@@ -95,7 +95,7 @@ describe(`${chalk.yellowBright(
}
// Check stripe customer
let stripeCus = (await stripeCli.customers.retrieve(
const stripeCus = (await stripeCli.customers.retrieve(
mainCustomer.processor?.id,
)) as Stripe.Customer;
@@ -103,7 +103,7 @@ describe(`${chalk.yellowBright(
});
let curTime = new Date();
it("customer should have discount for first purchase", async function () {
it("customer should have discount for first purchase", async () => {
await autumn.attach({
customer_id: mainCustomerId,
product_id: products.proWithTrial.id,
@@ -120,7 +120,7 @@ describe(`${chalk.yellowBright(
});
// 1. Get invoice
let { invoices } = await autumn.customers.get(mainCustomerId);
const { invoices } = await autumn.customers.get(mainCustomerId);
assert.equal(invoices!.length, 2);
assert.equal(invoices![0].total, 0);

View File

@@ -1,32 +1,32 @@
import { features, products, referralPrograms } from "../../global.js";
import {
type Customer,
ErrCode,
type ReferralCode,
type RewardRedemption,
} from "@autumn/shared";
import { assert } from "chai";
import chalk from "chalk";
import AutumnError, { AutumnInt } from "@/external/autumn/autumnCli.js";
import type { Stripe } from "stripe";
import { setupBefore } from "tests/before.js";
import {
Customer,
ErrCode,
ReferralCode,
RewardRedemption,
} from "@autumn/shared";
import { timeout } from "tests/utils/genUtils.js";
import { initCustomerWithTestClock } from "tests/utils/testInitUtils.js";
import { Stripe } from "stripe";
import { initCustomer } from "tests/utils/init.js";
import { compareProductEntitlements } from "tests/utils/compare.js";
import { timeout } from "tests/utils/genUtils.js";
import { initCustomer } from "tests/utils/init.js";
import { initCustomerWithTestClock } from "tests/utils/testInitUtils.js";
import AutumnError, { AutumnInt } from "@/external/autumn/autumnCli.js";
import { features, products, referralPrograms } from "../../global.js";
// UNCOMMENT FROM HERE
describe(`${chalk.yellowBright(
"referrals3: Testing free product referrals",
)}`, () => {
let mainCustomerId = "main-referral-3";
let redeemers = ["referral3-r1", "referral3-r2", "referral3-r3"];
const mainCustomerId = "main-referral-3";
const redeemers = ["referral3-r1", "referral3-r2", "referral3-r3"];
let autumn: AutumnInt = new AutumnInt();
let stripeCli: Stripe;
let testClockId: string;
let referralCode: ReferralCode;
let redemptions: RewardRedemption[] = [];
const redemptions: RewardRedemption[] = [];
let mainCustomer: Customer;
before(async function () {
@@ -50,8 +50,8 @@ describe(`${chalk.yellowBright(
product_id: products.proWithTrial.id,
});
let batchCreate = [];
for (let redeemer of redeemers) {
const batchCreate = [];
for (const redeemer of redeemers) {
batchCreate.push(
initCustomer({
customerId: redeemer,
@@ -66,7 +66,7 @@ describe(`${chalk.yellowBright(
await Promise.all(batchCreate);
});
it("should create code once", async function () {
it("should create code once", async () => {
referralCode = await autumn.referrals.createCode({
customerId: mainCustomerId,
referralId: referralPrograms.freeProduct.id,
@@ -75,9 +75,9 @@ describe(`${chalk.yellowBright(
assert.exists(referralCode.code);
});
it("should create redemption for each redeemer and fail if redeemed again", async function () {
for (let redeemer of redeemers) {
let redemption: RewardRedemption = await autumn.referrals.redeem({
it("should create redemption for each redeemer and fail if redeemed again", async () => {
for (const redeemer of redeemers) {
const redemption: RewardRedemption = await autumn.referrals.redeem({
customerId: redeemer,
code: referralCode.code,
});
@@ -90,7 +90,7 @@ describe(`${chalk.yellowBright(
// Try redeem for redeemer1 again
try {
let redemption1 = await autumn.referrals.redeem({
const redemption1 = await autumn.referrals.redeem({
customerId: redeemers[0],
code: referralCode.code,
});
@@ -101,9 +101,9 @@ describe(`${chalk.yellowBright(
}
});
it("should be triggered (and applied) when redeemers check out", async function () {
it("should be triggered (and applied) when redeemers check out", async () => {
for (let i = 0; i < redeemers.length; i++) {
let redeemer = redeemers[i];
const redeemer = redeemers[i];
await autumn.attach({
customer_id: redeemer,
@@ -113,10 +113,10 @@ describe(`${chalk.yellowBright(
await timeout(3000);
// Get redemption object
let redemption = await autumn.redemptions.get(redemptions[i].id);
const redemption = await autumn.redemptions.get(redemptions[i].id);
// Check if redemption is triggered
let count = i + 1;
const count = i + 1;
if (count > referralPrograms.freeProduct.max_redemptions) {
assert.equal(redemption.triggered, false);

View File

@@ -1,5 +1,4 @@
import { assert, expect } from "chai";
import { ErrCode } from "@autumn/shared";
import AutumnError from "@/external/autumn/autumnCli.js";
export const expectAutumnError = async ({
@@ -12,7 +11,7 @@ export const expectAutumnError = async ({
func: () => Promise<any>;
}) => {
try {
let result = await func();
const result = await func();
assert.fail(
`Expected to receive autumn error ${errCode}, but received none`,

View File

@@ -2,7 +2,7 @@
* Base error class for all Autumn API errors
* This should match the RecaseError interface from the server
*/
export class AutumnError extends Error {
export class RecaseError extends Error {
code: string;
statusCode: number;
data?: unknown;
@@ -19,7 +19,7 @@ export class AutumnError extends Error {
data?: unknown;
}) {
super(message);
this.name = "AutumnError";
this.name = "RecaseError";
this.code = code || "invalid_request";
this.statusCode = statusCode;
this.data = data;

View File

@@ -1,10 +1,10 @@
import { AutumnError } from "../base/AutumnError.js";
import { RecaseError } from "../base/RecaseError.js";
import { CusProductErrorCode } from "../codes/cusProductErrCodes.js";
/**
* Product not found error
*/
export class CusProductNotFoundError extends AutumnError {
export class CusProductNotFoundError extends RecaseError {
constructor(opts: {
customerId: string;
productId: string;
@@ -23,7 +23,7 @@ export class CusProductNotFoundError extends AutumnError {
}
}
export class CusProductAlreadyExistsError extends AutumnError {
export class CusProductAlreadyExistsError extends RecaseError {
constructor(opts: {
productId: string;
customerId?: string;

View File

@@ -1,10 +1,10 @@
import { AutumnError } from "../base/AutumnError.js";
import { RecaseError } from "../base/RecaseError.js";
import { ProductErrorCode } from "../codes/productErrCodes.js";
/**
* Product not found error
*/
export class ProductNotFoundError extends AutumnError {
export class ProductNotFoundError extends RecaseError {
constructor(opts: { productId: string; version?: string }) {
super({
message: `Product ${opts.productId} ${opts.version ? ` (version ${opts.version})` : ""} not found`,
@@ -18,7 +18,7 @@ export class ProductNotFoundError extends AutumnError {
/**
* Product already exists error
*/
export class ProductAlreadyExistsError extends AutumnError {
export class ProductAlreadyExistsError extends RecaseError {
constructor(opts: { productId: string; message?: string }) {
super({
message: opts.message || `Product ${opts.productId} already exists`,

View File

@@ -1,5 +1,8 @@
export * from "./base/AutumnError.js";
export * from "./base/InternalError.js";
export * from "./base/RecaseError.js";
export * from "./classes/cusProductErrClasses.js";
export * from "./classes/productErrClasses.js";
export * from "./codes/cusProductErrCodes.js";
export * from "./codes/productErrCodes.js";

View File

@@ -1,6 +1,10 @@
import { CreateFreeTrialSchema } from "@models/productModels/freeTrialModels/freeTrialModels.js";
import { ProductItemSchema } from "@models/productV2Models/productItemModels/productItemModels.js";
import { idRegex } from "@utils/utils.js";
import { z } from "zod/v4";
export const CreateProductItemParamsSchema = ProductItemSchema;
export const CreateProductParamsSchema = z.object({
id: z.string().nonempty().regex(idRegex),
@@ -12,4 +16,9 @@ export const CreateProductParamsSchema = z.object({
is_default: z.boolean().default(false),
version: z.number().optional(),
group: z.string().default(""),
items: z.array(CreateProductItemParamsSchema).optional(),
free_trial: CreateFreeTrialSchema.optional(),
});
export type CreateProductParams = z.infer<typeof CreateProductParamsSchema>;

View File

@@ -1,4 +1,4 @@
import { APIProductItemSchema } from "@api/models.js";
import { APIProductItemSchema } from "@api/products/apiProductItem.js";
import { z } from "zod/v4";
import { CusProductStatus } from "../../cusProductModels/cusProductEnums.js";