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 (
);
- } 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() {