From 2f88bc7e08c0588a2034bd525d9fe4dc570b8fbc Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Mon, 17 Nov 2025 12:12:44 +0000 Subject: [PATCH 01/58] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20free=20plans?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 + .../vercel/handlers/handleListBillingPlans.ts | 8 ++- .../installations/handleUpsertInstallation.ts | 2 +- .../vercel/misc/vercelSubscriptions.ts | 55 +++++++++++----- .../addProductFlow/handleAddProduct.ts | 62 +++++++++++++++++++ .../stripeHandlers/handleGetOAuthUrl.ts | 4 ++ 6 files changed, 114 insertions(+), 19 deletions(-) diff --git a/package.json b/package.json index 8b3b143b2..e5a70c140 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,8 @@ "migrate-functions:prod": "infisical run --env=prod -- bun scripts/migrations/migrate-functions.ts", "validate-schema": "infisical run --env=prod -- bun scripts/migrations/validate-schema.ts", + "vite:build": "cd shared && bun ts && cd ../vite && bun run build", + "setupci": "node scripts/setup/setupci.js", "replicate": "bun scripts/db/replicate.ts", "db:push": " bun -F @autumn/shared db:push", diff --git a/server/src/external/vercel/handlers/handleListBillingPlans.ts b/server/src/external/vercel/handlers/handleListBillingPlans.ts index 9362a5a3c..4a53c2b51 100644 --- a/server/src/external/vercel/handlers/handleListBillingPlans.ts +++ b/server/src/external/vercel/handlers/handleListBillingPlans.ts @@ -19,7 +19,10 @@ import { findPrepaidPrice } from "@/internal/products/prices/priceUtils/findPric import { formatAmount } from "@/utils/formatUtils.js"; import { sortProductsByPrice } from "../../../internal/products/productUtils/sortProductUtils.js"; -import { isOneOff } from "../../../internal/products/productUtils.js"; +import { + isFreeProduct, + isOneOff, +} from "../../../internal/products/productUtils.js"; import type { VercelBillingPlan } from "../misc/vercelTypes.js"; /** @@ -157,12 +160,13 @@ export const listVercelPlansForOrg = async ({ // 1. Get rid of products that have usage prices // 2. Get rid of products that are archived + // 3. Get rid of products that are one off, only if they are not free const filteredProducts = products .filter((p) => !p.prices.some((price) => isUsagePrice({ price }))) .filter( (p) => !p.is_add_on && - !isOneOff(p.prices) && + (!isOneOff(p.prices) || isFreeProduct(p.prices)) && !p.archived && (p.entitlements.length > 0 || p.is_default), ); diff --git a/server/src/external/vercel/handlers/installations/handleUpsertInstallation.ts b/server/src/external/vercel/handlers/installations/handleUpsertInstallation.ts index 77c2ff541..b16c11d91 100644 --- a/server/src/external/vercel/handlers/installations/handleUpsertInstallation.ts +++ b/server/src/external/vercel/handlers/installations/handleUpsertInstallation.ts @@ -163,7 +163,7 @@ export const handleUpsertInstallation = createRoute({ }), orgCurrency: ctx.org.default_currency ?? "usd", }) - : null, + : undefined, }; return c.json(installation, 200); diff --git a/server/src/external/vercel/misc/vercelSubscriptions.ts b/server/src/external/vercel/misc/vercelSubscriptions.ts index 0ed53ed75..1c48d4b51 100644 --- a/server/src/external/vercel/misc/vercelSubscriptions.ts +++ b/server/src/external/vercel/misc/vercelSubscriptions.ts @@ -15,7 +15,10 @@ import { getCusPaymentMethod } from "@/external/stripe/stripeCusUtils.js"; import { getStripeSubItems2 } from "@/external/stripe/stripeSubUtils/getStripeSubItems.js"; import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; import { createStripeSub2 } from "@/internal/customers/attach/attachFunctions/addProductFlow/createStripeSub2.js"; +import { handleFreeProduct } from "@/internal/customers/attach/attachFunctions/addProductFlow/handleAddProduct.js"; +import { CusService } from "@/internal/customers/CusService.js"; import { ProductService } from "@/internal/products/ProductService.js"; +import { isFreeProduct } from "@/internal/products/productUtils.js"; import { getVercelAttachBody, parseVercelPrepaidQuantities, @@ -64,7 +67,7 @@ export const createVercelSubscription = async ({ c: Context; metadata?: Record; resourceId?: string; -}): Promise<{ subscription: Stripe.Subscription; product: FullProduct }> => { +}): Promise<{ product: FullProduct }> => { // 1. Check for existing non-incomplete subscription (only allow one per installation) const existingSubscription = stripeCustomer.subscriptions?.data.find( (s) => @@ -101,6 +104,13 @@ export const createVercelSubscription = async ({ }); } + const refreshedCustomer = await CusService.getFull({ + db, + idOrInternalId: customer.internal_id, + orgId: org.id, + env, + }); + // 3. Get custom payment method (created in handleUpsertInstallation) const customPaymentMethod = await getCusPaymentMethod({ stripeCli, @@ -148,21 +158,34 @@ export const createVercelSubscription = async ({ resourceId, }); - // 6. Get subscription items - const itemSet = await getStripeSubItems2({ - attachParams, - config, - }); + if (isFreeProduct(product.prices)) { + if ( + !refreshedCustomer?.customer_products.find( + (cp) => cp.product_id === billingPlanId, + ) + ) { + await handleFreeProduct({ + ctx: c.get("ctx"), + attachParams, + }); + } + } else { + // 6. Get subscription items + const itemSet = await getStripeSubItems2({ + attachParams, + config, + }); - // 7. Create Stripe subscription - const subscription = await createStripeSub2({ - db, - stripeCli, - attachParams, - config, - itemSet, - logger, - }); + // 7. Create Stripe subscription + await createStripeSub2({ + db, + stripeCli, + attachParams, + config, + itemSet, + logger, + }); + } // Subscription will be 'incomplete' initially with an 'open' invoice // Payment flow: @@ -174,5 +197,5 @@ export const createVercelSubscription = async ({ // - Attaches payment record to invoice // 4. Invoice becomes 'paid' → Subscription becomes 'active' - return { subscription, product }; + return { product }; }; diff --git a/server/src/internal/customers/attach/attachFunctions/addProductFlow/handleAddProduct.ts b/server/src/internal/customers/attach/attachFunctions/addProductFlow/handleAddProduct.ts index c3962ccc2..617645234 100644 --- a/server/src/internal/customers/attach/attachFunctions/addProductFlow/handleAddProduct.ts +++ b/server/src/internal/customers/attach/attachFunctions/addProductFlow/handleAddProduct.ts @@ -5,6 +5,7 @@ import { SuccessCode, } from "@autumn/shared"; import { subToPeriodStartEnd } from "@/external/stripe/stripeSubUtils/convertSubUtils.js"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { attachToInsertParams } from "@/internal/products/productUtils.js"; import type { ExtendedRequest } from "@/utils/models/Request.js"; import { createFullCusProduct } from "../../../add-product/createFullCusProduct.js"; @@ -108,3 +109,64 @@ export const handleAddProduct = async ({ } } }; + +export const handleFreeProduct = async ({ + ctx, + attachParams, + config, +}: { + ctx: AutumnContext; + attachParams: AttachParams; + config?: AttachConfig; +}) => { + const { logger } = ctx; + const { products, prices } = attachParams; + + const defaultConfig: AttachConfig = getDefaultAttachConfig(); + + // 1. If paid product + + if (prices.length < 0) { + return; + } + + logger.info("Inserting free product in handleFreeProduct"); + + const batchInsert = []; + + const { mergeSub } = await getMergeCusProduct({ + attachParams, + config: config || defaultConfig, + products, + }); + + for (const product of products) { + const curCusProduct = attachParamsToCurCusProduct({ attachParams }); + let anchorToUnix; + + if (curCusProduct && config?.branch === AttachBranch.NewVersion) { + anchorToUnix = curCusProduct.created_at; + } + + if (mergeSub) { + const { end } = subToPeriodStartEnd({ sub: mergeSub }); + anchorToUnix = end * 1000; + } + + // Expire previous product + + batchInsert.push( + createFullCusProduct({ + db: ctx.db, + attachParams: attachToInsertParams(attachParams, product), + billLaterOnly: true, + carryExistingUsages: config?.carryUsage || false, + anchorToUnix, + logger, + }), + ); + } + await Promise.all(batchInsert); + + logger.info("Successfully created full cus product"); +}; diff --git a/server/src/internal/orgs/handlers/stripeHandlers/handleGetOAuthUrl.ts b/server/src/internal/orgs/handlers/stripeHandlers/handleGetOAuthUrl.ts index 2a0be9128..b47dd7707 100644 --- a/server/src/internal/orgs/handlers/stripeHandlers/handleGetOAuthUrl.ts +++ b/server/src/internal/orgs/handlers/stripeHandlers/handleGetOAuthUrl.ts @@ -46,6 +46,10 @@ export const handleGetOAuthUrl = createRoute({ serverUrl = `https://express.dev.useautumn.com`; } + if (process.env.NGROK_URL && serverUrl?.includes("localhost")) { + serverUrl = process.env.NGROK_URL; + } + // Add state + redirect_uri baseUrl.searchParams.set("state", stateKey); baseUrl.searchParams.set( From b48b02d8ad57f828a8bbe2f1de8ae498901458ad Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Mon, 17 Nov 2025 13:02:43 +0000 Subject: [PATCH 02/58] =?UTF-8?q?fix:=20=F0=9F=90=9B=20transactionize=20cr?= =?UTF-8?q?eate=20resource?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../resources/handleCreateResource.ts | 145 +++++++++++------- 1 file changed, 92 insertions(+), 53 deletions(-) diff --git a/server/src/external/vercel/handlers/resources/handleCreateResource.ts b/server/src/external/vercel/handlers/resources/handleCreateResource.ts index 68c2ba98d..735dcb7ed 100644 --- a/server/src/external/vercel/handlers/resources/handleCreateResource.ts +++ b/server/src/external/vercel/handlers/resources/handleCreateResource.ts @@ -1,7 +1,14 @@ -import { AppEnv, CusExpand, RecaseError } from "@autumn/shared"; +import { + AppEnv, + CusExpand, + type FullProduct, + RecaseError, +} from "@autumn/shared"; import { ErrCode } from "@shared/enums/ErrCode.js"; +import { DrizzleError } from "drizzle-orm"; import { StatusCodes } from "http-status-codes"; import { z } from "zod/v4"; +import type { DrizzleCli } from "@/db/initDrizzle.js"; import { createStripeCli } from "@/external/connect/createStripeCli.js"; import { sendCustomSvixEvent } from "@/external/svix/svixHelpers.js"; import { createVercelSubscription } from "@/external/vercel/misc/vercelSubscriptions.js"; @@ -73,37 +80,50 @@ export const handleCreateResource = createRoute({ // 2. Create resource in database (enforces 1-resource limit) const resourceId = generateId("vre"); - await VercelResourceService.create({ - db, - resource: { - id: resourceId, - org_id: orgId, - env: env as AppEnv, - installation_id: integrationConfigurationId, - name, - status: "pending", - metadata: metadata ?? {}, - }, - }); - - // 3. Create subscription (installation-level billing) - const { product } = await createVercelSubscription({ - db, - org, - env: env as AppEnv, - customer, - stripeCustomer, - stripeCli, - integrationConfigurationId, - billingPlanId, - features, - logger, - c, - metadata, - resourceId, - }); - try { + const product = await db.transaction(async (tx) => { + await VercelResourceService.create({ + db: tx as unknown as DrizzleCli, + resource: { + id: resourceId, + org_id: orgId, + env: env as AppEnv, + installation_id: integrationConfigurationId, + name, + status: "pending", + metadata: metadata ?? {}, + }, + }); + + let createdProduct: FullProduct; + + try { + // 3. Create subscription (installation-level billing) + const { product } = await createVercelSubscription({ + db: tx as unknown as DrizzleCli, + org, + env: env as AppEnv, + customer, + stripeCustomer, + stripeCli, + integrationConfigurationId, + billingPlanId, + features, + logger, + c, + metadata, + resourceId, + }); + + createdProduct = product; + } catch (error) { + tx.rollback(); + throw error; + } + + return createdProduct; + }); + await sendCustomSvixEvent({ appId: org.processor_configs?.vercel?.svix?.[ @@ -121,27 +141,46 @@ export const handleCreateResource = createRoute({ access_token: customer.processors?.vercel?.access_token ?? "", } satisfies VercelResourceCreatedEvent, }); - } catch (_error) {} - // 4. Return resource response - return c.json({ - id: resourceId, - productId, - name, - metadata, - status: "pending", // Will become "ready" after marketplace.invoice.paid confirms payment - billingPlan: { - ...productToBillingPlan({ - product, - orgCurrency: org?.default_currency ?? "usd", - }), - scope: "installation", // Always installation-level - }, - secrets: [], - notification: { - level: "info", - title: "Resource provisioning", - message: `Setting up ${name}...`, - }, - }); + + // 4. Return resource response + return c.json({ + id: resourceId, + productId, + name, + metadata, + status: "pending", // Will become "ready" after marketplace.invoice.paid confirms payment + billingPlan: { + ...productToBillingPlan({ + product, + orgCurrency: org?.default_currency ?? "usd", + }), + scope: "installation", // Always installation-level + }, + secrets: [], + notification: { + level: "info", + title: "Resource provisioning", + message: `Setting up ${name}...`, + }, + }); + } catch (error) { + return c.json( + { + error: { + code: "conflict", + message: + error instanceof DrizzleError + ? error.message.includes("Rollback") + ? "An error occurred while creating the resource's subscription" + : error.message + : error instanceof RecaseError + ? error.message + : "An error occurred while creating the resource", + user: null, + }, + }, + StatusCodes.CONFLICT, + ); + } }, }); From 50e8395c98a030679cae3bd13a893e3cad5ffc49 Mon Sep 17 00:00:00 2001 From: John Yeo Date: Thu, 20 Nov 2025 11:35:31 +0000 Subject: [PATCH 03/58] chore: moved referrals to hono router --- scripts/testGroups/g6.sh | 32 +- server/src/internal/api/apiRouter.ts | 3 - .../handlers/referrals/handleGetRedemption.ts | 31 +- .../referrals/handleGetReferralCode.ts | 150 +++-- .../referrals/handleRedeemReferral.ts | 524 ++++++++++++------ .../api/rewards/handlers/referrals/index.ts | 3 - .../internal/api/rewards/referralRouter.ts | 24 +- .../handleCreateEntity/getInputEntities.ts | 25 +- .../product-items/validateProductItems.ts | 3 +- .../internal/rewards/RewardProgramService.ts | 37 +- .../internal/rewards/triggerCheckoutReward.ts | 16 +- server/src/routers/apiRouter.ts | 7 + .../advanced/referrals/referrals1.test.ts | 8 +- .../advanced/referrals/referrals2.test.ts | 1 + .../advanced/referrals/referrals3.test.ts | 2 +- .../advanced/referrals/referrals4.test.ts | 6 +- shared/api/errors/classes/entityErrClasses.ts | 11 + shared/api/errors/codes/entityErrCodes.ts | 1 + 18 files changed, 526 insertions(+), 358 deletions(-) delete mode 100644 server/src/internal/api/rewards/handlers/referrals/index.ts diff --git a/scripts/testGroups/g6.sh b/scripts/testGroups/g6.sh index 8f7736aa5..de856b941 100755 --- a/scripts/testGroups/g6.sh +++ b/scripts/testGroups/g6.sh @@ -4,21 +4,23 @@ source "$(dirname "$0")/config.sh" -BUN_PARALLEL_COMPACT \ - 'server/tests/advanced/coupons' \ - 'server/tests/advanced/misc' \ - 'server/tests/attach/updateQuantity' \ - 'server/tests/attach/multiProduct' \ - 'server/tests/advanced/multiFeature' \ - 'server/tests/advanced/referrals' \ - 'server/tests/advanced/rollovers' \ - 'server/tests/advanced/customInterval' \ - 'server/tests/advanced/usageLimit' \ - --max=6 +BUN_PARALLEL_COMPACT 'server/tests/advanced/referrals' + +# BUN_PARALLEL_COMPACT \ +# 'server/tests/advanced/coupons' \ +# 'server/tests/advanced/misc' \ +# 'server/tests/attach/updateQuantity' \ +# 'server/tests/attach/multiProduct' \ +# 'server/tests/advanced/multiFeature' \ +# 'server/tests/advanced/referrals' \ +# 'server/tests/advanced/rollovers' \ +# 'server/tests/advanced/customInterval' \ +# 'server/tests/advanced/usageLimit' \ +# --max=6 -BUN_PARALLEL_COMPACT \ - 'server/tests/advanced/usage' - # 'server/tests/crud/plan' +# BUN_PARALLEL_COMPACT \ +# 'server/tests/advanced/usage' +# # 'server/tests/crud/plan' -# 'server/tests/advanced/referrals/paid' \ \ No newline at end of file +# # 'server/tests/advanced/referrals/paid' \ \ No newline at end of file diff --git a/server/src/internal/api/apiRouter.ts b/server/src/internal/api/apiRouter.ts index ad2e77679..bdf964f5b 100644 --- a/server/src/internal/api/apiRouter.ts +++ b/server/src/internal/api/apiRouter.ts @@ -12,7 +12,6 @@ import { platformRouter } from "../platform/platformLegacy/platformRouter.js"; import { expressProductRouter } from "../products/productRouter.js"; import { componentRouter } from "./components/componentRouter.js"; import { invoiceRouter } from "./invoiceRouter.js"; -import { redemptionRouter, referralRouter } from "./rewards/referralRouter.js"; import { rewardProgramRouter } from "./rewards/rewardProgramRouter.js"; import rewardRouter from "./rewards/rewardRouter.js"; @@ -30,8 +29,6 @@ apiRouter.use("/rewards", rewardRouter); // REWARDS apiRouter.use("/reward_programs", rewardProgramRouter); -apiRouter.use("/referrals", referralRouter); -apiRouter.use("/redemptions", redemptionRouter); // Cus Product apiRouter.use("", attachRouter); diff --git a/server/src/internal/api/rewards/handlers/referrals/handleGetRedemption.ts b/server/src/internal/api/rewards/handlers/referrals/handleGetRedemption.ts index 0f3a29e6d..dd8cf32d9 100644 --- a/server/src/internal/api/rewards/handlers/referrals/handleGetRedemption.ts +++ b/server/src/internal/api/rewards/handlers/referrals/handleGetRedemption.ts @@ -1,20 +1,17 @@ +import { z } from "zod/v4"; import { RewardRedemptionService } from "@/internal/rewards/RewardRedemptionService.js"; -import { routeHandler } from "@/utils/routerUtils.js"; +import { createRoute } from "../../../../../honoMiddlewares/routeHandler"; +export const handleGetRedemption = createRoute({ + params: z.object({ redemption_id: z.string() }), + handler: async (c) => { + const { db } = c.get("ctx"); + const { redemption_id } = c.req.param(); -export default async (req: any, res: any) => - routeHandler({ - req, - res, - action: "get redemption by id", - handler: async (req, res) => { - const { db } = req; - const { redemptionId } = req.params; + const redemption = await RewardRedemptionService.getById({ + db, + id: redemption_id, + }); - const redemption = await RewardRedemptionService.getById({ - db, - id: redemptionId, - }); - - res.status(200).json(redemption); - }, - }); + return c.json(redemption); + }, +}); diff --git a/server/src/internal/api/rewards/handlers/referrals/handleGetReferralCode.ts b/server/src/internal/api/rewards/handlers/referrals/handleGetReferralCode.ts index a29fd557b..949623edc 100644 --- a/server/src/internal/api/rewards/handlers/referrals/handleGetReferralCode.ts +++ b/server/src/internal/api/rewards/handlers/referrals/handleGetReferralCode.ts @@ -1,85 +1,83 @@ -import { ErrCode } from "@autumn/shared"; +import { CustomerNotFoundError, ErrCode, RecaseError } from "@autumn/shared"; +import { z } from "zod/v4"; import { CusService } from "@/internal/customers/CusService.js"; import { RewardProgramService } from "@/internal/rewards/RewardProgramService.js"; import { generateReferralCode } from "@/internal/rewards/referralUtils.js"; -import RecaseError from "@/utils/errorUtils.js"; import { generateId } from "@/utils/genUtils.js"; -import { routeHandler } from "@/utils/routerUtils.js"; +import { createRoute } from "../../../../../honoMiddlewares/routeHandler"; -export default async (req: any, res: any) => - routeHandler({ - req, - res, - action: "get referral code", - handler: async (req, res) => { - const { orgId, env, db } = req; - const { program_id: rewardProgramId, customer_id: customerId } = req.body; +export const handleGetReferralCode = createRoute({ + body: z.object({ + program_id: z.string(), + customer_id: z.string(), + }), + handler: async (c) => { + const ctx = c.get("ctx"); + const { db, org, env } = ctx; + const { program_id: rewardProgramId, customer_id: customerId } = + c.req.valid("json"); - const [rewardProgram, customer] = await Promise.all([ - RewardProgramService.get({ - db, - idOrInternalId: rewardProgramId, - orgId, - env, - errorIfNotFound: true, - }), - CusService.get({ - db: req.db, - orgId, - env, - idOrInternalId: customerId, - }), - ]); + const [rewardProgram, customer] = await Promise.all([ + RewardProgramService.get({ + db, + idOrInternalId: rewardProgramId, + orgId: org.id, + env, + errorIfNotFound: true, + }), + CusService.get({ + db, + orgId: org.id, + env, + idOrInternalId: customerId, + }), + ]); - if (!customer) { - throw new RecaseError({ - message: "Customer not found", - statusCode: 404, - code: ErrCode.CustomerNotFound, - }); - } + if (!customer) { + throw new CustomerNotFoundError({ customerId }); + } - if (!rewardProgram) { - throw new RecaseError({ - message: "Reward program not found", - statusCode: 404, - code: ErrCode.RewardProgramNotFound, - }); - } - - // Get referral code by customer and reward trigger - let referralCode = - await RewardProgramService.getCodeByCustomerAndRewardProgram({ - db, - orgId, - env, - internalCustomerId: customer.internal_id, - internalRewardProgramId: rewardProgram.internal_id, - }); - - if (!referralCode) { - const code = generateReferralCode(); - - referralCode = { - code, - org_id: orgId, - env, - internal_customer_id: customer.internal_id, - internal_reward_program_id: rewardProgram.internal_id, - id: generateId("rc"), - created_at: Date.now(), - }; - - referralCode = await RewardProgramService.createReferralCode({ - db, - data: referralCode, - }); - } - - res.status(200).json({ - code: referralCode.code, - customer_id: customer.id, - created_at: referralCode.created_at, + if (!rewardProgram) { + throw new RecaseError({ + message: "Reward program not found", + statusCode: 404, + code: ErrCode.RewardProgramNotFound, }); - }, - }); + } + + // Get referral code by customer and reward trigger + let referralCode = + await RewardProgramService.getCodeByCustomerAndRewardProgram({ + db, + orgId: org.id, + env, + internalCustomerId: customer.internal_id, + internalRewardProgramId: rewardProgram.internal_id, + }); + + if (!referralCode) { + const code = generateReferralCode(); + + referralCode = { + code, + org_id: org.id, + env, + internal_customer_id: customer.internal_id, + internal_reward_program_id: rewardProgram.internal_id, + id: generateId("rc"), + created_at: Date.now(), + }; + + referralCode = await RewardProgramService.createReferralCode({ + db, + data: referralCode, + }); + } + + return c.json({ + code: referralCode.code, + customer_id: customer.id, + created_at: referralCode.created_at, + }); + }, +}); diff --git a/server/src/internal/api/rewards/handlers/referrals/handleRedeemReferral.ts b/server/src/internal/api/rewards/handlers/referrals/handleRedeemReferral.ts index 19efc4cd8..a4e817fd4 100644 --- a/server/src/internal/api/rewards/handlers/referrals/handleRedeemReferral.ts +++ b/server/src/internal/api/rewards/handlers/referrals/handleRedeemReferral.ts @@ -1,13 +1,15 @@ import { + CustomerNotFoundError, ErrCode, + InternalError, RecaseError, RewardCategory, type RewardRedemption, RewardTriggerEvent, } from "@autumn/shared"; +import { z } from "zod/v4"; import { parseReqForAction } from "@/internal/analytics/actionUtils.js"; import { CusService } from "@/internal/customers/CusService.js"; -import { OrgService } from "@/internal/orgs/OrgService.js"; import { RewardProgramService } from "@/internal/rewards/RewardProgramService.js"; import { RewardRedemptionService } from "@/internal/rewards/RewardRedemptionService.js"; import { RewardService } from "@/internal/rewards/RewardService.js"; @@ -16,187 +18,359 @@ import { triggerRedemption } from "@/internal/rewards/referralUtils.js"; import { getRewardCat } from "@/internal/rewards/rewardUtils.js"; import { generateId, notNullish } from "@/utils/genUtils.js"; import type { ExtendedRequest } from "@/utils/models/Request.js"; -import { routeHandler } from "@/utils/routerUtils.js"; +import { createRoute } from "../../../../../honoMiddlewares/routeHandler"; -export default async (req: any, res: any) => - routeHandler({ - req, - res, - action: "redeem referral code", - handler: async (req, res) => { - const { orgId, env, logger, db } = req; - const { code, customer_id: customerId } = req.body; +export const handleRedeemReferral = createRoute({ + body: z.object({ + code: z.string(), + customer_id: z.string(), + }), + handler: async (c) => { + const ctx = c.get("ctx"); + const { db, org, env } = ctx; + const { code, customer_id: customerId } = c.req.valid("json"); - // 1. Get redeemed by customer, and referral code - const [customer, referralCode, org] = await Promise.all([ - CusService.get({ - db, - orgId, - env, - idOrInternalId: customerId, - }), - RewardProgramService.getReferralCode({ - db, - orgId, - env, - code, - withRewardProgram: true, - }), - OrgService.getFromReq(req), - ]); - - if (!customer) { - throw new RecaseError({ - message: "Customer not found", - statusCode: 404, - code: ErrCode.CustomerNotFound, - }); - } - - // 2. Check that code has not reached max redemptions - const redemptionCount = await RewardProgramService.getCodeRedemptionCount( - { - db, - referralCodeId: referralCode.id, - }, - ); - - if ( - referralCode.reward_program.max_redemptions && - redemptionCount >= referralCode.reward_program.max_redemptions - ) { - throw new RecaseError({ - message: "Referral code has reached max redemptions", - statusCode: 400, - code: ErrCode.ReferralCodeMaxRedemptionsReached, - }); - } - - // 3. Check that customer has not already redeemed a code in this referral program - const existingRedemptions = await RewardRedemptionService.getByCustomer({ + // 1. Get redeemed by customer, and referral code + const [customer, referralCode] = await Promise.all([ + CusService.get({ db, - internalCustomerId: customer.internal_id, - internalRewardProgramId: referralCode.internal_reward_program_id, - }); - - if (existingRedemptions.length > 0) { - throw new RecaseError({ - message: `Customer ${customer.id} has already redeemed a code in this referral program`, - statusCode: 400, - code: ErrCode.CustomerAlreadyRedeemedReferralCode, - }); - } - - // Don't let customer redeem their own code - const codeCustomer = await CusService.getByInternalId({ - db: req.db, - internalId: referralCode.internal_customer_id, - }); - - if (!codeCustomer) { - throw new RecaseError({ - message: "Referral code customer not found", - statusCode: 404, - code: ErrCode.CustomerNotFound, - }); - } - - if ( - codeCustomer.id === customer.id || - (notNullish(codeCustomer.fingerprint) && - codeCustomer.fingerprint === customer.fingerprint) - ) { - throw new RecaseError({ - message: "Customer cannot redeem their own code", - statusCode: 400, - code: ErrCode.CustomerCannotRedeemOwnCode, - }); - } - - // 4. Insert redemption into db - let redemption: RewardRedemption = { - id: generateId("rr"), - referral_code_id: referralCode.id, - internal_customer_id: customer.internal_id, // redeemed by customer - internal_reward_program_id: referralCode.internal_reward_program_id, - created_at: Date.now(), - triggered: - referralCode.reward_program.when === - RewardTriggerEvent.CustomerCreation, - applied: false, - updated_at: Date.now(), - redeemer_applied: false, - }; - - redemption = await RewardRedemptionService.insert({ + orgId: org.id, + env, + idOrInternalId: customerId, + }), + RewardProgramService.getReferralCode({ db, - rewardRedemption: redemption, - }); + orgId: org.id, + env, + code, + withRewardProgram: true, + }), + ]); - // 5. If reward trigger when is immediate: - const { reward_program } = referralCode; - const redeemRewardNow = + if (!customer) throw new CustomerNotFoundError({ customerId }); + + // 2. Check that code has not reached max redemptions + const redemptionCount = await RewardProgramService.getCodeRedemptionCount({ + db, + referralCodeId: referralCode.id, + }); + + if ( + referralCode.reward_program.max_redemptions && + redemptionCount >= referralCode.reward_program.max_redemptions + ) { + throw new RecaseError({ + message: "Referral code has reached max redemptions", + statusCode: 400, + code: ErrCode.ReferralCodeMaxRedemptionsReached, + }); + } + + // 3. Check that customer has not already redeemed a code in this referral program + const existingRedemptions = await RewardRedemptionService.getByCustomer({ + db, + internalCustomerId: customer.internal_id, + internalRewardProgramId: referralCode.internal_reward_program_id, + }); + + if (existingRedemptions.length > 0) { + throw new RecaseError({ + message: `Customer ${customer.id} has already redeemed a code in this referral program`, + statusCode: 400, + code: ErrCode.CustomerAlreadyRedeemedReferralCode, + }); + } + + // Don't let customer redeem their own code + const codeCustomer = await CusService.getByInternalId({ + db, + internalId: referralCode.internal_customer_id, + }); + + if (!codeCustomer) { + throw new InternalError({ + message: `Referral code customer not found, internal ID: ${referralCode.internal_customer_id}`, + }); + } + + if ( + codeCustomer.id === customer.id || + (notNullish(codeCustomer.fingerprint) && + codeCustomer.fingerprint === customer.fingerprint) + ) { + throw new RecaseError({ + message: "Customer cannot redeem their own code", + statusCode: 400, + code: ErrCode.CustomerCannotRedeemOwnCode, + }); + } + + // 4. Insert redemption into db + let redemption: RewardRedemption = { + id: generateId("rr"), + referral_code_id: referralCode.id, + internal_customer_id: customer.internal_id, // redeemed by customer + internal_reward_program_id: referralCode.internal_reward_program_id, + created_at: Date.now(), + triggered: referralCode.reward_program.when === - RewardTriggerEvent.CustomerCreation; + RewardTriggerEvent.CustomerCreation, + applied: false, + updated_at: Date.now(), + redeemer_applied: false, + }; - if (redeemRewardNow) { - const reward = await RewardService.get({ - db, - orgId, - env, - idOrInternalId: reward_program.internal_reward_id, + redemption = await RewardRedemptionService.insert({ + db, + rewardRedemption: redemption, + }); + + // 5. If reward trigger when is immediate: + const { reward_program } = referralCode; + const redeemRewardNow = + referralCode.reward_program.when === RewardTriggerEvent.CustomerCreation; + + if (redeemRewardNow) { + const reward = await RewardService.get({ + db, + orgId: org.id, + env, + idOrInternalId: reward_program.internal_reward_id, + }); + + if (!reward) { + throw new RecaseError({ + message: `Reward ${reward_program.internal_reward_id} not found`, + statusCode: 404, + code: ErrCode.RewardNotFound, }); - - if (!reward) { - throw new RecaseError({ - message: `Reward ${reward_program.internal_reward_id} not found`, - statusCode: 404, - code: ErrCode.RewardNotFound, - }); - } - - const rewardCat = getRewardCat(reward); - if (rewardCat === RewardCategory.FreeProduct) { - await triggerFreeProduct({ - req: parseReqForAction(req) as ExtendedRequest, - db, - referralCode, - redeemer: customer, - rewardProgram: reward_program, - org, - env, - logger, - redemption, - }); - } else { - await triggerRedemption({ - db, - referralCode, - org, - env, - logger, - reward, - redemption, - }); - } } - return res.status(200).json({ - id: redemption.id, - customer_id: customer.id, - reward_id: reward_program.reward.id, - referrer: { - id: codeCustomer.id, - name: codeCustomer.name, - email: codeCustomer.email, - created_at: codeCustomer.created_at, - }, - redeemer: { - id: customer.id, - name: customer.name, - email: customer.email, - created_at: customer.created_at, - }, - }); - }, - }); + const rewardCat = getRewardCat(reward); + if (rewardCat === RewardCategory.FreeProduct) { + await triggerFreeProduct({ + req: parseReqForAction(ctx as ExtendedRequest) as ExtendedRequest, + db, + referralCode, + redeemer: customer, + rewardProgram: reward_program, + org, + env, + logger: ctx.logger, + redemption, + }); + } else { + await triggerRedemption({ + db, + referralCode, + org, + env, + logger: ctx.logger, + reward, + redemption, + }); + } + } + + return c.json({ + id: redemption.id, + customer_id: customer.id, + reward_id: reward_program.reward.id, + referrer: { + id: codeCustomer.id, + name: codeCustomer.name, + email: codeCustomer.email, + created_at: codeCustomer.created_at, + }, + redeemer: { + id: customer.id, + name: customer.name, + email: customer.email, + created_at: customer.created_at, + }, + }); + }, +}); + +// export default async (req: any, res: any) => +// routeHandler({ +// req, +// res, +// action: "redeem referral code", +// handler: async (req, res) => { +// const { orgId, env, logger, db } = req; +// const { code, customer_id: customerId } = req.body; + +// // 1. Get redeemed by customer, and referral code +// const [customer, referralCode, org] = await Promise.all([ +// CusService.get({ +// db, +// orgId, +// env, +// idOrInternalId: customerId, +// }), +// RewardProgramService.getReferralCode({ +// db, +// orgId, +// env, +// code, +// withRewardProgram: true, +// }), +// OrgService.getFromReq(req), +// ]); + +// if (!customer) { +// throw new RecaseError({ +// message: "Customer not found", +// statusCode: 404, +// code: ErrCode.CustomerNotFound, +// }); +// } + +// // 2. Check that code has not reached max redemptions +// const redemptionCount = await RewardProgramService.getCodeRedemptionCount( +// { +// db, +// referralCodeId: referralCode.id, +// }, +// ); + +// if ( +// referralCode.reward_program.max_redemptions && +// redemptionCount >= referralCode.reward_program.max_redemptions +// ) { +// throw new RecaseError({ +// message: "Referral code has reached max redemptions", +// statusCode: 400, +// code: ErrCode.ReferralCodeMaxRedemptionsReached, +// }); +// } + +// // 3. Check that customer has not already redeemed a code in this referral program +// const existingRedemptions = await RewardRedemptionService.getByCustomer({ +// db, +// internalCustomerId: customer.internal_id, +// internalRewardProgramId: referralCode.internal_reward_program_id, +// }); + +// if (existingRedemptions.length > 0) { +// throw new RecaseError({ +// message: `Customer ${customer.id} has already redeemed a code in this referral program`, +// statusCode: 400, +// code: ErrCode.CustomerAlreadyRedeemedReferralCode, +// }); +// } + +// // Don't let customer redeem their own code +// const codeCustomer = await CusService.getByInternalId({ +// db: req.db, +// internalId: referralCode.internal_customer_id, +// }); + +// if (!codeCustomer) { +// throw new RecaseError({ +// message: "Referral code customer not found", +// statusCode: 404, +// code: ErrCode.CustomerNotFound, +// }); +// } + +// if ( +// codeCustomer.id === customer.id || +// (notNullish(codeCustomer.fingerprint) && +// codeCustomer.fingerprint === customer.fingerprint) +// ) { +// throw new RecaseError({ +// message: "Customer cannot redeem their own code", +// statusCode: 400, +// code: ErrCode.CustomerCannotRedeemOwnCode, +// }); +// } + +// // 4. Insert redemption into db +// let redemption: RewardRedemption = { +// id: generateId("rr"), +// referral_code_id: referralCode.id, +// internal_customer_id: customer.internal_id, // redeemed by customer +// internal_reward_program_id: referralCode.internal_reward_program_id, +// created_at: Date.now(), +// triggered: +// referralCode.reward_program.when === +// RewardTriggerEvent.CustomerCreation, +// applied: false, +// updated_at: Date.now(), +// redeemer_applied: false, +// }; + +// redemption = await RewardRedemptionService.insert({ +// db, +// rewardRedemption: redemption, +// }); + +// // 5. If reward trigger when is immediate: +// const { reward_program } = referralCode; +// const redeemRewardNow = +// referralCode.reward_program.when === +// RewardTriggerEvent.CustomerCreation; + +// if (redeemRewardNow) { +// const reward = await RewardService.get({ +// db, +// orgId, +// env, +// idOrInternalId: reward_program.internal_reward_id, +// }); + +// if (!reward) { +// throw new RecaseError({ +// message: `Reward ${reward_program.internal_reward_id} not found`, +// statusCode: 404, +// code: ErrCode.RewardNotFound, +// }); +// } + +// const rewardCat = getRewardCat(reward); +// if (rewardCat === RewardCategory.FreeProduct) { +// await triggerFreeProduct({ +// req: parseReqForAction(req) as ExtendedRequest, +// db, +// referralCode, +// redeemer: customer, +// rewardProgram: reward_program, +// org, +// env, +// logger, +// redemption, +// }); +// } else { +// await triggerRedemption({ +// db, +// referralCode, +// org, +// env, +// logger, +// reward, +// redemption, +// }); +// } +// } + +// return res.status(200).json({ +// id: redemption.id, +// customer_id: customer.id, +// reward_id: reward_program.reward.id, +// referrer: { +// id: codeCustomer.id, +// name: codeCustomer.name, +// email: codeCustomer.email, +// created_at: codeCustomer.created_at, +// }, +// redeemer: { +// id: customer.id, +// name: customer.name, +// email: customer.email, +// created_at: customer.created_at, +// }, +// }); +// }, +// }); diff --git a/server/src/internal/api/rewards/handlers/referrals/index.ts b/server/src/internal/api/rewards/handlers/referrals/index.ts deleted file mode 100644 index 4fd0e2a2c..000000000 --- a/server/src/internal/api/rewards/handlers/referrals/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { default as handleGetRedemption } from "./handleGetRedemption.js"; -export { default as handleGetReferralCode } from "./handleGetReferralCode.js"; -export { default as handleRedeemReferral } from "./handleRedeemReferral.js"; diff --git a/server/src/internal/api/rewards/referralRouter.ts b/server/src/internal/api/rewards/referralRouter.ts index 5e7774d12..49e863e5f 100644 --- a/server/src/internal/api/rewards/referralRouter.ts +++ b/server/src/internal/api/rewards/referralRouter.ts @@ -1,17 +1,13 @@ -import express, { type Router } from "express"; -import { - handleGetRedemption, - handleGetReferralCode, - handleRedeemReferral, -} from "./handlers/referrals/index.js"; +import { Hono } from "hono"; +import type { HonoEnv } from "../../../honoUtils/HonoEnv.js"; +import { handleGetRedemption } from "./handlers/referrals/handleGetRedemption.js"; +import { handleGetReferralCode } from "./handlers/referrals/handleGetReferralCode.js"; +import { handleRedeemReferral } from "./handlers/referrals/handleRedeemReferral.js"; -export const referralRouter: Router = express.Router(); +export const redemptionRouter = new Hono(); -// 1. Get referral code -referralRouter.post("/code", handleGetReferralCode); +redemptionRouter.get("/:redemption_id", ...handleGetRedemption); -referralRouter.post("/redeem", handleRedeemReferral); - -export const redemptionRouter: Router = express.Router(); - -redemptionRouter.get("/:redemptionId", handleGetRedemption); +export const referralRouter = new Hono(); +referralRouter.post("/code", ...handleGetReferralCode); +referralRouter.post("/redeem", ...handleRedeemReferral); diff --git a/server/src/internal/entities/handlers/handleCreateEntity/getInputEntities.ts b/server/src/internal/entities/handlers/handleCreateEntity/getInputEntities.ts index 05d85a798..8a58c4804 100644 --- a/server/src/internal/entities/handlers/handleCreateEntity/getInputEntities.ts +++ b/server/src/internal/entities/handlers/handleCreateEntity/getInputEntities.ts @@ -2,11 +2,13 @@ import { type CreateEntityParams, type CustomerData, type Entity, + EntityAlreadyExistsError, ErrCode, + FeatureNotFoundError, + RecaseError, } from "@autumn/shared"; import { StatusCodes } from "http-status-codes"; import { getOrCreateCustomer } from "@/internal/customers/cusUtils/getOrCreateCustomer.js"; -import RecaseError from "@/utils/errorUtils.js"; import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js"; export const validateAndGetInputEntities = async ({ @@ -30,13 +32,6 @@ export const validateAndGetInputEntities = async ({ withEntities: true, }); - if (!customer) { - throw new RecaseError({ - message: `Customer ${customerId} not found`, - code: ErrCode.CustomerNotFound, - }); - } - // 2. Get input entities let inputEntities: any[] = []; if (Array.isArray(createEntityData)) { @@ -48,10 +43,7 @@ export const validateAndGetInputEntities = async ({ for (const entity of inputEntities) { const feature = features.find((f: any) => f.id === entity.feature_id); if (!feature) { - throw new RecaseError({ - message: `Feature ${entity.feature_id} not found`, - code: ErrCode.FeatureNotFound, - }); + throw new FeatureNotFoundError({ featureId: entity.feature_id }); } } @@ -73,14 +65,7 @@ export const validateAndGetInputEntities = async ({ for (const entity of existingEntities) { if (inputEntities.some((e: any) => e.id === entity.id) && !entity.deleted) { - throw new RecaseError({ - message: `Entity ${entity.id} already exists`, - code: "ENTITY_ALREADY_EXISTS", - data: { - entity, - }, - statusCode: StatusCodes.CONFLICT, - }); + throw new EntityAlreadyExistsError({ entityId: entity.id }); } } diff --git a/server/src/internal/products/product-items/validateProductItems.ts b/server/src/internal/products/product-items/validateProductItems.ts index 90e6ea838..028b089cd 100644 --- a/server/src/internal/products/product-items/validateProductItems.ts +++ b/server/src/internal/products/product-items/validateProductItems.ts @@ -10,12 +10,13 @@ import { type ProductItem, ProductItemInterval, ProductItemSchema, + RecaseError, type RolloverConfig, RolloverExpiryDurationType, UsageModel, } from "@autumn/shared"; import { StatusCodes } from "http-status-codes"; -import RecaseError from "@/utils/errorUtils.js"; + import { notNullish, nullish } from "@/utils/genUtils.js"; import { createFeaturesFromItems } from "./createFeaturesFromItems.js"; import { diff --git a/server/src/internal/rewards/RewardProgramService.ts b/server/src/internal/rewards/RewardProgramService.ts index 58671485a..08e31bf5c 100644 --- a/server/src/internal/rewards/RewardProgramService.ts +++ b/server/src/internal/rewards/RewardProgramService.ts @@ -1,15 +1,16 @@ -import { and, arrayContains, count, eq, inArray, or } from "drizzle-orm"; -import RecaseError from "@/utils/errorUtils.js"; import { ErrCode, - Reward, - RewardProgram, - rewardPrograms, + RecaseError, + type ReferralCode, + type Reward, + type RewardProgram, RewardTriggerEvent, + referralCodes, + rewardPrograms, + rewardRedemptions, } from "@autumn/shared"; -import { ReferralCode } from "@autumn/shared"; -import { DrizzleCli } from "@/db/initDrizzle.js"; -import { referralCodes, rewardRedemptions } from "@autumn/shared"; +import { and, arrayContains, count, eq, or } from "drizzle-orm"; +import type { DrizzleCli } from "@/db/initDrizzle.js"; export class RewardProgramService { static async get({ @@ -25,7 +26,7 @@ export class RewardProgramService { env: string; errorIfNotFound?: boolean; }) { - let result = await db.query.rewardPrograms.findFirst({ + const result = await db.query.rewardPrograms.findFirst({ where: and( or( eq(rewardPrograms.id, idOrInternalId), @@ -59,7 +60,7 @@ export class RewardProgramService { orgId: string; env: string; }) { - let result = await db.query.rewardPrograms.findMany({ + const result = await db.query.rewardPrograms.findMany({ where: and(eq(rewardPrograms.org_id, orgId), eq(rewardPrograms.env, env)), }); @@ -77,7 +78,7 @@ export class RewardProgramService { orgId: string; env: string; }) { - let result = await db.query.rewardPrograms.findMany({ + const result = await db.query.rewardPrograms.findMany({ where: and( eq(rewardPrograms.org_id, orgId), eq(rewardPrograms.env, env), @@ -102,7 +103,7 @@ export class RewardProgramService { internalCustomerId: string; internalRewardProgramId: string; }) { - let result = await db.query.referralCodes.findFirst({ + const result = await db.query.referralCodes.findFirst({ where: and( eq(referralCodes.internal_customer_id, internalCustomerId), eq(referralCodes.internal_reward_program_id, internalRewardProgramId), @@ -125,7 +126,7 @@ export class RewardProgramService { db: DrizzleCli; data: RewardProgram | RewardProgram[]; }) { - let result = await db + const result = await db .insert(rewardPrograms) .values(data as any) .returning(); @@ -151,7 +152,7 @@ export class RewardProgramService { orgId: string; env: string; }) { - let result = await db + const result = await db .delete(rewardPrograms) .where( and( @@ -189,7 +190,7 @@ export class RewardProgramService { code: string; withRewardProgram?: boolean; }) { - let result = await db.query.referralCodes.findFirst({ + const result = await db.query.referralCodes.findFirst({ where: and( eq(referralCodes.code, code), eq(referralCodes.org_id, orgId), @@ -228,7 +229,7 @@ export class RewardProgramService { db: DrizzleCli; data: ReferralCode; }) { - let result = await db.insert(referralCodes).values(data).returning(); + const result = await db.insert(referralCodes).values(data).returning(); if (result.length === 0) { throw new RecaseError({ @@ -247,7 +248,7 @@ export class RewardProgramService { db: DrizzleCli; referralCodeId: string; }) { - let result = await db + const result = await db .select({ count: count() }) .from(rewardRedemptions) .where( @@ -273,7 +274,7 @@ export class RewardProgramService { env: string; data: RewardProgram; }) { - let result = await db + const result = await db .update(rewardPrograms) .set(data as any) .where( diff --git a/server/src/internal/rewards/triggerCheckoutReward.ts b/server/src/internal/rewards/triggerCheckoutReward.ts index 8be96a545..a13b89284 100644 --- a/server/src/internal/rewards/triggerCheckoutReward.ts +++ b/server/src/internal/rewards/triggerCheckoutReward.ts @@ -58,18 +58,18 @@ export const runTriggerCheckoutReward = async ({ }; const { reward } = reward_program; - console.info(`--------------------------------`); - console.info(`CHECKING FOR CHECKOUT REWARD, ORG: ${org.slug}`); - console.info( + logger.info(`--------------------------------`); + logger.info(`CHECKING FOR CHECKOUT REWARD, ORG: ${org.slug}`); + logger.info( `Redeemed by: ${customer.name} (${customer.id}) for referral program: ${reward_program.id}`, ); - console.info(`Referral code: ${referralCode.code} (${referralCode.id})`); - console.info( + logger.info(`Referral code: ${referralCode.code} (${referralCode.id})`); + logger.info( `Products: ${reward_program.product_ids?.join(", ")}, ${reward_program.reward.free_product_id}`, ); if (!reward_program.product_ids?.includes(product.id)) { - console.info( + logger.info( `Product ${product.name} (${product.id}) not included in referral program, skipping`, ); if (reward_program.reward.free_product_id !== product.id) { @@ -86,7 +86,7 @@ export const runTriggerCheckoutReward = async ({ } if (hasTrial) { - console.info(`Subscription is on trial, not triggering reward`); + logger.info(`Subscription is on trial, not triggering reward`); return; } @@ -99,7 +99,7 @@ export const runTriggerCheckoutReward = async ({ ); if (redemptionCount >= reward_program.max_redemptions!) { - console.info( + logger.info( `Max redemptions reached, not triggering latest redemption`, ); return; diff --git a/server/src/routers/apiRouter.ts b/server/src/routers/apiRouter.ts index 93bc0b7ba..3d9080a39 100644 --- a/server/src/routers/apiRouter.ts +++ b/server/src/routers/apiRouter.ts @@ -7,6 +7,10 @@ import { rateLimitMiddleware } from "../honoMiddlewares/rateLimitMiddleware.js"; import { refreshCacheMiddleware } from "../honoMiddlewares/refreshCacheMiddleware.js"; import { secretKeyMiddleware } from "../honoMiddlewares/secretKeyMiddleware.js"; import type { HonoEnv } from "../honoUtils/HonoEnv.js"; +import { + redemptionRouter, + referralRouter, +} from "../internal/api/rewards/referralRouter.js"; import { balancesRouter } from "../internal/balances/balancesRouter.js"; import { billingRouter } from "../internal/billing/billingRouter.js"; import { cusRouter } from "../internal/customers/cusRouter.js"; @@ -45,3 +49,6 @@ apiRouter.route("", balancesRouter); apiRouter.route("/platform", platformBetaRouter); apiRouter.route("/platform/beta", platformBetaRouter); apiRouter.route("/organization", honoOrgRouter); + +apiRouter.route("/referrals", referralRouter); +apiRouter.route("/redemptions", redemptionRouter); diff --git a/server/tests/advanced/referrals/referrals1.test.ts b/server/tests/advanced/referrals/referrals1.test.ts index bae341373..ab1388352 100644 --- a/server/tests/advanced/referrals/referrals1.test.ts +++ b/server/tests/advanced/referrals/referrals1.test.ts @@ -12,14 +12,14 @@ import { RewardTriggerEvent, RewardType, } from "@autumn/shared"; -import chalk from "chalk"; -import { addDays } from "date-fns"; -import type { Stripe } from "stripe"; import { TestFeature } from "@tests/setup/v2Features.js"; import { timeout } from "@tests/utils/genUtils.js"; import { createReferralProgram } from "@tests/utils/productUtils.js"; import { advanceTestClock } from "@tests/utils/stripeUtils.js"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; +import { addDays } from "date-fns"; +import type { Stripe } from "stripe"; import type { DrizzleCli } from "@/db/initDrizzle.js"; import AutumnError, { AutumnInt } from "@/external/autumn/autumnCli.js"; import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; @@ -231,7 +231,7 @@ describe(`${chalk.yellowBright( product_id: pro.id, }); - await timeout(3000); + await timeout(5000); // Get redemption object const redemption = await autumn.redemptions.get(redemptions[i].id); diff --git a/server/tests/advanced/referrals/referrals2.test.ts b/server/tests/advanced/referrals/referrals2.test.ts index 1f2fbb320..9c8d272d9 100644 --- a/server/tests/advanced/referrals/referrals2.test.ts +++ b/server/tests/advanced/referrals/referrals2.test.ts @@ -102,6 +102,7 @@ describe(`${chalk.yellowBright( const { testClockId: testClockId1, customer } = await initCustomerV3({ ctx, customerId: mainCustomerId, + attachPm: "success", }); testClockId = testClockId1; mainCustomer = customer; diff --git a/server/tests/advanced/referrals/referrals3.test.ts b/server/tests/advanced/referrals/referrals3.test.ts index dff57591b..46ebed624 100644 --- a/server/tests/advanced/referrals/referrals3.test.ts +++ b/server/tests/advanced/referrals/referrals3.test.ts @@ -10,11 +10,11 @@ import { RewardTriggerEvent, RewardType, } from "@autumn/shared"; -import chalk from "chalk"; import { TestFeature } from "@tests/setup/v2Features.js"; import { timeout } from "@tests/utils/genUtils.js"; import { createReferralProgram } from "@tests/utils/productUtils.js"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; import AutumnError, { AutumnInt } from "@/external/autumn/autumnCli.js"; import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; diff --git a/server/tests/advanced/referrals/referrals4.test.ts b/server/tests/advanced/referrals/referrals4.test.ts index 0a50956ee..a43948464 100644 --- a/server/tests/advanced/referrals/referrals4.test.ts +++ b/server/tests/advanced/referrals/referrals4.test.ts @@ -9,15 +9,15 @@ import { RewardTriggerEvent, RewardType, } from "@autumn/shared"; -import chalk from "chalk"; -import { addDays, addHours } from "date-fns"; -import type { Stripe } from "stripe"; import { TestFeature } from "@tests/setup/v2Features.js"; import { hoursToFinalizeInvoice } from "@tests/utils/constants.js"; import { timeout } from "@tests/utils/genUtils.js"; import { createReferralProgram } from "@tests/utils/productUtils.js"; import { advanceTestClock } from "@tests/utils/stripeUtils.js"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; +import { addDays, addHours } from "date-fns"; +import type { Stripe } from "stripe"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; diff --git a/shared/api/errors/classes/entityErrClasses.ts b/shared/api/errors/classes/entityErrClasses.ts index 686e24674..daeea4b1d 100644 --- a/shared/api/errors/classes/entityErrClasses.ts +++ b/shared/api/errors/classes/entityErrClasses.ts @@ -11,3 +11,14 @@ export class EntityNotFoundError extends RecaseError { this.name = "EntityNotFoundError"; } } + +export class EntityAlreadyExistsError extends RecaseError { + constructor(opts: { message?: string; entityId: string }) { + super({ + message: opts.message || `Entity ${opts.entityId} already exists`, + code: EntityErrorCode.EntityAlreadyExists, + statusCode: 409, + }); + this.name = "EntityAlreadyExistsError"; + } +} diff --git a/shared/api/errors/codes/entityErrCodes.ts b/shared/api/errors/codes/entityErrCodes.ts index 8bc5b69a2..20bb2dfb8 100644 --- a/shared/api/errors/codes/entityErrCodes.ts +++ b/shared/api/errors/codes/entityErrCodes.ts @@ -1,5 +1,6 @@ export const EntityErrorCode = { EntityNotFound: "entity_not_found", + EntityAlreadyExists: "entity_already_exists", } as const; export type EntityErrorCode = From 3e0a14960f5d05ff80b73e8c6230f6038d5d9ad4 Mon Sep 17 00:00:00 2001 From: John Yeo Date: Thu, 20 Nov 2025 13:16:51 +0000 Subject: [PATCH 04/58] fix: connecting to redis via ipv4 only --- server/src/external/redis/initRedis.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/server/src/external/redis/initRedis.ts b/server/src/external/redis/initRedis.ts index ca2333853..308f2803a 100644 --- a/server/src/external/redis/initRedis.ts +++ b/server/src/external/redis/initRedis.ts @@ -23,6 +23,7 @@ const caText = await loadCaCert({ const redis = new Redis(regionalCacheUrl || process.env.CACHE_URL, { tls: caText ? { ca: caText } : undefined, + family: 4, }); // biome-ignore lint/correctness/noUnusedFunctionParameters: Might uncomment this back in in the future From 389e5a64c305232daa7f8723fe4d3adb96d6fad0 Mon Sep 17 00:00:00 2001 From: John Yeo Date: Thu, 20 Nov 2025 13:34:10 +0000 Subject: [PATCH 05/58] chore: disabled auto instrumentation from ioredis --- server/src/instrumentation.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/server/src/instrumentation.ts b/server/src/instrumentation.ts index 2928609f9..fcbebbbe8 100644 --- a/server/src/instrumentation.ts +++ b/server/src/instrumentation.ts @@ -28,12 +28,15 @@ if (process.env.AXIOM_TOKEN) { resource: resource, instrumentations: [ // Then add other auto-instrumentations - getNodeAutoInstrumentations(), + getNodeAutoInstrumentations({ + "@opentelemetry/instrumentation-ioredis": { + enabled: false, + }, + }), ], }); // Starting the OpenTelemetry SDK to begin collecting telemetry data console.log("Starting OpenTelemetry"); sdk.start(); - console.log("OpenTelemetry started with IORedis instrumentation"); } From f10c685dc62562cb27066b4be1fab9f8da631073 Mon Sep 17 00:00:00 2001 From: John Yeo Date: Thu, 20 Nov 2025 14:06:53 +0000 Subject: [PATCH 06/58] fix: using EVALSHA --- server/src/external/redis/initRedis.ts | 61 +++ .../redisTrackUtils/executeBatchDeduction.ts | 5 +- .../apiCusCacheUtils/BatchingManager.ts | 386 +++++++++--------- .../apiCusCacheUtils/executeBatchDeduction.ts | 77 ++-- .../apiCusCacheUtils/getCachedApiCustomer.ts | 5 +- .../apiCusCacheUtils/setCachedApiCustomer.ts | 12 +- 6 files changed, 295 insertions(+), 251 deletions(-) diff --git a/server/src/external/redis/initRedis.ts b/server/src/external/redis/initRedis.ts index 308f2803a..51d4f96fa 100644 --- a/server/src/external/redis/initRedis.ts +++ b/server/src/external/redis/initRedis.ts @@ -1,4 +1,10 @@ import { Redis } from "ioredis"; +import { + GET_CUSTOMER_SCRIPT, + getBatchDeductionScript, + SET_CUSTOMER_SCRIPT, + SET_ENTITIES_BATCH_SCRIPT, +} from "../../_luaScripts/luaScripts.js"; import { loadCaCert } from "./loadCaCert.js"; if (!process.env.CACHE_URL) { @@ -24,8 +30,63 @@ const caText = await loadCaCert({ const redis = new Redis(regionalCacheUrl || process.env.CACHE_URL, { tls: caText ? { ca: caText } : undefined, family: 4, + keepAlive: 10000, }); +// Load Lua scripts using the builder functions that include dependencies +const batchDeductionScript = getBatchDeductionScript(); + +// Define commands +redis.defineCommand("batchDeduction", { + numberOfKeys: 0, + lua: batchDeductionScript, +}); + +redis.defineCommand("getCustomer", { + numberOfKeys: 0, + lua: GET_CUSTOMER_SCRIPT, +}); + +redis.defineCommand("setCustomer", { + numberOfKeys: 0, + lua: SET_CUSTOMER_SCRIPT, +}); + +redis.defineCommand("setEntitiesBatch", { + numberOfKeys: 0, + lua: SET_ENTITIES_BATCH_SCRIPT, +}); + +// Add type definitions +declare module "ioredis" { + interface RedisCommander { + batchDeduction( + requestsJson: string, + orgId: string, + env: string, + customerId: string, + adjustGrantedBalance?: string, + ): Promise; + getCustomer( + orgId: string, + env: string, + customerId: string, + skipEntityMerge: string, + ): Promise; + setCustomer( + customerData: string, + orgId: string, + env: string, + customerId: string, + ): Promise; + setEntitiesBatch( + entityBatch: string, + orgId: string, + env: string, + ): Promise; + } +} + // biome-ignore lint/correctness/noUnusedFunctionParameters: Might uncomment this back in in the future redis.on("error", (error) => { // logger.error(`redis (cache) error: ${error.message}`); diff --git a/server/src/internal/balances/track/redisTrackUtils/executeBatchDeduction.ts b/server/src/internal/balances/track/redisTrackUtils/executeBatchDeduction.ts index 35ab92716..2fc200b78 100644 --- a/server/src/internal/balances/track/redisTrackUtils/executeBatchDeduction.ts +++ b/server/src/internal/balances/track/redisTrackUtils/executeBatchDeduction.ts @@ -1,5 +1,4 @@ import type { ApiBalance } from "@autumn/shared"; -import { getBatchDeductionScript } from "@lua/luaScripts.js"; import type { Redis } from "ioredis"; import { logger } from "../../../../external/logtail/logtailUtils"; @@ -52,9 +51,7 @@ export const executeBatchDeduction = async ({ }): Promise => { try { // Execute Lua script (hot reload in dev) - const result = await redis.eval( - getBatchDeductionScript(), - 0, // No KEYS, all params in ARGV + const result = await redis.batchDeduction( JSON.stringify(requests), // ARGV[1] orgId, // ARGV[2] env, // ARGV[3] diff --git a/server/src/internal/customers/cusUtils/apiCusCacheUtils/BatchingManager.ts b/server/src/internal/customers/cusUtils/apiCusCacheUtils/BatchingManager.ts index ad53eb7ee..fea7ecc3f 100644 --- a/server/src/internal/customers/cusUtils/apiCusCacheUtils/BatchingManager.ts +++ b/server/src/internal/customers/cusUtils/apiCusCacheUtils/BatchingManager.ts @@ -1,217 +1,217 @@ -import type { Redis } from "ioredis"; -import { executeBatchDeduction } from "./executeBatchDeduction.js"; +// import type { Redis } from "ioredis"; +// import { executeBatchDeduction } from "./executeBatchDeduction.js"; -interface BatchRequest { - amount: number; - timestamp: number; - properties: Record; - resolve: (result: { success: boolean; error?: string }) => void; - reject: (error: Error) => void; -} +// interface BatchRequest { +// amount: number; +// timestamp: number; +// properties: Record; +// resolve: (result: { success: boolean; error?: string }) => void; +// reject: (error: Error) => void; +// } -export interface BatchContext { - customerId: string; - featureId: string; - orgId: string; - orgSlug: string; - env: string; - entityId?: string; -} +// export interface BatchContext { +// customerId: string; +// featureId: string; +// orgId: string; +// orgSlug: string; +// env: string; +// entityId?: string; +// } -interface Batch { - requests: BatchRequest[]; - timer: NodeJS.Timeout | null; - context?: BatchContext; -} +// interface Batch { +// requests: BatchRequest[]; +// timer: NodeJS.Timeout | null; +// context?: BatchContext; +// } -/** - * Batching manager for Redis track deductions - * Collects multiple deduction requests within a time window and processes them atomically in a single Lua script - * - * Benefits: - * - Massive performance improvements for high-concurrency scenarios - * - Atomic deductions across multiple requests - * - Reduced Redis round trips - */ -export class BatchingManager { - private batches = new Map(); - private readonly BATCH_WINDOW_MS = 10; // 10ms batching window - private readonly MAX_BATCH_SIZE = 100000; // Handle up to 100k concurrent requests +// /** +// * Batching manager for Redis track deductions +// * Collects multiple deduction requests within a time window and processes them atomically in a single Lua script +// * +// * Benefits: +// * - Massive performance improvements for high-concurrency scenarios +// * - Atomic deductions across multiple requests +// * - Reduced Redis round trips +// */ +// export class BatchingManager { +// private batches = new Map(); +// private readonly BATCH_WINDOW_MS = 10; // 10ms batching window +// private readonly MAX_BATCH_SIZE = 100000; // Handle up to 100k concurrent requests - /** - * Request a deduction with automatic batching - * Returns a promise that resolves when the batch is processed - */ - async deduct({ - redis, - cacheKey, - featureId, - amount, - timestamp, - properties, - context, - }: { - redis: Redis; - cacheKey: string; - featureId: string; - amount: number; - timestamp: number; - properties: Record; - context: BatchContext; - }): Promise<{ success: boolean; error?: string }> { - const batchKey = `${cacheKey}:${featureId}`; +// /** +// * Request a deduction with automatic batching +// * Returns a promise that resolves when the batch is processed +// */ +// async deduct({ +// redis, +// cacheKey, +// featureId, +// amount, +// timestamp, +// properties, +// context, +// }: { +// redis: Redis; +// cacheKey: string; +// featureId: string; +// amount: number; +// timestamp: number; +// properties: Record; +// context: BatchContext; +// }): Promise<{ success: boolean; error?: string }> { +// const batchKey = `${cacheKey}:${featureId}`; - return new Promise((resolve, reject) => { - // Create batch if it doesn't exist - if (!this.batches.has(batchKey)) { - this.batches.set(batchKey, { - requests: [], - timer: null, - context, - }); +// return new Promise((resolve, reject) => { +// // Create batch if it doesn't exist +// if (!this.batches.has(batchKey)) { +// this.batches.set(batchKey, { +// requests: [], +// timer: null, +// context, +// }); - // Schedule batch execution - this.scheduleBatch(batchKey, redis, cacheKey, featureId); - } +// // Schedule batch execution +// this.scheduleBatch(batchKey, redis, cacheKey, featureId); +// } - const batch = this.batches.get(batchKey); - if (!batch) { - reject(new Error("Failed to get batch")); - return; - } +// const batch = this.batches.get(batchKey); +// if (!batch) { +// reject(new Error("Failed to get batch")); +// return; +// } - // Add request to batch - batch.requests.push({ - amount, - timestamp, - properties, - resolve, - reject, - }); +// // Add request to batch +// batch.requests.push({ +// amount, +// timestamp, +// properties, +// resolve, +// reject, +// }); - // Force flush if batch is full - if (batch.requests.length >= this.MAX_BATCH_SIZE) { - this.executeBatch(batchKey, redis, cacheKey, featureId); - } - }); - } +// // Force flush if batch is full +// if (batch.requests.length >= this.MAX_BATCH_SIZE) { +// this.executeBatch(batchKey, redis, cacheKey, featureId); +// } +// }); +// } - /** - * Schedule batch execution after window expires - */ - private scheduleBatch( - batchKey: string, - redis: Redis, - cacheKey: string, - featureId: string, - ): void { - const batch = this.batches.get(batchKey); - if (!batch) return; +// /** +// * Schedule batch execution after window expires +// */ +// private scheduleBatch( +// batchKey: string, +// redis: Redis, +// cacheKey: string, +// featureId: string, +// ): void { +// const batch = this.batches.get(batchKey); +// if (!batch) return; - batch.timer = setTimeout(() => { - this.executeBatch(batchKey, redis, cacheKey, featureId); - }, this.BATCH_WINDOW_MS); - } +// batch.timer = setTimeout(() => { +// this.executeBatch(batchKey, redis, cacheKey, featureId); +// }, this.BATCH_WINDOW_MS); +// } - /** - * Execute the batch - process all requests in one Lua script - */ - private async executeBatch( - batchKey: string, - redis: Redis, - cacheKey: string, - featureId: string, - ): Promise { - // CRITICAL: Remove batch from map FIRST to prevent race condition - // New requests will create a new batch instead of adding to this one - const batch = this.batches.get(batchKey); - if (!batch || batch.requests.length === 0) { - return; - } +// /** +// * Execute the batch - process all requests in one Lua script +// */ +// private async executeBatch( +// batchKey: string, +// redis: Redis, +// cacheKey: string, +// featureId: string, +// ): Promise { +// // CRITICAL: Remove batch from map FIRST to prevent race condition +// // New requests will create a new batch instead of adding to this one +// const batch = this.batches.get(batchKey); +// if (!batch || batch.requests.length === 0) { +// return; +// } - // Clear timer and remove from map IMMEDIATELY - if (batch.timer) { - clearTimeout(batch.timer); - batch.timer = null; - } - this.batches.delete(batchKey); +// // Clear timer and remove from map IMMEDIATELY +// if (batch.timer) { +// clearTimeout(batch.timer); +// batch.timer = null; +// } +// this.batches.delete(batchKey); - const requests = batch.requests; - const amounts = requests.map((r) => r.amount); - const batchSize = requests.length; +// const requests = batch.requests; +// const amounts = requests.map((r) => r.amount); +// const batchSize = requests.length; - console.log( - `🚀 Executing batch with ${batchSize} requests for feature ${featureId}`, - ); +// console.log( +// `🚀 Executing batch with ${batchSize} requests for feature ${featureId}`, +// ); - try { - // Execute batch Lua script - const result = await executeBatchDeduction({ - redis, - cacheKey, - targetFeatureId: featureId, - amounts, - }); +// try { +// // Execute batch Lua script +// const result = await executeBatchDeduction({ +// redis, +// cacheKey, +// targetFeatureId: featureId, +// amounts, +// }); - console.log( - `✅ Batch completed (${batchSize} requests, ${result.successCount} succeeded)`, - ); +// console.log( +// `✅ Batch completed (${batchSize} requests, ${result.successCount} succeeded)`, +// ); - // Resolve each request based on success/fail counts - if (result.success) { - const successCount = result.successCount || 0; +// // Resolve each request based on success/fail counts +// if (result.success) { +// const successCount = result.successCount || 0; - // TODO: Queue Postgres sync job for successful deductions if needed - // This can be added later when integrating with the sync system +// // TODO: Queue Postgres sync job for successful deductions if needed +// // This can be added later when integrating with the sync system - // First N requests succeed, rest fail - for (let i = 0; i < requests.length; i++) { - requests[i].resolve({ - success: i < successCount, - error: - i < successCount - ? undefined - : result.error || "INSUFFICIENT_BALANCE", - }); - } - } else { - // Batch failed entirely (e.g., customer not found) - for (const request of requests) { - request.resolve({ - success: false, - error: result.error || "BATCH_FAILED", - }); - } - } - } catch (error) { - console.error(`❌ Batch execution error:`, error); - // Reject all requests on error - for (const request of requests) { - request.reject( - error instanceof Error ? error : new Error(String(error)), - ); - } - } - } +// // First N requests succeed, rest fail +// for (let i = 0; i < requests.length; i++) { +// requests[i].resolve({ +// success: i < successCount, +// error: +// i < successCount +// ? undefined +// : result.error || "INSUFFICIENT_BALANCE", +// }); +// } +// } else { +// // Batch failed entirely (e.g., customer not found) +// for (const request of requests) { +// request.resolve({ +// success: false, +// error: result.error || "BATCH_FAILED", +// }); +// } +// } +// } catch (error) { +// console.error(`❌ Batch execution error:`, error); +// // Reject all requests on error +// for (const request of requests) { +// request.reject( +// error instanceof Error ? error : new Error(String(error)), +// ); +// } +// } +// } - /** - * Get current batch statistics (for monitoring) - */ - getStats(): { - activeBatches: number; - totalPendingRequests: number; - } { - let totalPendingRequests = 0; - for (const batch of this.batches.values()) { - totalPendingRequests += batch.requests.length; - } +// /** +// * Get current batch statistics (for monitoring) +// */ +// getStats(): { +// activeBatches: number; +// totalPendingRequests: number; +// } { +// let totalPendingRequests = 0; +// for (const batch of this.batches.values()) { +// totalPendingRequests += batch.requests.length; +// } - return { - activeBatches: this.batches.size, - totalPendingRequests, - }; - } -} +// return { +// activeBatches: this.batches.size, +// totalPendingRequests, +// }; +// } +// } -// Singleton instance -export const globalBatchingManager = new BatchingManager(); +// // Singleton instance +// export const globalBatchingManager = new BatchingManager(); diff --git a/server/src/internal/customers/cusUtils/apiCusCacheUtils/executeBatchDeduction.ts b/server/src/internal/customers/cusUtils/apiCusCacheUtils/executeBatchDeduction.ts index 2eaa17035..05bfebe5f 100644 --- a/server/src/internal/customers/cusUtils/apiCusCacheUtils/executeBatchDeduction.ts +++ b/server/src/internal/customers/cusUtils/apiCusCacheUtils/executeBatchDeduction.ts @@ -1,47 +1,44 @@ -import { BATCH_DEDUCTION_SCRIPT } from "@lua/luaScripts.js"; -import type { Redis } from "ioredis"; - interface BatchDeductionResult { success: boolean; successCount: number; error?: string; } -/** - * Execute batch deduction Lua script - * Processes multiple deductions atomically in a single Redis call - * Supports credit system features as alternative payment sources - */ -export const executeBatchDeduction = async ({ - redis, - cacheKey, - targetFeatureId, - amounts, -}: { - redis: Redis; - cacheKey: string; - targetFeatureId: string; // The feature we're trying to deduct from - amounts: number[]; -}): Promise => { - try { - // Execute Lua script - const result = await redis.eval( - BATCH_DEDUCTION_SCRIPT, - 2, // number of keys - cacheKey, // KEYS[1] - targetFeatureId, // KEYS[2] - target feature ID - JSON.stringify(amounts), // ARGV[1] - ); +// /** +// * Execute batch deduction Lua script +// * Processes multiple deductions atomically in a single Redis call +// * Supports credit system features as alternative payment sources +// */ +// export const executeBatchDeduction = async ({ +// redis, +// cacheKey, +// targetFeatureId, +// amounts, +// }: { +// redis: Redis; +// cacheKey: string; +// targetFeatureId: string; // The feature we're trying to deduct from +// amounts: number[]; +// }): Promise => { +// try { +// // Execute Lua script +// const result = await redis.eval( +// BATCH_DEDUCTION_SCRIPT, +// 2, // number of keys +// cacheKey, // KEYS[1] +// targetFeatureId, // KEYS[2] - target feature ID +// JSON.stringify(amounts), // ARGV[1] +// ); - // Parse result - const parsed = JSON.parse(result as string) as BatchDeductionResult; - return parsed; - } catch (error) { - console.error("Error executing batch deduction:", error); - return { - success: false, - successCount: 0, - error: error instanceof Error ? error.message : "UNKNOWN_ERROR", - }; - } -}; +// // Parse result +// const parsed = JSON.parse(result as string) as BatchDeductionResult; +// return parsed; +// } catch (error) { +// console.error("Error executing batch deduction:", error); +// return { +// success: false, +// successCount: 0, +// error: error instanceof Error ? error.message : "UNKNOWN_ERROR", +// }; +// } +// }; diff --git a/server/src/internal/customers/cusUtils/apiCusCacheUtils/getCachedApiCustomer.ts b/server/src/internal/customers/cusUtils/apiCusCacheUtils/getCachedApiCustomer.ts index add51960b..0ef99a91d 100644 --- a/server/src/internal/customers/cusUtils/apiCusCacheUtils/getCachedApiCustomer.ts +++ b/server/src/internal/customers/cusUtils/apiCusCacheUtils/getCachedApiCustomer.ts @@ -10,7 +10,6 @@ import { filterPlanAndFeatureExpand, } from "@autumn/shared"; import { CACHE_CUSTOMER_VERSION } from "@lua/cacheConfig.js"; -import { GET_CUSTOMER_SCRIPT } from "@lua/luaScripts.js"; import { redis } from "../../../../external/redis/initRedis.js"; import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js"; import { tryRedisRead } from "../../../../utils/cacheUtils/cacheUtils.js"; @@ -56,9 +55,7 @@ export const getCachedApiCustomer = async ({ // Try to get from cache using Lua script (unless skipCache is true) if (!skipCache) { const cachedResult = await tryRedisRead(() => - redis.eval( - GET_CUSTOMER_SCRIPT, - 0, // No KEYS, all params in ARGV + redis.getCustomer( org.id, env, customerId, diff --git a/server/src/internal/customers/cusUtils/apiCusCacheUtils/setCachedApiCustomer.ts b/server/src/internal/customers/cusUtils/apiCusCacheUtils/setCachedApiCustomer.ts index 81eb80e48..6bdf020f8 100644 --- a/server/src/internal/customers/cusUtils/apiCusCacheUtils/setCachedApiCustomer.ts +++ b/server/src/internal/customers/cusUtils/apiCusCacheUtils/setCachedApiCustomer.ts @@ -7,10 +7,6 @@ import { filterEntityLevelCusProducts, filterOutEntitiesFromCusProducts, } from "@autumn/shared"; -import { - SET_CUSTOMER_SCRIPT, - SET_ENTITIES_BATCH_SCRIPT, -} from "@lua/luaScripts.js"; import { redis } from "../../../../external/redis/initRedis.js"; import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js"; import { tryRedisWrite } from "../../../../utils/cacheUtils/cacheUtils.js"; @@ -100,9 +96,7 @@ export const setCachedApiCustomer = async ({ // ); await tryRedisWrite(async () => { - await redis.eval( - SET_CUSTOMER_SCRIPT, - 0, // No KEYS, all params in ARGV + await redis.setCustomer( JSON.stringify(masterApiCustomerData), org.id, env, @@ -114,9 +108,7 @@ export const setCachedApiCustomer = async ({ ); if (entityBatch.length > 0) { - await redis.eval( - SET_ENTITIES_BATCH_SCRIPT, - 0, + await redis.setEntitiesBatch( JSON.stringify(filteredEntityBatch), org.id, env, From 76f176606d5cf6ca376092f3e8724747bfe234da Mon Sep 17 00:00:00 2001 From: John Yeo Date: Thu, 20 Nov 2025 14:36:52 +0000 Subject: [PATCH 07/58] fix: moving more calls to evalsha --- server/src/external/redis/initRedis.ts | 73 +++++++++++++++++++ .../deleteCachedApiCustomer.ts | 9 +-- .../setCachedApiCusDetails.ts | 5 +- .../apiCusCacheUtils/setCachedApiInvoices.ts | 5 +- .../apiCusCacheUtils/setCachedApiSubs.ts | 12 +-- .../apiEntityCacheUtils/getCachedApiEntity.ts | 15 ++-- 6 files changed, 84 insertions(+), 35 deletions(-) diff --git a/server/src/external/redis/initRedis.ts b/server/src/external/redis/initRedis.ts index 51d4f96fa..2deacdbb8 100644 --- a/server/src/external/redis/initRedis.ts +++ b/server/src/external/redis/initRedis.ts @@ -1,9 +1,15 @@ import { Redis } from "ioredis"; import { + DELETE_CUSTOMER_SCRIPT, GET_CUSTOMER_SCRIPT, + GET_ENTITY_SCRIPT, getBatchDeductionScript, + SET_CUSTOMER_DETAILS_SCRIPT, SET_CUSTOMER_SCRIPT, SET_ENTITIES_BATCH_SCRIPT, + SET_ENTITY_PRODUCTS_SCRIPT, + SET_INVOICES_SCRIPT, + SET_SUBSCRIPTIONS_SCRIPT, } from "../../_luaScripts/luaScripts.js"; import { loadCaCert } from "./loadCaCert.js"; @@ -57,6 +63,36 @@ redis.defineCommand("setEntitiesBatch", { lua: SET_ENTITIES_BATCH_SCRIPT, }); +redis.defineCommand("getEntity", { + numberOfKeys: 0, + lua: GET_ENTITY_SCRIPT, +}); + +redis.defineCommand("setSubscriptions", { + numberOfKeys: 0, + lua: SET_SUBSCRIPTIONS_SCRIPT, +}); + +redis.defineCommand("setEntityProducts", { + numberOfKeys: 0, + lua: SET_ENTITY_PRODUCTS_SCRIPT, +}); + +redis.defineCommand("setInvoices", { + numberOfKeys: 0, + lua: SET_INVOICES_SCRIPT, +}); + +redis.defineCommand("setCustomerDetails", { + numberOfKeys: 0, + lua: SET_CUSTOMER_DETAILS_SCRIPT, +}); + +redis.defineCommand("deleteCustomer", { + numberOfKeys: 0, + lua: DELETE_CUSTOMER_SCRIPT, +}); + // Add type definitions declare module "ioredis" { interface RedisCommander { @@ -84,6 +120,43 @@ declare module "ioredis" { orgId: string, env: string, ): Promise; + getEntity( + orgId: string, + env: string, + customerId: string, + entityId: string, + skipCustomerMerge: string, + ): Promise; + setSubscriptions( + subscriptionsJson: string, + orgId: string, + env: string, + customerId: string, + ): Promise; + setEntityProducts( + productsJson: string, + orgId: string, + env: string, + customerId: string, + entityId: string, + ): Promise; + setInvoices( + invoicesJson: string, + orgId: string, + env: string, + customerId: string, + ): Promise; + setCustomerDetails( + updatesJson: string, + orgId: string, + env: string, + customerId: string, + ): Promise; + deleteCustomer( + orgId: string, + env: string, + customerId: string, + ): Promise; } } diff --git a/server/src/internal/customers/cusUtils/apiCusCacheUtils/deleteCachedApiCustomer.ts b/server/src/internal/customers/cusUtils/apiCusCacheUtils/deleteCachedApiCustomer.ts index 34aba22c4..33cd43413 100644 --- a/server/src/internal/customers/cusUtils/apiCusCacheUtils/deleteCachedApiCustomer.ts +++ b/server/src/internal/customers/cusUtils/apiCusCacheUtils/deleteCachedApiCustomer.ts @@ -1,4 +1,3 @@ -import { DELETE_CUSTOMER_SCRIPT } from "@lua/luaScripts.js"; import { redis } from "@/external/redis/initRedis.js"; import { logger } from "../../../../external/logtail/logtailUtils.js"; @@ -29,13 +28,7 @@ export const deleteCachedApiCustomer = async ({ if (!customerId) return; try { - const deletedCount = await redis.eval( - DELETE_CUSTOMER_SCRIPT, - 0, // No KEYS, all params in ARGV - orgId, - env, - customerId, - ); + const deletedCount = await redis.deleteCustomer(orgId, env, customerId); logger.info( `Deleted ${deletedCount} cache keys for customer ${customerId}, source: ${source}`, diff --git a/server/src/internal/customers/cusUtils/apiCusCacheUtils/setCachedApiCusDetails.ts b/server/src/internal/customers/cusUtils/apiCusCacheUtils/setCachedApiCusDetails.ts index 70f596a0f..6ee214dc8 100644 --- a/server/src/internal/customers/cusUtils/apiCusCacheUtils/setCachedApiCusDetails.ts +++ b/server/src/internal/customers/cusUtils/apiCusCacheUtils/setCachedApiCusDetails.ts @@ -1,5 +1,4 @@ import type { ApiCustomer, FullCustomer } from "@autumn/shared"; -import { SET_CUSTOMER_DETAILS_SCRIPT } from "@lua/luaScripts.js"; import { redis } from "../../../../external/redis/initRedis.js"; import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js"; import { tryRedisWrite } from "../../../../utils/cacheUtils/cacheUtils.js"; @@ -30,9 +29,7 @@ export const setCachedApiCusDetails = async ({ // Try to update cache await tryRedisWrite(async () => { - const result = await redis.eval( - SET_CUSTOMER_DETAILS_SCRIPT, - 0, // No KEYS, all params in ARGV + const result = await redis.setCustomerDetails( JSON.stringify(updates), org.id, env, diff --git a/server/src/internal/customers/cusUtils/apiCusCacheUtils/setCachedApiInvoices.ts b/server/src/internal/customers/cusUtils/apiCusCacheUtils/setCachedApiInvoices.ts index 9d6594e7e..32120e6e1 100644 --- a/server/src/internal/customers/cusUtils/apiCusCacheUtils/setCachedApiInvoices.ts +++ b/server/src/internal/customers/cusUtils/apiCusCacheUtils/setCachedApiInvoices.ts @@ -1,5 +1,4 @@ import type { FullCustomer } from "@autumn/shared"; -import { SET_INVOICES_SCRIPT } from "@lua/luaScripts.js"; import { redis } from "../../../../external/redis/initRedis.js"; import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js"; import { tryRedisWrite } from "../../../../utils/cacheUtils/cacheUtils.js"; @@ -45,9 +44,7 @@ export const setCachedApiInvoices = async ({ // Then write to Redis await tryRedisWrite(async () => { // Update customer invoices - await redis.eval( - SET_INVOICES_SCRIPT, - 0, // No KEYS, all params in ARGV + await redis.setInvoices( JSON.stringify(masterApiInvoices), org.id, env, diff --git a/server/src/internal/customers/cusUtils/apiCusCacheUtils/setCachedApiSubs.ts b/server/src/internal/customers/cusUtils/apiCusCacheUtils/setCachedApiSubs.ts index b0d092f84..e3c423755 100644 --- a/server/src/internal/customers/cusUtils/apiCusCacheUtils/setCachedApiSubs.ts +++ b/server/src/internal/customers/cusUtils/apiCusCacheUtils/setCachedApiSubs.ts @@ -5,10 +5,6 @@ import { filterCusProductsByEntity, filterOutEntitiesFromCusProducts, } from "@autumn/shared"; -import { - SET_ENTITY_PRODUCTS_SCRIPT, - SET_SUBSCRIPTIONS_SCRIPT, -} from "@lua/luaScripts.js"; import { redis } from "../../../../external/redis/initRedis.js"; import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js"; import { tryRedisWrite } from "../../../../utils/cacheUtils/cacheUtils.js"; @@ -50,9 +46,7 @@ export const setCachedApiSubs = async ({ // Then write to Redis await tryRedisWrite(async () => { // Update customer subscriptions - await redis.eval( - SET_SUBSCRIPTIONS_SCRIPT, - 0, // No KEYS, all params in ARGV + await redis.setSubscriptions( JSON.stringify(masterApiSubs), org.id, env, @@ -80,9 +74,7 @@ export const setCachedApiSubs = async ({ }, }); - await redis.eval( - SET_ENTITY_PRODUCTS_SCRIPT, - 0, // No KEYS, all params in ARGV + await redis.setEntityProducts( JSON.stringify(entityProducts), org.id, env, diff --git a/server/src/internal/entities/entityUtils/apiEntityCacheUtils/getCachedApiEntity.ts b/server/src/internal/entities/entityUtils/apiEntityCacheUtils/getCachedApiEntity.ts index c3f20511d..e36946c23 100644 --- a/server/src/internal/entities/entityUtils/apiEntityCacheUtils/getCachedApiEntity.ts +++ b/server/src/internal/entities/entityUtils/apiEntityCacheUtils/getCachedApiEntity.ts @@ -10,7 +10,6 @@ import { filterPlanAndFeatureExpand, } from "@autumn/shared"; import { CACHE_CUSTOMER_VERSION } from "@lua/cacheConfig.js"; -import { GET_ENTITY_SCRIPT } from "@lua/luaScripts.js"; import { redis } from "@/external/redis/initRedis.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { CusService } from "@/internal/customers/CusService.js"; @@ -58,14 +57,12 @@ export const getCachedApiEntity = async ({ // Try to get from cache using Lua script (unless skipCache is true) if (!skipCache) { const cachedResult = await tryRedisRead(() => - redis.eval( - GET_ENTITY_SCRIPT, - 0, // No KEYS, all params in ARGV - org.id, // ARGV[1] - env, // ARGV[2] - customerId, // ARGV[3] - entityId, // ARGV[4] - skipCustomerMerge ? "true" : "false", // ARGV[5] + redis.getEntity( + org.id, + env, + customerId, + entityId, + skipCustomerMerge ? "true" : "false", ), ); From ec9c473c33ca137a4061d05ff2ecfa045673b1a6 Mon Sep 17 00:00:00 2001 From: John Yeo Date: Thu, 20 Nov 2025 15:28:21 +0000 Subject: [PATCH 08/58] fix: made delete cached customer more efficient --- scripts/testGroups/g6.sh | 31 +++---- .../cusLuaScripts/deleteCustomer.lua | 81 +++++++++++++------ .../apiCusCacheUtils/getCachedApiCustomer.ts | 13 ++- .../advanced/rollovers/rollover2.test.ts | 10 +-- 4 files changed, 87 insertions(+), 48 deletions(-) diff --git a/scripts/testGroups/g6.sh b/scripts/testGroups/g6.sh index 8f7736aa5..c921c1723 100755 --- a/scripts/testGroups/g6.sh +++ b/scripts/testGroups/g6.sh @@ -3,22 +3,23 @@ # Source shared configuration source "$(dirname "$0")/config.sh" +BUN_PARALLEL_COMPACT 'server/tests/advanced/rollovers' -BUN_PARALLEL_COMPACT \ - 'server/tests/advanced/coupons' \ - 'server/tests/advanced/misc' \ - 'server/tests/attach/updateQuantity' \ - 'server/tests/attach/multiProduct' \ - 'server/tests/advanced/multiFeature' \ - 'server/tests/advanced/referrals' \ - 'server/tests/advanced/rollovers' \ - 'server/tests/advanced/customInterval' \ - 'server/tests/advanced/usageLimit' \ - --max=6 +# BUN_PARALLEL_COMPACT \ +# 'server/tests/advanced/coupons' \ +# 'server/tests/advanced/misc' \ +# 'server/tests/attach/updateQuantity' \ +# 'server/tests/attach/multiProduct' \ +# 'server/tests/advanced/multiFeature' \ +# 'server/tests/advanced/referrals' \ +# 'server/tests/advanced/rollovers' \ +# 'server/tests/advanced/customInterval' \ +# 'server/tests/advanced/usageLimit' \ +# --max=6 -BUN_PARALLEL_COMPACT \ - 'server/tests/advanced/usage' - # 'server/tests/crud/plan' +# BUN_PARALLEL_COMPACT \ +# 'server/tests/advanced/usage' +# # 'server/tests/crud/plan' -# 'server/tests/advanced/referrals/paid' \ \ No newline at end of file +# # 'server/tests/advanced/referrals/paid' \ \ No newline at end of file diff --git a/server/src/_luaScripts/cusLuaScripts/deleteCustomer.lua b/server/src/_luaScripts/cusLuaScripts/deleteCustomer.lua index fdb89e621..fa9daf1aa 100644 --- a/server/src/_luaScripts/cusLuaScripts/deleteCustomer.lua +++ b/server/src/_luaScripts/cusLuaScripts/deleteCustomer.lua @@ -9,37 +9,72 @@ local orgId = ARGV[1] local env = ARGV[2] local customerId = ARGV[3] +-- Helper function to add balance-related keys for a cache key +local function addBalanceKeys(keysToDelete, cacheKey, featureIds) + for _, featureId in ipairs(featureIds) do + local balanceKey = buildBalanceCacheKey(cacheKey, featureId) + table.insert(keysToDelete, balanceKey) + + -- Get the balance HSET to find breakdown/rollover counts + local balanceData = redis.call("HGETALL", balanceKey) + if balanceData and #balanceData > 0 then + -- Convert array to hash table + local balanceHash = {} + for i = 1, #balanceData, 2 do + balanceHash[balanceData[i]] = balanceData[i + 1] + end + + -- Delete rollover keys + local rolloverCount = tonumber(balanceHash["_rollover_count"] or 0) + for i = 0, rolloverCount - 1 do + table.insert(keysToDelete, buildRolloverCacheKey(cacheKey, featureId, i)) + end + + -- Delete breakdown keys + local breakdownCount = tonumber(balanceHash["_breakdown_count"] or 0) + for i = 0, breakdownCount - 1 do + table.insert(keysToDelete, buildBreakdownCacheKey(cacheKey, featureId, i)) + end + end + end +end + -- Build versioned cache key using shared utility local cacheKey = buildCustomerCacheKey(orgId, env, customerId) -local basePattern = cacheKey .. "*" -local keysToDelete = {} --- Scan for all keys matching the pattern --- This includes the customer base key and ALL entity keys under it -local cursor = "0" -repeat - local result = redis.call("SCAN", cursor, "MATCH", basePattern, "COUNT", 100) - cursor = result[1] - local keys = result[2] +-- Get the customer base JSON to find entity and feature IDs +local baseJson = redis.call("GET", cacheKey) +local keysToDelete = {cacheKey} + +if baseJson then + local customer = cjson.decode(baseJson) + local entityIds = customer._entityIds or {} + local balanceFeatureIds = customer._balanceFeatureIds or {} - for _, key in ipairs(keys) do - table.insert(keysToDelete, key) + -- Add customer balance keys (with rollover/breakdown) + addBalanceKeys(keysToDelete, cacheKey, balanceFeatureIds) + + -- Process each entity + for _, entityId in ipairs(entityIds) do + local entityCacheKey = buildEntityCacheKey(orgId, env, customerId, entityId) + table.insert(keysToDelete, entityCacheKey) + + -- Get entity to find its feature IDs + local entityJson = redis.call("GET", entityCacheKey) + if entityJson then + local entity = cjson.decode(entityJson) + local entityFeatureIds = entity._balanceFeatureIds or {} + + -- Add entity balance keys (with rollover/breakdown) + addBalanceKeys(keysToDelete, entityCacheKey, entityFeatureIds) + end end -until cursor == "0" +end --- Delete all keys in one atomic operation +-- Use UNLINK instead of DEL for async deletion (non-blocking) local deletedCount = 0 if #keysToDelete > 0 then - -- Redis DEL can handle multiple keys, but has argument limits - -- So we batch delete in chunks of 1000 - local chunkSize = 1000 - for i = 1, #keysToDelete, chunkSize do - local chunk = {} - for j = i, math.min(i + chunkSize - 1, #keysToDelete) do - table.insert(chunk, keysToDelete[j]) - end - deletedCount = deletedCount + redis.call("DEL", unpack(chunk)) - end + deletedCount = redis.call("UNLINK", unpack(keysToDelete)) end return deletedCount diff --git a/server/src/internal/customers/cusUtils/apiCusCacheUtils/getCachedApiCustomer.ts b/server/src/internal/customers/cusUtils/apiCusCacheUtils/getCachedApiCustomer.ts index 0ef99a91d..1d547c60e 100644 --- a/server/src/internal/customers/cusUtils/apiCusCacheUtils/getCachedApiCustomer.ts +++ b/server/src/internal/customers/cusUtils/apiCusCacheUtils/getCachedApiCustomer.ts @@ -1,4 +1,5 @@ import { + ApiBaseEntitySchema, type ApiCustomer, ApiCustomerSchema, type AppEnv, @@ -105,7 +106,7 @@ export const getCachedApiCustomer = async ({ // Build ApiCustomer (base only, no expand) to return const ctxWithExpand = addToExpand({ ctx, - add: [CusExpand.Invoices], + add: [CusExpand.Invoices, CusExpand.Entities], }); const { apiCustomer, legacyData } = await getApiCustomerBase({ ctx: ctxWithExpand, @@ -113,6 +114,16 @@ export const getCachedApiCustomer = async ({ withAutumnId: true, }); + try { + apiCustomer.entities = fullCus.entities.map((e) => + ApiBaseEntitySchema.parse(e), + ); + } catch (error) { + ctx.logger.error( + `[getCachedApiCustomer] Error parsing entities: ${error}`, + ); + } + const { apiCustomer: masterApiCustomer } = await getApiCustomerBase({ ctx, fullCus: { diff --git a/server/tests/advanced/rollovers/rollover2.test.ts b/server/tests/advanced/rollovers/rollover2.test.ts index a73aa3686..ad015286a 100644 --- a/server/tests/advanced/rollovers/rollover2.test.ts +++ b/server/tests/advanced/rollovers/rollover2.test.ts @@ -9,7 +9,6 @@ import { import { TestFeature } from "@tests/setup/v2Features.js"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; import chalk from "chalk"; -import type Stripe from "stripe"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { timeout } from "@/utils/genUtils.js"; import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; @@ -44,15 +43,9 @@ const testCase = "rollover2"; describe(`${chalk.yellowBright(`${testCase}: Testing rollovers for feature item (per entity)`)}`, () => { const customerId = testCase; const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 }); - let testClockId: string; let customer: Customer; - let stripeCli: Stripe; - - const curUnix = new Date().getTime(); beforeAll(async () => { - stripeCli = ctx.stripeCli; - await initProductsV0({ ctx, products: [free], @@ -67,8 +60,6 @@ describe(`${chalk.yellowBright(`${testCase}: Testing rollovers for feature item attachPm: "success", withTestClock: true, }); - - testClockId = res.testClockId!; customer = res.customer; }); @@ -161,6 +152,7 @@ describe(`${chalk.yellowBright(`${testCase}: Testing rollovers for feature item expect(nonCachedMsgesFeature.rollovers[0].balance).toBe(expectedRollover); } }); + return; test("should reset again and have correct rollovers", async () => { await resetAndGetCusEnt({ From 631b12d5f9b7ff0e2a9930d7a6dd33f5e6b17079 Mon Sep 17 00:00:00 2001 From: John Yeo Date: Thu, 20 Nov 2025 16:58:07 +0000 Subject: [PATCH 09/58] fix: v1.2 cus feature schema for python --- server/experiments/batchDelete.ts | 17 +++ .../cusLuaScripts/batchDeleteCustomers.lua | 103 ++++++++++++++++++ server/src/_luaScripts/luaScripts.ts | 7 ++ .../src/_luaScripts/luaUtils/loadBalances.lua | 12 +- server/src/cron/cronInit.ts | 12 +- server/src/cron/cronUtils.ts | 58 ++++++---- server/src/cron/productCron/runProductCron.ts | 34 ++++-- server/src/external/redis/initRedis.ts | 7 ++ .../batchDeleteCachedCustomers.ts | 75 +++++++++++++ .../advanced/rollovers/rollover2.test.ts | 1 - .../advanced/rollovers/rolloverTestUtils.ts | 17 ++- .../changes/V1.2_CusFeatureChange.ts | 2 +- .../edit-plan-feature/UsageReset.tsx | 6 + .../components/SelectResetCycle.tsx | 24 ++-- 14 files changed, 314 insertions(+), 61 deletions(-) create mode 100644 server/experiments/batchDelete.ts create mode 100644 server/src/_luaScripts/cusLuaScripts/batchDeleteCustomers.lua create mode 100644 server/src/internal/customers/cusUtils/apiCusCacheUtils/batchDeleteCachedCustomers.ts diff --git a/server/experiments/batchDelete.ts b/server/experiments/batchDelete.ts new file mode 100644 index 000000000..91c433fef --- /dev/null +++ b/server/experiments/batchDelete.ts @@ -0,0 +1,17 @@ +import { CusEntService } from "../src/internal/customers/cusProducts/cusEnts/CusEntitlementService"; +import { initDrizzle } from "../src/db/initDrizzle"; +import { clearCusEntsFromCache } from "../src/cron/cronUtils"; + +const main = async () => { + const { db } = initDrizzle(); + const cusEnts = await CusEntService.getActiveResetPassed({ + db, + batchSize: 500, + }); + + + await clearCusEntsFromCache({ cusEnts }); +}; + +await main(); +process.exit(0); \ No newline at end of file diff --git a/server/src/_luaScripts/cusLuaScripts/batchDeleteCustomers.lua b/server/src/_luaScripts/cusLuaScripts/batchDeleteCustomers.lua new file mode 100644 index 000000000..e9d2ae690 --- /dev/null +++ b/server/src/_luaScripts/cusLuaScripts/batchDeleteCustomers.lua @@ -0,0 +1,103 @@ +-- batchDeleteCustomers.lua +-- Atomically deletes multiple customers and all their associated entity caches +-- ARGV[1]: JSON array of {orgId, env, customerId} objects +-- Returns: number of keys deleted + +local customersJson = ARGV[1] +local customers = cjson.decode(customersJson) +local allKeysToDelete = {} + +-- Helper function to add balance-related keys for a cache key +local function addBalanceKeys(keysToDelete, cacheKey, featureIds) + if not featureIds or #featureIds == 0 then + return + end + + for _, featureId in ipairs(featureIds) do + local balanceKey = buildBalanceCacheKey(cacheKey, featureId) + table.insert(keysToDelete, balanceKey) + + -- Get the balance HSET to find breakdown/rollover counts + local balanceData = redis.call("HGETALL", balanceKey) + if balanceData and #balanceData > 0 then + -- Convert array to hash table + local balanceHash = {} + for i = 1, #balanceData, 2 do + balanceHash[balanceData[i]] = balanceData[i + 1] + end + + -- Delete rollover keys + local rolloverCount = tonumber(balanceHash["_rollover_count"]) or 0 + for i = 0, rolloverCount - 1 do + table.insert(keysToDelete, buildRolloverCacheKey(cacheKey, featureId, i)) + end + + -- Delete breakdown keys + local breakdownCount = tonumber(balanceHash["_breakdown_count"]) or 0 + for i = 0, breakdownCount - 1 do + table.insert(keysToDelete, buildBreakdownCacheKey(cacheKey, featureId, i)) + end + end + end +end + +-- Process each customer +for _, customerInfo in ipairs(customers) do + local orgId = customerInfo.orgId + local env = customerInfo.env + local customerId = customerInfo.customerId + + -- Build versioned cache key using shared utility + local cacheKey = buildCustomerCacheKey(orgId, env, customerId) + + -- Get the customer base JSON to find entity and feature IDs + local baseJson = redis.call("GET", cacheKey) + + -- Skip if customer not in cache + if baseJson then + table.insert(allKeysToDelete, cacheKey) + local success, customer = pcall(cjson.decode, baseJson) + if success and customer then + local entityIds = customer._entityIds or {} + local balanceFeatureIds = customer._balanceFeatureIds or {} + + -- Add customer balance keys (with rollover/breakdown) + addBalanceKeys(allKeysToDelete, cacheKey, balanceFeatureIds) + + -- Process each entity + for _, entityId in ipairs(entityIds) do + local entityCacheKey = buildEntityCacheKey(orgId, env, customerId, entityId) + table.insert(allKeysToDelete, entityCacheKey) + + -- Get entity to find its feature IDs + local entityJson = redis.call("GET", entityCacheKey) + if entityJson then + local entitySuccess, entity = pcall(cjson.decode, entityJson) + if entitySuccess and entity then + local entityFeatureIds = entity._balanceFeatureIds or {} + + -- Add entity balance keys (with rollover/breakdown) + addBalanceKeys(allKeysToDelete, entityCacheKey, entityFeatureIds) + end + end + end + end + end +end + +-- Use UNLINK instead of DEL for async deletion (non-blocking) +local deletedCount = 0 +if #allKeysToDelete > 0 then + -- UNLINK has a limit, so batch in chunks of 1000 keys + local chunkSize = 1000 + for i = 1, #allKeysToDelete, chunkSize do + local chunk = {} + for j = i, math.min(i + chunkSize - 1, #allKeysToDelete) do + table.insert(chunk, allKeysToDelete[j]) + end + deletedCount = deletedCount + redis.call("UNLINK", unpack(chunk)) + end +end + +return deletedCount + diff --git a/server/src/_luaScripts/luaScripts.ts b/server/src/_luaScripts/luaScripts.ts index 7c136fe8c..de132b35c 100644 --- a/server/src/_luaScripts/luaScripts.ts +++ b/server/src/_luaScripts/luaScripts.ts @@ -101,6 +101,13 @@ const deleteCustomerScript = readFileSync( ); export const DELETE_CUSTOMER_SCRIPT = `${CACHE_KEY_UTILS}\n${deleteCustomerScript}`; +// Prepend cache key utils to BATCH_DELETE_CUSTOMERS_SCRIPT +const batchDeleteCustomersScript = readFileSync( + join(__dirname, "cusLuaScripts/batchDeleteCustomers.lua"), + "utf-8", +); +export const BATCH_DELETE_CUSTOMERS_SCRIPT = `${CACHE_KEY_UTILS}\n${batchDeleteCustomersScript}`; + // ============================================================================ // ENTITY SCRIPTS // ============================================================================ diff --git a/server/src/_luaScripts/luaUtils/loadBalances.lua b/server/src/_luaScripts/luaUtils/loadBalances.lua index 6d3fb54c0..7ab205a73 100644 --- a/server/src/_luaScripts/luaUtils/loadBalances.lua +++ b/server/src/_luaScripts/luaUtils/loadBalances.lua @@ -282,11 +282,13 @@ local function mergeFeatureBalances(targetBalance, sourceBalance) -- Merge rollover balances if sourceBalance.rollovers and #sourceBalance.rollovers > 0 then - -- Both have rollovers, merge them - for i, targetRollover in ipairs(targetBalance.rollovers) do - local sourceRollover = sourceBalance.rollovers[i] - if sourceRollover then - targetRollover.balance = toNum(targetRollover.balance) + toNum(sourceRollover.balance) + -- Both have rollovers, merge them + if targetBalance.rollovers and #targetBalance.rollovers > 0 then + for i, targetRollover in ipairs(targetBalance.rollovers) do + local sourceRollover = sourceBalance.rollovers[i] + if sourceRollover then + targetRollover.balance = toNum(targetRollover.balance) + toNum(sourceRollover.balance) + end end end end diff --git a/server/src/cron/cronInit.ts b/server/src/cron/cronInit.ts index 98ebc40cb..1cd0edac1 100644 --- a/server/src/cron/cronInit.ts +++ b/server/src/cron/cronInit.ts @@ -5,17 +5,15 @@ import { format } from "date-fns"; import { initDrizzle } from "../db/initDrizzle.js"; import { CusEntService } from "../internal/customers/cusProducts/cusEnts/CusEntitlementService.js"; import { notNullish } from "../utils/genUtils.js"; -import { resetCustomerEntitlement } from "./cronUtils.js"; +import { + clearCusEntsFromCache, + resetCustomerEntitlement, +} from "./cronUtils.js"; import { runProductCron } from "./productCron/runProductCron.js"; const { db, client } = initDrizzle(); export const cronTask = async () => { - console.log( - "\n----------------------------------\nRUNNING RESET CRON:", - format(new UTCDate(), "yyyy-MM-dd HH:mm:ss"), - ); - try { const cusEnts: ResetCusEnt[] = await CusEntService.getActiveResetPassed({ db, @@ -43,6 +41,8 @@ export const cronTask = async () => { data: toUpsert as CustomerEntitlement[], }); console.log(`Upserted ${toUpsert.length} short entitlements`); + + await clearCusEntsFromCache({ cusEnts: batch }); } console.log( diff --git a/server/src/cron/cronUtils.ts b/server/src/cron/cronUtils.ts index beeda2eb0..85aa9aa71 100644 --- a/server/src/cron/cronUtils.ts +++ b/server/src/cron/cronUtils.ts @@ -5,6 +5,7 @@ import { type FullCusEntWithProduct, type FullEntitlement, getStartingBalance, + notNullish, type Organization, type ResetCusEnt, } from "@autumn/shared"; @@ -19,12 +20,11 @@ import { getRelatedCusPrice } from "@/internal/customers/cusProducts/cusEnts/cus import { getRolloverUpdates } from "@/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverUtils.js"; import { getResetBalancesUpdate } from "@/internal/customers/cusProducts/cusEnts/groupByUtils.js"; import { CusPriceService } from "@/internal/customers/cusProducts/cusPrices/CusPriceService.js"; -import { OrgService } from "@/internal/orgs/OrgService.js"; import { getEntOptions } from "@/internal/products/prices/priceUtils.js"; import { getNextResetAt } from "@/utils/timeUtils.js"; import type { DrizzleCli } from "../db/initDrizzle.js"; import { RolloverService } from "../internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService.js"; -import { deleteCachedApiCustomer } from "../internal/customers/cusUtils/apiCusCacheUtils/deleteCachedApiCustomer.js"; +import { batchDeleteCachedCustomers } from "../internal/customers/cusUtils/apiCusCacheUtils/batchDeleteCachedCustomers.js"; const checkSubAnchor = async ({ db, @@ -134,16 +134,16 @@ const handleShortDurationCusEnt = async ({ `Reseting short cus ent (${cusEnt.feature_id}) [${ent.interval}], customer: ${cusEnt.customer_id}, org: ${cusEnt.customer.org_id}`, ); - const org = await OrgService.get({ - db, - orgId: cusEnt.customer.org_id, - }); + // const org = await OrgService.get({ + // db, + // orgId: cusEnt.customer.org_id, + // }); - await deleteCachedApiCustomer({ - customerId: cusEnt.customer.id!, - orgId: org.id, - env: cusEnt.customer.env, - }); + // await deleteCachedApiCustomer({ + // customerId: cusEnt.customer.id!, + // orgId: org.id, + // env: cusEnt.customer.env, + // }); return newCusEnt; }; @@ -294,19 +294,37 @@ export const resetCustomerEntitlement = async ({ )}`, ); - const org = await OrgService.get({ - db, - orgId: cusEnt.customer.org_id, - }); + // const org = await OrgService.get({ + // db, + // orgId: cusEnt.customer.org_id, + // }); - await deleteCachedApiCustomer({ - customerId: cusEnt.customer.id!, - orgId: org.id, - env: cusEnt.customer.env, - }); + // await deleteCachedApiCustomer({ + // customerId: cusEnt.customer.id!, + // orgId: org.id, + // env: cusEnt.customer.env, + // }); } catch (error: any) { console.log( `Failed to reset ${cusEnt.id} | ${cusEnt.customer_id} | ${cusEnt.feature_id}, error: ${error}`, ); } }; + +export const clearCusEntsFromCache = async ({ + cusEnts, +}: { + cusEnts: ResetCusEnt[]; +}) => { + const customersToDelete = cusEnts + .filter((ce) => notNullish(ce.customer.id)) + .map((cusEnt) => ({ + orgId: cusEnt.customer.org_id, + env: cusEnt.customer.env, + customerId: cusEnt.customer.id!, + })); + + if (customersToDelete.length === 0) return; + + await batchDeleteCachedCustomers({ customers: customersToDelete }); +}; diff --git a/server/src/cron/productCron/runProductCron.ts b/server/src/cron/productCron/runProductCron.ts index b281bfc5e..f5ac96edb 100644 --- a/server/src/cron/productCron/runProductCron.ts +++ b/server/src/cron/productCron/runProductCron.ts @@ -4,10 +4,11 @@ import { customerPrices, customerProducts, customers, + notNullish, } from "@autumn/shared"; import { and, eq, inArray, isNotNull, lt, notExists, sql } from "drizzle-orm"; import { db } from "@/db/initDrizzle.js"; -import { deleteCachedApiCustomer } from "../../internal/customers/cusUtils/apiCusCacheUtils/deleteCachedApiCustomer"; +import { batchDeleteCachedCustomers } from "../../internal/customers/cusUtils/apiCusCacheUtils/batchDeleteCachedCustomers"; export const runProductCron = async () => { console.log("Running product cron"); @@ -64,17 +65,26 @@ export const runProductCron = async () => { `Expired batch of ${i + batch.length}/${results.length} customer products`, ); - const clearCachePromises = []; - for (const result of batch) { - clearCachePromises.push( - deleteCachedApiCustomer({ - customerId: result.customers.id ?? "", - orgId: result.customers.org_id, - env: result.customers.env, - }), - ); - } - await Promise.all(clearCachePromises); + await batchDeleteCachedCustomers({ + customers: batch + .filter((r) => notNullish(r.customers.id)) + .map((r) => ({ + orgId: r.customers.org_id, + env: r.customers.env, + customerId: r.customers.id!, + })), + }); + // const clearCachePromises = []; + // for (const result of batch) { + // clearCachePromises.push( + // deleteCachedApiCustomer({ + // customerId: result.customers.id ?? "", + // orgId: result.customers.org_id, + // env: result.customers.env, + // }), + // ); + // } + // await Promise.all(clearCachePromises); } return results; diff --git a/server/src/external/redis/initRedis.ts b/server/src/external/redis/initRedis.ts index 2deacdbb8..c9631b569 100644 --- a/server/src/external/redis/initRedis.ts +++ b/server/src/external/redis/initRedis.ts @@ -1,5 +1,6 @@ import { Redis } from "ioredis"; import { + BATCH_DELETE_CUSTOMERS_SCRIPT, DELETE_CUSTOMER_SCRIPT, GET_CUSTOMER_SCRIPT, GET_ENTITY_SCRIPT, @@ -93,6 +94,11 @@ redis.defineCommand("deleteCustomer", { lua: DELETE_CUSTOMER_SCRIPT, }); +redis.defineCommand("batchDeleteCustomers", { + numberOfKeys: 0, + lua: BATCH_DELETE_CUSTOMERS_SCRIPT, +}); + // Add type definitions declare module "ioredis" { interface RedisCommander { @@ -157,6 +163,7 @@ declare module "ioredis" { env: string, customerId: string, ): Promise; + batchDeleteCustomers(customersJson: string): Promise; } } diff --git a/server/src/internal/customers/cusUtils/apiCusCacheUtils/batchDeleteCachedCustomers.ts b/server/src/internal/customers/cusUtils/apiCusCacheUtils/batchDeleteCachedCustomers.ts new file mode 100644 index 000000000..e2431cd3b --- /dev/null +++ b/server/src/internal/customers/cusUtils/apiCusCacheUtils/batchDeleteCachedCustomers.ts @@ -0,0 +1,75 @@ +import * as Sentry from "@sentry/bun"; +import { redis } from "@/external/redis/initRedis.js"; + +/** + * Batch delete multiple customer caches in one Redis operation + * Much more efficient than calling deleteCachedApiCustomer multiple times + * @param customers Array of {orgId, env, customerId} to delete + * @returns Number of keys deleted + */ +export const batchDeleteCachedCustomers = async ({ + customers, +}: { + customers: Array<{ + orgId: string; + env: string; + customerId: string; + }>; +}): Promise => { + if (redis.status !== "ready") { + console.warn("❗️ Redis not ready, skipping batch cache deletion", { + status: redis.status, + count: customers.length, + }); + return 0; + } + + if (customers.length === 0) { + return 0; + } + + try { + // Group customers by orgId to avoid Redis Cluster hash slot errors + // All keys in a Lua script must be in the same hash slot (same {orgId}) + const customersByOrg = new Map(); + + for (const customer of customers) { + const key = customer.orgId; + if (!customersByOrg.has(key)) { + customersByOrg.set(key, []); + } + customersByOrg.get(key)?.push(customer); + } + + // Use pipeline to batch all org deletions into one network round trip + const pipeline = redis.pipeline(); + + for (const orgCustomers of customersByOrg.values()) { + pipeline.batchDeleteCustomers(JSON.stringify(orgCustomers)); + } + + const results = await pipeline.exec(); + + // Sum up all deleted counts + let totalDeleted = 0; + if (results) { + for (const [error, result] of results) { + if (error) { + console.error("Error in pipeline batch delete:", error); + throw error; + } + totalDeleted += result as number; + } + } + + console.log( + `Batch deleted ${totalDeleted} cache keys for ${customers.length} customers across ${customersByOrg.size} orgs`, + ); + + return totalDeleted; + } catch (error) { + console.error("Error batch deleting customers:", error); + Sentry.captureException(error); + throw error; + } +}; diff --git a/server/tests/advanced/rollovers/rollover2.test.ts b/server/tests/advanced/rollovers/rollover2.test.ts index ad015286a..f747eff35 100644 --- a/server/tests/advanced/rollovers/rollover2.test.ts +++ b/server/tests/advanced/rollovers/rollover2.test.ts @@ -152,7 +152,6 @@ describe(`${chalk.yellowBright(`${testCase}: Testing rollovers for feature item expect(nonCachedMsgesFeature.rollovers[0].balance).toBe(expectedRollover); } }); - return; test("should reset again and have correct rollovers", async () => { await resetAndGetCusEnt({ diff --git a/server/tests/advanced/rollovers/rolloverTestUtils.ts b/server/tests/advanced/rollovers/rolloverTestUtils.ts index 59e3f7924..42a8e1985 100644 --- a/server/tests/advanced/rollovers/rolloverTestUtils.ts +++ b/server/tests/advanced/rollovers/rolloverTestUtils.ts @@ -1,5 +1,8 @@ import type { Customer } from "@autumn/shared"; -import { resetCustomerEntitlement } from "@/cron/cronUtils.js"; +import { + clearCusEntsFromCache, + resetCustomerEntitlement, +} from "@/cron/cronUtils.js"; import type { DrizzleCli } from "@/db/initDrizzle.js"; import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js"; import { cusProductToCusEnt } from "@/internal/customers/cusProducts/cusProductUtils/convertCusProduct.js"; @@ -28,14 +31,18 @@ export const resetAndGetCusEnt = async ({ featureId, }); + const resetCusEnt = { + ...cusEnt!, + customer, + }; + const updatedCusEnt = await resetCustomerEntitlement({ db, - cusEnt: { - ...cusEnt!, - customer, - }, + cusEnt: resetCusEnt, }); + await clearCusEntsFromCache({ cusEnts: [resetCusEnt] }); + if (updatedCusEnt) { await CusEntService.upsert({ db, diff --git a/shared/api/customers/cusFeatures/changes/V1.2_CusFeatureChange.ts b/shared/api/customers/cusFeatures/changes/V1.2_CusFeatureChange.ts index acaf10c8e..b9370529e 100644 --- a/shared/api/customers/cusFeatures/changes/V1.2_CusFeatureChange.ts +++ b/shared/api/customers/cusFeatures/changes/V1.2_CusFeatureChange.ts @@ -84,7 +84,7 @@ const toV3Type = ({ feature }: { feature?: ApiFeatureV1 }) => { return ApiFeatureType.ContinuousUse; } } else if (feature?.type === FeatureType.CreditSystem) { - return ApiFeatureType.CreditSystem; + return ApiFeatureType.SingleUsage; } else { return ApiFeatureType.Static; } diff --git a/vite/src/views/products/plan/components/edit-plan-feature/UsageReset.tsx b/vite/src/views/products/plan/components/edit-plan-feature/UsageReset.tsx index a42377abd..93663a682 100644 --- a/vite/src/views/products/plan/components/edit-plan-feature/UsageReset.tsx +++ b/vite/src/views/products/plan/components/edit-plan-feature/UsageReset.tsx @@ -68,6 +68,12 @@ export function UsageReset({ showBillingLabel = false }: UsageResetProps) { {Object.values(isFeaturePrice ? BillingInterval : EntInterval) .filter((i) => { + if ( + i === EntInterval.Minute && + itemToEntInterval({ item }) !== EntInterval.Minute + ) { + return false; + } if (isFeaturePrice && item.usage_model === UsageModel.PayPerUse) { return i !== BillingInterval.OneOff; } diff --git a/vite/src/views/products/product/product-item/product-item-config/components/SelectResetCycle.tsx b/vite/src/views/products/product/product-item/product-item-config/components/SelectResetCycle.tsx index 8837ceddd..4d25c611a 100644 --- a/vite/src/views/products/product/product-item/product-item-config/components/SelectResetCycle.tsx +++ b/vite/src/views/products/product/product-item/product-item-config/components/SelectResetCycle.tsx @@ -95,22 +95,24 @@ export const SelectResetCycle = () => { {getIntervalText({ interval, - intervalCount: item!.interval_count, + intervalCount: item?.interval_count ?? 1, })} - {Object.values(EntInterval).map((intervalOption) => { - const isSelected = intervalOption === interval; - return ( - - ); - })} + {Object.values(EntInterval) + .filter((i) => i !== EntInterval.Minute) + .map((intervalOption) => { + const isSelected = intervalOption === interval; + return ( + + ); + })} From 2603bc9033d77e223c5602ccf868c4a6fd9155d3 Mon Sep 17 00:00:00 2001 From: John Yeo Date: Thu, 20 Nov 2025 17:28:49 +0000 Subject: [PATCH 10/58] fix: check credit system no feature_amount --- server/src/cron/cronInit.ts | 1 + server/src/internal/features/creditSystemUtils.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/server/src/cron/cronInit.ts b/server/src/cron/cronInit.ts index 1cd0edac1..8d385fe8c 100644 --- a/server/src/cron/cronInit.ts +++ b/server/src/cron/cronInit.ts @@ -1,3 +1,4 @@ +import "../sentry.ts"; import type { CustomerEntitlement, ResetCusEnt } from "@autumn/shared"; import { UTCDate } from "@date-fns/utc"; import { CronJob } from "cron"; diff --git a/server/src/internal/features/creditSystemUtils.ts b/server/src/internal/features/creditSystemUtils.ts index a1b1d550e..f89ac5292 100644 --- a/server/src/internal/features/creditSystemUtils.ts +++ b/server/src/internal/features/creditSystemUtils.ts @@ -58,7 +58,7 @@ export const featureToCreditSystem = ({ for (const schemaItem of schema) { if (schemaItem.metered_feature_id === featureId) { const creditAmount = schemaItem.credit_amount; - const featureAmount = schemaItem.feature_amount; + const featureAmount = schemaItem.feature_amount ?? 1; return new Decimal(creditAmount) .div(featureAmount) From 9a79a0b1389fde3adab1821a41b8491d639c7cd8 Mon Sep 17 00:00:00 2001 From: John Yeo Date: Thu, 20 Nov 2025 18:09:32 +0000 Subject: [PATCH 11/58] fix: cron, only refreshing cache for cusEnts that were updated --- server/src/cron/cronInit.ts | 4 +++- server/src/cron/cronUtils.ts | 20 +++++++++---------- .../advanced/rollovers/rolloverTestUtils.ts | 1 + 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/server/src/cron/cronInit.ts b/server/src/cron/cronInit.ts index 8d385fe8c..537205587 100644 --- a/server/src/cron/cronInit.ts +++ b/server/src/cron/cronInit.ts @@ -25,11 +25,13 @@ export const cronTask = async () => { for (let i = 0; i < cusEnts.length; i += batchSize) { const batch = cusEnts.slice(i, i + batchSize); const batchResets = []; + const updatedCusEnts: ResetCusEnt[] = []; for (const cusEnt of batch) { batchResets.push( resetCustomerEntitlement({ db, cusEnt: cusEnt, + updatedCusEnts, }), ); } @@ -43,7 +45,7 @@ export const cronTask = async () => { }); console.log(`Upserted ${toUpsert.length} short entitlements`); - await clearCusEntsFromCache({ cusEnts: batch }); + await clearCusEntsFromCache({ cusEnts: updatedCusEnts }); } console.log( diff --git a/server/src/cron/cronUtils.ts b/server/src/cron/cronUtils.ts index 85aa9aa71..b48aa72ac 100644 --- a/server/src/cron/cronUtils.ts +++ b/server/src/cron/cronUtils.ts @@ -94,9 +94,11 @@ const checkSubAnchor = async ({ const handleShortDurationCusEnt = async ({ db, cusEnt, + updatedCusEnts, }: { db: DrizzleCli; cusEnt: ResetCusEnt; + updatedCusEnts: ResetCusEnt[]; }) => { const ent = cusEnt.entitlement as FullEntitlement; @@ -145,6 +147,8 @@ const handleShortDurationCusEnt = async ({ // env: cusEnt.customer.env, // }); + updatedCusEnts.push(newCusEnt); + return newCusEnt; }; @@ -153,9 +157,11 @@ const shortDurations = [EntInterval.Minute, EntInterval.Hour, EntInterval.Day]; export const resetCustomerEntitlement = async ({ db, cusEnt, + updatedCusEnts, }: { db: DrizzleCli; cusEnt: ResetCusEnt; + updatedCusEnts: ResetCusEnt[]; }) => { try { const ent = cusEnt.entitlement as FullEntitlement; @@ -167,6 +173,7 @@ export const resetCustomerEntitlement = async ({ return await handleShortDurationCusEnt({ db, cusEnt, + updatedCusEnts, }); } @@ -282,6 +289,8 @@ export const resetCustomerEntitlement = async ({ }); } + updatedCusEnts.push(cusEnt); + console.log( `Reset ${cusEnt.id} | customer: ${chalk.yellow( cusEnt.customer_id, @@ -293,17 +302,6 @@ export const resetCustomerEntitlement = async ({ format(new UTCDate(nextResetAt), "dd MMM yyyy HH:mm:ss"), )}`, ); - - // const org = await OrgService.get({ - // db, - // orgId: cusEnt.customer.org_id, - // }); - - // await deleteCachedApiCustomer({ - // customerId: cusEnt.customer.id!, - // orgId: org.id, - // env: cusEnt.customer.env, - // }); } catch (error: any) { console.log( `Failed to reset ${cusEnt.id} | ${cusEnt.customer_id} | ${cusEnt.feature_id}, error: ${error}`, diff --git a/server/tests/advanced/rollovers/rolloverTestUtils.ts b/server/tests/advanced/rollovers/rolloverTestUtils.ts index 42a8e1985..2a5be2d90 100644 --- a/server/tests/advanced/rollovers/rolloverTestUtils.ts +++ b/server/tests/advanced/rollovers/rolloverTestUtils.ts @@ -39,6 +39,7 @@ export const resetAndGetCusEnt = async ({ const updatedCusEnt = await resetCustomerEntitlement({ db, cusEnt: resetCusEnt, + updatedCusEnts: [], }); await clearCusEntsFromCache({ cusEnts: [resetCusEnt] }); From 644ce624007cae76d5dbfea29a0aa6ebb577edd5 Mon Sep 17 00:00:00 2001 From: John Yeo Date: Thu, 20 Nov 2025 18:13:40 +0000 Subject: [PATCH 12/58] fix: admin hover --- vite/src/components/general/AdminHover.tsx | 19 ++-- vite/src/components/general/DateInputUnix.tsx | 24 ++--- vite/src/views/admin/adminUtils.ts | 100 +++++++++++++++++- vite/src/views/admin/hooks/useAdmin.tsx | 5 +- .../CustomerProductsColumns.tsx | 6 +- .../CustomerProductsTable.tsx | 10 +- .../components/CustomerBalanceModal.tsx | 16 ++- 7 files changed, 148 insertions(+), 32 deletions(-) diff --git a/vite/src/components/general/AdminHover.tsx b/vite/src/components/general/AdminHover.tsx index 7d40accb0..261992c16 100644 --- a/vite/src/components/general/AdminHover.tsx +++ b/vite/src/components/general/AdminHover.tsx @@ -36,30 +36,27 @@ export const AdminHover = forwardRef< return ( - - {triggerChild} - + {triggerChild} {isAdmin && (
- {texts.map((text: any) => { - if (!text) return; + {texts.map((text) => { + if (!text) return null; if (typeof text === "object") { return (

{text.key}

- +
); - } else { - return ; } + return ; })}
@@ -90,7 +87,9 @@ const CopyText = ({ text }: { text: string }) => { }, 1000); }} > - {text && text.split("\n").map((line, i) => {line})} + {text?.split("\n").map((line, i) => ( + {line} + ))}

