diff --git a/AGENTS.md b/AGENTS.md index e3ec09e55..305350836 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,7 +7,9 @@ - When using db schemas in Drizzle, import them from '@autumn/shared', and don't do schemas. # Testing -- When writing tests, ALWAYS consult the corresponding guide in `server/tests/_guides/` to understand the proper patterns and structure. For example, when writing `/check` endpoint tests, read `server/tests/_guides/check-endpoint-tests.md` first. +- When writing tests, ALWAYS read: + 1. `server/tests/_guides/general-test-guide.md` - Common patterns, client initialization, public keys + 2. Case-specific guide (e.g., `server/tests/_guides/check-endpoint-tests.md` for `/check` tests) # Linting and Codebase rules - You can access the biome linter by running `npx biome check `. Always specify a folder path, as the codebase is quite large and you will get out of scope errors that you are not burdened to correct. If you would like to let biome automatically fix as much as it can, use `npx biome check --write ` diff --git a/CLAUDE.md b/CLAUDE.md index db956f7b3..56ea3c1cc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,9 @@ - When using db schemas in Drizzle, import them from '@autumn/shared', and don't do schemas. # Testing -- When writing tests, ALWAYS consult the corresponding guide in `server/tests/_guides/` to understand the proper patterns and structure. For example, when writing `/check` endpoint tests, read `server/tests/_guides/check-endpoint-tests.md` first. +- When writing tests, ALWAYS read: + 1. `server/tests/_guides/general-test-guide.md` - Common patterns, client initialization, public keys + 2. Case-specific guide (e.g., `server/tests/_guides/check-endpoint-tests.md` for `/check` tests) # Linting and Codebase rules - You can access the biome linter by running `bunx biome check `. Always specify a folder path, as the codebase is quite large and you will get out of scope errors that you are not burdened to correct. If you would like to let biome automatically fix as much as it can, use `bunx biome check --write ` diff --git a/scripts/testGroups/g1.sh b/scripts/testGroups/g1.sh index c5fab0a6b..f7d548e34 100755 --- a/scripts/testGroups/g1.sh +++ b/scripts/testGroups/g1.sh @@ -15,15 +15,16 @@ fi # Run tests using TypeScript runner with compact mode # Adjust --max to control concurrency (default: 6) BUN_PARALLEL_COMPACT \ + 'server/tests/check/basic' \ + 'server/tests/check/credit-systems' \ + 'server/tests/attach/basic' \ + 'server/tests/attach/upgrade' \ + 'server/tests/attach/downgrade' \ + 'server/tests/attach/free' \ + 'server/tests/attach/addOn' \ + 'server/tests/attach/entities' \ 'server/tests/attach/checkout' \ --max=6 \ - # 'server/tests/check/basic' \ - # 'server/tests/attach/basic' \ - # 'server/tests/attach/upgrade' \ - # 'server/tests/attach/downgrade' \ - # 'server/tests/attach/free' \ - # 'server/tests/attach/addOn' \ - # 'server/tests/attach/entities' \ diff --git a/server/src/honoMiddlewares/publicKeyMiddleware.ts b/server/src/honoMiddlewares/publicKeyMiddleware.ts new file mode 100644 index 000000000..c275b3794 --- /dev/null +++ b/server/src/honoMiddlewares/publicKeyMiddleware.ts @@ -0,0 +1,125 @@ +import { AppEnv, AuthType, ErrCode } from "@autumn/shared"; +import type { Context, Next } from "hono"; +import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; +import { verifyPublicKey } from "@/internal/dev/api-keys/publicKeyUtils.js"; +import RecaseError from "@/utils/errorUtils.js"; + +const allowedEndpoints = [ + { + method: "GET", + path: "/v1/products", + }, + { + method: "POST", + path: "/v1/entitled", + }, + { + method: "POST", + path: "/v1/check", + }, + { + method: "POST", + path: "/v1/attach", + }, + { + method: "GET", + path: "/v1/customers/:customerId", + }, +]; + +interface IsAllowedEndpointProps { + path: string; + method: string; +} + +const isAllowedEndpoint = ({ path, method }: IsAllowedEndpointProps) => { + // Convert pattern to regex, handling path params like :id + const matchPath = (pattern: string, path: string) => { + // Convert pattern to regex, handling path params like :id + const regexPattern = pattern.replace(/:[^/]+/g, "[^/]+"); + const regex = new RegExp(`^${regexPattern}$`); + // Remove query params before testing + const pathWithoutQuery = path.split("?")[0]; + return regex.test(pathWithoutQuery); + }; + + for (const endpoint of allowedEndpoints) { + if (endpoint.method === method && matchPath(endpoint.path, path)) { + return true; + } + } + return false; +}; + +/** + * Middleware to verify publishable key and populate auth context + * Only allows access to specific public endpoints + * + * Steps: + * 1. Check if endpoint is allowed for publishable keys + * 2. Validate publishable key format (am_pk_test or am_pk_live) + * 3. Determine environment from key prefix + * 4. Verify the publishable key + * 5. Store org, features, env, authType, isPublic in context + */ +export const publicKeyMiddleware = async ( + c: Context, + pkey: string, + next: Next, +) => { + const ctx = c.get("ctx"); + + // Step 1: Check if endpoint is allowed + if ( + !isAllowedEndpoint({ + path: c.req.path, + method: c.req.method, + }) + ) { + throw new RecaseError({ + message: `Endpoint ${c.req.path} not accessible via publishable key. Please try with a secret key instead.`, + code: ErrCode.EndpointNotPublic, + statusCode: 401, + }); + } + + // Step 2: Validate publishable key format + if (!pkey.startsWith("am_pk_test") && !pkey.startsWith("am_pk_live")) { + throw new RecaseError({ + message: "Invalid publishable key", + code: ErrCode.InvalidPublishableKey, + statusCode: 401, + }); + } + + // Step 3: Determine environment from key prefix + const env: AppEnv = pkey.startsWith("am_pk_test") + ? AppEnv.Sandbox + : AppEnv.Live; + + // Step 4: Verify the publishable key + const data = await verifyPublicKey({ + db: ctx.db, + pkey, + env, + }); + + if (!data) { + throw new RecaseError({ + message: "Invalid publishable key", + code: ErrCode.InvalidPublishableKey, + statusCode: 401, + }); + } + + // Step 5: Store auth data in context + const { org, features } = data; + + ctx.org = org; + ctx.features = features; + ctx.env = env; + ctx.authType = AuthType.PublicKey; + ctx.isPublic = true; + + await next(); +}; diff --git a/server/src/honoMiddlewares/secretKeyMiddleware.ts b/server/src/honoMiddlewares/secretKeyMiddleware.ts index 1f38a300f..e36e213f2 100644 --- a/server/src/honoMiddlewares/secretKeyMiddleware.ts +++ b/server/src/honoMiddlewares/secretKeyMiddleware.ts @@ -4,6 +4,7 @@ import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; import { verifyKey } from "@/internal/dev/api-keys/apiKeyUtils.js"; import RecaseError from "@/utils/errorUtils.js"; import { betterAuthMiddleware } from "./betterAuthMiddleware.js"; +import { publicKeyMiddleware } from "./publicKeyMiddleware.js"; const maskApiKey = (apiKey: string) => { return apiKey.slice(0, 15) + apiKey.slice(15).replace(/./g, "*"); @@ -12,13 +13,15 @@ const maskApiKey = (apiKey: string) => { /** * Middleware to verify secret key and populate auth context * Falls back to Better Auth (dashboard session) if request is from dashboard + * Delegates to publicKeyMiddleware if key is a publishable key (am_pk) * * Steps: * 1. Check if Authorization header is present * 2. If from dashboard and no auth header, use Better Auth * 3. Check if it has correct Bearer format - * 4. Verify the API key - * 5. Store org, features, env, userId, authType in context + * 4. Handle publishable key verification if key starts with am_pk + * 5. Verify the secret API key + * 6. Store org, features, env, userId, authType in context */ export const secretKeyMiddleware = async (c: Context, next: Next) => { const ctx = c.get("ctx"); @@ -51,12 +54,12 @@ export const secretKeyMiddleware = async (c: Context, next: Next) => { }); } - // TODO: Handle publishable key (am_pk) verification - // if (apiKey.startsWith("am_pk")) { - // await verifyPublishableKey(...) - // } + // Step 3: Handle publishable key verification + if (apiKey.startsWith("am_pk")) { + return publicKeyMiddleware(c, apiKey, next); + } - // Step 3: Verify the API key + // Step 4: Verify the API key const { valid, data } = await verifyKey({ db: ctx.db, key: apiKey, @@ -71,7 +74,7 @@ export const secretKeyMiddleware = async (c: Context, next: Next) => { }); } - // Step 4: Store auth data in context + // Step 5: Store auth data in context const { org, features, env, userId } = data; ctx.org = org; diff --git a/server/src/initHono.ts b/server/src/initHono.ts index ceb584fc7..749333882 100644 --- a/server/src/initHono.ts +++ b/server/src/initHono.ts @@ -92,6 +92,7 @@ export const createHonoApp = () => { app.use("/v1/*", queryMiddleware()); // API Routes + app.post("/v1/entitled", ...handleCheck); app.post("/v1/check", ...handleCheck); app.route("v1/customers", cusRouter); app.route("v1/products", honoProductRouter); diff --git a/server/src/internal/api/apiRouter.ts b/server/src/internal/api/apiRouter.ts index 652e43f75..60a4cf8b4 100644 --- a/server/src/internal/api/apiRouter.ts +++ b/server/src/internal/api/apiRouter.ts @@ -16,7 +16,6 @@ import { migrationRouter } from "../migrations/migrationRouter.js"; import { handleGetOrg } from "../orgs/handlers/handleGetOrg.js"; import { platformRouter } from "../platform/platformLegacy/platformRouter.js"; import { productBetaRouter, productRouter } from "../products/productRouter.js"; -import { checkRouter } from "./check/checkRouter.js"; import { componentRouter } from "./components/componentRouter.js"; import { entityRouter } from "./entities/entityRouter.js"; // import { checkRouter } from "./entitled/checkRouter.js"; @@ -57,7 +56,7 @@ apiRouter.use("/redemptions", redemptionRouter); apiRouter.use("", attachRouter); apiRouter.use("/cancel", cancelRouter); -apiRouter.use("/entitled", checkRouter); +// apiRouter.use("/entitled", checkRouter); // apiRouter.use("/check", checkRouter); apiRouter.use("/events", eventsRouter); diff --git a/server/src/internal/api/check/checkRouter.ts b/server/src/internal/api/check/checkRouter.ts index d1933b599..84f9ef6f2 100644 --- a/server/src/internal/api/check/checkRouter.ts +++ b/server/src/internal/api/check/checkRouter.ts @@ -1,219 +1,219 @@ -import { ApiVersion, ErrCode, type Feature, FeatureType } from "@autumn/shared"; -import { Router } from "express"; -import { StatusCodes } from "http-status-codes"; -import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; -import RecaseError, { handleRequestError } from "@/utils/errorUtils.js"; -import { notNullish } from "@/utils/genUtils.js"; -import { handleEventSent } from "../events/eventRouter.js"; -import { getCheckData } from "./checkUtils/getCheckData.js"; -import { getV1CheckResponse } from "./checkUtils/getV1CheckResponse.js"; -import { getV2CheckResponse } from "./checkUtils/getV2CheckResponse.js"; -import { getBooleanEntitledResult } from "./checkUtils.js"; -import { getCheckPreview } from "./getCheckPreview.js"; -import { handleProductCheck } from "./handlers/handleProductCheck.js"; +// import { ApiVersion, ErrCode, type Feature, FeatureType } from "@autumn/shared"; +// import { Router } from "express"; +// import { StatusCodes } from "http-status-codes"; +// import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +// import RecaseError, { handleRequestError } from "@/utils/errorUtils.js"; +// import { notNullish } from "@/utils/genUtils.js"; +// import { handleEventSent } from "../events/eventRouter.js"; +// import { getCheckData } from "./checkUtils/getCheckData.js"; +// import { getV1CheckResponse } from "./checkUtils/getV1CheckResponse.js"; +// import { getV2CheckResponse } from "./checkUtils/getV2CheckResponse.js"; +// import { getBooleanEntitledResult } from "./checkUtils.js"; +// import { getCheckPreview } from "./getCheckPreview.js"; +// import { handleProductCheck } from "./handlers/handleProductCheck.js"; -export const checkRouter: Router = Router(); +// export const checkRouter: Router = Router(); -checkRouter.post("", async (req: any, res: any) => { - try { - const { - customer_id, - feature_id, - product_id, - required_quantity, - required_balance, - customer_data, - send_event, - event_data, - entity_id, - } = req.body; +// checkRouter.post("", async (req: any, res: any) => { +// try { +// const { +// customer_id, +// feature_id, +// product_id, +// required_quantity, +// required_balance, +// customer_data, +// send_event, +// event_data, +// entity_id, +// } = req.body; - const { logger, db } = req; +// const { logger, db } = req; - if (!customer_id) { - throw new RecaseError({ - message: "`customer_id` is required", - code: ErrCode.InvalidRequest, - statusCode: StatusCodes.BAD_REQUEST, - }); - } +// if (!customer_id) { +// throw new RecaseError({ +// message: "`customer_id` is required", +// code: ErrCode.InvalidRequest, +// statusCode: StatusCodes.BAD_REQUEST, +// }); +// } - if (!feature_id && !product_id) { - throw new RecaseError({ - message: "`feature_id` or `product_id` is required", - code: ErrCode.InvalidRequest, - statusCode: StatusCodes.BAD_REQUEST, - }); - } +// if (!feature_id && !product_id) { +// throw new RecaseError({ +// message: "`feature_id` or `product_id` is required", +// code: ErrCode.InvalidRequest, +// statusCode: StatusCodes.BAD_REQUEST, +// }); +// } - if (feature_id && product_id) { - throw new RecaseError({ - message: - "Provide either feature_id or product_id. Not allowed to provide both", - code: ErrCode.InvalidRequest, - statusCode: StatusCodes.BAD_REQUEST, - }); - } +// if (feature_id && product_id) { +// throw new RecaseError({ +// message: +// "Provide either feature_id or product_id. Not allowed to provide both", +// code: ErrCode.InvalidRequest, +// statusCode: StatusCodes.BAD_REQUEST, +// }); +// } - if (product_id) { - const result = await handleProductCheck({ - ctx: req as AutumnContext, - body: req.body, - }); - return res.status(200).json(result); - } +// if (product_id) { +// const result = await handleProductCheck({ +// ctx: req as AutumnContext, +// body: req.body, +// }); +// return res.status(200).json(result); +// } - const requiredBalance = notNullish(required_balance) - ? required_balance - : notNullish(required_quantity) - ? required_quantity - : null; +// const requiredBalance = notNullish(required_balance) +// ? required_balance +// : notNullish(required_quantity) +// ? required_quantity +// : null; - let quantity = 1; - if (notNullish(requiredBalance)) { - const floatQuantity = parseFloat(requiredBalance); +// let quantity = 1; +// if (notNullish(requiredBalance)) { +// const floatQuantity = parseFloat(requiredBalance); - if (Number.isNaN(floatQuantity)) { - throw new RecaseError({ - message: "Invalid required_balance", - code: ErrCode.InvalidRequest, - statusCode: StatusCodes.BAD_REQUEST, - }); - } - quantity = floatQuantity; - } +// if (Number.isNaN(floatQuantity)) { +// throw new RecaseError({ +// message: "Invalid required_balance", +// code: ErrCode.InvalidRequest, +// statusCode: StatusCodes.BAD_REQUEST, +// }); +// } +// quantity = floatQuantity; +// } - const { - fullCus, - cusEnts, - feature, - creditSystems, - org, - cusProducts, - allFeatures, - } = await getCheckData({ req }); +// const { +// fullCus, +// cusEnts, +// feature, +// creditSystems, +// org, +// cusProducts, +// allFeatures, +// } = await getCheckData({ req }); - // 2. If boolean, return true - if (feature.type === FeatureType.Boolean) { - return await getBooleanEntitledResult({ - db, - fullCus, - res, - cusEnts, - feature, - apiVersion: req.apiVersion, - withPreview: req.body.with_preview, - cusProducts, - allFeatures, - }); - } +// // 2. If boolean, return true +// if (feature.type === FeatureType.Boolean) { +// return await getBooleanEntitledResult({ +// db, +// fullCus, +// res, +// cusEnts, +// feature, +// apiVersion: req.apiVersion, +// withPreview: req.body.with_preview, +// cusProducts, +// allFeatures, +// }); +// } - const v1Response = getV1CheckResponse({ - originalFeature: feature, - creditSystems, - cusEnts: cusEnts!, - quantity, - entityId: entity_id, - org, - }); +// const v1Response = getV1CheckResponse({ +// originalFeature: feature, +// creditSystems, +// cusEnts: cusEnts!, +// quantity, +// entityId: entity_id, +// org, +// }); - const v2Response = await getV2CheckResponse({ - fullCus, - cusEnts, - feature, - creditSystems, - org, - cusProducts, - requiredBalance, - apiVersion: req.apiVersion, - }); +// const v2Response = await getV2CheckResponse({ +// fullCus, +// cusEnts, +// feature, +// creditSystems, +// org, +// cusProducts, +// requiredBalance, +// apiVersion: req.apiVersion, +// }); - const { allowed, balance } = v2Response; - const featureToUse = allFeatures.find( - (f: Feature) => f.id === v2Response.feature_id, - ); +// const { allowed, balance } = v2Response; +// const featureToUse = allFeatures.find( +// (f: Feature) => f.id === v2Response.feature_id, +// ); - if (allowed && req.isPublic !== true) { - if (send_event) { - await handleEventSent({ - req: { - ...req, - body: { - ...req.body, - value: quantity, - }, - }, - customer_id: customer_id, - customer_data: customer_data, - event_data: { - customer_id: customer_id, - feature_id: feature_id, - value: quantity, - entity_id: entity_id, - }, - }); - } else if (notNullish(event_data)) { - await handleEventSent({ - req, - customer_id: customer_id, - customer_data: customer_data, - event_data: { - customer_id: customer_id, - feature_id: feature_id, - ...event_data, - }, - }); - } - } +// if (allowed && req.isPublic !== true) { +// if (send_event) { +// await handleEventSent({ +// req: { +// ...req, +// body: { +// ...req.body, +// value: quantity, +// }, +// }, +// customer_id: customer_id, +// customer_data: customer_data, +// event_data: { +// customer_id: customer_id, +// feature_id: feature_id, +// value: quantity, +// entity_id: entity_id, +// }, +// }); +// } else if (notNullish(event_data)) { +// await handleEventSent({ +// req, +// customer_id: customer_id, +// customer_data: customer_data, +// event_data: { +// customer_id: customer_id, +// feature_id: feature_id, +// ...event_data, +// }, +// }); +// } +// } - let preview; - if (req.body.with_preview) { - try { - preview = await getCheckPreview({ - db, - allowed, - balance: notNullish(balance) ? balance : undefined, - feature: featureToUse!, - cusProducts, - allFeatures, - }); - } catch (error) { - logger.error("Failed to get check preview", error); - console.error(error); - } - } +// let preview; +// if (req.body.with_preview) { +// try { +// preview = await getCheckPreview({ +// db, +// allowed, +// balance: notNullish(balance) ? balance : undefined, +// feature: featureToUse!, +// cusProducts, +// allFeatures, +// }); +// } catch (error) { +// logger.error("Failed to get check preview", error); +// console.error(error); +// } +// } - if (req.apiVersion.gte(ApiVersion.V1_1)) { - res.status(200).json({ - ...v2Response, - preview, - }); - } else { - res.status(200).json({ - ...v1Response, - preview, - }); - } +// if (req.apiVersion.gte(ApiVersion.V1_1)) { +// res.status(200).json({ +// ...v2Response, +// preview, +// }); +// } else { +// res.status(200).json({ +// ...v1Response, +// preview, +// }); +// } - return; - } catch (error) { - handleRequestError({ req, error, res, action: "Failed to GET entitled" }); - } -}); +// return; +// } catch (error) { +// handleRequestError({ req, error, res, action: "Failed to GET entitled" }); +// } +// }); -// let features = [feature, ...creditSystems]; -// let balanceObj: any, featureToUse: any; -// try { -// balanceObj = balances.length > 0 ? balances[0] : null; +// // let features = [feature, ...creditSystems]; +// // let balanceObj: any, featureToUse: any; +// // try { +// // balanceObj = balances.length > 0 ? balances[0] : null; -// featureToUse = -// notNullish(balanceObj) && balanceObj.feature_id !== feature.id -// ? features.find((f) => f.id === balanceObj.feature_id) -// : creditSystems.length > 0 -// ? creditSystems[0] -// : feature; -// } catch (error) { -// logger.error(`/check: failed to get balance & feature to use`, error); -// } +// // featureToUse = +// // notNullish(balanceObj) && balanceObj.feature_id !== feature.id +// // ? features.find((f) => f.id === balanceObj.feature_id) +// // : creditSystems.length > 0 +// // ? creditSystems[0] +// // : feature; +// // } catch (error) { +// // logger.error(`/check: failed to get balance & feature to use`, error); +// // } -// 3. If with preview, get preview +// // 3. If with preview, get preview diff --git a/server/src/internal/api/check/checkUtils/getCheckData.ts b/server/src/internal/api/check/checkUtils/getCheckData.ts index 45b488731..765577491 100644 --- a/server/src/internal/api/check/checkUtils/getCheckData.ts +++ b/server/src/internal/api/check/checkUtils/getCheckData.ts @@ -1,10 +1,13 @@ import { type CheckParams, CusProductStatus, + cusEntToBalance, cusProductsToCusEnts, ErrCode, type Feature, type FullCusEntWithFullCusProduct, + notNullish, + sumValues, } from "@autumn/shared"; import { StatusCodes } from "http-status-codes"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; @@ -60,14 +63,36 @@ export const getFeatureToUse = ({ cusEntMatchesFeature({ cusEnt, feature: creditSystems[0] }), ); - if (creditCusEnts.length > 0) { - return creditSystems[0]; - } + const totalFeatureCusEntBalance = sumValues( + featureCusEnts + .map((cusEnt) => + cusEntToBalance({ + cusEnt, + withRollovers: true, + }), + ) + .filter(notNullish), + ); - if (featureCusEnts.length > 0) { + const totalCreditCusEntBalance = sumValues( + creditCusEnts + .map((cusEnt) => + cusEntToBalance({ + cusEnt, + withRollovers: true, + }), + ) + .filter(notNullish), + ); + + if (featureCusEnts.length > 0 && totalFeatureCusEntBalance > 0) { return feature; } + // if (creditCusEnts.length > 0) { + // return creditSystems[0]; + // } + return creditSystems[0]; } diff --git a/server/src/internal/api/check/checkUtils/getV2CheckResponse.ts b/server/src/internal/api/check/checkUtils/getV2CheckResponse.ts index c90be670d..6d70d580a 100644 --- a/server/src/internal/api/check/checkUtils/getV2CheckResponse.ts +++ b/server/src/internal/api/check/checkUtils/getV2CheckResponse.ts @@ -16,27 +16,12 @@ export const getV2CheckResponse = async ({ ctx, checkData, requiredBalance, - // fullCus, - // cusEnts, - // feature, - // creditSystems, - // cusProducts, - // requiredBalance, - // apiVersion, }: { ctx: AutumnContext; checkData: CheckData; requiredBalance: number; - // fullCus: FullCustomer; - // cusEnts: FullCusEntWithFullCusProduct[]; - // feature: Feature; - // creditSystems: Feature[]; - // cusProducts: FullCusProduct[]; - // requiredBalance?: number; - // apiVersion: ApiVersionClass; }) => { - const { fullCus, cusEnts, originalFeature, featureToUse, cusProducts } = - checkData; + const { fullCus, cusEnts, originalFeature, featureToUse } = checkData; // If credit system used, need to convert required balance to credit system required balance if ( @@ -101,12 +86,12 @@ export const getV2CheckResponse = async ({ }, 0); if ( - apiCusFeature.balance && + notNullish(apiCusFeature.balance) && new Decimal(apiCusFeature.balance) .plus(totalPaidUsageAllowance) .gte(requiredBalance) ) { - // console.log("Balance + total paid usage allowance >= required balance"); + console.log("Balance + total paid usage allowance >= required balance"); allowed = true; } diff --git a/server/src/internal/api/check/handleCheck.ts b/server/src/internal/api/check/handleCheck.ts index daf4f118a..04fc049af 100644 --- a/server/src/internal/api/check/handleCheck.ts +++ b/server/src/internal/api/check/handleCheck.ts @@ -4,10 +4,13 @@ import { type CheckParams, CheckParamsSchema, type CheckResult, + notNullish, } from "@autumn/shared"; import { createRoute } from "@/honoMiddlewares/routeHandler.js"; +import { handleEventSent } from "../events/eventRouter.js"; import { getCheckData } from "./checkUtils/getCheckData.js"; import { getV2CheckResponse } from "./checkUtils/getV2CheckResponse.js"; +import { getCheckPreview } from "./getCheckPreview.js"; import { handleProductCheck } from "./handlers/handleProductCheck.js"; const DEFAULT_REQUIRED_BALANCE = 1; @@ -25,6 +28,7 @@ export const handleCheck = createRoute({ required_quantity, required_balance, send_event, + with_preview, } = body; // Legacy path - product check @@ -39,55 +43,65 @@ export const handleCheck = createRoute({ const requiredBalance = required_balance ?? required_quantity ?? DEFAULT_REQUIRED_BALANCE; - // let quantity = 1; - // if (notNullish(requiredBalance)) { - // const floatQuantity = parseFloat(requiredBalance); - - // if (Number.isNaN(floatQuantity)) { - // throw new RecaseError({ - // message: "Invalid required_balance", - // code: ErrCode.InvalidRequest, - // statusCode: StatusCodes.BAD_REQUEST, - // }); - // } - // quantity = floatQuantity; - // } - const checkData = await getCheckData({ ctx, body: body as CheckParams & { feature_id: string }, }); - // // 2. If boolean, return true - // if (feature.type === FeatureType.Boolean) { - // return await getBooleanEntitledResult({ - // db, - // fullCus, - // res, - // cusEnts, - // feature, - // apiVersion: req.apiVersion, - // withPreview: req.body.with_preview, - // cusProducts, - // allFeatures, - // }); - // } - - // const v1Response = getV1CheckResponse({ - // originalFeature: feature, - // creditSystems, - // cusEnts: cusEnts!, - // quantity, - // entityId: entity_id, - // org, - // }); - const v2Response = await getV2CheckResponse({ ctx, checkData, requiredBalance, }); + const preview = with_preview + ? await getCheckPreview({ + db: ctx.db, + allowed: v2Response.allowed, + balance: notNullish(v2Response.balance) + ? v2Response.balance + : undefined, + feature: checkData.featureToUse!, + cusProducts: checkData.cusProducts, + allFeatures: ctx.features, + }) + : undefined; + + if (v2Response.allowed && ctx.isPublic !== true) { + if (send_event) { + await handleEventSent({ + req: { + ...ctx, + body: { + ...body, + value: requiredBalance, + }, + }, + customer_id: customer_id, + customer_data: customer_data, + event_data: { + customer_id: customer_id, + feature_id: feature_id, + value: requiredBalance, + entity_id: entity_id, + }, + }); + } + + // else if (notNullish(event_data)) { + // await handleEventSent({ + // req, + // customer_id: customer_id, + // customer_data: customer_data, + // event_data: { + // customer_id: customer_id, + // feature_id: feature_id, + // ...event_data, + // }, + // }); + // } + } + // Apply version transformations based on API version const transformedResponse = applyResponseVersionChanges({ input: v2Response, @@ -99,72 +113,47 @@ export const handleCheck = createRoute({ }, }); - return c.json(transformedResponse); - - // if (allowed && req.isPublic !== true) { - // if (send_event) { - // await handleEventSent({ - // req: { - // ...req, - // body: { - // ...req.body, - // value: quantity, - // }, - // }, - // customer_id: customer_id, - // customer_data: customer_data, - // event_data: { - // customer_id: customer_id, - // feature_id: feature_id, - // value: quantity, - // entity_id: entity_id, - // }, - // }); - // } else if (notNullish(event_data)) { - // await handleEventSent({ - // req, - // customer_id: customer_id, - // customer_data: customer_data, - // event_data: { - // customer_id: customer_id, - // feature_id: feature_id, - // ...event_data, - // }, - // }); - // } - // } - - // let preview; - // if (req.body.with_preview) { - // try { - // preview = await getCheckPreview({ - // db, - // allowed, - // balance: notNullish(balance) ? balance : undefined, - // feature: featureToUse!, - // cusProducts, - // allFeatures, - // }); - // } catch (error) { - // logger.error("Failed to get check preview", error); - // console.error(error); - // } - // } - - // if (req.apiVersion.gte(ApiVersion.V1_1)) { - // res.status(200).json({ - // ...v2Response, - // preview, - // }); - // } else { - // res.status(200).json({ - // ...v1Response, - // preview, - // }); - // } - - // const body = c.req.valid("json"); - // const res = await handleCheck(body); - // return c.json(res); + return c.json({ + ...transformedResponse, + preview, + }); }, }); + +// // 2. If boolean, return true +// if (feature.type === FeatureType.Boolean) { +// return await getBooleanEntitledResult({ +// db, +// fullCus, +// res, +// cusEnts, +// feature, +// apiVersion: req.apiVersion, +// withPreview: req.body.with_preview, +// cusProducts, +// allFeatures, +// }); +// } + +// const v1Response = getV1CheckResponse({ +// originalFeature: feature, +// creditSystems, +// cusEnts: cusEnts!, +// quantity, +// entityId: entity_id, +// org, +// }); + +// let quantity = 1; +// if (notNullish(requiredBalance)) { +// const floatQuantity = parseFloat(requiredBalance); + +// if (Number.isNaN(floatQuantity)) { +// throw new RecaseError({ +// message: "Invalid required_balance", +// code: ErrCode.InvalidRequest, +// statusCode: StatusCodes.BAD_REQUEST, +// }); +// } +// quantity = floatQuantity; +// } diff --git a/server/src/internal/api/events/eventRouter.ts b/server/src/internal/api/events/eventRouter.ts index a0558f96d..810c3c826 100644 --- a/server/src/internal/api/events/eventRouter.ts +++ b/server/src/internal/api/events/eventRouter.ts @@ -17,7 +17,6 @@ import type { DrizzleCli } from "@/db/initDrizzle.js"; import { getOrCreateCustomer } from "@/internal/customers/cusUtils/getOrCreateCustomer.js"; import { creditSystemContainsFeature } from "@/internal/features/creditSystemUtils.js"; import { FeatureService } from "@/internal/features/FeatureService.js"; -import { OrgService } from "@/internal/orgs/OrgService.js"; import { JobName } from "@/queue/JobName.js"; import { addTaskToQueue } from "@/queue/queueUtils.js"; import RecaseError, { handleRequestError } from "@/utils/errorUtils.js"; @@ -150,13 +149,10 @@ export const handleEventSent = async ({ }); } - const { env, db } = req; + const { env, db, org, features } = req; const eventName = event_data.event_name; - const org = await OrgService.getFromReq(req); - const features = await FeatureService.getFromReq(req); - const affectedFeatures = await getAffectedFeatures({ req, eventName, diff --git a/server/src/internal/api/events/usageRouter.ts b/server/src/internal/api/events/usageRouter.ts index cca4a1ef8..33480a125 100644 --- a/server/src/internal/api/events/usageRouter.ts +++ b/server/src/internal/api/events/usageRouter.ts @@ -104,7 +104,7 @@ const createAndInsertEvent = async ({ const newEvent: EventInsert = { id: generateId("evt"), - org_id: req.orgId, + org_id: req.org.id, org_slug: req.org.slug, env: req.env, internal_customer_id: customer.internal_id, @@ -153,17 +153,13 @@ export const handleUsageEvent = async ({ properties = properties || {}; - logger.info(`/track: customer ${customer_id}, feature ${feature_id}`); - const startTime = Date.now(); - const { customer, org, feature, creditSystems } = await getCusFeatureAndOrg({ + const { customer, feature, creditSystems } = await getCusFeatureAndOrg({ req, customerId: customer_id, featureId: feature_id, customerData: customer_data, entityId: entity_id, }); - logger.info(`/track: get customer took ${Date.now() - startTime}ms`); - const startTime2 = Date.now(); const newEvent = await createAndInsertEvent({ req, @@ -174,7 +170,6 @@ export const handleUsageEvent = async ({ properties, idempotencyKey: idempotency_key, }); - logger.info(`/track: insert event took ${Date.now() - startTime2}ms`); const features = [feature, ...creditSystems]; @@ -190,7 +185,7 @@ export const handleUsageEvent = async ({ eventId: newEvent.id, features, allFeatures: req.features, - org, + org: req.org, env: req.env, properties, value, @@ -219,7 +214,7 @@ export const handleUsageEvent = async ({ }); } - return { event: newEvent, affectedFeatures: features, org }; + return { event: newEvent, affectedFeatures: features, org: req.org }; }; usageRouter.post("", async (req: any, res: any) => { diff --git a/server/src/trigger/handleThresholdReached.ts b/server/src/trigger/handleThresholdReached.ts index b59b3b79d..d84c6054b 100644 --- a/server/src/trigger/handleThresholdReached.ts +++ b/server/src/trigger/handleThresholdReached.ts @@ -1,5 +1,6 @@ import { type AppEnv, + AuthType, createdAtToVersion, type Feature, type FullCusEntWithFullCusProduct, @@ -14,6 +15,9 @@ import { getV2CheckResponse } from "@/internal/api/check/checkUtils/getV2CheckRe import { getSingleEntityResponse } from "@/internal/api/entities/getEntityUtils.js"; import { getCustomerDetails } from "@/internal/customers/cusUtils/getCustomerDetails.js"; import { toApiFeature } from "@/internal/features/utils/mapFeatureUtils.js"; +import type { AutumnContext } from "../honoUtils/HonoEnv.js"; +import type { CheckData } from "../internal/api/check/checkTypes/CheckData.js"; +import { generateId } from "../utils/genUtils.js"; export const mergeNewCusEntsIntoCusProducts = ({ cusProducts, @@ -100,29 +104,19 @@ export const sendSvixThresholdReachedEvent = async ({ }; export const handleAllowanceUsed = async ({ - db, - org, - env, - features, - logger, + ctx, cusEnts, newCusEnts, feature, fullCus, }: { - db: DrizzleCli; - org: Organization; - env: AppEnv; + ctx: AutumnContext; cusEnts: FullCusEntWithFullCusProduct[]; newCusEnts: FullCusEntWithFullCusProduct[]; feature: Feature; fullCus: FullCustomer; - features: Feature[]; - logger: any; }) => { - const apiVersion = createdAtToVersion({ - createdAt: org.created_at || undefined, - }); + const { db, org, env, features, logger } = ctx; // Allowance used... // Make sure overage allowed is false @@ -136,34 +130,35 @@ export const handleAllowanceUsed = async ({ cusEnt.usage_allowed = false; } - const prevCheckResponse = await getV2CheckResponse({ + const prevCheckData: CheckData = { fullCus, cusEnts: oldCusEnts, - creditSystems: [], - feature, - org, + originalFeature: feature, + featureToUse: feature, cusProducts: fullCus.customer_products, - apiVersion, + entity: fullCus.entity, + }; + + const newCheckData: CheckData = { + fullCus, + cusEnts: clonedNewCusEnts, + originalFeature: feature, + featureToUse: feature, + cusProducts: fullCus.customer_products, + entity: fullCus.entity, + }; + const prevCheckResponse = await getV2CheckResponse({ + ctx, + checkData: prevCheckData, + requiredBalance: 1, }); const v2CheckResponse = await getV2CheckResponse({ - fullCus, - cusEnts: clonedNewCusEnts, - creditSystems: [], - feature, - org, - cusProducts: fullCus.customer_products, - apiVersion, + ctx, + checkData: newCheckData, + requiredBalance: 1, }); - // console.log(`Handling allowance used for feature: ${feature.id}`); - // console.log( - // `Prev: allowed (${prevCheckResponse.allowed}), balance (${prevCheckResponse.balance})` - // ); - // console.log( - // `Current: allowed (${v2CheckResponse.allowed}), balance (${v2CheckResponse.balance})` - // ); - if (prevCheckResponse.allowed === true && v2CheckResponse.allowed === false) { await sendSvixThresholdReachedEvent({ db, @@ -205,6 +200,36 @@ export const handleThresholdReached = async ({ createdAt: org.created_at || undefined, }); + const ctx: AutumnContext = { + db, + org, + env, + features, + logger, + + isPublic: false, + authType: AuthType.SecretKey, + apiVersion, + timestamp: Date.now(), + id: generateId("local_req"), + clickhouseClient: null as any, + }; + + const checkData1: CheckData = { + fullCus, + cusEnts, + originalFeature: feature, + featureToUse: feature, + cusProducts: fullCus.customer_products, + entity: fullCus.entity, + }; + + const prevCheckResponse = await getV2CheckResponse({ + ctx, + checkData: checkData1, + requiredBalance: 1, + }); + const newCusProducts = mergeNewCusEntsIntoCusProducts({ cusProducts: fullCus.customer_products, newCusEnts: newCusEnts, @@ -212,29 +237,24 @@ export const handleThresholdReached = async ({ fullCus.customer_products = newCusProducts; - const prevCheckResponse = await getV2CheckResponse({ - fullCus, - cusEnts: cusEnts, - creditSystems: [], - feature, - org, - cusProducts: fullCus.customer_products, - apiVersion, - }); - - const v2CheckResponse = await getV2CheckResponse({ + const checkData2: CheckData = { fullCus, cusEnts: newCusEnts, - creditSystems: [], - feature, - org, + originalFeature: feature, + featureToUse: feature, cusProducts: newCusProducts, - apiVersion, + entity: fullCus.entity, + }; + + const newCheckResponse = await getV2CheckResponse({ + ctx, + checkData: checkData2, + requiredBalance: 1, }); if ( prevCheckResponse.allowed === true && - v2CheckResponse.allowed === false + newCheckResponse.allowed === false ) { const cusDetails = await getCustomerDetails({ db, @@ -276,11 +296,7 @@ export const handleThresholdReached = async ({ return; } await handleAllowanceUsed({ - db, - org, - env, - features, - logger, + ctx, cusEnts, newCusEnts, feature, diff --git a/server/src/utils/scriptUtils/initCustomer.ts b/server/src/utils/scriptUtils/initCustomer.ts index 3791fb18a..4edaaf480 100644 --- a/server/src/utils/scriptUtils/initCustomer.ts +++ b/server/src/utils/scriptUtils/initCustomer.ts @@ -229,7 +229,7 @@ export const initCustomerV2 = async ({ id: customerId, name, email, - fingerprint: customerData?.fingerprint || fingerprint_, + fingerprint: customerData?.fingerprint || undefined, stripe_id: stripeCus.id, }); diff --git a/server/tests/_guides/general-test-guide.md b/server/tests/_guides/general-test-guide.md new file mode 100644 index 000000000..370b8f4ce --- /dev/null +++ b/server/tests/_guides/general-test-guide.md @@ -0,0 +1,92 @@ +# General Test Guide + +## Test Context + +All tests have access to `ctx` which contains: +- `ctx.org` - Test organization +- `ctx.db` - Database connection +- `ctx.features` - Organization features + +## Initializing Autumn Clients + +### Secret Key (Default) +```typescript +const autumnV1 = new AutumnInt({ version: ApiVersion.V1_2 }); +``` + +### Public Key +```typescript +const autumnPublic = new AutumnInt({ + version: ApiVersion.V1_2, + secretKey: ctx.org.test_pkey!, +}); +``` + +### With Custom Config +```typescript +const autumn = new AutumnInt({ + version: ApiVersion.V1_2, + orgConfig: { include_past_due: true }, +}); +``` + +## API Versions + +- `ApiVersion.V0_2` - Legacy v0 API +- `ApiVersion.V1_2` - Current v1 API + +## Common Test Patterns + +### Wait for Async Processing +```typescript +await new Promise((resolve) => setTimeout(resolve, 2000)); +``` + +### Get Customer with Feature Balance +```typescript +const customer: any = await autumn.customers.get(customerId); +const balance = customer.features[TestFeature.Messages].balance; +const used = customer.features[TestFeature.Messages].used; +``` + +### Expect Error +```typescript +import { expectAutumnError } from "tests/utils/expectUtils/expectErrUtils.js"; + +await expectAutumnError({ + errCode: ErrCode.CustomerNotFound, + func: async () => { + await autumn.customers.get("invalid-id"); + }, +}); +``` + +## Public Key Restrictions + +Public keys can only access: +- `GET /v1/products` +- `POST /v1/entitled` +- `POST /v1/check` +- `POST /v1/attach` +- `GET /v1/customers/:customerId` + +Public keys CANNOT: +- Send events (`send_event: true` is silently ignored) +- Access other endpoints + +## Test Organization + +- `beforeAll` - Setup (create customers, products, attach) +- `test` - Individual test cases +- Use descriptive test names with `chalk.yellowBright()` + +## Imports + +```typescript +import { beforeAll, describe, expect, test } from "bun:test"; +import { ApiVersion, ErrCode } from "@autumn/shared"; +import chalk from "chalk"; +import ctx from "tests/utils/testInitUtils/createTestContext.js"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +``` + diff --git a/server/tests/advanced/referrals/referrals4.ts b/server/tests/advanced/referrals/referrals4.ts index 2de7fc1a4..2a1133509 100644 --- a/server/tests/advanced/referrals/referrals4.ts +++ b/server/tests/advanced/referrals/referrals4.ts @@ -1,4 +1,4 @@ -import type { Customer, ReferralCode, RewardRedemption } from "@autumn/shared"; +import type { ReferralCode, RewardRedemption } from "@autumn/shared"; import { assert } from "chai"; import chalk from "chalk"; import { addDays, addHours } from "date-fns"; @@ -7,10 +7,9 @@ import { setupBefore } from "tests/before.js"; import { compareProductEntitlements } from "tests/utils/compare.js"; import { hoursToFinalizeInvoice } from "tests/utils/constants.js"; import { timeout } from "tests/utils/genUtils.js"; -import { initCustomer } from "tests/utils/init.js"; import { advanceTestClock } from "tests/utils/stripeUtils.js"; -import { initCustomerWithTestClock } from "tests/utils/testInitUtils.js"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { initCustomerV2 } from "../../../src/utils/scriptUtils/initCustomer.js"; import { features, products, referralPrograms } from "../../global.js"; // UNCOMMENT FROM HERE @@ -26,8 +25,6 @@ describe(`${chalk.yellowBright( let referralCode: ReferralCode; const redemptions: RewardRedemption[] = []; - let mainCustomer: Customer; - let redeemer: Customer; let testClockId: string; before(async function () { @@ -35,12 +32,13 @@ describe(`${chalk.yellowBright( autumn = this.autumn; stripeCli = this.stripeCli; - await initCustomer({ + await initCustomerV2({ + autumn, customerId: mainCustomerId, - db: this.db, org: this.org, env: this.env, - attachPm: true, + db: this.db, + attachPm: "success", }); await autumn.attach({ @@ -48,16 +46,16 @@ describe(`${chalk.yellowBright( product_id: products.proWithTrial.id, }); - const { testClockId: testClockId1, customer } = - await initCustomerWithTestClock({ - customerId: redeemerId, - db: this.db, - org: this.org, - env: this.env, - }); + const { testClockId: testClockId1 } = await initCustomerV2({ + autumn, + customerId: redeemerId, + db: this.db, + org: this.org, + env: this.env, + attachPm: "success", + }); testClockId = testClockId1; - redeemer = customer; }); it("should create referral code", async () => { diff --git a/server/tests/attach/basic/basic4.test.ts b/server/tests/attach/basic/basic4.test.ts index 593819929..537d7c36f 100644 --- a/server/tests/attach/basic/basic4.test.ts +++ b/server/tests/attach/basic/basic4.test.ts @@ -85,7 +85,9 @@ describe(`${chalk.yellowBright("basic4: Testing attach monthly add on")}`, () => products.monthlyAddOnMetered1.entitlements.metered1.interval, ); - expect(monthlyMetered1Balance!.balance).toBe(proMetered1! + monthlyQuantity); + expect(monthlyMetered1Balance!.balance).toBe( + proMetered1! + monthlyQuantity, + ); expect(cusRes.add_ons).toHaveLength(1); const monthlyAddOnId = cusRes.add_ons.find( @@ -95,6 +97,7 @@ describe(`${chalk.yellowBright("basic4: Testing attach monthly add on")}`, () => expect(monthlyAddOnId).toBeDefined(); expect(cusRes.invoices.length).toBe(2); }); + return; test("should have correct /check result for metered1", async () => { const res: any = await AutumnCli.entitled(customerId, features.metered1.id); diff --git a/server/tests/check/basic/check8.test.ts b/server/tests/check/basic/check8.test.ts new file mode 100644 index 000000000..48b0ed2ad --- /dev/null +++ b/server/tests/check/basic/check8.test.ts @@ -0,0 +1,154 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import { ApiVersion, type CheckResponse, SuccessCode } from "@autumn/shared"; +import chalk from "chalk"; +import { TestFeature } from "tests/setup/v2Features.js"; +import ctx from "tests/utils/testInitUtils/createTestContext.js"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; +import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; +import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; +import { timeout } from "../../utils/genUtils.js"; + +const messagesFeature = constructFeatureItem({ + featureId: TestFeature.Messages, + includedUsage: 1000, +}); + +const freeProd = constructProduct({ + type: "free", + isDefault: false, + items: [messagesFeature], +}); + +const testCase = "check8"; +const customerId = "check8"; + +describe(`${chalk.yellowBright("check8: test public key & send_event")}`, () => { + const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 }); + let autumnPublic: AutumnInt; + + beforeAll(async () => { + await initCustomerV3({ + ctx, + customerId, + withTestClock: false, + }); + + await initProductsV0({ + ctx, + products: [freeProd], + prefix: testCase, + }); + + await autumnV1.attach({ + customer_id: customerId, + product_id: freeProd.id, + }); + + // Initialize Autumn client with public key + autumnPublic = new AutumnInt({ + version: ApiVersion.V1_2, + secretKey: ctx.org.test_pkey!, + }); + }); + + test("should work with public key for /check endpoint", async () => { + const res = (await autumnPublic.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: 100, + })) as unknown as CheckResponse; + + expect(res).toMatchObject({ + allowed: true, + customer_id: customerId, + feature_id: TestFeature.Messages, + balance: 1000, + required_balance: 100, + code: SuccessCode.FeatureFound, + usage: 0, + included_usage: 1000, + overage_allowed: false, + }); + + expect(res.next_reset_at).toBeDefined(); + }); + + test("should not track usage when send_event: true with public key", async () => { + // Get current balance before + const customerBefore: any = await autumnV1.customers.get(customerId); + const balanceBefore = customerBefore.features[TestFeature.Messages].balance; + const usedBefore = customerBefore.features[TestFeature.Messages].used; + + // Call check with public key and send_event: true + // This should succeed but NOT send events (silently skipped) + const checkRes = (await autumnPublic.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: 50, + send_event: true, + })) as unknown as CheckResponse; + + expect(checkRes.allowed).toBe(true); + + // Wait for potential event processing + await new Promise((resolve) => setTimeout(resolve, 2000)); + + // Get customer and verify balance stayed the same + const customerAfter: any = await autumnV1.customers.get(customerId); + const balanceAfter = customerAfter.features[TestFeature.Messages].balance; + + expect(balanceAfter).toBe(balanceBefore); + expect(customerAfter.features[TestFeature.Messages].used).toBe(usedBefore); + }); + + test("should track usage when send_event: true with secret key", async () => { + // Call check with send_event: true + const checkRes = (await autumnV1.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: 150, + send_event: true, + })) as unknown as CheckResponse; + + expect(checkRes.allowed).toBe(true); + expect(checkRes.balance).toBe(1000); + + // Wait for event to be processed + await timeout(2000); + + // Get customer and verify balance decreased + const customer: any = await autumnV1.customers.get(customerId); + const balanceAfter = customer.features[TestFeature.Messages].balance; + + expect(balanceAfter).toBe(850); // 1000 - 150 + expect(customer.features[TestFeature.Messages].usage).toBe(150); + }); + + test("should not track usage when send_event: true but insufficient balance", async () => { + // Get current balance first + const customerBefore: any = await autumnV1.customers.get(customerId); + const balanceBefore = customerBefore.features[TestFeature.Messages].balance; + + // Call check with required_balance > current balance + const checkRes = (await autumnV1.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: 900, // More than available (850) + send_event: true, + })) as unknown as CheckResponse; + + expect(checkRes.allowed).toBe(false); + + // Wait for potential event processing + await timeout(2000); + + // Get customer and verify balance stayed the same + const customerAfter: any = await autumnV1.customers.get(customerId); + const balanceAfter = customerAfter.features[TestFeature.Messages].balance; + + expect(balanceAfter).toBe(balanceBefore); + expect(customerAfter.features[TestFeature.Messages].usage).toBe(150); // Same as before + }); +}); diff --git a/server/tests/check/credit-systems/credit-systems3.test.ts b/server/tests/check/credit-systems/credit-systems3.test.ts index f6586a6be..4e7ff5322 100644 --- a/server/tests/check/credit-systems/credit-systems3.test.ts +++ b/server/tests/check/credit-systems/credit-systems3.test.ts @@ -1,184 +1,129 @@ -// import { beforeAll, describe, expect, test } from "bun:test"; -// import { -// ApiVersion, -// type CheckResponse, -// type CheckResponseV0, -// type LimitedItem, -// SuccessCode, -// } from "@autumn/shared"; -// import chalk from "chalk"; -// import { TestFeature } from "tests/setup/v2Features.js"; -// import ctx from "tests/utils/testInitUtils/createTestContext.js"; -// import { AutumnInt } from "@/external/autumn/autumnCli.js"; -// import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; -// import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -// import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; -// import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; -// import { -// featureToCreditSystem, -// getCreditCost, -// } from "../../../src/internal/features/creditSystemUtils.js"; +import { beforeAll, describe, expect, test } from "bun:test"; +import { + ApiVersion, + type CheckResponse, + type LimitedItem, + SuccessCode, +} from "@autumn/shared"; +import chalk from "chalk"; +import { TestFeature } from "tests/setup/v2Features.js"; +import ctx from "tests/utils/testInitUtils/createTestContext.js"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; +import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; +import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; +import { featureToCreditSystem } from "../../../src/internal/features/creditSystemUtils.js"; +import { timeout } from "../../utils/genUtils.js"; -// const action1Feature = constructFeatureItem({ -// featureId: TestFeature.Action1, -// includedUsage: 50, -// }) as LimitedItem; +const action1Feature = constructFeatureItem({ + featureId: TestFeature.Action1, + includedUsage: 50, +}) as LimitedItem; -// const creditsFeature = constructFeatureItem({ -// featureId: TestFeature.Credits, -// includedUsage: 100, -// }) as LimitedItem; +const creditsFeature = constructFeatureItem({ + featureId: TestFeature.Credits, + includedUsage: 100, +}) as LimitedItem; -// const proProd = constructProduct({ -// type: "pro", -// isDefault: false, -// items: [action1Feature, creditsFeature], -// }); +const proProd = constructProduct({ + type: "pro", + isDefault: false, + items: [action1Feature, creditsFeature], +}); -// const testCase = "credit-systems3"; +const testCase = "credit-systems3"; -// describe(`${chalk.yellowBright("credit-systems3: test /check fallback from metered feature to credit system")}`, () => { -// const customerId = "credit-systems3"; -// const autumnV0: AutumnInt = new AutumnInt({ version: ApiVersion.V0_2 }); -// const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 }); -// const creditFeature = ctx.features.find((f) => f.id === TestFeature.Credits); +describe(`${chalk.yellowBright("credit-systems3: test /check fallback from metered feature to credit system")}`, () => { + const customerId = "credit-systems3"; + const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 }); + const creditFeature = ctx.features.find((f) => f.id === TestFeature.Credits); -// beforeAll(async () => { -// await initCustomerV3({ -// ctx, -// customerId, -// attachPm: "success", -// withTestClock: false, -// }); + beforeAll(async () => { + await initCustomerV3({ + ctx, + customerId, + attachPm: "success", + withTestClock: false, + }); -// await initProductsV0({ -// ctx, -// products: [proProd], -// prefix: testCase, -// }); + await initProductsV0({ + ctx, + products: [proProd], + prefix: testCase, + }); -// await autumnV1.attach({ -// customer_id: customerId, -// product_id: proProd.id, -// }); -// }); + await autumnV1.attach({ + customer_id: customerId, + product_id: proProd.id, + }); + }); -// test("v0 response - before consuming metered feature, returns Action1", async () => { -// const requiredAction1Units = 25.5; -// const res = (await autumnV0.check({ -// customer_id: customerId, -// feature_id: TestFeature.Action1, -// required_balance: requiredAction1Units, -// })) as unknown as CheckResponseV0; + test("right after attach, check should return Action1 feature", async () => { + const requiredAction1Units = 25.5; + const res = (await autumnV1.check({ + customer_id: customerId, + feature_id: TestFeature.Action1, + required_balance: requiredAction1Units, + })) as unknown as CheckResponse; -// expect(res).toMatchObject({ -// allowed: true, -// balances: [ -// { -// balance: action1Feature.included_usage, -// required: requiredAction1Units, -// feature_id: TestFeature.Action1, -// }, -// ], -// }); -// }); + expect(res).toMatchObject({ + allowed: true, + customer_id: customerId, + balance: action1Feature.included_usage, + feature_id: TestFeature.Action1, + required_balance: requiredAction1Units, + code: SuccessCode.FeatureFound, + unlimited: false, + usage: 0, + included_usage: action1Feature.included_usage, + overage_allowed: false, + interval: "month", + interval_count: 1, + }); -// test("v1 response - before consuming metered feature, returns Action1", async () => { -// const requiredAction1Units = 25.5; -// const res = (await autumnV1.check({ -// customer_id: customerId, -// feature_id: TestFeature.Action1, -// required_balance: requiredAction1Units, -// })) as unknown as CheckResponse; + expect(res.next_reset_at).toBeDefined(); + }); -// expect(res).toMatchObject({ -// allowed: true, -// customer_id: customerId, -// balance: action1Feature.included_usage, -// feature_id: TestFeature.Action1, -// required_balance: requiredAction1Units, -// code: SuccessCode.FeatureFound, -// unlimited: false, -// usage: 0, -// included_usage: action1Feature.included_usage, -// overage_allowed: false, -// interval: "month", -// interval_count: 1, -// }); + test("after consuming Action1, check should return Credits feature", async () => { + // First, consume all Action1 balance + await autumnV1.track({ + customer_id: customerId, + feature_id: TestFeature.Action1, + value: action1Feature.included_usage, + }); + await timeout(2000); -// expect(res.next_reset_at).toBeDefined(); -// }); -// return; + // Now check - should fall back to credit system + const requiredAction1Units = 25.5; + const res = (await autumnV1.check({ + customer_id: customerId, + feature_id: TestFeature.Action1, + required_balance: requiredAction1Units, + })) as unknown as CheckResponse; -// test("consume all Action1 balance", async () => { -// await autumnV1.track({ -// customer_id: customerId, -// feature_id: TestFeature.Action1, -// value: action1Feature.included_usage, -// }); -// }); + // Calculate the credit cost for the required Action1 units + const convertedCreditCost = featureToCreditSystem({ + featureId: TestFeature.Action1, + creditSystem: creditFeature!, + amount: requiredAction1Units, + }); -// test("v0 response - after consuming metered feature, returns Credits", async () => { -// const requiredAction1Units = 25.5; -// const res = (await autumnV0.check({ -// customer_id: customerId, -// feature_id: TestFeature.Action1, -// required_balance: requiredAction1Units, -// })) as unknown as CheckResponseV0; + expect(res).toMatchObject({ + allowed: true, + customer_id: customerId, + balance: creditsFeature.included_usage, + feature_id: TestFeature.Credits, + required_balance: convertedCreditCost, + code: SuccessCode.FeatureFound, + unlimited: false, + usage: 0, + included_usage: creditsFeature.included_usage, + overage_allowed: false, + interval: "month", + interval_count: 1, + }); -// const creditFeature = ctx.features.find( -// (f) => f.id === TestFeature.Credits, -// ); - -// const meteredCost = getCreditCost({ -// featureId: TestFeature.Action1, -// creditSystem: creditFeature!, -// amount: requiredAction1Units, -// }); - -// expect(res.allowed).toBe(true); -// expect(res.balances).toBeDefined(); -// expect(res.balances).toHaveLength(1); -// expect(res.balances[0]).toMatchObject({ -// balance: creditsFeature.included_usage, -// required: meteredCost, -// feature_id: TestFeature.Credits, -// }); -// }); - -// test("v1 response - after consuming metered feature, returns Credits", async () => { -// const requiredAction1Units = 25.5; -// const res = (await autumnV1.check({ -// customer_id: customerId, -// feature_id: TestFeature.Action1, -// required_balance: requiredAction1Units, -// })) as unknown as CheckResponse; - -// const creditFeature = ctx.features.find( -// (f) => f.id === TestFeature.Credits, -// ); - -// const convertedCreditCost = featureToCreditSystem({ -// featureId: TestFeature.Action1, -// creditSystem: creditFeature!, -// amount: requiredAction1Units, -// }); - -// expect(res).toMatchObject({ -// allowed: true, -// customer_id: customerId, -// balance: creditsFeature.included_usage, -// feature_id: TestFeature.Credits, -// required_balance: convertedCreditCost, -// code: SuccessCode.FeatureFound, -// unlimited: false, -// usage: action1Feature.included_usage, -// included_usage: creditsFeature.included_usage, -// overage_allowed: false, -// interval: "month", -// interval_count: 1, -// }); - -// expect(res.next_reset_at).toBeDefined(); -// }); -// }); + expect(res.next_reset_at).toBeDefined(); + }); +}); diff --git a/server/tests/check/credit-systems/credit-systems4.test.ts b/server/tests/check/credit-systems/credit-systems4.test.ts new file mode 100644 index 000000000..155064758 --- /dev/null +++ b/server/tests/check/credit-systems/credit-systems4.test.ts @@ -0,0 +1,161 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import { ApiVersion, type CheckResponse, SuccessCode } from "@autumn/shared"; +import chalk from "chalk"; +import { TestFeature } from "tests/setup/v2Features.js"; +import ctx from "tests/utils/testInitUtils/createTestContext.js"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; +import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; +import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; +import { featureToCreditSystem } from "../../../src/internal/features/creditSystemUtils.js"; +import { timeout } from "../../utils/genUtils.js"; + +const creditsFeature = constructFeatureItem({ + featureId: TestFeature.Credits, + includedUsage: 1000, +}); + +const freeProd = constructProduct({ + type: "free", + isDefault: false, + items: [creditsFeature], +}); + +const testCase = "credit-systems4"; +const customerId = "credit-systems4"; + +describe(`${chalk.yellowBright("credit-systems4: test send_event with credit system")}`, () => { + const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 }); + const creditFeature = ctx.features.find((f) => f.id === TestFeature.Credits); + + beforeAll(async () => { + await initCustomerV3({ + ctx, + customerId, + withTestClock: false, + }); + + await initProductsV0({ + ctx, + products: [freeProd], + prefix: testCase, + }); + + await autumnV1.attach({ + customer_id: customerId, + product_id: freeProd.id, + }); + }); + + test("should deduct credits correctly when using send_event with decimal value", async () => { + // Use a decimal value for required_balance + const requiredAction1Units = 25.75; + + // Calculate how many credits this should consume + const expectedCreditCost = featureToCreditSystem({ + featureId: TestFeature.Action1, + creditSystem: creditFeature!, + amount: requiredAction1Units, + }); + + // Call check with send_event: true + const checkRes = (await autumnV1.check({ + customer_id: customerId, + feature_id: TestFeature.Action1, + required_balance: requiredAction1Units, + send_event: true, + })) as unknown as CheckResponse; + + expect(checkRes.allowed).toBe(true); + expect(checkRes.feature_id).toBe(TestFeature.Credits); + expect(checkRes.balance).toBe(1000); + expect(checkRes.code).toBe(SuccessCode.FeatureFound); + + // Wait for event to be processed + await timeout(2000); + + // Get customer and verify credits were deducted correctly + const customer: any = await autumnV1.customers.get(customerId); + const creditsBalance = customer.features[TestFeature.Credits].balance; + const creditsUsage = customer.features[TestFeature.Credits].usage; + + expect(creditsBalance).toBe(1000 - expectedCreditCost); + expect(creditsUsage).toBe(expectedCreditCost); + }); + + test("should handle multiple send_event calls with different decimal values", async () => { + // Get current balance + const customerBefore: any = await autumnV1.customers.get(customerId); + const balanceBefore = customerBefore.features[TestFeature.Credits].balance; + + // First call with Action1 + const requiredAction1Units = 10.5; + const creditCost1 = featureToCreditSystem({ + featureId: TestFeature.Action1, + creditSystem: creditFeature!, + amount: requiredAction1Units, + }); + + await autumnV1.check({ + customer_id: customerId, + feature_id: TestFeature.Action1, + required_balance: requiredAction1Units, + send_event: true, + }); + + // Second call with Action2 + const requiredAction2Units = 15.25; + const creditCost2 = featureToCreditSystem({ + featureId: TestFeature.Action2, + creditSystem: creditFeature!, + amount: requiredAction2Units, + }); + + await autumnV1.check({ + customer_id: customerId, + feature_id: TestFeature.Action2, + required_balance: requiredAction2Units, + send_event: true, + }); + + // Wait for events to be processed + await timeout(2000); + + // Verify total credits deducted + const customerAfter: any = await autumnV1.customers.get(customerId); + const balanceAfter = customerAfter.features[TestFeature.Credits].balance; + const totalExpectedCost = creditCost1 + creditCost2; + + expect(balanceAfter).toBe(balanceBefore - totalExpectedCost); + expect(customerAfter.features[TestFeature.Credits].usage).toBe( + customerBefore.features[TestFeature.Credits].usage + totalExpectedCost, + ); + }); + + test("should not deduct credits when check fails due to insufficient balance", async () => { + // Get current balance + const customerBefore: any = await autumnV1.customers.get(customerId); + const balanceBefore = customerBefore.features[TestFeature.Credits].balance; + + // Try to use more credits than available + const requiredAction1Units = 10000; // More than remaining balance + const checkRes = (await autumnV1.check({ + customer_id: customerId, + feature_id: TestFeature.Action1, + required_balance: requiredAction1Units, + send_event: true, + })) as unknown as CheckResponse; + + expect(checkRes.allowed).toBe(false); + + // Wait for potential event processing + await timeout(2000); + + // Verify no credits were deducted + const customerAfter: any = await autumnV1.customers.get(customerId); + expect(customerAfter.features[TestFeature.Credits].balance).toBe( + balanceBefore, + ); + }); +}); diff --git a/server/tests/utils/advancedUsageUtils.ts b/server/tests/utils/advancedUsageUtils.ts index e24660c9f..0a28d914e 100644 --- a/server/tests/utils/advancedUsageUtils.ts +++ b/server/tests/utils/advancedUsageUtils.ts @@ -1,12 +1,10 @@ -import { getPriceForOverage } from "@/internal/products/prices/priceUtils.js"; +import type { Feature } from "@autumn/shared"; import assert from "assert"; -import { expect } from "chai"; import { Decimal } from "decimal.js"; import { AutumnCli } from "tests/cli/AutumnCli.js"; -import { creditSystems } from "tests/global.js"; +import { creditSystems, features } from "tests/global.js"; +import { getPriceForOverage } from "@/internal/products/prices/priceUtils.js"; import { timeout } from "./genUtils.js"; -import { features } from "tests/global.js"; -import { Feature } from "@autumn/shared"; const PRECISION = 10; const CREDIT_MULTIPLIER = 100000; @@ -16,7 +14,7 @@ export const getCreditsUsed = ( meteredFeatureId: string, value: number, ) => { - let schemaItem = creditSystem.config.schema.find( + const schemaItem = creditSystem.config.schema.find( (item: any) => item.metered_feature_id === meteredFeatureId, ); @@ -80,8 +78,8 @@ export const checkUsageInvoiceAmount = async ({ (entitlement: any) => entitlement.feature_id === featureId, ); - let meteredPrice = product.prices[product.prices.length - 1]; - let overage = new Decimal(totalUsage) + const meteredPrice = product.prices[product.prices.length - 1]; + const overage = new Decimal(totalUsage) .minus(featureEntitlement.allowance) .toNumber(); const overagePrice = getPriceForOverage(meteredPrice, overage); @@ -91,14 +89,14 @@ export const checkUsageInvoiceAmount = async ({ basePrice = product.prices[0].config.amount; } - let totalPrice = new Decimal(overagePrice.toFixed(2)) + const totalPrice = new Decimal(overagePrice.toFixed(2)) .plus(basePrice) .toNumber(); try { for (let i = 0; i < invoices.length; i++) { - let invoice = invoices[i]; - if (invoice.total == totalPrice) { + const invoice = invoices[i]; + if (invoice.total === totalPrice) { invoiceIndex = i; assert.equal(invoice.product_ids[0], product.id); return; @@ -132,12 +130,12 @@ export const sendGPUEvents = async ({ let totalCreditsUsed = 0; const batchEvents = []; for (let i = 0; i < eventCount; i++) { - let randomVal = new Decimal(Math.random().toFixed(PRECISION)) + const randomVal = new Decimal(Math.random().toFixed(PRECISION)) .mul(CREDIT_MULTIPLIER) .toNumber(); - let gpuId = i % 2 == 0 ? features.gpu1.id : features.gpu2.id; + const gpuId = i % 2 === 0 ? features.gpu1.id : features.gpu2.id; - let creditsUsed = getCreditsUsed( + const creditsUsed = getCreditsUsed( creditSystems.gpuCredits, gpuId, randomVal, @@ -157,7 +155,7 @@ export const sendGPUEvents = async ({ } await Promise.all(batchEvents); - await timeout(10000); + await timeout(15000); return { creditsUsed: totalCreditsUsed }; }; diff --git a/shared/api/versionUtils/versionChangeUtils/applyVersionChanges.ts b/shared/api/versionUtils/versionChangeUtils/applyVersionChanges.ts index ca02d4f24..f49412958 100644 --- a/shared/api/versionUtils/versionChangeUtils/applyVersionChanges.ts +++ b/shared/api/versionUtils/versionChangeUtils/applyVersionChanges.ts @@ -56,16 +56,17 @@ export function applyResponseVersionChanges({ // Sort versions from newest to oldest (we apply backwards) versionsToApply.reverse(); + const printLogs = false; + // Apply each version's changes let transformedData = input; for (const version of versionsToApply) { const changes = VersionChangeRegistryClass.getChangesForVersion({ version, }); - // console.log(`Changes for version ${version}`); - // for (const change of changes) { - // console.log(`[${change.oldVersion}]`, change.name); - // } + if (printLogs) { + console.log(`Changes for version ${version}`); + } for (const change of changes) { // Skip if this change doesn't affect our resource @@ -88,6 +89,10 @@ export function applyResponseVersionChanges({ const shouldApply = targetVersion.lte(change.oldVersion); if (!shouldApply) continue; + if (printLogs) { + console.log(`Applying change ${change.name} for version ${version}`); + } + // Apply the response transformation (backward) transformedData = change.transformResponse({ input: transformedData, diff --git a/shared/api/versionUtils/versionChangeUtils/versionChangeRegistry.ts b/shared/api/versionUtils/versionChangeUtils/versionChangeRegistry.ts index ccae19a07..9a657e2c1 100644 --- a/shared/api/versionUtils/versionChangeUtils/versionChangeRegistry.ts +++ b/shared/api/versionUtils/versionChangeUtils/versionChangeRegistry.ts @@ -6,8 +6,10 @@ import { V0_1_CustomerChange } from "@api/customers/changes/V0_1_CustomerChange. // Import customer changes import { V0_2_CustomerChange } from "@api/customers/changes/V0_2_CustomerChange.js"; import { V0_2_InvoicesAlwaysExpanded } from "@api/customers/changes/V0_2_InvoicesAlwaysExpanded.js"; + // Import customer product changes +import { V1_1_FeaturesArrayToObject } from "../../customers/changes/V1_1_FeaturesArrayToObject.js"; import { ApiVersion } from "../ApiVersion.js"; import type { VersionChangeConstructor } from "./VersionChange.js"; import { VersionChangeRegistryClass } from "./VersionChangeRegistryClass.js"; @@ -17,7 +19,7 @@ export const V1_4_CHANGES: VersionChangeConstructor[] = [ ]; export const V1_2_CHANGES: VersionChangeConstructor[] = [ - // V1_1_FeaturesArrayToObject, // Transforms TO V1_1 + V1_1_FeaturesArrayToObject, // Transforms TO V1_1 ]; export const V1_1_CHANGES: VersionChangeConstructor[] = [