{isCopied || isHover ? (
- + { + return [ + { + key: "Cus Product ID", + value: cusProduct.id, + }, + ...(cusProduct.subscription_ids + ? cusProduct.subscription_ids.map((id: string) => ({ + key: "Stripe Subscription ID", + value: id, + })) + : []), + ...(cusProduct.scheduled_ids + ? [ + { + key: "Stripe Scheduled IDs", + value: cusProduct.scheduled_ids.join(", "), + }, + ] + : []), + { + key: "Entity ID", + value: cusProduct.entity_id || "N/A", + }, + ]; +}; export const impersonateUser = async (userId: string) => { console.log("impersonating user", userId); @@ -19,3 +54,66 @@ export const impersonateUser = async (userId: string) => { window.location.reload(); }; + +export const getCusEntHoverTexts = ({ + cusEnt, + entities, +}: { + cusEnt: FullCustomerEntitlement; + entities: Entity[]; +}) => { + const entitlement = cusEnt.entitlement; + const featureEntities = entities.filter( + (e: Entity) => e.feature_id === entitlement.feature.id, + ); + + const hoverTexts = [ + { + key: "Cus Ent ID", + value: cusEnt.id, + }, + ]; + + if (featureEntities.length > 0) { + hoverTexts.push({ + key: "Entities", + value: featureEntities + .map((e: Entity) => `${e.id} (${e.name})${e.deleted ? " Deleted" : ""}`) + .join("\n"), + }); + } else if (cusEnt.entities && Object.keys(cusEnt.entities).length > 0) { + const mappedEntities = Object.keys(cusEnt.entities) + .map((e: string) => { + const entity = entities.find((ee: Entity) => ee.id === e); + const balance = cusEnt.entities?.[e]?.balance; + return `${entity?.id} (${entity?.name}): ${balance ?? "N/A"}`; + }) + .join("\n"); + hoverTexts.push({ + key: "Entities", + value: mappedEntities, + }); + } + + if (cusEnt.rollovers.length > 0) { + hoverTexts.push({ + key: "Rollovers", + value: cusEnt.rollovers + .map((r: Rollover) => { + if (Object.values(r.entities).length > 0) { + return ( + Object.values(r.entities) + .map((e: EntityRolloverBalance) => `${e.balance} (${e.id})`) + .join(", ") + + ` (expires: ${r.expires_at ? formatUnixToDate(r.expires_at) : "N/A"})` + ); + } else { + return `${r.balance} (ex: ${r.expires_at ? formatUnixToDate(r.expires_at) : "N/A"})`; + } + }) + .join("\n"), + }); + } + + return hoverTexts; +}; diff --git a/vite/src/views/admin/hooks/useAdmin.tsx b/vite/src/views/admin/hooks/useAdmin.tsx index 5d6c980e2..3c468b4c2 100644 --- a/vite/src/views/admin/hooks/useAdmin.tsx +++ b/vite/src/views/admin/hooks/useAdmin.tsx @@ -8,8 +8,9 @@ export const useAdmin = () => { useEffect(() => { if ( - data?.user?.role === "admin" || - notNullish(data?.session.impersonatedBy) + (data?.user?.role === "admin" || + notNullish(data?.session.impersonatedBy)) && + data?.user?.id !== "user_2tMgAiPsQzX8JTHjZZh9m0VdvUv" ) { setIsAdmin(true); } else { diff --git a/vite/src/views/customers2/components/table/customer-products/CustomerProductsColumns.tsx b/vite/src/views/customers2/components/table/customer-products/CustomerProductsColumns.tsx index a73cc9671..8f9898319 100644 --- a/vite/src/views/customers2/components/table/customer-products/CustomerProductsColumns.tsx +++ b/vite/src/views/customers2/components/table/customer-products/CustomerProductsColumns.tsx @@ -4,6 +4,8 @@ import { Delete } from "lucide-react"; import { TableDropdownMenuCell } from "@/components/general/table/table-dropdown-menu-cell"; import { DropdownMenuItem } from "@/components/ui/dropdown-menu"; import { createDateTimeColumn } from "@/views/customers2/utils/ColumnHelpers"; +import { AdminHover } from "../../../../../components/general/AdminHover"; +import { getCusProductHoverTexts } from "../../../../admin/adminUtils"; import { CustomerProductPrice } from "./CustomerProductPrice"; import { CustomerProductsStatus } from "./CustomerProductsStatus"; @@ -17,7 +19,9 @@ export const CustomerProductsColumns = [ return (
- {row.original.product.name} + + {row.original.product.name} + {showQuantity && (
{quantity} diff --git a/vite/src/views/customers2/components/table/customer-products/CustomerProductsTable.tsx b/vite/src/views/customers2/components/table/customer-products/CustomerProductsTable.tsx index 63ffc5329..53af68fe0 100644 --- a/vite/src/views/customers2/components/table/customer-products/CustomerProductsTable.tsx +++ b/vite/src/views/customers2/components/table/customer-products/CustomerProductsTable.tsx @@ -12,6 +12,8 @@ import { useFullCusSearchQuery } from "@/views/customers/hooks/useFullCusSearchQ import { useSavedViewsQuery } from "@/views/customers/hooks/useSavedViewsQuery"; import { useCustomerContext } from "@/views/customers2/customer/CustomerContext"; import { useCustomerTable } from "@/views/customers2/hooks/useCustomerTable"; +import { AdminHover } from "../../../../../components/general/AdminHover"; +import { getCusProductHoverTexts } from "../../../../admin/adminUtils"; import { AttachProductDropdown } from "./AttachProductDropdown"; import { CancelProductDialog } from "./CancelProductDialog"; import { CustomerProductPrice } from "./CustomerProductPrice"; @@ -97,7 +99,7 @@ export function CustomerProductsTable() {