chore: merge with dev

This commit is contained in:
Scofield
2025-11-19 20:56:51 +00:00
1306 changed files with 41035 additions and 35428 deletions

View File

@@ -1 +1 @@
1.2.19
1.3.2

View File

@@ -5,6 +5,7 @@
- JS Doc comments should be SHORT and SWEET. Don't need examples unless ABSOLUTELY necessary
- When writing DB queries, for the `customers`, `products` and `features` tables (and others possibly not mentioned here), the primary key when updating is `internal_id`, not `id`
- When using db schemas in Drizzle, import them from '@autumn/shared', and don't do schemas.
- Do NOT use "any" type.
# Testing
- When writing tests, ALWAYS read:
@@ -24,6 +25,8 @@
- Always prefer foo({ bar }) over foo(bar) method signatures - no matter if we are using only one argument or not, object as param are always better, as in the future when wanting to change the order of parameters, or add new ones - its easier.
- **ALWAYS use `c.req.param()` to get route parameters in Hono handlers**, NOT `c.req.valid("param")`. Example: `const { customer_id } = c.req.param();`
- When creating "hooks" folders, don't nest them under "components"
- Functions (unless there's a very good reason) should always take in objects as arguments. Object params are named and easy to understand.

View File

@@ -31,6 +31,8 @@
- Do not run "npx tsc" - run "tsc" instead as it is installed globally.
- **ALWAYS use `.meta()` for zod-openapi schema registration**, NOT `.openapi()`. Example: `ApiProductSchema.meta({ id: "Product" })`
- **ALWAYS use `c.req.param()` to get route parameters in Hono handlers**, NOT `c.req.valid("param")`. Example: `const { customer_id } = c.req.param();`
## Error Handling in API Routes
- NEVER use `c.json({ message: "...", code: "..." }, statusCode)` pattern for input validation or expected errors in Hono routes
- ALWAYS throw `RecaseError` from `@autumn/shared` for all validation errors, not found errors, forbidden errors, etc.
@@ -65,12 +67,19 @@
/ root
-> components
|-> hooks
## Good example
/ root
-> components
-> hooks
- Functions (unless there's a very good reason) should always take in objects as arguments. Object params are named and easy to understand.
- This codebase uses Bun for all of its operations in `/server`, `/vite` and `/shared`. It uses Bun for the package management, Bun for the workspace management and Bun for the runtime. Prefer Bun over PNPM. If you ever want to trace a package dependency tree, run `bun why <package name>` which will tell you why a certain package was installed and by who.
- Prefer Guard clauses "if(!admin) return;" over explicity "if(admin) do X;" Early returns are better
- Do not run "npx tsc" - run "tsc" instead as it is installed globally.
# Figma MCP guidance
- When you are using the Figma MCP server, you **must** follow our design system. Below is an example implementation of CVA with out design system

1121
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -126,9 +126,7 @@ fi
# echo "✅ Copied migration files"
# fi
# Build shared workspace (required for other workspaces)
echo "🔨 Building shared workspace..."
bun -F @autumn/shared build
# Shared workspace is now used directly from source (no build needed)
# # Run database migrations if DATABASE_URL exists
# if grep -q "DATABASE_URL=" server/.env 2>/dev/null; then

View File

@@ -1,64 +0,0 @@
services:
valkey:
image: docker.io/valkey/valkey:8.0
environment:
- ALLOW_EMPTY_PASSWORD=yes
- VALKEY_DISABLE_COMMANDS=FLUSHDB,FLUSHALL
volumes:
- valkey-data:/bitnami/valkey/data
healthcheck:
test: ['CMD', 'redis-cli', '-h', 'localhost', '-p', '6379', 'ping']
interval: 10s
timeout: 5s
retries: 5
ports:
- "6379:6379"
restart: unless-stopped
vite:
build:
context: .
dockerfile: docker/prod.dockerfile
target: vite-prod
ports:
- "3000:3000"
restart: always
server:
environment:
- REDIS_URL=redis://valkey:6379
build:
context: .
dockerfile: docker/prod.dockerfile
target: server-prod
ports:
- "8080:8080"
restart: always
localtunnel:
image: oven/bun:latest
build:
dockerfile: docker/prod.dockerfile
context: .
target: localtunnel
volumes:
- ./server:/app/server
depends_on:
- server
restart: unless-stopped
workers:
environment:
- REDIS_URL=redis://valkey:6379
build:
context: .
dockerfile: docker/prod.dockerfile
target: workers-prod
restart: always
volumes:
valkey-data:
shared-dist:
shared-node-modules:
root-node-modules:

View File

@@ -21,32 +21,25 @@ WORKDIR /app
COPY localtunnel-start.sh ./
CMD ["sh", "localtunnel-start.sh"]
# Stage 2: /shared
FROM base AS shared
COPY shared/ ./shared/
WORKDIR /app/shared
RUN bun run build
CMD ["bun", "dev"]
# Stage 3: /vite
# Stage 2: /vite
FROM base AS vite
COPY --from=shared /app/shared/dist ./shared/dist
COPY shared/ ./shared/
WORKDIR /app/vite
COPY vite/ ./
EXPOSE 3000
CMD ["bun", "dev"]
# Stage 4: /server
# Stage 3: /server
FROM base AS server
COPY --from=shared /app/shared/dist ./shared/dist
COPY shared/ ./shared/
COPY server/ ./server/
WORKDIR /app/server
EXPOSE 8080
CMD ["bun", "dev"]
# Stage 5: Workers
# Stage 4: Workers
FROM base AS workers
COPY --from=shared /app/shared/dist ./shared/dist
COPY shared/ ./shared/
COPY server/ ./server/
WORKDIR /app/server
CMD ["bun", "workers:dev"]

View File

@@ -1,42 +0,0 @@
version: '3.8'
services:
# Redis for caching and BullMQ
valkey:
image: docker.io/bitnami/valkey:8.0
environment:
- ALLOW_EMPTY_PASSWORD=yes
- VALKEY_DISABLE_COMMANDS=FLUSHDB,FLUSHALL
volumes:
- valkey-data:/bitnami/valkey/data
healthcheck:
test: ['CMD', 'redis-cli', '-h', 'localhost', '-p', '6379', 'ping']
interval: 10s
timeout: 5s
retries: 5
# PostgreSQL database
postgres:
image: postgres:15-alpine
container_name: autumn-postgres
ports:
- "5432:5432"
environment:
POSTGRES_DB: autumn
POSTGRES_USER: postgres
POSTGRES_PASSWORD: autumn_dev_password
volumes:
- postgres-data:/var/lib/postgresql/data
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
volumes:
valkey-data:
postgres-data:
networks:
autumn-network:

View File

@@ -1,52 +0,0 @@
# ---- Base dependencies ----
FROM oven/bun:latest AS base
WORKDIR /app
RUN bun install -g serve typescript tsc tsc-alias tsx
COPY package.json bun.lock ./
COPY shared/package*.json ./shared/
COPY server/package*.json ./server/
COPY vite/package*.json ./vite/
RUN bun install
FROM base AS localtunnel
WORKDIR /app
COPY localtunnel-start.sh ./
CMD ["sh", "localtunnel-start.sh"]
# ---- Build shared package ----
FROM base AS shared-build
COPY shared/ ./shared/
WORKDIR /app/shared
RUN bun run build
# ---- Build frontend (vite) ----
FROM base AS vite-build
COPY --from=shared-build /app/shared/dist ./shared/dist
COPY . .
WORKDIR /app
RUN bun run build
# ---- Build backend (server) ----
FROM base AS server-build
COPY --from=shared-build /app/shared/dist ./shared/dist
COPY . .
WORKDIR /app
# RUN bun run server:build:bun
# ---- Production frontend image ----
FROM vite-build AS vite-prod
EXPOSE 3000
WORKDIR /app
CMD ["bun", "start"]
FROM server-build AS server-prod
EXPOSE 8080
WORKDIR /app/server
CMD ["bun", "start"]
FROM server-build AS workers-prod
EXPOSE 8080
WORKDIR /app/server
CMD ["bun", "workers"]

View File

@@ -9,40 +9,29 @@
"scripts"
],
"catalog": {
"stripe": "18.4.0-beta.2",
"stripe": "19.3.0-beta.1",
"drizzle-orm": "0.43.1",
"drizzle-kit": "^0.31.1"
"drizzle-kit": "^0.31.1",
"@sentry/bun": "10.25.0"
}
},
"type": "module",
"scripts": {
"dev": "bun scripts/start-dev.js",
"d": "ENV_FILE=.env infisical run --env=dev -- bun scripts/start-dev.js",
"p": "ENV_FILE=.env.prod infisical run --env=prod -- bun scripts/start-dev.js",
"vite:build": "bun -F @autumn/shared build && bun -F @autumn/vite build:bun",
"vite:start": "bun -F @autumn/vite start:bun",
"shared": "bun -F @autumn/shared build",
"server": "cd server && bun start",
"workers": "bun -F @autumn/shared build && bun -F @autumn/server workers",
"cron": "bun -F @autumn/shared build && bun -F @autumn/server cron",
"check": "bun -F @autumn/shared build && bun -F @autumn/server check",
"server:cron": "pnpm -F server cron:start",
"server:check": "NODE_ENV=production pnpm -F server check",
"setup": "node scripts/setup.js",
"setup:test": "bun scripts/setup-test.ts",
"tests": "bun scripts/test.ts",
"setupci": "node scripts/setupci.js",
"replicate": "bun scripts/replicate.ts",
"dev": "bun scripts/dev.ts",
"vite:build": "bun -F @autumn/vite build:bun",
"d": "ENV_FILE=.env infisical run --env=dev -- bun scripts/dev.ts",
"p": "ENV_FILE=.env.prod infisical run --env=prod -- bun scripts/dev.ts",
"setup": "node scripts/setup/setup.js",
"setup:test": "bun scripts/setup/setup-test.ts",
"tests": "infisical run --env=dev -- bun scripts/test.ts",
"migrate-functions": "infisical run --env=dev -- bun scripts/migrations/migrate-functions.ts",
"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",
"setupci": "node scripts/setup/setupci.js",
"replicate": "bun scripts/db/replicate.ts",
"db:push": " bun -F @autumn/shared db:push",
"db:generate": "bun -F @autumn/shared db:generate",
"db:migrate": " bun -F @autumn/shared db:migrate",
"docker:up": "docker compose -f docker-compose.dev.yml up --build",
"docker:up:unix": "docker compose -f docker-compose.unix.yml up --build",
"docker:up:ci": "docker compose -f docker-compose.ci.yml up --build",
"vite:build:bun": "bun -F @autumn/shared build && bun -F @autumn/vite build:bun",
"vite:start:bun": "bun -F @autumn/shared build && bun -F @autumn/vite start:bun"
"db:migrate": " bun -F @autumn/shared db:migrate"
},
"dependencies": {
"@wooorm/starry-night": "^3.8.0",

View File

@@ -1,52 +0,0 @@
#!/usr/bin/env node
import { getProcessOnPort, killProcess } from "./detect-ports.js";
const PORTS_TO_CHECK = [3000, 3001, 8080, 8081, 8082, 8083];
async function cleanupPorts() {
console.log("🧹 Cleaning up dev server ports...\n");
let killedCount = 0;
for (const port of PORTS_TO_CHECK) {
const processInfo = await getProcessOnPort(port);
if (processInfo) {
const { pid, processName } = processInfo;
// Check if it's a dev server process
const isDevProcess =
processName.includes("bun") ||
processName.includes("node") ||
processName.includes("vite");
if (isDevProcess) {
console.log(`⚠️ Port ${port}: ${processName} (PID: ${pid})`);
const killed = await killProcess(pid);
if (killed) {
console.log(` ✅ Killed process ${pid}\n`);
killedCount++;
} else {
console.log(` ❌ Failed to kill process ${pid}\n`);
}
} else {
console.log(
` Port ${port}: ${processName} (PID: ${pid}) - skipping (not a dev server)\n`,
);
}
}
}
if (killedCount === 0) {
console.log("✨ No dev server processes found on common ports");
} else {
console.log(`✅ Cleaned up ${killedCount} process(es)`);
}
}
cleanupPorts().catch((error) => {
console.error("Error cleaning up ports:", error);
process.exit(1);
});

View File

@@ -1,248 +0,0 @@
import { exec } from "node:child_process";
import fs from "node:fs";
import net from "node:net";
import path from "node:path";
import { promisify } from "node:util";
const execAsync = promisify(exec);
const DEFAULT_VITE_PORT = 3000;
const DEFAULT_SERVER_PORT = 8080;
/**
* Get the process ID using a specific port
*/
async function getProcessOnPort(port) {
try {
const { stdout } = await execAsync(`lsof -ti:${port}`);
const pid = stdout.trim();
if (pid) {
// Get process details
const { stdout: psOut } = await execAsync(`ps -p ${pid} -o comm=`);
const processName = psOut.trim();
return { pid: parseInt(pid), processName };
}
return null;
} catch (error) {
return null;
}
}
/**
* Kill a process by PID
*/
async function killProcess(pid) {
try {
await execAsync(`kill -9 ${pid}`);
return true;
} catch (error) {
return false;
}
}
/**
* Check if a port is available, and optionally kill old dev server processes
*/
async function isPortAvailable(port, killIfDevServer = true) {
return new Promise((resolve) => {
const server = net.createServer();
server.once("error", async (err) => {
if (err.code === "EADDRINUSE") {
// Port is in use - check what's using it
if (killIfDevServer) {
const processInfo = await getProcessOnPort(port);
if (processInfo) {
const { pid, processName } = processInfo;
// Check if it's likely an old dev server process (bun, node, vite)
const isDevProcess =
processName.includes("bun") ||
processName.includes("node") ||
processName.includes("vite");
if (isDevProcess) {
console.log(
`⚠️ Port ${port} is in use by ${processName} (PID: ${pid})`,
);
console.log(` Attempting to kill old dev server process...`);
const killed = await killProcess(pid);
if (killed) {
console.log(` ✅ Killed process ${pid}`);
// Wait a bit for the port to be released
await new Promise((r) => setTimeout(r, 500));
resolve(true);
return;
} else {
console.log(` ❌ Failed to kill process ${pid}`);
}
} else {
console.log(
`⚠️ Port ${port} is in use by ${processName} (PID: ${pid})`,
);
console.log(` Skipping - not a dev server process`);
}
}
}
resolve(false);
} else {
resolve(false);
}
});
server.once("listening", () => {
server.close();
resolve(true);
});
server.listen(port);
});
}
/**
* Find the next available port starting from the given port
*/
async function findAvailablePort(startPort, maxAttempts = 10) {
for (let i = 0; i < maxAttempts; i++) {
const port = startPort + i;
if (await isPortAvailable(port)) {
return port;
}
}
throw new Error(`Could not find available port starting from ${startPort}`);
}
/**
* Update or create .env file with the detected ports
*/
function updateEnvFile(filePath, updates) {
let content = "";
if (fs.existsSync(filePath)) {
content = fs.readFileSync(filePath, "utf-8");
}
// Parse existing env file
const lines = content.split("\n");
const envMap = new Map();
for (const line of lines) {
const trimmed = line.trim();
if (trimmed && !trimmed.startsWith("#")) {
const [key, ...valueParts] = trimmed.split("=");
if (key) {
envMap.set(key.trim(), valueParts.join("="));
}
}
}
// Update with new values
for (const [key, value] of Object.entries(updates)) {
envMap.set(key, value);
}
// Rebuild content preserving comments and empty lines
const newLines = [];
const processedKeys = new Set();
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) {
newLines.push(line);
continue;
}
const [key] = trimmed.split("=");
if (key && envMap.has(key.trim())) {
processedKeys.add(key.trim());
newLines.push(`${key.trim()}=${envMap.get(key.trim())}`);
} else {
newLines.push(line);
}
}
// Add new keys that weren't in the original file
for (const [key, value] of envMap.entries()) {
if (!processedKeys.has(key)) {
newLines.push(`${key}=${value}`);
}
}
fs.writeFileSync(filePath, newLines.join("\n"));
}
async function detectAndSetPorts() {
console.log(
`🔍 Checking default ports (Frontend: ${DEFAULT_VITE_PORT}, Backend: ${DEFAULT_SERVER_PORT})...`,
);
// First try default ports and kill old dev servers if needed
const viteAvailable = await isPortAvailable(DEFAULT_VITE_PORT, true);
const serverAvailable = await isPortAvailable(DEFAULT_SERVER_PORT, true);
// If still not available after cleanup, find alternative ports
const vitePort = viteAvailable
? DEFAULT_VITE_PORT
: await findAvailablePort(DEFAULT_VITE_PORT + 1);
const serverPort = serverAvailable
? DEFAULT_SERVER_PORT
: await findAvailablePort(DEFAULT_SERVER_PORT + 1);
console.log(`\n✅ Using ports:`);
console.log(
` Frontend: ${vitePort}${vitePort !== DEFAULT_VITE_PORT ? " (alternative)" : ""}`,
);
console.log(
` Backend: ${serverPort}${serverPort !== DEFAULT_SERVER_PORT ? " (alternative)" : ""}`,
);
// Get root directory (parent of scripts folder)
const rootDir = path.dirname(new URL(import.meta.url).pathname);
const projectRoot = path.join(rootDir, "..");
// Update vite .env
const viteEnvPath = path.join(projectRoot, "vite", ".env");
updateEnvFile(viteEnvPath, {
VITE_FRONTEND_URL: `http://localhost:${vitePort}`,
VITE_BACKEND_URL: `http://localhost:${serverPort}`,
});
// Update server .env
const serverEnvPath = path.join(projectRoot, "server", ".env");
updateEnvFile(serverEnvPath, {
BETTER_AUTH_URL: `http://localhost:${serverPort}`,
CLIENT_URL: `http://localhost:${vitePort}`,
});
// Set environment variables for current process
process.env.VITE_PORT = vitePort.toString();
process.env.SERVER_PORT = serverPort.toString();
process.env.VITE_FRONTEND_URL = `http://localhost:${vitePort}`;
process.env.VITE_BACKEND_URL = `http://localhost:${serverPort}`;
process.env.BETTER_AUTH_URL = `http://localhost:${serverPort}`;
process.env.CLIENT_URL = `http://localhost:${vitePort}`;
console.log(`✅ Environment variables updated\n`);
return { vitePort, serverPort };
}
// Only run if called directly
if (import.meta.url === `file://${process.argv[1]}`) {
detectAndSetPorts()
.then(({ vitePort, serverPort }) => {
console.log(`\n🚀 Ready to start development servers`);
process.exit(0);
})
.catch((error) => {
console.error("Error detecting ports:", error);
process.exit(1);
});
}
export {
detectAndSetPorts,
findAvailablePort,
isPortAvailable,
getProcessOnPort,
killProcess,
};

107
scripts/dev.ts Normal file
View File

@@ -0,0 +1,107 @@
import { existsSync, readFileSync, rmSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const VITE_PORT = 3000;
const SERVER_PORT = 8080;
/**
* Read environment variable from .env file
*/
function getEnvVariable(filePath: string, key: string): string | null {
if (!existsSync(filePath)) {
return null;
}
const content = readFileSync(filePath, "utf-8");
const lines = content.split("\n");
for (const line of lines) {
const trimmed = line.trim();
if (trimmed && !trimmed.startsWith("#")) {
const [envKey, ...valueParts] = trimmed.split("=");
if (envKey && envKey.trim() === key) {
return valueParts.join("=");
}
}
}
return null;
}
async function startDev() {
const rootDir = dirname(fileURLToPath(import.meta.url));
const projectRoot = join(rootDir, "..");
try {
// Check if using remote backend (api.useautumn.com)
const viteEnvPath = join(projectRoot, "vite", ".env");
const backendUrl =
process.env.VITE_BACKEND_URL ||
getEnvVariable(viteEnvPath, "VITE_BACKEND_URL");
const isUsingRemoteBackend = backendUrl?.includes("api.useautumn.com");
if (isUsingRemoteBackend) {
console.log("\n🌐 Using remote backend (api.useautumn.com)");
console.log("⏭️ Skipping port cleanup...\n");
} else {
// Port cleanup disabled (detection is unreliable)
console.log("⏭️ Skipping port cleanup...\n");
}
// Clear Vite cache to prevent dep optimization issues
const viteCachePath = join(projectRoot, "vite", "node_modules", ".vite");
if (existsSync(viteCachePath)) {
console.log("🧹 Clearing Vite cache...\n");
rmSync(viteCachePath, { recursive: true, force: true });
}
console.log("🚀 Starting development servers in watch mode...\n");
// Start server, workers, and vite using Bun.spawn
// Use sh -c to run the shell command with cd
const concurrentlyProc = Bun.spawn(
[
"sh",
"-c",
`bunx concurrently -n server,workers,vite -c green,yellow,blue "cd server && SERVER_PORT=${SERVER_PORT} bun dev" "cd server && bun workers:dev" "cd vite && VITE_PORT=${VITE_PORT} bun dev"`,
],
{
cwd: projectRoot,
env: {
...process.env,
VITE_PORT: VITE_PORT.toString(),
SERVER_PORT: SERVER_PORT.toString(),
},
stdout: "inherit",
stderr: "inherit",
onExit(proc, exitCode, signalCode, error) {
if (error) {
console.error("Failed to start development servers:", error);
process.exit(1);
}
if (exitCode !== 0 && exitCode !== null) {
console.error(`Development servers exited with code ${exitCode}`);
}
process.exit(exitCode ?? 0);
},
},
);
// Handle termination signals
process.on("SIGINT", () => {
console.log("\n\n🛑 Shutting down development servers...");
concurrentlyProc.kill("SIGINT");
});
process.on("SIGTERM", () => {
console.log("\n\n🛑 Shutting down development servers...");
concurrentlyProc.kill("SIGTERM");
});
// Wait for the process to exit
await concurrentlyProc.exited;
} catch (error) {
console.error("Error starting development servers:", error);
process.exit(1);
}
}
startDev();

View File

@@ -0,0 +1,27 @@
import { initializeDatabaseFunctions } from "@server/db/initializeDatabaseFunctions";
import inquirer from "inquirer";
export const migrateFunctions = async () => {
const databaseUrl = process.env.DATABASE_URL;
if (databaseUrl?.includes("us-west-3")) {
const { confirm } = await inquirer.prompt([
{
type: "confirm",
name: "confirm",
message:
"You are about to initialize database functions on PRODUCTION (us-west-3). Continue?",
default: false,
},
]);
if (!confirm) {
console.log("Operation cancelled.");
process.exit(0);
}
}
await initializeDatabaseFunctions();
};
await migrateFunctions();
process.exit(0);

View File

@@ -0,0 +1,26 @@
import { initDrizzle } from "@server/db/initDrizzle";
import { validateDbSchema } from "@server/db/validateDbSchema";
import { validateSqlFunctions } from "@server/db/validateSqlFunctions";
const { db } = initDrizzle({ maxConnections: 5 });
// Check if --validate-content flag is passed
const validateContent = process.argv.includes("--validate-content");
try {
console.log("Validating database schema...");
await validateDbSchema({ db });
console.log("✅ Database schema validated successfully\n");
console.log(
`Validating SQL functions${validateContent ? " (with content validation)" : ""}...`,
);
await validateSqlFunctions({ db, validateContent });
console.log("✅ SQL functions validated successfully\n");
console.log("✅ All validations passed!");
process.exit(0);
} catch (error) {
console.error("❌ Validation failed:", error);
process.exit(1);
}

View File

@@ -4,9 +4,9 @@
"type": "module",
"private": true,
"scripts": {
"setup": "tsx setup.js",
"setup-test": "tsx setup-test.ts",
"replicate": "bun run replicate.ts"
"setup": "tsx setup/setup.js",
"setup-test": "tsx setup/setup-test.ts",
"replicate": "bun run db/replicate.ts"
},
"dependencies": {
"@autumn/shared": "workspace:*",

View File

@@ -1,35 +1,48 @@
#!/usr/bin/env node
import chalk from "chalk";
import inquirer from "inquirer";
import { createTestOrg, TEST_ORG_CONFIG } from "./setupTestUtils/createTestOrg.js";
import {
createTestOrg,
TEST_ORG_CONFIG,
} from "../setupTestUtils/createTestOrg.js";
import {
updateMultipleEnvVars,
updateSingleEnvVar,
} from "../setupTestUtils/incrementalEnvUpdate.js";
import {
setupStripeTestKey,
setupTunnelUrl,
setupUpstash,
} from "./setupTestUtils/setupPrompts.js";
import { updateEnvFile } from "./setupTestUtils/updateEnvFile.js";
import {
updateSingleEnvVar,
updateMultipleEnvVars,
} from "./setupTestUtils/incrementalEnvUpdate.js";
} from "../setupTestUtils/setupPrompts.js";
import { updateEnvFile } from "../setupTestUtils/updateEnvFile.js";
async function showPreparationChecklist() {
console.log(
chalk.magentaBright("\n================ Autumn Test Setup ================\n"),
chalk.magentaBright(
"\n================ Autumn Test Setup ================\n",
),
);
console.log(
chalk.cyan("This script will set up a test organization for development.\n"),
chalk.cyan(
"This script will set up a test organization for development.\n",
),
);
console.log(
chalk.yellowBright("Before you begin, please have the following ready:\n"),
);
console.log(chalk.yellowBright("Before you begin, please have the following ready:\n"));
console.log(chalk.whiteBright("1. Stripe Test API Key (sk_test_...)"));
console.log(
chalk.gray(" → Used to link Stripe to your test account for payment processing\n"),
chalk.gray(
" → Used to link Stripe to your test account for payment processing\n",
),
);
console.log(chalk.whiteBright("2. Upstash Redis REST URL and Token"));
console.log(
chalk.gray(" → Used for caching customer objects and testing race conditions\n"),
chalk.gray(
" → Used for caching customer objects and testing race conditions\n",
),
);
console.log(chalk.whiteBright("3. Tunnel URL (e.g., ngrok URL)"));
@@ -50,7 +63,11 @@ async function showPreparationChecklist() {
]);
if (!ready) {
console.log(chalk.yellow("\nSetup cancelled. Run the script again when you're ready!\n"));
console.log(
chalk.yellow(
"\nSetup cancelled. Run the script again when you're ready!\n",
),
);
process.exit(0);
}
}
@@ -61,7 +78,7 @@ async function main() {
try {
// Import db from server
const { db } = await import("../server/src/db/initDrizzle.js");
const { db } = await import("@server/db/initDrizzle.js");
// Step 1: Create test organization in database and get API key
const autumnSecretKey = await createTestOrg({ db });

View File

@@ -12,7 +12,7 @@ const __dirname = dirname(__filename);
* Find server/.env file robustly - works whether running from root or scripts dir
*/
function findEnvPath(): string {
// Try from script directory (scripts/setup-test.ts -> ../server/.env)
// Try from setupTestUtils directory (scripts/setupTestUtils/updateEnvFile.ts -> ../../server/.env)
const fromScriptDir = resolve(__dirname, "../../server/.env");
if (existsSync(fromScriptDir)) {
return fromScriptDir;

View File

@@ -1,236 +0,0 @@
import { exec, spawn } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import readline from "node:readline";
import { promisify } from "node:util";
const execAsync = promisify(exec);
const VITE_PORT = 3000;
const SERVER_PORT = 8080;
/**
* Read environment variable from .env file
*/
function getEnvVariable(filePath, key) {
if (!fs.existsSync(filePath)) {
return null;
}
const content = fs.readFileSync(filePath, "utf-8");
const lines = content.split("\n");
for (const line of lines) {
const trimmed = line.trim();
if (trimmed && !trimmed.startsWith("#")) {
const [envKey, ...valueParts] = trimmed.split("=");
if (envKey && envKey.trim() === key) {
return valueParts.join("=");
}
}
}
return null;
}
/**
* Get the process info using a specific port
*/
async function getProcessOnPort(port) {
try {
const { stdout } = await execAsync(`lsof -ti:${port}`);
const pid = stdout.trim();
if (pid) {
const { stdout: psOut } = await execAsync(`ps -p ${pid} -o comm=`);
const processName = psOut.trim();
return { pid: parseInt(pid), processName };
}
return null;
} catch {
return null;
}
}
/**
* Kill a process by PID
*/
async function killProcess(pid) {
try {
await execAsync(`kill -9 ${pid}`);
return true;
} catch {
return false;
}
}
/**
* Prompt user for confirmation
*/
function promptUser(question) {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise((resolve) => {
rl.question(question, (answer) => {
rl.close();
resolve(answer.toLowerCase() === "y" || answer.toLowerCase() === "yes");
});
});
}
/**
* Check and kill processes on ports 3000 and 8080
*/
async function handlePorts() {
const viteProcess = await getProcessOnPort(VITE_PORT);
const serverProcess = await getProcessOnPort(SERVER_PORT);
const processesToKill = [];
if (viteProcess) processesToKill.push({ port: VITE_PORT, ...viteProcess });
if (serverProcess)
processesToKill.push({ port: SERVER_PORT, ...serverProcess });
if (processesToKill.length === 0) {
console.log("✅ Ports 3000 and 8080 are available\n");
return;
}
console.log("\n⚠ Found processes on required ports:");
for (const proc of processesToKill) {
console.log(` Port ${proc.port}: ${proc.processName} (PID: ${proc.pid})`);
}
const shouldKill = await promptUser(
"\nKill these processes and continue? (y/n): ",
);
if (!shouldKill) {
console.log("❌ Aborted by user");
process.exit(0);
}
console.log("\n🔨 Killing processes...");
for (const proc of processesToKill) {
const killed = await killProcess(proc.pid);
if (killed) {
console.log(` ✅ Killed process ${proc.pid} on port ${proc.port}`);
} else {
console.log(
` ❌ Failed to kill process ${proc.pid} on port ${proc.port}`,
);
}
}
// Wait for ports to be released
await new Promise((r) => setTimeout(r, 500));
console.log("");
}
async function startDev() {
try {
// Check if using remote backend (api.useautumn.com)
const rootDir = path.dirname(new URL(import.meta.url).pathname);
const projectRoot = path.join(rootDir, "..");
const viteEnvPath = path.join(projectRoot, "vite", ".env");
const backendUrl =
process.env.VITE_BACKEND_URL ||
getEnvVariable(viteEnvPath, "VITE_BACKEND_URL");
const isUsingRemoteBackend = backendUrl?.includes("api.useautumn.com");
if (isUsingRemoteBackend) {
console.log("\n🌐 Using remote backend (api.useautumn.com)");
console.log("⏭️ Skipping port cleanup...\n");
} else {
// Port cleanup disabled (detection is unreliable)
console.log("⏭️ Skipping port cleanup...\n");
// await handlePorts();
}
// Step 1: Build shared package first (initial build)
console.log("\n📦 Building shared package...\n");
const buildShared = spawn("bun", ["run", "build"], {
cwd: "shared",
stdio: "inherit",
shell: true,
});
await new Promise((resolve, reject) => {
buildShared.on("close", (code) => {
if (code !== 0) {
reject(new Error(`Shared package build failed with code ${code}`));
} else {
resolve();
}
});
buildShared.on("error", reject);
});
console.log("\n✅ Shared package built successfully!\n");
// Clear Vite cache to prevent dep optimization issues
const viteCachePath = path.join(
projectRoot,
"vite",
"node_modules",
".vite",
);
if (fs.existsSync(viteCachePath)) {
console.log("🧹 Clearing Vite cache...\n");
fs.rmSync(viteCachePath, { recursive: true, force: true });
}
console.log("🚀 Starting development servers in watch mode...\n");
// Step 2: Start server, workers, and vite first (they'll use the built shared package)
const concurrentlyCmd = spawn(
"bunx",
[
"concurrently",
"-n",
"server,workers,vite,shared",
"-c",
"green,yellow,blue,cyan",
`"cd server && SERVER_PORT=${SERVER_PORT} bun dev"`,
`"cd server && bun workers:dev"`,
`"cd vite && VITE_PORT=${VITE_PORT} bun dev"`,
`"cd shared && bun run dev:watch"`,
],
{
stdio: "inherit",
shell: true,
env: {
...process.env,
VITE_PORT: VITE_PORT.toString(),
SERVER_PORT: SERVER_PORT.toString(),
},
},
);
concurrentlyCmd.on("error", (error) => {
console.error("Failed to start development servers:", error);
process.exit(1);
});
concurrentlyCmd.on("exit", (code) => {
if (code !== 0) {
console.error(`Development servers exited with code ${code}`);
}
process.exit(code);
});
// Handle termination signals
process.on("SIGINT", () => {
console.log("\n\n🛑 Shutting down development servers...");
concurrentlyCmd.kill("SIGINT");
});
process.on("SIGTERM", () => {
console.log("\n\n🛑 Shutting down development servers...");
concurrentlyCmd.kill("SIGTERM");
});
} catch (error) {
console.error("Error starting development servers:", error);
process.exit(1);
}
}
startDev();

View File

@@ -2,8 +2,11 @@
import { spawn } from "node:child_process";
import { existsSync, readdirSync, statSync } from "node:fs";
import { join, relative, resolve } from "node:path";
import { loadLocalEnv } from "@server/utils/envUtils.js";
import chalk from "chalk";
loadLocalEnv();
/**
* Recursively finds all test files in a directory
*/
@@ -99,7 +102,7 @@ function detectTestFramework({
// Check first 20 lines for bun:test import
const lines = content.split("\n").slice(0, 20);
const hasBunTest = lines.some(
(line) =>
(line: string) =>
line.includes('from "bun:test"') || line.includes("from 'bun:test'"),
);
return hasBunTest ? "bun" : "mocha";
@@ -267,23 +270,17 @@ async function runTest() {
const frameworkLabel = framework === "bun" ? "Bun" : "Mocha";
console.log(chalk.cyan(`🧪 Running test file with ${frameworkLabel}...\n`));
// Run the test file with the appropriate framework
const child =
framework === "bun"
? spawn("bun", ["test", "--timeout", "0", testFile.relative], {
cwd: serverDir,
stdio: "inherit",
env: { ...process.env, NODE_ENV: "production" },
})
: spawn(
"npx",
["mocha", "--bail", "--timeout", "10000000", testFile.relative],
{
cwd: serverDir,
stdio: "inherit",
env: { ...process.env, NODE_ENV: "production" },
},
);
if (framework !== "bun") {
console.error(chalk.red("❌ Mocha tests are deprecated"));
process.exit(1);
}
// Run the test file with the appropriate framework, wrapped with Infisical
const child = spawn("bun", ["test", "--timeout", "0", testFile.relative], {
cwd: serverDir,
stdio: "inherit",
env: { ...process.env, NODE_ENV: "production" },
});
// Store the process group ID
const pgid = child.pid;
@@ -340,3 +337,25 @@ async function runTest() {
}
runTest();
// framework === "bun"
// ?
// : spawn(
// "infisical",
// [
// "run",
// "--env=dev",
// "--",
// "npx",
// "mocha",
// "--bail",
// "--timeout",
// "10000000",
// testFile.relative,
// ],
// {
// cwd: serverDir,
// stdio: "inherit",
// env: { ...process.env, NODE_ENV: "production" },
// },
// );

View File

@@ -7,17 +7,19 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
SERVER_DIR="$PROJECT_ROOT/server"
# Find bun executable (check common locations)
if command -v bun &> /dev/null; then
BUN_CMD="bun"
elif [ -f "$HOME/.bun/bin/bun" ]; then
BUN_CMD="$HOME/.bun/bin/bun"
elif [ -f "/usr/local/bin/bun" ]; then
BUN_CMD="/usr/local/bin/bun"
else
echo "Error: bun not found. Please install bun or add it to PATH."
exit 1
fi
# # Find bun executable (check common locations)
# if command -v bun &> /dev/null; then
# BUN_CMD="bun"
# elif [ -f "$HOME/.bun/bin/bun" ]; then
# BUN_CMD="$HOME/.bun/bin/bun"
# elif [ -f "/usr/local/bin/bun" ]; then
# BUN_CMD="/usr/local/bin/bun"
# else
# echo "Error: bun not found. Please install bun or add it to PATH."
# exit 1
# fi
BUN_CMD="infisical run --env=dev -- bun"
# Test runner function
BUN_PARALLEL() {

View File

@@ -6,34 +6,19 @@
# Source shared configuration
source "$(dirname "$0")/config.sh"
# Setup if requested
if [[ "$1" == *"setup"* ]]; then
echo "Running test setup..."
BUN_SETUP
fi
# Run tests using TypeScript runner with compact mode
# Adjust --max to control concurrency (default: 6)
# BUN_PARALLEL_COMPACT \
# 'server/tests/balances/track/basic' \
# 'server/tests/balances/track/concurrency' \
# 'server/tests/balances/track/allocated' \
# 'server/tests/balances/track/credit-systems' \
# 'server/tests/balances/track/entity-balances' \
# 'server/tests/balances/track/entity-products' \
# 'server/tests/balances/track/legacy' \
# 'server/tests/balances/check/basic' \
# 'server/tests/balances/check/credit-systems' \
# 'server/tests/balances/check/misc' \
BUN_PARALLEL_COMPACT \
'server/tests/attach/basic' \
'server/tests/attach/entities' \
'server/tests/attach/upgrade' \
'server/tests/attach/downgrade' \
'server/tests/attach/free' \
'server/tests/attach/addOn' \
'server/tests/attach/entities' \
'server/tests/attach/checkout' \
'server/tests/attach/misc' \
--max=6 \
'server/tests/balances/check/basic' \
'server/tests/balances/check/credit-systems' \
'server/tests/balances/check/misc' \
'server/tests/balances/check/prepaid' \
'server/tests/balances/track/basic' \
'server/tests/balances/track/credit-systems' \
'server/tests/balances/track/entity-products' \
'server/tests/balances/track/legacy' \
'server/tests/balances/track/allocated' \
'server/tests/balances/track/entity-balances' \
'server/tests/balances/track/concurrency' \
'server/tests/balances/track/negative' \

View File

@@ -1,28 +1,15 @@
#!/bin/bash
# Core attach tests
# Test Group 2: Migrations, Versions & Others
# Description: Tests for migrations, version updates, and miscellaneous features
# Source shared configuration
source "$(dirname "$0")/config.sh"
# Setup if requested
if [[ "$1" == *"setup"* ]]; then
echo "Running test setup..."
BUN_SETUP
fi
# BUN_PARALLEL_COMPACT \
# 'server/tests/attach/migrations' \
# 'server/tests/attach/others' \
# 'server/tests/attach/newVersion' \
# 'server/tests/attach/upgradeOld' \
# 'server/tests/attach/updateEnts' \
# 'server/tests/advanced/check' \
# 'server/tests/attach/prepaid' \
# 'server/tests/interval/upgrade' \
# 'server/tests/interval/multiSub' \
# --max=6
BUN_PARALLEL_COMPACT \
'server/tests/attach/basic' \
'server/tests/attach/entities' \
'server/tests/attach/upgrade' \
'server/tests/attach/downgrade' \
'server/tests/attach/free' \
'server/tests/attach/addOn' \
'server/tests/attach/entities' \
'server/tests/attach/checkout' \
'server/tests/attach/misc' \
--max=6 \

View File

@@ -1,7 +1,7 @@
#!/bin/bash
# Test Group 3: Continuous Use Tests
# Description: Tests for continuous usage tracking, entities, and roles
# Test Group 3: Migrations, Versions & Others
# Description: Tests for migrations, version updates, and miscellaneous features
# Source shared configuration
source "$(dirname "$0")/config.sh"
@@ -12,10 +12,17 @@ if [[ "$1" == *"setup"* ]]; then
BUN_SETUP
fi
BUN_PARALLEL_COMPACT \
'server/tests/contUse/track' \
'server/tests/contUse/roles' \
'server/tests/contUse/update' \
'server/tests/contUse/entities' \
'server/tests/attach/migrations' \
'server/tests/attach/others' \
'server/tests/attach/newVersion' \
'server/tests/attach/upgradeOld' \
'server/tests/attach/updateEnts' \
'server/tests/advanced/check' \
'server/tests/attach/prepaid' \
'server/tests/interval/upgrade' \
'server/tests/interval/multiSub' \
--max=6

View File

@@ -1,33 +1,14 @@
#!/bin/bash
# Test Group 4: Merged & Core Tests
# Description: Tests for merged subscriptions and core functionality
# Source shared configuration
source "$(dirname "$0")/config.sh"
# Setup if requested
if [[ "$1" == *"setup"* ]]; then
echo "Running test setup..."
BUN_SETUP
fi
BUN_PARALLEL_COMPACT \
'server/tests/merged/separate' \
'server/tests/merged/downgrade' \
'server/tests/merged/add' \
'server/tests/merged/group' \
'server/tests/merged/prepaid' \
'server/tests/merged/upgrade' \
'server/tests/merged/addOn' \
'server/tests/merged/trial' \
'server/tests/core/cancel' \
'server/tests/contUse/roles' \
'server/tests/contUse/update' \
'server/tests/contUse/entities' \
'server/tests/balances/track/paid-allocated' \
'server/tests/balances/set-usage' \
--max=6
# deprecated tests(?)
# 'server/tests/core/multiAttach' \
# 'server/tests/core/multiAttach/multiInvoice' \
# 'server/tests/core/multiAttach/multiUpgrade' \
# 'sever/tests/core/multiAttach/multiReward'
# 'server/tests/contUse/track' \

View File

@@ -1,37 +1,24 @@
#!/bin/bash
# Test Group 5: Advanced Features
# Description: Tests for advanced features like coupons, referrals, usage limits
# Source shared configuration
source "$(dirname "$0")/config.sh"
# Setup if requested
if [[ "$1" == *"setup"* ]]; then
echo "Running test setup..."
BUN_SETUP
fi
# Note: advanced/multiFeature, advanced/rollovers, advanced/customInterval,
# advanced/usageLimit still use Mocha (not migrated yet)
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' \
'server/tests/merged/separate' \
'server/tests/merged/downgrade' \
'server/tests/merged/add' \
'server/tests/merged/group' \
'server/tests/merged/prepaid' \
'server/tests/merged/upgrade' \
'server/tests/merged/addOn' \
'server/tests/merged/trial' \
'server/tests/core/cancel' \
--max=6
# BUN_PARALLEL_COMPACT \
# 'server/tests/advanced/usage'
# 'server/tests/advanced/referrals/paid' \
# deprecated tests(?)
# 'server/tests/core/multiAttach' \
# 'server/tests/core/multiAttach/multiInvoice' \
# 'server/tests/core/multiAttach/multiUpgrade' \
# 'sever/tests/core/multiAttach/multiReward'

View File

@@ -1,22 +1,24 @@
#!/bin/bash
# Test Group 6: Alex Tests
# Description: Integration tests for Alex scenarios
# Source shared configuration
source "$(dirname "$0")/config.sh"
# Setup if requested
if [[ "$1" == *"setup"* ]]; then
echo "Running test setup..."
BUN_SETUP
fi
# These tests still use Mocha - will be migrated later
cd "$SERVER_DIR"
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
npx mocha --parallel --timeout 10000000 \
'tests/alex/01_free.ts' 'tests/alex/02_pro.ts' 'tests/alex/03_premium.ts' \
'tests/alex/04_topups.ts' 'tests/alex/05_cancel.ts' 'tests/alex/06_switch.ts' \
--ignore 'tests/alex/00_setup.ts'
BUN_PARALLEL_COMPACT \
'server/tests/advanced/usage'
# 'server/tests/crud/plan'
# 'server/tests/advanced/referrals/paid' \

22
scripts/testGroups/g7.sh Executable file
View File

@@ -0,0 +1,22 @@
#!/bin/bash
# Test Group 6: Alex Tests
# Description: Integration tests for Alex scenarios
# Source shared configuration
source "$(dirname "$0")/config.sh"
# Setup if requested
if [[ "$1" == *"setup"* ]]; then
echo "Running test setup..."
BUN_SETUP
fi
# These tests still use Mocha - will be migrated later
cd "$SERVER_DIR"
npx mocha --parallel --timeout 10000000 \
'tests/alex/01_free.ts' 'tests/alex/02_pro.ts' 'tests/alex/03_premium.ts' \
'tests/alex/04_topups.ts' 'tests/alex/05_cancel.ts' 'tests/alex/06_switch.ts' \
--ignore 'tests/alex/00_setup.ts'

View File

@@ -1,11 +1,14 @@
#!/usr/bin/env bun
import { readdir } from "node:fs/promises";
import { basename, resolve } from "node:path";
import { loadLocalEnv } from "@server/utils/envUtils.js";
import { spawn } from "bun";
import chalk from "chalk";
import dotenv from "dotenv";
import { readdir } from "fs/promises";
import pLimit from "p-limit";
import { basename, resolve } from "path";
loadLocalEnv();
// Load environment variables from server/.env
dotenv.config({ path: resolve(process.cwd(), "server", ".env") });

View File

@@ -1 +0,0 @@
../../CLAUDE.md

View File

@@ -1,56 +0,0 @@
# ⚡ Autumn Server Benchmarks
Fast, reliable performance testing for Autumn's core operations using dry runs and realistic workloads.
## 🎯 What This Measures
**Customer Operations** - Core customer lifecycle performance
- Customer creation and setup
- Usage tracking (light and heavy workloads)
- Multi-feature billing calculations
- Entitlement checks
- Batch processing
**Product & Billing** - Subscription and pricing workflows
- Free and paid plan signups
- Plan upgrades and downgrades
- Usage-based pricing calculations
- Team plan setups
- Bulk plan changes
## 🚀 Quick Start
```bash
# Run all benchmarks (recommended)
bun run benchmark
# Run specific category
bun run benchmark customer
bun run benchmark attach
# Export results for analysis
bun run benchmark --export
```
## 📊 Understanding Results
- **🥇🥈🥉** Rankings by speed (fastest to slowest)
- **Green** = Fast (< 5ms) | **Yellow** = Medium (5-20ms) | **Red** = Slow (> 20ms)
- **ops/sec** = Operations per second throughput
### Example Output
```
[1] Customer Creation 2.1ms
[2] Usage Tracking (Light) 1.8ms
[3] Free Plan Signup 3.2ms
💡 12/14 operations under 5ms | Average: 3.1ms
```
## 🔧 Technical Details
- **50 iterations** per test with 5 warmup runs
- **Dry runs only** - no database mutations or external API calls
- **Realistic latency simulation** - mimics actual DB/Stripe response times
- **CPU work simulation** - represents complex calculations
Perfect for CI/CD performance monitoring and optimization work!

View File

@@ -1,211 +0,0 @@
import chalk from "chalk";
import {
BenchmarkRunner,
DryRunHelper,
createMockCustomer,
createMockProduct,
} from "./benchmark-utils.js";
// Mock product attachment operations
const mockAttachProduct = async (params: any) => {
const { customer_id, product_id, force_checkout } = params;
// Simulate the attach workflow from existing tests
DryRunHelper.mockDbOperation("getCustomer", { customerId: customer_id });
DryRunHelper.mockDbOperation("getProduct", { productId: product_id });
// Simulate pricing calculations
DryRunHelper.mockComplexCalculation(300); // Price calculation logic
if (force_checkout) {
// Simulate Stripe checkout creation
DryRunHelper.mockStripeOperation("createCheckout", {
customer_id,
product_id,
});
}
// Simulate entitlement updates
DryRunHelper.mockDbOperation("updateEntitlements", {
customer_id,
product_id,
entitlements: ["premium_feature", "advanced_api"],
});
return { success: true, attached: true };
};
const mockUpgradeProduct = async (params: any) => {
const { customer_id, from_product_id, to_product_id } = params;
// Simulate upgrade workflow
DryRunHelper.mockDbOperation("getCurrentProduct", {
customer_id,
from_product_id,
});
DryRunHelper.mockDbOperation("getTargetProduct", { to_product_id });
// Simulate prorated billing calculation (CPU intensive)
DryRunHelper.mockComplexCalculation(800);
// Simulate Stripe subscription update
DryRunHelper.mockStripeOperation("updateSubscription", {
customer_id,
from_product_id,
to_product_id,
});
// Update entitlements
DryRunHelper.mockDbOperation("migrateEntitlements", {
customer_id,
from_product_id,
to_product_id,
});
return { success: true, upgraded: true };
};
const mockDowngradeProduct = async (params: any) => {
const { customer_id, from_product_id, to_product_id } = params;
// Similar to upgrade but with different calculations
DryRunHelper.mockDbOperation("getCurrentProduct", {
customer_id,
from_product_id,
});
DryRunHelper.mockDbOperation("getTargetProduct", { to_product_id });
// Downgrade calculations (typically simpler)
DryRunHelper.mockComplexCalculation(400);
// Stripe operations
DryRunHelper.mockStripeOperation("updateSubscription", {
customer_id,
from_product_id,
to_product_id,
});
// Handle feature restrictions
DryRunHelper.mockDbOperation("restrictEntitlements", {
customer_id,
restricted_features: ["premium_feature"],
});
return { success: true, downgraded: true };
};
const mockCalculatePricing = async (params: any) => {
const { product_id, customer_id, usage_data } = params;
// Simulate complex pricing calculation
DryRunHelper.mockDbOperation("getProductPricing", { product_id });
DryRunHelper.mockDbOperation("getCustomerUsage", { customer_id });
// CPU-intensive pricing calculations
DryRunHelper.mockComplexCalculation(600);
// Simulate tier-based pricing logic
const tiers = usage_data?.tiers || [100, 1000, 10000];
let totalCost = 0;
for (const tier of tiers) {
totalCost += tier * 0.01; // Mock pricing calculation
}
return { totalCost, breakdown: tiers };
};
const mockEntityAttachment = async (params: any) => {
const { customer_id, product_id, entity_id } = params;
// Simulate entity-specific attachment
DryRunHelper.mockDbOperation("getEntity", { entity_id });
DryRunHelper.mockDbOperation("attachToEntity", {
customer_id,
product_id,
entity_id,
});
// Entity-specific calculations
DryRunHelper.mockComplexCalculation(200);
return { success: true, entity_attached: true };
};
export const runAttachBenchmarks = async () => {
const runner = new BenchmarkRunner({
iterations: 50,
warmupIterations: 5,
});
console.log(chalk.cyan("🔗 Product & Billing Operations"));
console.log(chalk.gray("Measuring subscription and pricing workflows\n"));
// Real-world product operations
await runner.run("Free Plan Signup", async () => {
await mockAttachProduct({
customer_id: "new_customer_123",
product_id: "starter_free",
force_checkout: false,
});
});
await runner.run("Paid Plan Subscription", async () => {
await mockAttachProduct({
customer_id: "converting_customer_456",
product_id: "pro_monthly",
force_checkout: true,
});
});
await runner.run("Plan Upgrade (Basic → Pro)", async () => {
await mockUpgradeProduct({
customer_id: "existing_customer_789",
from_product_id: "basic_monthly",
to_product_id: "pro_monthly",
});
});
await runner.run("Plan Downgrade (Pro → Basic)", async () => {
await mockDowngradeProduct({
customer_id: "downgrading_customer_321",
from_product_id: "pro_monthly",
to_product_id: "basic_monthly",
});
});
await runner.run("Usage-Based Pricing Calc", async () => {
await mockCalculatePricing({
product_id: "usage_tier_product",
customer_id: "heavy_user_654",
usage_data: {
tiers: [1000, 5000, 25000, 100000],
features: ["api_requests", "storage_gb", "compute_hours"],
},
});
});
await runner.run("Team Plan Setup", async () => {
await mockEntityAttachment({
customer_id: "team_lead_987",
product_id: "team_plan",
entity_id: "team_acme_corp",
});
});
await runner.run("Bulk Plan Changes (5 customers)", async () => {
const promises: Promise<any>[] = [];
for (let i = 0; i < 5; i++) {
promises.push(
mockAttachProduct({
customer_id: `bulk_customer_${i}`,
product_id: "standard_plan",
force_checkout: false,
})
);
}
await Promise.all(promises);
});
runner.printSummary();
return runner.getResults();
};

View File

@@ -1,216 +0,0 @@
import chalk from "chalk";
export interface BenchmarkResult {
name: string;
iterations: number;
totalTime: number;
averageTime: number;
minTime: number;
maxTime: number;
standardDeviation: number;
operationsPerSecond: number;
}
export interface BenchmarkOptions {
iterations?: number;
warmupIterations?: number;
dryRun?: boolean;
verbose?: boolean;
}
export class BenchmarkRunner {
private results: BenchmarkResult[] = [];
constructor(private options: BenchmarkOptions = {}) {
this.options = {
iterations: 100,
warmupIterations: 10,
dryRun: true,
verbose: false,
...options,
};
}
async run(name: string, operation: () => Promise<any> | any): Promise<BenchmarkResult> {
const { iterations = 100, warmupIterations = 10, verbose } = this.options;
// Warmup phase (silent)
for (let i = 0; i < warmupIterations; i++) {
await operation();
}
// Actual benchmark with progress
const times: number[] = [];
const startTime = Date.now();
for (let i = 0; i < iterations; i++) {
const start = performance.now();
await operation();
const end = performance.now();
times.push(end - start);
}
const result = this.calculateStats(name, times, iterations);
this.results.push(result);
// Show immediate result with progress indicator
const progress = `${this.results.length}`.padStart(2, ' ');
const avgColor = result.averageTime < 5 ? chalk.green : result.averageTime < 20 ? chalk.yellow : chalk.red;
console.log(`${chalk.gray(`[${progress}]`)} ${chalk.cyan(name.padEnd(35))} ${avgColor(`${result.averageTime.toFixed(2)}ms`)}`);
return result;
}
private calculateStats(name: string, times: number[], iterations: number): BenchmarkResult {
const totalTime = times.reduce((sum, time) => sum + time, 0);
const averageTime = totalTime / iterations;
const minTime = Math.min(...times);
const maxTime = Math.max(...times);
// Calculate standard deviation
const variance = times.reduce((sum, time) => sum + Math.pow(time - averageTime, 2), 0) / iterations;
const standardDeviation = Math.sqrt(variance);
const operationsPerSecond = 1000 / averageTime;
return {
name,
iterations,
totalTime,
averageTime,
minTime,
maxTime,
standardDeviation,
operationsPerSecond,
};
}
private printResult(result: BenchmarkResult) {
const { name, averageTime, minTime, maxTime, operationsPerSecond } = result;
// Color code performance: green for fast, yellow for medium, red for slow
const avgColor = averageTime < 5 ? chalk.green : averageTime < 20 ? chalk.yellow : chalk.red;
const opsColor = operationsPerSecond > 200 ? chalk.green : operationsPerSecond > 50 ? chalk.yellow : chalk.red;
console.log(chalk.cyan(`\n📊 ${name}`));
console.log(` ${avgColor(`${averageTime.toFixed(2)}ms avg`)} | ${chalk.gray(`${minTime.toFixed(2)}-${maxTime.toFixed(2)}ms range`)} | ${opsColor(`${operationsPerSecond.toFixed(0)} ops/sec`)}`);
}
printSummary() {
if (this.results.length === 0) return;
console.log(chalk.yellow('\n🏁 Performance Summary'));
console.log(chalk.yellow('─'.repeat(50)));
// Sort results by average time for better readability
const sortedResults = [...this.results].sort((a, b) => a.averageTime - b.averageTime);
sortedResults.forEach((result, index) => {
const medal = index === 0 ? '🥇' : index === 1 ? '🥈' : index === 2 ? '🥉' : ' ';
const { name, averageTime, operationsPerSecond } = result;
const avgColor = averageTime < 5 ? chalk.green : averageTime < 20 ? chalk.yellow : chalk.red;
console.log(`${medal} ${chalk.cyan(name.padEnd(30))} ${avgColor(`${averageTime.toFixed(2)}ms`)} ${chalk.gray(`(${operationsPerSecond.toFixed(0)} ops/sec)`)}`);
});
// Performance insights
const totalTests = this.results.length;
const avgPerformance = this.results.reduce((sum, r) => sum + r.averageTime, 0) / totalTests;
const fastTests = this.results.filter(r => r.averageTime < 5).length;
console.log(chalk.gray(`\n💡 ${fastTests}/${totalTests} operations under 5ms | Average: ${avgPerformance.toFixed(2)}ms`));
}
getResults(): BenchmarkResult[] {
return [...this.results];
}
exportResults(filename?: string): string {
const data = {
timestamp: new Date().toISOString(),
options: this.options,
results: this.results,
};
const json = JSON.stringify(data, null, 2);
if (filename) {
// In a real implementation, you'd write to file here
console.log(chalk.blue(`📄 Results would be exported to: ${filename}`));
}
return json;
}
}
// Dry run helpers
export class DryRunHelper {
private static mockDatabase = new Map();
private static mockStripe = {
customers: { create: () => ({ id: 'cus_mock' }) },
prices: { create: () => ({ id: 'price_mock' }) },
products: { create: () => ({ id: 'prod_mock' }) },
};
static mockDbOperation<T>(operation: string, data?: any): T {
// Simulate database latency
const latency = Math.random() * 5; // 0-5ms
const start = performance.now();
while (performance.now() - start < latency) {
// Busy wait to simulate actual work
}
// Store/retrieve mock data
if (data) {
this.mockDatabase.set(operation, data);
}
return this.mockDatabase.get(operation) || { id: 'mock_id', ...data };
}
static mockStripeOperation<T>(operation: string, data?: any): T {
// Simulate Stripe API latency (higher than DB)
const latency = Math.random() * 50 + 10; // 10-60ms
const start = performance.now();
while (performance.now() - start < latency) {
// Busy wait to simulate network call
}
return { id: `stripe_mock_${Date.now()}`, ...data } as T;
}
static mockComplexCalculation(iterations: number = 1000): number {
// Simulate CPU-intensive calculation
let result = 0;
for (let i = 0; i < iterations; i++) {
result += Math.sqrt(i) * Math.sin(i);
}
return result;
}
}
// Utility to create mock data similar to test fixtures
export const createMockCustomer = (customerId: string) => ({
id: customerId,
internal_id: `internal_${customerId}`,
email: `${customerId}@example.com`,
created_at: new Date(),
balance: 1000,
entities: [],
});
export const createMockProduct = (productId: string) => ({
id: productId,
name: `Product ${productId}`,
type: 'subscription',
prices: [],
entitlements: [],
});
export const createMockEvent = (customerId: string, featureId: string, usage: number = 1) => ({
customer_id: customerId,
feature_id: featureId,
usage,
properties: {},
timestamp: new Date(),
});

View File

@@ -1,151 +0,0 @@
import chalk from 'chalk';
import {
BenchmarkRunner,
DryRunHelper,
createMockCustomer,
createMockEvent
} from './benchmark-utils.js';
// Mock the heavy imports to avoid actual database connections
const mockPerformDeductionOnCusEnt = (params: any) => {
// Simulate the complex calculation logic from updateBalanceTask.ts
DryRunHelper.mockComplexCalculation(500); // CPU work
const { cusEnt, toDeduct } = params;
const currentBalance = cusEnt.balance || 1000;
const newBalance = Math.max(0, currentBalance - toDeduct);
return {
newBalance,
newEntities: cusEnt.entities || [],
deducted: Math.min(toDeduct, currentBalance),
};
};
const mockUpdateCustomerBalance = async (params: any) => {
const { customerId, features, event } = params;
// Simulate database fetch (based on updateBalanceTask.ts timing)
DryRunHelper.mockDbOperation('getCustomer', { customerId });
DryRunHelper.mockDbOperation('getCusEnts', { features });
// Simulate the balance calculation logic
const featureDeductions = features.map((feature: any) => ({
feature,
deduction: event.usage || 1,
}));
// Simulate the deduction process for each feature
for (const { feature, deduction } of featureDeductions) {
const cusEnt = { balance: 1000, entities: [] };
mockPerformDeductionOnCusEnt({
cusEnt,
toDeduct: deduction,
entityId: event.entity_id,
});
}
return { success: true };
};
const mockInitCustomer = async (customerId: string) => {
// Simulate customer initialization process
DryRunHelper.mockDbOperation('createCustomer', createMockCustomer(customerId));
DryRunHelper.mockStripeOperation('createStripeCustomer', { id: `cus_${customerId}` });
// Simulate setting up default entitlements
DryRunHelper.mockDbOperation('createEntitlements', {
customerId,
entitlements: ['free_tier'],
});
return { customerId, initialized: true };
};
const mockEntitlementCheck = async (customerId: string, featureId: string) => {
// Simulate the entitled check logic
DryRunHelper.mockDbOperation('getEntitlements', { customerId, featureId });
// Simulate complex entitlement calculation
DryRunHelper.mockComplexCalculation(100);
return {
allowed: true,
balances: [{ feature_id: featureId, balance: 950, unlimited: false }],
};
};
export const runCustomerBenchmarks = async () => {
const runner = new BenchmarkRunner({
iterations: 50,
warmupIterations: 5,
});
console.log(chalk.cyan('🧑‍💼 Customer Operations Benchmark'));
console.log(chalk.gray('Measuring core customer lifecycle operations\n'));
// Core customer operations in realistic scenarios
await runner.run('Customer Creation', async () => {
const customerId = `cust_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`;
await mockInitCustomer(customerId);
});
await runner.run('Usage Tracking (Light)', async () => {
const event = createMockEvent('customer_123', 'api_calls', 5);
await mockUpdateCustomerBalance({
customerId: 'customer_123',
features: [{ id: 'api_calls', internal_id: 'api_internal' }],
event,
org: { slug: 'prod-org', config: { reverse_deduction_order: false } },
env: 'production',
});
});
await runner.run('Usage Tracking (Heavy)', async () => {
const event = createMockEvent('customer_456', 'compute_hours', 100);
await mockUpdateCustomerBalance({
customerId: 'customer_456',
features: [{ id: 'compute_hours', internal_id: 'compute_internal' }],
event,
org: { slug: 'prod-org', config: { reverse_deduction_order: false } },
env: 'production',
});
});
await runner.run('Multi-Feature Deduction', async () => {
const event = createMockEvent('customer_789', 'api_calls', 25);
await mockUpdateCustomerBalance({
customerId: 'customer_789',
features: [
{ id: 'api_calls', internal_id: 'api_internal' },
{ id: 'storage_gb', internal_id: 'storage_internal' },
{ id: 'bandwidth_gb', internal_id: 'bandwidth_internal' },
],
event,
org: { slug: 'prod-org', config: { reverse_deduction_order: false } },
env: 'production',
});
});
await runner.run('Entitlement Check', async () => {
await mockEntitlementCheck('customer_premium', 'advanced_analytics');
});
await runner.run('Batch Processing (10 customers)', async () => {
const promises: Promise<any>[] = [];
for (let i = 0; i < 10; i++) {
const event = createMockEvent(`batch_cust_${i}`, 'api_calls', 2);
promises.push(mockUpdateCustomerBalance({
customerId: `batch_cust_${i}`,
features: [{ id: 'api_calls', internal_id: 'api_internal' }],
event,
org: { slug: 'prod-org', config: { reverse_deduction_order: false } },
env: 'production',
}));
}
await Promise.all(promises);
});
runner.printSummary();
return runner.getResults();
};

View File

@@ -1,115 +0,0 @@
#!/usr/bin/env tsx
import chalk from 'chalk';
import { runCustomerBenchmarks } from './customer-benchmarks.js';
import { runAttachBenchmarks } from './attach-benchmarks.js';
interface BenchmarkSuite {
name: string;
runner: () => Promise<any>;
enabled: boolean;
}
const BENCHMARK_SUITES: BenchmarkSuite[] = [
{
name: 'Customer Operations',
runner: runCustomerBenchmarks,
enabled: true,
},
{
name: 'Product Attachments',
runner: runAttachBenchmarks,
enabled: true,
},
];
async function main() {
const args = process.argv.slice(2);
const suiteFilter = args[0];
console.log(chalk.cyan('⚡ Autumn Server Performance Benchmarks'));
console.log(chalk.gray('Dry-run performance testing with realistic workloads'));
console.log(chalk.gray(`${new Date().toLocaleString()} | Node ${process.version} | ${process.platform}\n`));
const startTime = performance.now();
const allResults: any[] = [];
// Filter suites if specified
const suitesToRun = suiteFilter
? BENCHMARK_SUITES.filter(suite =>
suite.name.toLowerCase().includes(suiteFilter.toLowerCase()) ||
suite.name.toLowerCase().replace(/\s+/g, '').includes(suiteFilter.toLowerCase())
)
: BENCHMARK_SUITES.filter(suite => suite.enabled);
if (suitesToRun.length === 0) {
console.log(chalk.red(`❌ No benchmark suites found matching: ${suiteFilter}`));
console.log(chalk.yellow('\nAvailable suites:'));
BENCHMARK_SUITES.forEach(suite => {
console.log(chalk.yellow(`${suite.name.toLowerCase().replace(/\s+/g, '')}`));
});
process.exit(1);
}
// Run each benchmark suite
for (const suite of suitesToRun) {
try {
const suiteStartTime = performance.now();
const results = await suite.runner();
const suiteEndTime = performance.now();
allResults.push({
suite: suite.name,
results,
duration: suiteEndTime - suiteStartTime,
});
} catch (error) {
console.error(chalk.red(`❌ Error in ${suite.name}:`), error);
}
}
const endTime = performance.now();
const totalDuration = endTime - startTime;
// Print concise overall summary
console.log(chalk.yellow('\n🎯 Overall Results'));
console.log(chalk.yellow('─'.repeat(40)));
let totalBenchmarks = 0;
let fastOperations = 0;
allResults.forEach(suiteResult => {
totalBenchmarks += suiteResult.results.length;
fastOperations += suiteResult.results.filter((r: any) => r.averageTime < 5).length;
const avgTime = suiteResult.results.reduce((sum: number, r: any) => sum + r.averageTime, 0) / suiteResult.results.length;
const timeColor = avgTime < 5 ? chalk.green : avgTime < 20 ? chalk.yellow : chalk.red;
console.log(`${chalk.cyan(suiteResult.suite.padEnd(25))} ${timeColor(`${avgTime.toFixed(1)}ms avg`)} ${chalk.gray(`(${suiteResult.results.length} tests)`)}`);
});
console.log(chalk.gray(`\n💡 ${fastOperations}/${totalBenchmarks} operations under 5ms | Total time: ${totalDuration.toFixed(0)}ms`));
// Export results if requested
if (args.includes('--export') || args.includes('-e')) {
const exportData = {
timestamp: new Date().toISOString(),
platform: { node: process.version, platform: process.platform, arch: process.arch },
totalDuration,
suites: allResults,
};
console.log(chalk.blue(`\n📄 Results exported (${JSON.stringify(exportData).length} bytes)`));
}
console.log(chalk.green('\n✅ Benchmark completed successfully!'));
}
// Handle CLI execution - always run when this file is executed directly
main().catch(error => {
console.error(chalk.red('❌ Benchmark execution failed:'), error);
process.exit(1);
});
export { main as runAllBenchmarks };

View File

@@ -1,42 +0,0 @@
import dotenv from "dotenv";
dotenv.config();
import { AppEnv } from "@autumn/shared";
import { initDrizzle } from "@/db/initDrizzle.js";
import { FeatureService } from "@/internal/features/FeatureService.js";
import { OrgService } from "@/internal/orgs/OrgService.js";
import { StripeAccountService } from "@/internal/stripe/StripeAccountService.js";
const orgSlug = process.env.TESTS_ORG || "test-debug|org_2sWv2S8LJ9iaTjLI6UtNsfL88Kt";
async function main() {
const { db, client } = initDrizzle();
const org = await OrgService.getBySlug({ db, slug: orgSlug });
console.log("\n=== ORG ===");
console.log("ID:", org.id);
console.log("Slug:", org.slug);
console.log("Name:", org.name);
console.log("\n=== STRIPE CONFIG ===");
console.log("test_stripe_connect:", org.test_stripe_connect);
console.log("live_stripe_connect:", org.live_stripe_connect);
const features = await FeatureService.list({
db,
orgId: org.id,
env: AppEnv.Sandbox,
});
console.log("\n=== FEATURES ===");
console.log("Total features:", features.length);
for (const feature of features) {
console.log(`- ${feature.id} (${feature.type})`, feature.usage_type ? `usage_type: ${feature.usage_type}` : '');
if (feature.id === 'messages') {
console.log("\n Full messages feature:", JSON.stringify(feature, null, 2));
}
}
await client.end();
}
main();

View File

@@ -1,257 +0,0 @@
// @ts-nocheck
import { customers, } from "@autumn/shared";
const entitiesCTE = cte({
name: 'entities',
from: entities,
where: eq(entities.internal_customer_id, customer.internal_id),
limit: 100,
})
const cusProductsCTE = cte({
name: 'customer_products',
from: customer_products,
with: {
product: cte({
from: products,
where: eq(products.internal_id, customer_products.internal_product_id),
}),
customer_prices: cte({
from: customer_prices,
where: eq(customer_prices.customer_product_id, customer_products.id),
with: {
price: join({
from: prices,
where: eq(prices.id, customer_prices.price_id),
})
}
}),
free_trial: cte({
from: free_trials,
where: eq(free_trials.id, customer_products.free_trial_id),
})
},
// where: eq(customer_products.internal_customer_id, customer.internal_id),
limit: 100,
})
const fullCustomerCTE = cte({
name: 'full_customer',
from: customers, // drizzle table
with: {
entities: entitiesCTE(),
customer_products: cusProductsCTE(),
organization: organizationsCTE(), // this is not an array, but buildCTE should be dynamic enough to handle this?
}
});
await fullCustomerCTE.execute();
const childProduct = {
product_id: "user_seat",
entity: {
type: "user",
enable_on_creation: true,
},
price: {
tiers: [
{
amount: 500,
to: 3,
},
{
amount: 400,
to: -1,
}
]
}
}
// Relationship between parent product / child product (?)
const product = {
id: "pro_plan",
name: "Pro Plan",
group: null,
version: 1,
add_on: false, // combine add on / default?
default: false,
price: {
amount: 20,
interval: "month",
},
features: [],
entity_products: []
}
// Product Feature
const productFeature = {
feature_id: "messages",
reset_interval: "month",
included: 500,
price: {
tiers: [
{
amount: 1,
to: 1000,
},
{
amount: 0.5,
to: -1,
}
],
interval: "month",
billing_units: 10,
usage_model: "usage",
proration: {
on_increase: "",
on_decrease: "prorate_immediately",
}
},
reset_usage_when_enabled: true,
rollover: {
max: 500,
duration: "month",
duration_count: 1, // only if duration is month
}
}
// To think about: prepaid vs pay-per-use?
// Entity Products
const entityProduct = {
product_id: "user_seat",
price: {
amount: 14,
interval: "month",
},
}
// Checkout / Buy (prepay for entity products)
// @ts-ignore
await autumn.checkout({
product_id: "pro_plan",
customer_id: "cus_123",
quantities: [
{
entity_product_id: "user_seat",
quantity: 4,
}
]
})
// Sweep Flow
// Create entity
// @ts-ignore
await autumn.entities.create({
id: "entity_123",
name: "Entity 123",
})
// @ts-ignore
await autumn.attach({
id: "entity_123",
product_id: "user_seat",
quantity: 4,
})
// Checkout / Buy (prepay for features)
// @ts-ignore
await autumn.checkout({
product_id: "pro_plan",
customer_id: "cus_123",
quantities: [
{
feature_id: "messages",
quantity: 3500,
}
]
})
// Customer
const customer = {
id: "cus_123",
name: "Apple Inc.",
products: [
{
product_id: "team_plan",
status: "active",
current_period_start: 1717852800,
current_period_end: 1720531200,
feature_quantities: [
{
feature_id: "messages",
quantity: 3500,
}
],
product_quantities: [
{
entity_product_id: "user_seat",
quantity: 4,
used: 2,
},
{
entity_product_id: "dev_seat",
quantity: 8,
used: 2,
}
]
}
],
features: {
messages: {
feature_id: "messages",
starting_balance: 3500,
balance: 2500,
used: 1000,
limit: 1000,
next_reset_at: 1720531200,
interval: "month",
interval_count: 1,
},
}
}
// Full customer feature response
const customerFeature = {
feature_id: "messages",
starting_balance: 3500,
balance: 2500,
used: 1000,
limit: 1000, // or null
next_reset_at: 1720531200,
interval: "month",
interval_count: 1,
// expand parameter
breakdown: [
{
interval: "month",
interval_count: 1,
starting_balance: 400,
balance: 350,
used: 50,
limit: 400,
next_reset_at: 1720531200,
}
],
// expand parameter
rollovers: [
{
balance: 50,
expires_at: 1720531200,
}
]
}

View File

@@ -0,0 +1,122 @@
import { ApiVersion } from "@autumn/shared";
import AutumnError, { AutumnInt } from "../src/external/autumn/autumnCli";
export const main = async () => {
console.log("🚀 Starting rate limit test...\n");
// Initialize client
const autumn = new AutumnInt({version: ApiVersion.V1_2});
const numRequests = 100;
const customerId1 = "test1";
const customerId2 = "test2";
console.log(`Testing with ${numRequests} concurrent requests to /track endpoint`);
console.log(`Using base URL: ${autumn.baseUrl}\n`);
// Create all track requests
const cusId1Promises = [];
const cusId2Promises = [];
for (let i = 0; i < numRequests; i++) {
cusId1Promises.push(
autumn.customers.get(customerId1),
);
cusId2Promises.push(
autumn.customers.get(customerId2),
);
}
// Execute all requests concurrently for both customers
const startTime = Date.now();
const [cusId1Results, cusId2Results] = await Promise.all([
Promise.allSettled(cusId1Promises),
Promise.allSettled(cusId2Promises),
]);
const duration = Date.now() - startTime;
// Helper function to analyze results
const analyzeResults = (results: PromiseSettledResult<unknown>[]) => {
const succeeded = results.filter((r) => r.status === "fulfilled").length;
const rateLimited = results.filter(
(r) =>
r.status === "rejected" &&
r.reason instanceof AutumnError &&
r.reason.code === "rate_limit_exceeded",
).length;
const otherErrors = results.filter(
(r) =>
r.status === "rejected" &&
!(
r.reason instanceof AutumnError &&
r.reason.code === "rate_limit_exceeded"
),
).length;
return { succeeded, rateLimited, otherErrors };
};
const cus1Stats = analyzeResults(cusId1Results);
const cus2Stats = analyzeResults(cusId2Results);
const totalRequests = numRequests * 2;
// Display results
console.log("📊 Results:");
console.log("═".repeat(60));
console.log(`Total requests: ${totalRequests} (${numRequests} per customer)`);
console.log(`⏱️ Duration: ${duration}ms`);
console.log(`📈 Throughput: ${Math.round(totalRequests / (duration / 1000))} req/s`);
console.log("═".repeat(60));
console.log(`\n👤 Customer 1 (${customerId1}):`);
console.log("─".repeat(60));
console.log(` Total: ${numRequests}`);
console.log(` ✅ Succeeded: ${cus1Stats.succeeded}`);
console.log(` ⛔ Rate limited: ${cus1Stats.rateLimited}`);
console.log(` ❌ Other errors: ${cus1Stats.otherErrors}`);
console.log(`\n👤 Customer 2 (${customerId2}):`);
console.log("─".repeat(60));
console.log(` Total: ${numRequests}`);
console.log(` ✅ Succeeded: ${cus2Stats.succeeded}`);
console.log(` ⛔ Rate limited: ${cus2Stats.rateLimited}`);
console.log(` ❌ Other errors: ${cus2Stats.otherErrors}`);
console.log("\n📈 Combined Stats:");
console.log("─".repeat(60));
console.log(
` ✅ Total succeeded: ${cus1Stats.succeeded + cus2Stats.succeeded}`,
);
console.log(
` ⛔ Total rate limited: ${cus1Stats.rateLimited + cus2Stats.rateLimited}`,
);
console.log(
` ❌ Total errors: ${cus1Stats.otherErrors + cus2Stats.otherErrors}`,
);
// Show sample errors if any
const totalErrors = cus1Stats.otherErrors + cus2Stats.otherErrors;
if (totalErrors > 0) {
console.log("\n⚠ Sample of other errors:");
const errorSamples = [...cusId1Results, ...cusId2Results]
.filter(
(r) =>
r.status === "rejected" &&
!(
r.reason instanceof AutumnError &&
r.reason.code === "rate_limit_exceeded"
),
)
.slice(0, 3);
for (const sample of errorSamples) {
if (sample.status === "rejected") {
console.log(` - ${sample.reason}`);
}
}
}
console.log("\n✨ Test complete!");
};
await main();

View File

@@ -1,69 +0,0 @@
import { AppEnv } from "@autumn/shared";
import { globalBatchingManager } from "../src/internal/balances/track/redisTrackUtils/BatchingManager.js";
import {
buildCachedApiCustomerKey,
getCachedApiCustomer,
} from "../src/internal/customers/cusUtils/apiCusCacheUtils/getCachedApiCustomer.js";
import { initDrizzle } from "../src/db/initDrizzle.js";
import { initScript } from "../src/utils/scriptUtils/scriptUtils.js";
import { redis } from "../src/external/redis/initRedis.js";
const DEDUCTION_COUNT = 100_000;
const DEDUCTION_AMOUNT = 1;
const logCredits = (label: string, customer: Awaited<ReturnType<typeof getCachedApiCustomer>>) => {
const credits = customer?.apiCustomer?.features?.credits;
console.log(`\n${label}`);
console.log(` Total Balance: ${credits?.balance ?? "N/A"}`);
console.log(` Monthly Credits: ${credits?.breakdown?.[0]?.balance ?? "N/A"}`);
console.log(` Lifetime Credits: ${credits?.breakdown?.[1]?.balance ?? "N/A"}`);
};
const main = async () => {
const orgId = "org_2s4vfEyYVgFZDlOwcMHjsHR0eef";
const env = AppEnv.Sandbox;
const customerId = "john";
const { db } = initDrizzle();
const { req } = await initScript({ orgId, env });
await redis.del(buildCachedApiCustomerKey({ customerId, orgId, env }));
const customerBefore = await getCachedApiCustomer({
ctx: req as any,
customerId,
});
logCredits("📊 Credits Before:", customerBefore);
console.log(`\n⏳ Processing ${DEDUCTION_COUNT.toLocaleString()} deductions...`);
const start = Date.now();
const promises = Array.from({ length: DEDUCTION_COUNT }, () =>
globalBatchingManager.deduct({
customerId,
featureDeductions: [{ featureId: "credits", amount: DEDUCTION_AMOUNT }],
orgId,
env,
}),
);
await Promise.all(promises);
const elapsed = Date.now() - start;
const customerAfter = await getCachedApiCustomer({
ctx: req as any,
customerId,
});
logCredits("📊 Credits After:", customerAfter);
const deductionDiff = (customerBefore?.features?.credits?.balance ?? 0) - (customerAfter?.features?.credits?.balance ?? 0);
console.log("\n✅ Test Complete!");
console.log(` Time Elapsed: ${elapsed.toLocaleString()}ms`);
console.log(` Total Deducted: ${deductionDiff}`);
console.log(` Avg per Deduction: ${(elapsed / DEDUCTION_COUNT).toFixed(3)}ms\n`);
};
await main();
process.exit(0);

View File

@@ -1,72 +0,0 @@
import { AppEnv } from "@autumn/shared";
import {
buildCachedApiCustomerKey,
getCachedApiCustomer,
} from "../src/internal/customers/cusUtils/apiCusCacheUtils/getCachedApiCustomer.js";
import { initDrizzle } from "../src/db/initDrizzle.js";
import { initScript } from "../src/utils/scriptUtils/scriptUtils.js";
import { redis } from "../src/external/redis/initRedis.js";
import { AutumnInt } from "../src/external/autumn/autumnCli.js";
const DEDUCTION_COUNT = 15_000;
const DEDUCTION_AMOUNT = 1;
const logCredits = (label: string, customer: Awaited<ReturnType<typeof getCachedApiCustomer>>) => {
const credits = customer?.features?.credits;
console.log(`\n${label}`);
console.log(` Total Balance: ${credits?.balance ?? "N/A"}`);
console.log(` Monthly Credits: ${credits?.breakdown?.[0]?.balance ?? "N/A"}`);
console.log(` Lifetime Credits: ${credits?.breakdown?.[1]?.balance ?? "N/A"}`);
};
const main = async () => {
const orgId = "org_2s4vfEyYVgFZDlOwcMHjsHR0eef";
const env = AppEnv.Sandbox;
const customerId = "john";
const { db } = initDrizzle();
const { req } = await initScript({ orgId, env });
const autumn = new AutumnInt({
secretKey: process.env.JDEV!,
});
await redis.del(buildCachedApiCustomerKey({ customerId, orgId, env }));
const customerBefore = await getCachedApiCustomer({
ctx: req as any,
customerId,
});
logCredits("📊 Credits Before:", customerBefore);
console.log(`\n⏳ Processing ${DEDUCTION_COUNT.toLocaleString()} deductions...`);
const start = Date.now();
const promises = Array.from({ length: DEDUCTION_COUNT }, () =>
autumn.track({
customer_id: customerId,
feature_id: "credits",
value: DEDUCTION_AMOUNT,
}),
);
await Promise.all(promises);
const elapsed = Date.now() - start;
const customerAfter = await getCachedApiCustomer({
ctx: req as any,
customerId,
});
logCredits("📊 Credits After:", customerAfter);
const deductionDiff = (customerBefore?.features?.credits?.balance ?? 0) - (customerAfter?.features?.credits?.balance ?? 0);
console.log("\n✅ Test Complete!");
console.log(` Time Elapsed: ${elapsed.toLocaleString()}ms`);
console.log(` Total Deducted: ${deductionDiff}`);
console.log(` Avg per Deduction: ${(elapsed / DEDUCTION_COUNT).toFixed(3)}ms\n`);
};
await main();
process.exit(0);

View File

@@ -1,41 +0,0 @@
import { globalBatchingManager } from "../src/internal/balances/track/redisTrackUtils/BatchingManager.js";
/**
* Test script to debug the simplified batchDeduction.lua
* This will show you what the Lua script can retrieve about a feature
*/
async function testLuaDebug() {
// Replace these with real values from your test data
const customerId = "your-customer-id";
const orgId = "your-org-id";
const env = "development";
console.log("Testing Lua script with:");
console.log("- Customer ID:", customerId);
console.log("- Org ID:", orgId);
console.log("- Environment:", env);
console.log("");
try {
const result = await globalBatchingManager.deduct({
customerId,
featureDeductions: [
{ featureId: "credits", amount: 10 },
{ featureId: "api_calls", amount: 5 },
],
orgId,
env,
overageBehavior: "cap",
});
console.log("📦 Batching manager result:");
console.log(JSON.stringify(result, null, 2));
} catch (error) {
console.error("❌ Error:", error);
}
process.exit(0);
}
testLuaDebug();

16
server/nodemon.json Normal file
View File

@@ -0,0 +1,16 @@
{
"watch": ["../shared", "src"],
"ext": "js,ts",
"ignore": [
"../shared/dist/**/*.d.ts",
"../shared/node_modules",
"../shared/scripts",
"scripts",
"tests"
],
"exec": "bun src/index.ts",
"env": {
"NODE_ENV": "development"
}
}

View File

@@ -6,23 +6,23 @@
"type": "module",
"scripts": {
"email": "email dev -p 3001",
"start": "bun src/index.ts",
"d": "ENV_FILE=.env infisical run --env=dev -- bun dev",
"p": "ENV_FILE=.env.prod infisical run --env=prod -- bun dev",
"dev": "cross-env NODE_ENV=development bunx nodemon -w ../shared/dist -w src --ext js,ts,env --ignore '../shared/dist/**/*.d.ts' --ignore scripts --ignore tests --exec bun src/index.ts",
"workers:dev": "cross-env NODE_ENV=development bunx nodemon --signal SIGTERM --delay 500ms -w ../shared/dist -w src/workers.ts -w src/queue -w src/internal --ext js,ts --ignore '../shared/dist/**/*.d.ts' --ignore scripts --ignore tests --exec bun src/workers.ts",
"w": "ENV_FILE=.env infisical run --env=dev -- bun workers:dev",
"c": "ENV_FILE=.env infisical run --env=dev -- bun cron",
"dev": "cross-env NODE_ENV=development bunx nodemon",
"workers:dev": "cross-env NODE_ENV=development bunx nodemon --exec bun src/workers.ts --signal SIGTERM --delay 500ms",
"start": "bun src/index.ts",
"workers": "bun src/workers.ts",
"cron": "bun src/cron.ts",
"check": "bun src/check.ts",
"build:check": "tsc -b tsconfig.build.json --noEmit",
"t": "bun tests/testRunner/runParallelGroupsV3.ts",
"parallel-tests": "bun tests/testRunner/runParallelGroupsV3.ts",
"parallel-tests:v1": "bun tests/testRunner/runParallelGroups.ts",
"parallel-tests:verbose": "bun tests/testRunner/runParallelGroups.ts --verbose",
"parallel-tests:debug": "bun tests/testRunner/runParallelGroups.ts --debug",
"clear-master": "bun tests/clearMasterOrg.ts"
"t": "ENV_FILE=.env infisical run --env=dev -- bun tests/testRunner/runParallelGroupsV3.ts",
"parallel-tests": "ENV_FILE=.env infisical run --env=dev -- bun tests/testRunner/runParallelGroupsV3.ts",
"parallel-tests:v1": "ENV_FILE=.env infisical run --env=dev -- bun tests/testRunner/runParallelGroups.ts",
"parallel-tests:verbose": "ENV_FILE=.env infisical run --env=dev -- bun tests/testRunner/runParallelGroups.ts --verbose",
"parallel-tests:debug": "ENV_FILE=.env infisical run --env=dev -- bun tests/testRunner/runParallelGroups.ts --debug",
"clear-master": "ENV_FILE=.env infisical run --env=dev -- bun tests/clearMasterOrg.ts",
"ts": "bunx tsgo --build --noEmit"
},
"mocha": {
"node-option": [
@@ -33,13 +33,10 @@
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/anthropic": "^1.2.10",
"@amplitude/analytics-node": "^1.5.18",
"@anthropic-ai/sdk": "^0.32.1",
"@autumn/shared": "workspace:*",
"@aws-sdk/client-sqs": "^3.926.0",
"@axiomhq/pino": "^1.3.1",
"@browserbasehq/sdk": "^2.6.0",
"@clerk/express": "^1.3.22",
"@clickhouse/client": "^1.11.2",
"@date-fns/tz": "^1.2.0",
"@date-fns/utc": "^2.1.0",
@@ -47,12 +44,11 @@
"@hono/node-server": "^1.19.5",
"@hono/zod-validator": "^0.7.3",
"@hyperbrowser/sdk": "^0.54.0",
"@hyperdx/node-opentelemetry": "^0.8.2",
"@infisical/sdk": "^4.0.6",
"@logtail/node": "^0.5.2",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/auto-instrumentations-node": "^0.60.1",
"@opentelemetry/exporter-trace-otlp-proto": "^0.202.0",
"@opentelemetry/instrumentation-ioredis": "^0.56.0",
"@opentelemetry/resources": "^2.0.1",
"@opentelemetry/sdk-metrics": "^2.0.1",
"@opentelemetry/sdk-node": "^0.202.0",
@@ -60,11 +56,14 @@
"@opentelemetry/sdk-trace-node": "^2.0.1",
"@opentelemetry/semantic-conventions": "^1.34.0",
"@react-email/components": "^0.0.42",
"@sentry/node": "^9.30.0",
"@sentry/bun": "catalog:",
"@supabase/supabase-js": "^2.46.2",
"@types/qs": "^6.14.0",
"@types/semver": "^7.7.1",
"@upstash/redis": "^1.35.1",
"@typescript/native-preview": "^7.0.0-dev.20251114.1",
"@upstash/ratelimit": "^2.0.7",
"@upstash/redis": "^1.35.6",
"@vercel/sdk": "^1.17.0",
"ai": "^4.3.10",
"autumn-js": "^0.1.8",
"axios": "^1.8.3",
@@ -74,7 +73,6 @@
"chai": "^5.1.2",
"chai-http": "^5.1.1",
"chalk": "^5.3.0",
"cloudflare": "^4.4.1",
"cors": "^2.8.5",
"cron": "^3.5.0",
"csv-parse": "^5.6.0",
@@ -94,6 +92,7 @@
"ink": "^6.3.1",
"ink-spinner": "^5.0.0",
"ioredis": "^5.5.0",
"jose": "^6.1.0",
"ksuid": "^3.0.0",
"lodash-es": "^4.17.21",
"loops": "^5.0.1",
@@ -109,7 +108,6 @@
"puppeteer-core": "^24.14.0",
"qs": "^6.14.0",
"react": "^18.2.0",
"recaseai": "^0.0.37",
"resend": "^4.1.1",
"semver": "^7.7.2",
"stripe": "catalog:",

View File

@@ -2,10 +2,10 @@ import "dotenv/config";
import Stripe from "stripe";
const main = async () => {
const stripe = new Stripe(process.env.STRIPE_LIVE_SECRET_KEY || "");
const stripe = new Stripe(process.env.STRIPE_SANDBOX_SECRET_KEY || "");
const result = await stripe.webhookEndpoints.create({
url: "https://express.dev.useautumn.com/webhooks/connect/live",
url: "https://express.dev.useautumn.com/webhooks/connect/sandbox",
enabled_events: [
"checkout.session.completed",
"customer.subscription.created",

View File

@@ -4,8 +4,6 @@
filename="$1"
# Check if the file path contains "shell"
if [[ "$filename" == *"shell"* ]]; then
"$filename" "${@:2}"
@@ -15,10 +13,13 @@ elif [[ "$filename" == *"/tests/"* ]]; then
# Remove .ts extension if present
path_after_tests="${path_after_tests%.ts}"
# Use scripts/test.ts which auto-detects framework
bun ../scripts/test.ts "$path_after_tests"
infisical run --env=dev -- bun ../scripts/test.ts "$path_after_tests"
elif [[ "$filename" == *".sh"* ]]; then
"$filename"
elif [[ "$filename" == *"/scripts/"* ]]; then
# Run scripts with infisical prod environment
infisical run --env=prod -- bun "$filename"
else
# NODE_ENV=development npx tsx $filename
NODE_ENV=development bun "$filename"

View File

@@ -18,4 +18,4 @@ export const CACHE_CUSTOMER_VERSION = ApiVersion.V1_2;
* Cache time-to-live in seconds (7 days)
* All customer and entity caches will expire after this duration
*/
export const CACHE_TTL_SECONDS = 7 * 24 * 60 * 60; // 7 days = 604800 seconds
export const CACHE_TTL_SECONDS = 3 * 24 * 60 * 60; // 7 days = 604800 seconds

View File

@@ -17,3 +17,21 @@ local function buildEntityCacheKey(orgId, env, customerId, entityId)
return "{" .. orgId .. "}:" .. env .. ":customer:{CUSTOMER_VERSION}:" .. customerId .. ":entity:" .. entityId
end
-- Build balance cache key
-- Returns: {cacheKey}:balances:{featureId}
local function buildBalanceCacheKey(cacheKey, featureId)
return cacheKey .. ":balances:" .. featureId
end
-- Build rollover cache key
-- Returns: {cacheKey}:balances:{featureId}:rollover:{index}
local function buildRolloverCacheKey(cacheKey, featureId, index)
return cacheKey .. ":balances:" .. featureId .. ":rollover:" .. index
end
-- Build breakdown cache key
-- Returns: {cacheKey}:balances:{featureId}:breakdown:{index}
local function buildBreakdownCacheKey(cacheKey, featureId, index)
return cacheKey .. ":balances:" .. featureId .. ":breakdown:" .. index
end

View File

@@ -1,169 +1,21 @@
-- getCustomer.lua
-- Atomically retrieves a customer object from Redis, reconstructing from base JSON and feature HSETs
-- Merges master customer features with entity features (unless skipEntityMerge is true)
-- Atomically retrieves a customer object from Redis, reconstructing from base JSON and balance HSETs
-- Merges master customer balances with entity balances (unless skipEntityMerge is true)
-- ARGV[1]: org_id
-- ARGV[2]: env
-- ARGV[3]: customer_id
-- ARGV[4]: skipEntityMerge (optional, "true" to skip merging with entities)
-- Helper function to merge products array by product ID and normalized status
-- Groups products by key (product_id:normalized_status) and merges quantities
local function mergeProducts(productsArray)
if not productsArray or #productsArray == 0 then
return {}
end
-- Helper function to get product key for grouping
local function getProductKey(product)
local status = product.status
-- Normalize status: "active" or "past_due" -> "active", otherwise use actual status
if status == "active" or status == "past_due" then
status = "active"
end
return product.id .. ":" .. status
end
local record = {}
for _, curr in ipairs(productsArray) do
local key = getProductKey(curr)
local latest = record[key]
local currStartedAt = curr.started_at
-- Start with latest (or current if no latest exists), then override specific fields
local mergedProduct = {}
if latest then
-- Copy all fields from latest first
for k, v in pairs(latest) do
mergedProduct[k] = v
end
else
-- Copy all fields from current
for k, v in pairs(curr) do
mergedProduct[k] = v
end
end
-- Apply merge logic for specific fields
if latest then
-- version: max(latest.version or 1, current.version or 1)
local latestVersion = latest.version or 1
local currVersion = curr.version or 1
mergedProduct.version = math.max(latestVersion, currVersion)
-- canceled_at: current.canceled_at if exists, else latest.canceled_at, else null
if curr.canceled_at and curr.canceled_at ~= cjson.null and curr.canceled_at ~= nil then
mergedProduct.canceled_at = curr.canceled_at
elseif latest.canceled_at and latest.canceled_at ~= cjson.null and latest.canceled_at ~= nil then
mergedProduct.canceled_at = latest.canceled_at
else
mergedProduct.canceled_at = cjson.null
end
-- started_at: latest.started_at ? min(latest.started_at, current.started_at) : current.started_at
if latest.started_at then
mergedProduct.started_at = math.min(latest.started_at, currStartedAt)
else
mergedProduct.started_at = currStartedAt
end
-- quantity: (latest.quantity or 0) + (current.quantity or 0)
local latestQuantity = latest.quantity or 0
local currQuantity = curr.quantity or 0
mergedProduct.quantity = latestQuantity + currQuantity
else
-- First product in group, ensure defaults
mergedProduct.version = curr.version or 1
mergedProduct.canceled_at = curr.canceled_at or cjson.null
mergedProduct.started_at = currStartedAt
mergedProduct.quantity = curr.quantity or 0
end
record[key] = mergedProduct
end
-- Convert record back to array
local mergedProducts = {}
for _, product in pairs(record) do
table.insert(mergedProducts, product)
end
return mergedProducts
end
local orgId = ARGV[1]
local env = ARGV[2]
local customerId = ARGV[3]
local skipEntityMerge = ARGV[4] == "true"
-- Build versioned cache key using shared utility
local cacheKey = buildCustomerCacheKey(orgId, env, customerId)
-- Get customer object using shared utility function
local customer = getCustomerObject(orgId, env, customerId, skipEntityMerge)
-- Load features based on merge mode
-- If skipEntityMerge is true, only load customer's own features (no entity merging)
-- If skipEntityMerge is false, load merged features (customer + entities)
local features
if skipEntityMerge then
-- Load only customer's own features without entity merging
features = loadCusFeatures(cacheKey, orgId, env, customerId, "__CUSTOMER_ONLY__")
else
-- Load merged features (customer + entities)
features = loadCusFeatures(cacheKey, orgId, env, customerId)
end
if not features then
return nil -- Customer not in cache or partial eviction detected
end
-- Get base customer JSON for products and metadata
local baseJson = redis.call("GET", cacheKey)
if not baseJson then
if not customer then
return nil
end
local baseCustomer = cjson.decode(baseJson)
local entityIds = baseCustomer._entityIds or {}
-- ============================================================================
-- MERGE ENTITY PRODUCTS INTO CUSTOMER PRODUCTS
-- ============================================================================
-- Build entity base data map for product access
local entityBaseData = {}
for _, entityId in ipairs(entityIds) do
local entityCacheKey = buildEntityCacheKey(orgId, env, customerId, entityId)
local entityBaseJson = redis.call("GET", entityCacheKey)
if entityBaseJson then
entityBaseData[entityId] = cjson.decode(entityBaseJson)
end
end
-- Collect all products: start with customer's products, then add all entity products
local allProducts = {}
if baseCustomer.products then
for _, product in ipairs(baseCustomer.products) do
table.insert(allProducts, product)
end
end
-- Add products from each entity
for _, entityId in ipairs(entityIds) do
local entityBase = entityBaseData[entityId]
if entityBase and entityBase.products then
for _, product in ipairs(entityBase.products) do
table.insert(allProducts, product)
end
end
end
-- Merge products by product ID and normalized status
baseCustomer.products = mergeProducts(allProducts)
-- Build final customer object
baseCustomer._featureIds = nil -- Remove tracking field
baseCustomer._entityIds = nil -- Remove tracking field
baseCustomer.features = features
return cjson.encode(baseCustomer)
return cjson.encode(customer)

View File

@@ -1,505 +0,0 @@
-- loadCusFeatures.lua
-- Shared function to load customer features with merged balances (customer + entities)
-- Returns: { [featureId] = { balance, usage, unlimited, ... } } or nil if not in cache
-- Helper function to safely convert values to numbers for arithmetic
local function toNum(value)
return type(value) == "number" and value or 0
end
-- Helper function to parse HGETALL result into feature data object
local function parseFeatureHash(featureHash)
local featureData = {}
for i = 1, #featureHash, 2 do
local key = featureHash[i]
local value = featureHash[i + 1]
-- Check for null first before parsing
if value == "null" then
featureData[key] = cjson.null
elseif key == "balance" or key == "usage" or key == "included_usage" or key == "usage_limit" or key == "interval_count" or key == "next_reset_at" or key == "_breakdown_count" or key == "_rollover_count" then
featureData[key] = tonumber(value)
elseif key == "unlimited" or key == "overage_allowed" then
featureData[key] = (value == "true")
elseif key == "credit_schema" then
-- Parse credit_schema JSON array
if value ~= "" then
featureData[key] = cjson.decode(value)
else
featureData[key] = cjson.null
end
else
featureData[key] = value
end
end
return featureData
end
-- Helper function to fetch and parse rollover items
-- Returns: array of rollover data objects, or nil if any key is missing (partial eviction)
local function fetchRollovers(baseKey, rolloverCount)
local rollovers = {}
for i = 0, rolloverCount - 1 do
local rolloverKey = baseKey .. ":rollover:" .. i
local rolloverHash = redis.call("HGETALL", rolloverKey)
-- If rollover key is missing, return nil (partial eviction detected)
if #rolloverHash == 0 then
return nil
end
local rolloverData = {}
for j = 1, #rolloverHash, 2 do
local key = rolloverHash[j]
local value = rolloverHash[j + 1]
if value == "null" then
rolloverData[key] = cjson.null
elseif key == "balance" or key == "expires_at" then
rolloverData[key] = tonumber(value)
else
rolloverData[key] = value
end
end
table.insert(rollovers, rolloverData)
end
return rollovers
end
-- Helper function to fetch and parse breakdown items
-- Returns: array of breakdown data objects, or nil if any key is missing (partial eviction)
local function fetchBreakdown(baseKey, breakdownCount)
local breakdown = {}
for i = 0, breakdownCount - 1 do
local breakdownKey = baseKey .. ":breakdown:" .. i
local breakdownHash = redis.call("HGETALL", breakdownKey)
-- If breakdown key is missing, return nil (partial eviction detected)
if #breakdownHash == 0 then
return nil
end
local breakdownData = {}
for j = 1, #breakdownHash, 2 do
local key = breakdownHash[j]
local value = breakdownHash[j + 1]
if value == "null" then
breakdownData[key] = cjson.null
elseif key == "balance" or key == "usage" or key == "included_usage" or key == "usage_limit" or key == "interval_count" or key == "next_reset_at" then
breakdownData[key] = tonumber(value)
elseif key == "overage_allowed" then
breakdownData[key] = (value == "true")
else
breakdownData[key] = value
end
end
table.insert(breakdown, breakdownData)
end
return breakdown
end
-- Helper function to merge source feature balances into target feature
-- Mutates targetFeature by adding sourceFeature's balances, usage, breakdowns, and rollovers
-- Also handles minimum next_reset_at (earliest reset time)
local function mergeFeatureBalances(targetFeature, sourceFeature)
if not sourceFeature then return end
-- Merge top-level balance and usage
targetFeature.balance = toNum(targetFeature.balance) + toNum(sourceFeature.balance)
targetFeature.usage = toNum(targetFeature.usage) + toNum(sourceFeature.usage)
targetFeature.included_usage = toNum(targetFeature.included_usage) + toNum(sourceFeature.included_usage)
targetFeature.usage_limit = toNum(targetFeature.usage_limit) + toNum(sourceFeature.usage_limit)
-- Use minimum next_reset_at (earliest reset time)
if type(sourceFeature.next_reset_at) == "number" then
if type(targetFeature.next_reset_at) == "number" then
if sourceFeature.next_reset_at < targetFeature.next_reset_at then
targetFeature.next_reset_at = sourceFeature.next_reset_at
end
else
targetFeature.next_reset_at = sourceFeature.next_reset_at
end
end
-- Merge breakdown balances and usage
if targetFeature.breakdown and sourceFeature.breakdowns then
for i, targetBreakdown in ipairs(targetFeature.breakdown) do
local sourceBreakdown = sourceFeature.breakdowns[i]
if sourceBreakdown then
targetBreakdown.balance = toNum(targetBreakdown.balance) + toNum(sourceBreakdown.balance)
targetBreakdown.usage = toNum(targetBreakdown.usage) + toNum(sourceBreakdown.usage)
targetBreakdown.included_usage = toNum(targetBreakdown.included_usage) + toNum(sourceBreakdown.included_usage)
targetBreakdown.usage_limit = toNum(targetBreakdown.usage_limit) + toNum(sourceBreakdown.usage_limit)
-- Use minimum next_reset_at for breakdown
if type(sourceBreakdown.next_reset_at) == "number" then
if type(targetBreakdown.next_reset_at) == "number" then
if sourceBreakdown.next_reset_at < targetBreakdown.next_reset_at then
targetBreakdown.next_reset_at = sourceBreakdown.next_reset_at
end
else
targetBreakdown.next_reset_at = sourceBreakdown.next_reset_at
end
end
end
end
end
-- Merge rollover balances
if targetFeature.rollovers and sourceFeature.rollovers then
for i, targetRollover in ipairs(targetFeature.rollovers) do
local sourceRollover = sourceFeature.rollovers[i]
if sourceRollover then
targetRollover.balance = toNum(targetRollover.balance) + toNum(sourceRollover.balance)
end
end
end
end
-- Load entity-level features (entity + customer merged)
-- Used for entity-level sync mode
-- Parameters: cacheKey (customer cache key), orgId, env, customerId, entityId
-- Returns: merged features table (entity + customer) or nil
local function loadEntityLevelFeatures(cacheKey, orgId, env, customerId, entityId)
-- Build versioned entity cache key using shared utility
local entityCacheKey = buildEntityCacheKey(orgId, env, customerId, entityId)
-- Get entity base JSON
local entityBaseJson = redis.call("GET", entityCacheKey)
if not entityBaseJson then
return nil
end
local entityBase = cjson.decode(entityBaseJson)
local entityFeatureIds = entityBase._featureIds or {}
-- Load entity features
local entityFeatures = {}
for _, featureId in ipairs(entityFeatureIds) do
local featureKey = entityCacheKey .. ":features:" .. featureId
local featureHash = redis.call("HGETALL", featureKey)
-- If feature key is missing, return nil (partial eviction detected)
if #featureHash == 0 then
return nil
end
-- Parse feature hash using helper function
local featureData = parseFeatureHash(featureHash)
-- Fetch rollovers using helper function
local rolloverCount = featureData._rollover_count or 0
featureData._rollover_count = nil
local rollovers = fetchRollovers(featureKey, rolloverCount)
if rollovers == nil then
return nil -- Partial eviction detected
end
if #rollovers > 0 then
featureData.rollovers = rollovers
end
-- Fetch breakdown using helper function
local breakdownCount = featureData._breakdown_count or 0
featureData._breakdown_count = nil
local breakdown = fetchBreakdown(featureKey, breakdownCount)
if breakdown == nil then
return nil -- Partial eviction detected
end
if #breakdown > 0 then
featureData.breakdown = breakdown
end
entityFeatures[featureId] = featureData
end
-- Load customer features (raw, no entity aggregation)
local customerCacheKey = cacheKey
local customerBaseJson = redis.call("GET", customerCacheKey)
local customerFeatures = {}
if customerBaseJson then
local customerBase = cjson.decode(customerBaseJson)
local customerFeatureIds = customerBase._featureIds or {}
for _, featureId in ipairs(customerFeatureIds) do
local featureKey = customerCacheKey .. ":features:" .. featureId
local featureHash = redis.call("HGETALL", featureKey)
if #featureHash > 0 then
-- Parse feature hash using helper function
local featureData = parseFeatureHash(featureHash)
-- Fetch rollovers
local rolloverCount = featureData._rollover_count or 0
featureData._rollover_count = nil
local rollovers = fetchRollovers(featureKey, rolloverCount) or {}
if #rollovers > 0 then
featureData.rollovers = rollovers
end
-- Fetch breakdown
local breakdownCount = featureData._breakdown_count or 0
featureData._breakdown_count = nil
local breakdown = fetchBreakdown(featureKey, breakdownCount) or {}
if #breakdown > 0 then
featureData.breakdown = breakdown
end
customerFeatures[featureId] = featureData
end
end
end
-- Merge customer and entity features (entity + customer)
local mergedFeatures = {}
-- First, add all customer features (inherited)
for featureId, customerFeature in pairs(customerFeatures) do
mergedFeatures[featureId] = customerFeature
end
-- Then, merge or add entity features
for featureId, entityFeature in pairs(entityFeatures) do
local customerFeature = customerFeatures[featureId]
if customerFeature then
-- Both customer and entity have this feature - merge balances
if not entityFeature.unlimited and not customerFeature.unlimited then
mergeFeatureBalances(entityFeature, customerFeature)
end
mergedFeatures[featureId] = entityFeature
else
-- Only entity has this feature - use entity's feature
mergedFeatures[featureId] = entityFeature
end
end
return mergedFeatures
end
-- Load customer features with merged entity balances
-- Parameters: cacheKey, orgId, env, customerId, entityId (optional)
-- If entityId is "__CUSTOMER_ONLY__": returns ONLY customer features (no merging)
-- If entityId is provided (string): returns entity-level merged features (entity + customer)
-- If entityId is nil: returns customer-level merged features (customer + all entities)
-- Returns: merged features table or nil
local function loadCusFeatures(cacheKey, orgId, env, customerId, entityId)
-- Special case: Customer-only mode (no entity merging)
if entityId == "__CUSTOMER_ONLY__" then
local baseJson = redis.call("GET", cacheKey)
if not baseJson then
return nil
end
local base = cjson.decode(baseJson)
local featureIds = base._featureIds or {}
-- Load only customer's own features without entity merging
local customerFeatures = {}
for _, featureId in ipairs(featureIds) do
local featureKey = cacheKey .. ":features:" .. featureId
local featureHash = redis.call("HGETALL", featureKey)
if #featureHash == 0 then
return nil -- Partial eviction detected
end
-- Parse feature hash
local featureData = parseFeatureHash(featureHash)
featureData.id = featureId
-- Fetch rollovers
local rollovers = fetchRollovers(featureKey, featureData._rollover_count or 0)
if rollovers == nil then
return nil -- Partial eviction
end
if #rollovers > 0 then
featureData.rollovers = rollovers
end
-- Fetch breakdown
local breakdown = fetchBreakdown(featureKey, featureData._breakdown_count or 0)
if breakdown == nil then
return nil -- Partial eviction
end
if #breakdown > 0 then
featureData.breakdown = breakdown
end
-- Remove metadata fields
featureData._breakdown_count = nil
featureData._rollover_count = nil
customerFeatures[featureId] = featureData
end
return customerFeatures
end
-- If entityId is provided, load entity-level features (entity + customer merged)
if entityId then
return loadEntityLevelFeatures(cacheKey, orgId, env, customerId, entityId)
end
-- Otherwise, load customer-level features (customer + all entities merged)
-- Get base customer JSON
local baseJson = redis.call("GET", cacheKey)
if not baseJson then
return nil
end
local baseCustomer = cjson.decode(baseJson)
local featureIds = baseCustomer._featureIds or {}
local entityIds = baseCustomer._entityIds or {}
-- Build features object
local features = {}
for _, featureId in ipairs(featureIds) do
local featureKey = cacheKey .. ":features:" .. featureId
local featureHash = redis.call("HGETALL", featureKey)
-- If feature key is missing, return nil (partial eviction detected)
if #featureHash == 0 then
return nil
end
-- Parse feature hash using helper function
local featureData = parseFeatureHash(featureHash)
-- Fetch rollovers using helper function
local rolloverCount = featureData._rollover_count or 0
featureData._rollover_count = nil -- Remove from final output
local rollovers = fetchRollovers(featureKey, rolloverCount)
if rollovers == nil then
return nil -- Partial eviction detected
end
if #rollovers > 0 then
featureData.rollovers = rollovers
end
-- Fetch breakdown using helper function
local breakdownCount = featureData._breakdown_count or 0
featureData._breakdown_count = nil -- Remove from final output
local breakdown = fetchBreakdown(featureKey, breakdownCount)
if breakdown == nil then
return nil -- Partial eviction detected
end
if #breakdown > 0 then
featureData.breakdown = breakdown
end
features[featureId] = featureData
end
-- ============================================================================
-- FETCH AND MERGE ENTITY FEATURES
-- ============================================================================
-- Fetch all entity features and aggregate balances
local entityFeatureData = {} -- {[entityId][featureId] = featureData}
local entityBaseData = {} -- {[entityId] = entityBase} - Store entity base for product access
for _, entityId in ipairs(entityIds) do
local entityCacheKey = buildEntityCacheKey(orgId, env, customerId, entityId)
local entityBaseJson = redis.call("GET", entityCacheKey)
if entityBaseJson then
local entityBase = cjson.decode(entityBaseJson)
entityBaseData[entityId] = entityBase -- Store entity base for product access
local entityFeatureIds = entityBase._featureIds or {}
entityFeatureData[entityId] = {}
for _, featureId in ipairs(entityFeatureIds) do
local entityFeatureKey = entityCacheKey .. ":features:" .. featureId
local entityFeatureHash = redis.call("HGETALL", entityFeatureKey)
if #entityFeatureHash > 0 then
-- Parse entity feature using helper function
local entityFeature = parseFeatureHash(entityFeatureHash)
-- Fetch breakdown items for this entity feature using helper function
local breakdownCount = entityFeature._breakdown_count or 0
entityFeature._breakdown_count = nil
entityFeature.breakdowns = fetchBreakdown(entityFeatureKey, breakdownCount) or {}
-- Fetch rollover items for this entity feature using helper function
local rolloverCount = entityFeature._rollover_count or 0
entityFeature._rollover_count = nil
entityFeature.rollovers = fetchRollovers(entityFeatureKey, rolloverCount) or {}
entityFeatureData[entityId][featureId] = entityFeature
end
end
end
end
-- ============================================================================
-- MERGE ENTITY BALANCES INTO CUSTOMER FEATURES
-- ============================================================================
for featureId, customerFeature in pairs(features) do
-- Skip if unlimited
if not customerFeature.unlimited then
-- Merge each entity's feature balances into customer feature
for entityId, entityFeatures in pairs(entityFeatureData) do
local entityFeature = entityFeatures[featureId]
if entityFeature then
mergeFeatureBalances(customerFeature, entityFeature)
end
end
end
end
-- Add entity-only features (features that exist in entities but not in customer)
for entityId, entityFeatures in pairs(entityFeatureData) do
for featureId, entityFeature in pairs(entityFeatures) do
if not features[featureId] then
-- This feature doesn't exist in customer, add it with zero values
features[featureId] = {
id = entityFeature.id,
type = entityFeature.type,
name = entityFeature.name,
interval = entityFeature.interval,
interval_count = entityFeature.interval_count,
unlimited = entityFeature.unlimited,
balance = 0,
usage = 0,
included_usage = 0,
next_reset_at = cjson.null,
overage_allowed = entityFeature.overage_allowed,
usage_limit = 0,
credit_schema = entityFeature.credit_schema
}
end
end
end
-- Aggregate balances for entity-only features using mergeFeatureBalances
for featureId, customerFeature in pairs(features) do
-- Only process if this was an entity-only feature (balance is still 0 from initialization)
if customerFeature.balance == 0 and customerFeature.usage == 0 then
for entityId, entityFeatures in pairs(entityFeatureData) do
local entityFeature = entityFeatures[featureId]
if entityFeature then
mergeFeatureBalances(customerFeature, entityFeature)
end
end
end
end
-- Return merged features
return features
end

View File

@@ -1,6 +1,6 @@
-- setCustomer.lua
-- Atomically stores a customer object with base data as JSON and features/breakdowns as HSETs
-- Separates master customer features from entity features
-- Atomically stores a customer object with base data as JSON and balances/breakdowns as HSETs
-- Uses new ApiCustomer schema with balances (replacing features) and subscriptions (replacing products)
-- ARGV[1]: serialized customer data JSON string
-- ARGV[2]: org_id
-- ARGV[3]: env
@@ -22,11 +22,11 @@ end
-- Decode the customer data
local customerData = cjson.decode(customerDataJson)
-- Extract feature IDs for tracking
local featureIds = {}
if customerData.features then
for featureId, _ in pairs(customerData.features) do
table.insert(featureIds, featureId)
-- Extract balance IDs (feature_ids) for tracking
local balanceFeatureIds = {}
if customerData.balances then
for featureId, _ in pairs(customerData.balances) do
table.insert(balanceFeatureIds, featureId)
end
end
@@ -40,11 +40,11 @@ if customerData.entities then
end
end
-- Store feature IDs and entity IDs in the base data for retrieval
customerData._featureIds = featureIds
-- Store balance feature IDs and entity IDs in the base data for retrieval
customerData._balanceFeatureIds = balanceFeatureIds
customerData._entityIds = entityIds
-- Build base customer object (everything except features)
-- Build base customer object (everything except balances)
local baseCustomer = {
id = customerData.id,
autumn_id = customerData.autumn_id,
@@ -55,11 +55,11 @@ local baseCustomer = {
stripe_id = customerData.stripe_id,
env = customerData.env,
metadata = customerData.metadata,
products = customerData.products,
subscriptions = customerData.subscriptions,
invoices = customerData.invoices,
legacyData = customerData.legacyData,
entities = customerData.entities,
_featureIds = featureIds,
_balanceFeatureIds = balanceFeatureIds,
_entityIds = entityIds
}
@@ -68,90 +68,8 @@ local baseKey = cacheKey
redis.call("SET", baseKey, cjson.encode(baseCustomer))
redis.call("EXPIRE", baseKey, CACHE_TTL_SECONDS)
-- Helper function to convert values to strings, handling cjson.null
local function toString(value)
if value == cjson.null or value == nil then
return "null"
end
return tostring(value)
end
-- Store each feature as HSET
if customerData.features then
for featureId, featureData in pairs(customerData.features) do
local featureKey = cacheKey .. ":features:" .. featureId
-- Store breakdown count for reconstruction
local breakdownCount = 0
if featureData.breakdown then
breakdownCount = #featureData.breakdown
end
-- Store rollover count for reconstruction
local rolloverCount = 0
if featureData.rollovers then
rolloverCount = #featureData.rollovers
end
-- Serialize credit_schema as JSON string
local creditSchemaJson = "null"
if featureData.credit_schema and #featureData.credit_schema > 0 then
creditSchemaJson = cjson.encode(featureData.credit_schema)
end
-- Store all top-level feature fields in a single HSET call with TTL
redis.call("HSET", featureKey,
"id", toString(featureData.id),
"type", toString(featureData.type),
"name", toString(featureData.name),
"interval", toString(featureData.interval),
"interval_count", toString(featureData.interval_count),
"unlimited", toString(featureData.unlimited),
"balance", toString(featureData.balance),
"usage", toString(featureData.usage),
"included_usage", toString(featureData.included_usage),
"next_reset_at", toString(featureData.next_reset_at),
"overage_allowed", toString(featureData.overage_allowed),
"usage_limit", toString(featureData.usage_limit),
"credit_schema", creditSchemaJson,
"_breakdown_count", toString(breakdownCount),
"_rollover_count", toString(rolloverCount)
)
redis.call("EXPIRE", featureKey, CACHE_TTL_SECONDS)
-- Store each rollover item as separate HSET with TTL (single call per rollover)
if featureData.rollovers then
for index, rolloverItem in ipairs(featureData.rollovers) do
local rolloverKey = cacheKey .. ":features:" .. featureId .. ":rollover:" .. (index - 1)
redis.call("HSET", rolloverKey,
"balance", toString(rolloverItem.balance),
"expires_at", toString(rolloverItem.expires_at)
)
redis.call("EXPIRE", rolloverKey, CACHE_TTL_SECONDS)
end
end
-- Store each breakdown item as separate HSET with TTL (single call per breakdown)
if featureData.breakdown then
for index, breakdownItem in ipairs(featureData.breakdown) do
local breakdownKey = cacheKey .. ":features:" .. featureId .. ":breakdown:" .. (index - 1)
redis.call("HSET", breakdownKey,
"interval", toString(breakdownItem.interval),
"interval_count", toString(breakdownItem.interval_count),
"balance", toString(breakdownItem.balance),
"usage", toString(breakdownItem.usage),
"included_usage", toString(breakdownItem.included_usage),
"next_reset_at", toString(breakdownItem.next_reset_at),
"usage_limit", toString(breakdownItem.usage_limit),
"overage_allowed", toString(breakdownItem.overage_allowed)
)
redis.call("EXPIRE", breakdownKey, CACHE_TTL_SECONDS)
end
end
end
end
-- Store balances using shared utility function
storeBalances(cacheKey, customerData.balances)
return "OK"

View File

@@ -1,11 +1,11 @@
-- setCustomerProducts.lua
-- Updates only the products array in the customer cache
-- ARGV[1]: serialized products array JSON string
-- setInvoices.lua
-- Updates only the invoices array in the customer cache
-- ARGV[1]: serialized invoices array JSON string (ApiInvoiceV1[])
-- ARGV[2]: org_id
-- ARGV[3]: env
-- ARGV[4]: customer_id
local productsJson = ARGV[1]
local invoicesJson = ARGV[1]
local orgId = ARGV[2]
local env = ARGV[3]
local customerId = ARGV[4]
@@ -20,12 +20,12 @@ if not baseJson then
return "OK" -- Customer doesn't exist, return early
end
-- Decode the base customer and products
-- Decode the base customer and invoices
local baseCustomer = cjson.decode(baseJson)
local products = cjson.decode(productsJson)
local invoices = cjson.decode(invoicesJson)
-- Update only the products array
baseCustomer.products = products
-- Update the invoices field
baseCustomer.invoices = invoices
-- Store updated base customer as JSON and extend TTL
redis.call("SET", baseKey, cjson.encode(baseCustomer))

View File

@@ -0,0 +1,35 @@
-- setSubscriptions.lua
-- Updates only the subscriptions array in the customer cache
-- ARGV[1]: serialized subscriptions array JSON string (ApiSubscription[])
-- ARGV[2]: org_id
-- ARGV[3]: env
-- ARGV[4]: customer_id
local subscriptionsJson = ARGV[1]
local orgId = ARGV[2]
local env = ARGV[3]
local customerId = ARGV[4]
-- Build versioned cache key using shared utility
local cacheKey = buildCustomerCacheKey(orgId, env, customerId)
local baseKey = cacheKey
-- Get base customer JSON
local baseJson = redis.call("GET", baseKey)
if not baseJson then
return "OK" -- Customer doesn't exist, return early
end
-- Decode the base customer and subscriptions
local baseCustomer = cjson.decode(baseJson)
local subscriptions = cjson.decode(subscriptionsJson)
-- Update the subscriptions field
baseCustomer.subscriptions = subscriptions
-- Store updated base customer as JSON and extend TTL
redis.call("SET", baseKey, cjson.encode(baseCustomer))
redis.call("EXPIRE", baseKey, CACHE_TTL_SECONDS)
return "OK"

File diff suppressed because it is too large Load Diff

View File

@@ -1,133 +1,24 @@
-- getEntity.lua
-- Atomically retrieves an entity object from Redis, reconstructing from base JSON and feature HSETs
-- Merges entity features with customer features (unless skipCustomerMerge is true)
-- Atomically retrieves an entity object from Redis, reconstructing from base JSON and balance HSETs
-- Merges entity balances with customer balances (unless skipCustomerMerge is true)
-- ARGV[1]: org_id
-- ARGV[2]: env
-- ARGV[3]: customerId
-- ARGV[4]: entityId
-- ARGV[5]: skipCustomerMerge (optional, "true" to skip merging with customer)
-- Helper function to safely convert values to numbers for arithmetic
-- Returns the value if it's a number, otherwise returns 0
local function toNum(value)
return type(value) == "number" and value or 0
end
-- Helper function to get product key for grouping (product_id:normalized_status)
local function getProductKey(product)
local status = product.status
-- Normalize status: "active" or "past_due" -> "active", otherwise use actual status
if status == "active" or status == "past_due" then
status = "active"
end
return product.id .. ":" .. status
end
-- Helper function to merge customer products into entity products
-- Adds customer products that don't already exist in entity products (by product key)
local function mergeCustomerProductsIntoEntity(entityProducts, customerProducts)
if not customerProducts or #customerProducts == 0 then
return entityProducts or {}
end
if not entityProducts then
entityProducts = {}
end
-- Build a set of existing product keys in entity products
local existingKeys = {}
for _, product in ipairs(entityProducts) do
local key = getProductKey(product)
existingKeys[key] = true
end
-- Add customer products that don't exist in entity products
local mergedProducts = {}
-- First, add all entity products
for _, product in ipairs(entityProducts) do
table.insert(mergedProducts, product)
end
-- Then, add customer products that don't exist
for _, customerProduct in ipairs(customerProducts) do
local key = getProductKey(customerProduct)
if not existingKeys[key] then
table.insert(mergedProducts, customerProduct)
end
end
return mergedProducts
end
local orgId = ARGV[1]
local env = ARGV[2]
local customerId = ARGV[3]
local entityId = ARGV[4]
local skipCustomerMerge = ARGV[5] == "true"
-- Build versioned entity cache key using shared utility
local entityCacheKey = buildEntityCacheKey(orgId, env, customerId, entityId)
-- Get entity object using shared utility function
local entity = getEntityObject(orgId, env, customerId, entityId, skipCustomerMerge)
-- Get base entity JSON
local baseJson = redis.call("GET", entityCacheKey)
if not baseJson then
if not entity then
return nil
end
local baseEntity = cjson.decode(baseJson)
-- Build customer cache key for feature loading
local customerCacheKey = buildCustomerCacheKey(orgId, env, customerId)
-- ============================================================================
-- LOAD FEATURES USING loadCusFeatures
-- ============================================================================
local mergedFeatures
if skipCustomerMerge then
-- Load only entity's own features (no customer merging)
-- We'll use loadCusFeatures with "__CUSTOMER_ONLY__" mode on the entity cache key
-- This is a bit of a hack but works with the current structure
mergedFeatures = loadCusFeatures(entityCacheKey, orgId, env, customerId, "__CUSTOMER_ONLY__")
else
-- Load entity-level merged features (entity + customer)
-- loadCusFeatures handles this when entityId is provided
mergedFeatures = loadCusFeatures(customerCacheKey, orgId, env, customerId, entityId)
end
-- If features loading failed (partial eviction), return nil
if not mergedFeatures then
return nil
end
-- ============================================================================
-- MERGE CUSTOMER PRODUCTS INTO ENTITY PRODUCTS
-- Skip if skipCustomerMerge is true
-- ============================================================================
-- Get entity products (start with entity's own products)
local entityProducts = baseEntity.products or {}
if not skipCustomerMerge then
-- Get customer products
local customerProducts = nil
local customerBaseJson = redis.call("GET", customerCacheKey)
if customerBaseJson then
local customerBase = cjson.decode(customerBaseJson)
customerProducts = customerBase.products
end
-- Merge customer products into entity products (only add if not exists)
baseEntity.products = mergeCustomerProductsIntoEntity(entityProducts, customerProducts)
else
-- No merging - just use entity's own products
baseEntity.products = entityProducts
end
-- Build final entity object
baseEntity._featureIds = nil -- Remove tracking field
baseEntity.features = mergedFeatures
return cjson.encode(baseEntity)
return cjson.encode(entity)

View File

@@ -1,5 +1,6 @@
-- setEntitiesBatch.lua
-- Atomically stores multiple entity objects in a single call
-- Uses new ApiEntity schema with balances (replacing features) and subscriptions (replacing products)
-- ARGV[1]: JSON array of entity data objects: [{entityId: "...", entityData: {...}}, ...]
-- ARGV[2]: org_id
-- ARGV[3]: env
@@ -11,14 +12,6 @@ local env = ARGV[3]
-- Decode the entities array
local entities = cjson.decode(entitiesJson)
-- Helper function to convert values to strings, handling cjson.null
local function toString(value)
if value == cjson.null or value == nil then
return "null"
end
return tostring(value)
end
-- Process each entity
for _, entityWrapper in ipairs(entities) do
local entityId = entityWrapper.entityId
@@ -28,15 +21,18 @@ for _, entityWrapper in ipairs(entities) do
local customerId = entityData.customer_id
local cacheKey = buildEntityCacheKey(orgId, env, customerId, entityId)
-- Extract feature IDs for tracking
local featureIds = {}
if entityData.features then
for featureId, _ in pairs(entityData.features) do
table.insert(featureIds, featureId)
-- Extract balance IDs (feature_ids) for tracking
local balanceFeatureIds = {}
if entityData.balances then
for featureId, _ in pairs(entityData.balances) do
table.insert(balanceFeatureIds, featureId)
end
end
-- Build base entity object (everything except features)
-- Store balance feature IDs in the base data for retrieval
entityData._balanceFeatureIds = balanceFeatureIds
-- Build base entity object (everything except balances)
local baseEntity = {
id = entityData.id,
autumn_id = entityData.autumn_id,
@@ -44,90 +40,17 @@ for _, entityWrapper in ipairs(entities) do
customer_id = entityData.customer_id,
created_at = entityData.created_at,
env = entityData.env,
products = entityData.products,
_featureIds = featureIds
subscriptions = entityData.subscriptions,
legacyData = entityData.legacyData,
_balanceFeatureIds = balanceFeatureIds
}
-- Store base entity as JSON with TTL
redis.call("SET", cacheKey, cjson.encode(baseEntity))
redis.call("EXPIRE", cacheKey, CACHE_TTL_SECONDS)
-- Store each feature as HSET
if entityData.features then
for featureId, featureData in pairs(entityData.features) do
local featureKey = cacheKey .. ":features:" .. featureId
-- Store breakdown count for reconstruction
local breakdownCount = 0
if featureData.breakdown then
breakdownCount = #featureData.breakdown
end
-- Store rollover count for reconstruction
local rolloverCount = 0
if featureData.rollovers then
rolloverCount = #featureData.rollovers
end
-- Serialize credit_schema as JSON string
local creditSchemaJson = "null"
if featureData.credit_schema and #featureData.credit_schema > 0 then
creditSchemaJson = cjson.encode(featureData.credit_schema)
end
-- Store all top-level feature fields in a single HSET call with TTL
redis.call("HSET", featureKey,
"id", toString(featureData.id),
"type", toString(featureData.type),
"name", toString(featureData.name),
"interval", toString(featureData.interval),
"interval_count", toString(featureData.interval_count),
"unlimited", toString(featureData.unlimited),
"balance", toString(featureData.balance),
"usage", toString(featureData.usage),
"included_usage", toString(featureData.included_usage),
"next_reset_at", toString(featureData.next_reset_at),
"overage_allowed", toString(featureData.overage_allowed),
"usage_limit", toString(featureData.usage_limit),
"credit_schema", creditSchemaJson,
"_breakdown_count", toString(breakdownCount),
"_rollover_count", toString(rolloverCount)
)
redis.call("EXPIRE", featureKey, CACHE_TTL_SECONDS)
-- Store each rollover item as separate HSET with TTL (single call per rollover)
if featureData.rollovers then
for index, rolloverItem in ipairs(featureData.rollovers) do
local rolloverKey = cacheKey .. ":features:" .. featureId .. ":rollover:" .. (index - 1)
redis.call("HSET", rolloverKey,
"balance", toString(rolloverItem.balance),
"expires_at", toString(rolloverItem.expires_at)
)
redis.call("EXPIRE", rolloverKey, CACHE_TTL_SECONDS)
end
end
-- Store each breakdown item as separate HSET with TTL (single call per breakdown)
if featureData.breakdown then
for index, breakdownItem in ipairs(featureData.breakdown) do
local breakdownKey = cacheKey .. ":features:" .. featureId .. ":breakdown:" .. (index - 1)
redis.call("HSET", breakdownKey,
"interval", toString(breakdownItem.interval),
"interval_count", toString(breakdownItem.interval_count),
"balance", toString(breakdownItem.balance),
"usage", toString(breakdownItem.usage),
"included_usage", toString(breakdownItem.included_usage),
"next_reset_at", toString(breakdownItem.next_reset_at),
"usage_limit", toString(breakdownItem.usage_limit),
"overage_allowed", toString(breakdownItem.overage_allowed)
)
redis.call("EXPIRE", breakdownKey, CACHE_TTL_SECONDS)
end
end
end
end
-- Store balances using shared utility function
storeBalances(cacheKey, entityData.balances)
end
return "OK"

View File

@@ -1,5 +1,5 @@
-- setEntity.lua
-- Atomically stores an entity object with base data as JSON and features/breakdowns as HSETs
-- Atomically stores an entity object with base data as JSON and balances/breakdowns as HSETs
-- ARGV[1]: serialized entity data JSON string
-- ARGV[2]: org_id
-- ARGV[3]: env
@@ -23,18 +23,18 @@ end
-- Decode the entity data
local entityData = cjson.decode(entityDataJson)
-- Extract feature IDs for tracking
local featureIds = {}
if entityData.features then
for featureId, _ in pairs(entityData.features) do
table.insert(featureIds, featureId)
-- Extract balance IDs (feature_ids) for tracking
local balanceFeatureIds = {}
if entityData.balances then
for featureId, _ in pairs(entityData.balances) do
table.insert(balanceFeatureIds, featureId)
end
end
-- Store feature IDs in the base data for retrieval
entityData._featureIds = featureIds
-- Store balance feature IDs in the base data for retrieval
entityData._balanceFeatureIds = balanceFeatureIds
-- Build base entity object (everything except features)
-- Build base entity object (everything except balances)
local baseEntity = {
id = entityData.id,
autumn_id = entityData.autumn_id,
@@ -42,8 +42,9 @@ local baseEntity = {
customer_id = entityData.customer_id,
created_at = entityData.created_at,
env = entityData.env,
products = entityData.products,
_featureIds = featureIds
subscriptions = entityData.subscriptions,
legacyData = entityData.legacyData,
_balanceFeatureIds = balanceFeatureIds
}
-- Store base entity as JSON with TTL
@@ -51,90 +52,8 @@ local baseKey = cacheKey
redis.call("SET", baseKey, cjson.encode(baseEntity))
redis.call("EXPIRE", baseKey, CACHE_TTL_SECONDS)
-- Helper function to convert values to strings, handling cjson.null
local function toString(value)
if value == cjson.null or value == nil then
return "null"
end
return tostring(value)
end
-- Store each feature as HSET
if entityData.features then
for featureId, featureData in pairs(entityData.features) do
local featureKey = cacheKey .. ":features:" .. featureId
-- Store breakdown count for reconstruction
local breakdownCount = 0
if featureData.breakdown then
breakdownCount = #featureData.breakdown
end
-- Store rollover count for reconstruction
local rolloverCount = 0
if featureData.rollovers then
rolloverCount = #featureData.rollovers
end
-- Serialize credit_schema as JSON string
local creditSchemaJson = "null"
if featureData.credit_schema and #featureData.credit_schema > 0 then
creditSchemaJson = cjson.encode(featureData.credit_schema)
end
-- Store all top-level feature fields in a single HSET call with TTL
redis.call("HSET", featureKey,
"id", toString(featureData.id),
"type", toString(featureData.type),
"name", toString(featureData.name),
"interval", toString(featureData.interval),
"interval_count", toString(featureData.interval_count),
"unlimited", toString(featureData.unlimited),
"balance", toString(featureData.balance),
"usage", toString(featureData.usage),
"included_usage", toString(featureData.included_usage),
"next_reset_at", toString(featureData.next_reset_at),
"overage_allowed", toString(featureData.overage_allowed),
"usage_limit", toString(featureData.usage_limit),
"credit_schema", creditSchemaJson,
"_breakdown_count", toString(breakdownCount),
"_rollover_count", toString(rolloverCount)
)
redis.call("EXPIRE", featureKey, CACHE_TTL_SECONDS)
-- Store each rollover item as separate HSET with TTL (single call per rollover)
if featureData.rollovers then
for index, rolloverItem in ipairs(featureData.rollovers) do
local rolloverKey = cacheKey .. ":features:" .. featureId .. ":rollover:" .. (index - 1)
redis.call("HSET", rolloverKey,
"balance", toString(rolloverItem.balance),
"expires_at", toString(rolloverItem.expires_at)
)
redis.call("EXPIRE", rolloverKey, CACHE_TTL_SECONDS)
end
end
-- Store each breakdown item as separate HSET with TTL (single call per breakdown)
if featureData.breakdown then
for index, breakdownItem in ipairs(featureData.breakdown) do
local breakdownKey = cacheKey .. ":features:" .. featureId .. ":breakdown:" .. (index - 1)
redis.call("HSET", breakdownKey,
"interval", toString(breakdownItem.interval),
"interval_count", toString(breakdownItem.interval_count),
"balance", toString(breakdownItem.balance),
"usage", toString(breakdownItem.usage),
"included_usage", toString(breakdownItem.included_usage),
"next_reset_at", toString(breakdownItem.next_reset_at),
"usage_limit", toString(breakdownItem.usage_limit),
"overage_allowed", toString(breakdownItem.overage_allowed)
)
redis.call("EXPIRE", breakdownKey, CACHE_TTL_SECONDS)
end
end
end
end
-- Store balances using shared utility function
storeBalances(cacheKey, entityData.balances)
return "OK"

View File

@@ -12,7 +12,7 @@ const __dirname = dirname(__filename);
// Load cache key utilities and inject version constants
const CACHE_KEY_UTILS_RAW = readFileSync(
join(__dirname, "cacheKeyUtils.lua"),
join(__dirname, "luaUtils/cacheKeyUtils.lua"),
"utf-8",
);
@@ -22,9 +22,27 @@ const CACHE_KEY_UTILS = CACHE_KEY_UTILS_RAW.replace(
CACHE_CUSTOMER_VERSION,
).replace("{TTL_SECONDS}", CACHE_TTL_SECONDS.toString());
// Load shared feature loading function (used by customer, entity, and deduction scripts)
const LOAD_CUS_FEATURES = readFileSync(
join(__dirname, "cusLuaScripts/loadCusFeatures.lua"),
// Load balance storage utilities
const CACHE_BALANCE_UTILS = readFileSync(
join(__dirname, "luaUtils/storeBalances.lua"),
"utf-8",
);
// Load shared balance loading function (used by customer, entity, and deduction scripts)
const LOAD_BALANCES = readFileSync(
join(__dirname, "luaUtils/loadBalances.lua"),
"utf-8",
);
// Load shared subscription utilities (used by customer and entity scripts)
const SUBSCRIPTION_UTILS = readFileSync(
join(__dirname, "luaUtils/apiSubscriptionUtils.lua"),
"utf-8",
);
// Load shared customer/entity getter utilities
const GET_CUSTOMER_ENTITY_UTILS = readFileSync(
join(__dirname, "luaUtils/getCustomerEntityUtils.lua"),
"utf-8",
);
@@ -38,26 +56,29 @@ const CHECK_CACHE_EXISTS = readFileSync(
"utf-8",
);
// Prepend cache key utils and loadCusFeatures to GET_CUSTOMER_SCRIPT
// Prepend cache key utils, loadBalances, subscription utils, and getter utils to GET_CUSTOMER_SCRIPT
const getCustomerScript = readFileSync(
join(__dirname, "cusLuaScripts/getCustomer.lua"),
"utf-8",
);
export const GET_CUSTOMER_SCRIPT = `${CACHE_KEY_UTILS}\n${LOAD_CUS_FEATURES}\n${getCustomerScript}`;
export const GET_CUSTOMER_SCRIPT = `${CACHE_KEY_UTILS}\n${LOAD_BALANCES}\n${SUBSCRIPTION_UTILS}\n${GET_CUSTOMER_ENTITY_UTILS}\n${getCustomerScript}`;
// Prepend cache key utils and validation function to SET_CUSTOMER_SCRIPT
const setCustomerScript = readFileSync(
join(__dirname, "cusLuaScripts/setCustomer.lua"),
"utf-8",
);
export const SET_CUSTOMER_SCRIPT = `${CACHE_KEY_UTILS}\n${CHECK_CACHE_EXISTS}\n${setCustomerScript}`;
export const SET_CUSTOMER_SCRIPT = `${CACHE_KEY_UTILS}\n${CACHE_BALANCE_UTILS}\n${CHECK_CACHE_EXISTS}\n${setCustomerScript}`;
// Prepend cache key utils to SET_CUSTOMER_PRODUCTS_SCRIPT
const setCustomerProductsScript = readFileSync(
join(__dirname, "cusLuaScripts/setCustomerProducts.lua"),
// Prepend cache key utils to SET_SUBSCRIPTIONS_SCRIPT
const setSubscriptionsScript = readFileSync(
join(__dirname, "cusLuaScripts/setSubscriptions.lua"),
"utf-8",
);
export const SET_CUSTOMER_PRODUCTS_SCRIPT = `${CACHE_KEY_UTILS}\n${setCustomerProductsScript}`;
export const SET_SUBSCRIPTIONS_SCRIPT = `${CACHE_KEY_UTILS}\n${setSubscriptionsScript}`;
// Legacy export for backwards compatibility
export const SET_CUSTOMER_PRODUCTS_SCRIPT = SET_SUBSCRIPTIONS_SCRIPT;
// Prepend cache key utils to SET_CUSTOMER_DETAILS_SCRIPT
const setCustomerDetailsScript = readFileSync(
@@ -66,6 +87,13 @@ const setCustomerDetailsScript = readFileSync(
);
export const SET_CUSTOMER_DETAILS_SCRIPT = `${CACHE_KEY_UTILS}\n${setCustomerDetailsScript}`;
// Prepend cache key utils to SET_INVOICES_SCRIPT
const setInvoicesScript = readFileSync(
join(__dirname, "cusLuaScripts/setInvoices.lua"),
"utf-8",
);
export const SET_INVOICES_SCRIPT = `${CACHE_KEY_UTILS}\n${setInvoicesScript}`;
// Prepend cache key utils to DELETE_CUSTOMER_SCRIPT
const deleteCustomerScript = readFileSync(
join(__dirname, "cusLuaScripts/deleteCustomer.lua"),
@@ -83,26 +111,26 @@ const CHECK_ENTITY_CACHE_EXISTS = readFileSync(
"utf-8",
);
// Prepend cache key utils and loadCusFeatures to GET_ENTITY_SCRIPT
// Prepend cache key utils, loadBalances, subscription utils, and getter utils to GET_ENTITY_SCRIPT
const getEntityScript = readFileSync(
join(__dirname, "entityLuaScripts/getEntity.lua"),
"utf-8",
);
export const GET_ENTITY_SCRIPT = `${CACHE_KEY_UTILS}\n${LOAD_CUS_FEATURES}\n${getEntityScript}`;
export const GET_ENTITY_SCRIPT = `${CACHE_KEY_UTILS}\n${LOAD_BALANCES}\n${SUBSCRIPTION_UTILS}\n${GET_CUSTOMER_ENTITY_UTILS}\n${getEntityScript}`;
// Prepend cache key utils and validation function to SET_ENTITY_SCRIPT
const setEntityScript = readFileSync(
join(__dirname, "entityLuaScripts/setEntity.lua"),
"utf-8",
);
export const SET_ENTITY_SCRIPT = `${CACHE_KEY_UTILS}\n${CHECK_ENTITY_CACHE_EXISTS}\n${setEntityScript}`;
export const SET_ENTITY_SCRIPT = `${CACHE_KEY_UTILS}\n${CACHE_BALANCE_UTILS}\n${CHECK_ENTITY_CACHE_EXISTS}\n${setEntityScript}`;
// Prepend cache key utils to SET_ENTITIES_BATCH_SCRIPT
// Prepend cache key utils and balance utils to SET_ENTITIES_BATCH_SCRIPT
const setEntitiesBatchScript = readFileSync(
join(__dirname, "entityLuaScripts/setEntitiesBatch.lua"),
"utf-8",
);
export const SET_ENTITIES_BATCH_SCRIPT = `${CACHE_KEY_UTILS}\n${setEntitiesBatchScript}`;
export const SET_ENTITIES_BATCH_SCRIPT = `${CACHE_KEY_UTILS}\n${CACHE_BALANCE_UTILS}\n${setEntitiesBatchScript}`;
// Prepend cache key utils to SET_ENTITY_PRODUCTS_SCRIPT
const setEntityProductsScript = readFileSync(
@@ -122,7 +150,7 @@ const batchDeduction = readFileSync(
);
export function getBatchDeductionScript(): string {
return `${CACHE_KEY_UTILS}\n${LOAD_CUS_FEATURES}\n${batchDeduction}`;
return `${CACHE_KEY_UTILS}\n${LOAD_BALANCES}\n${SUBSCRIPTION_UTILS}\n${GET_CUSTOMER_ENTITY_UTILS}\n${batchDeduction}`;
}
export const BATCH_DEDUCTION_SCRIPT = getBatchDeductionScript();

View File

@@ -0,0 +1,134 @@
-- apiSubscriptionUtils.lua
-- Shared utility functions for subscription merging and manipulation
-- Helper function to get subscription key for grouping (plan_id:normalized_status)
-- Normalizes status: "active" or past_due=true -> "active", otherwise uses actual status
local function getSubscriptionKey(subscription)
local status = subscription.status
-- Normalize status: "active" or past_due=true -> "active", otherwise use actual status
if status == "active" or (subscription.past_due == true) then
status = "active"
end
return subscription.plan_id .. ":" .. status
end
-- Helper function to merge subscriptions array by plan ID and normalized status
-- Groups subscriptions by key (plan_id:normalized_status) and merges quantities
-- Used by getCustomer.lua to merge customer + entity subscriptions
-- Parameters: subscriptionsArray - array of subscriptions to merge
-- Returns: array of merged subscriptions
local function mergeSubscriptions(subscriptionsArray)
if not subscriptionsArray or #subscriptionsArray == 0 then
return {}
end
local record = {}
for _, curr in ipairs(subscriptionsArray) do
local key = getSubscriptionKey(curr)
local latest = record[key]
local currStartedAt = curr.started_at
-- Start with latest (or current if no latest exists), then override specific fields
local mergedSubscription = {}
if latest then
-- Copy all fields from latest first
for k, v in pairs(latest) do
mergedSubscription[k] = v
end
else
-- Copy all fields from current
for k, v in pairs(curr) do
mergedSubscription[k] = v
end
end
-- Apply merge logic for specific fields
if latest then
-- canceled_at: current.canceled_at if exists, else latest.canceled_at, else null
if curr.canceled_at and curr.canceled_at ~= cjson.null and curr.canceled_at ~= nil then
mergedSubscription.canceled_at = curr.canceled_at
elseif latest.canceled_at and latest.canceled_at ~= cjson.null and latest.canceled_at ~= nil then
mergedSubscription.canceled_at = latest.canceled_at
else
mergedSubscription.canceled_at = cjson.null
end
-- started_at: latest.started_at ? min(latest.started_at, current.started_at) : current.started_at
if latest.started_at then
mergedSubscription.started_at = math.min(latest.started_at, currStartedAt)
else
mergedSubscription.started_at = currStartedAt
end
-- quantity: (latest.quantity or 0) + (current.quantity or 0)
local latestQuantity = latest.quantity or 0
local currQuantity = curr.quantity or 0
mergedSubscription.quantity = latestQuantity + currQuantity
-- past_due: true if either is true
mergedSubscription.past_due = (latest.past_due == true) or (curr.past_due == true)
else
-- First subscription in group, ensure defaults
mergedSubscription.canceled_at = curr.canceled_at or cjson.null
mergedSubscription.started_at = currStartedAt
mergedSubscription.quantity = curr.quantity or 0
mergedSubscription.past_due = curr.past_due or false
end
record[key] = mergedSubscription
end
-- Convert record back to array
local mergedSubscriptions = {}
for _, subscription in pairs(record) do
table.insert(mergedSubscriptions, subscription)
end
return mergedSubscriptions
end
-- Helper function to merge customer subscriptions into entity subscriptions
-- Adds customer subscriptions that don't already exist in entity subscriptions (by subscription key)
-- Does NOT merge quantities - only adds missing subscriptions
-- Used by getEntity.lua to add customer subscriptions to entity subscriptions
-- Parameters:
-- entitySubscriptions - array of entity subscriptions (base)
-- customerSubscriptions - array of customer subscriptions to add
-- Returns: array of merged subscriptions (entity subscriptions + customer subscriptions that don't exist)
local function mergeCustomerSubscriptionsIntoEntity(entitySubscriptions, customerSubscriptions)
if not customerSubscriptions or #customerSubscriptions == 0 then
return entitySubscriptions or {}
end
if not entitySubscriptions then
entitySubscriptions = {}
end
-- Build a set of existing subscription keys in entity subscriptions
local existingKeys = {}
for _, subscription in ipairs(entitySubscriptions) do
local key = getSubscriptionKey(subscription)
existingKeys[key] = true
end
-- Add customer subscriptions that don't exist in entity subscriptions
local mergedSubscriptions = {}
-- First, add all entity subscriptions
for _, subscription in ipairs(entitySubscriptions) do
table.insert(mergedSubscriptions, subscription)
end
-- Then, add customer subscriptions that don't exist
for _, customerSubscription in ipairs(customerSubscriptions) do
local key = getSubscriptionKey(customerSubscription)
if not existingKeys[key] then
table.insert(mergedSubscriptions, customerSubscription)
end
end
return mergedSubscriptions
end

View File

@@ -0,0 +1,37 @@
-- cacheKeyUtils.lua
-- Shared cache key builders for customer and entity caches
-- Version placeholder {CUSTOMER_VERSION} is replaced at load time
-- Cache TTL constant (replaced at load time)
local CACHE_TTL_SECONDS = {TTL_SECONDS}
-- Build customer cache key with version
-- Returns: {orgId}:env:customer:{version}:customerId
local function buildCustomerCacheKey(orgId, env, customerId)
return "{" .. orgId .. "}:" .. env .. ":customer:{CUSTOMER_VERSION}:" .. customerId
end
-- Build entity cache key with version
-- Returns: {orgId}:env:customer:{version}:customerId:entity:entityId
local function buildEntityCacheKey(orgId, env, customerId, entityId)
return "{" .. orgId .. "}:" .. env .. ":customer:{CUSTOMER_VERSION}:" .. customerId .. ":entity:" .. entityId
end
-- Build balance cache key
-- Returns: {cacheKey}:balances:{featureId}
local function buildBalanceCacheKey(cacheKey, featureId)
return cacheKey .. ":balances:" .. featureId
end
-- Build rollover cache key
-- Returns: {cacheKey}:balances:{featureId}:rollover:{index}
local function buildRolloverCacheKey(cacheKey, featureId, index)
return cacheKey .. ":balances:" .. featureId .. ":rollover:" .. index
end
-- Build breakdown cache key
-- Returns: {cacheKey}:balances:{featureId}:breakdown:{index}
local function buildBreakdownCacheKey(cacheKey, featureId, index)
return cacheKey .. ":balances:" .. featureId .. ":breakdown:" .. index
end

View File

@@ -0,0 +1,161 @@
-- ============================================================================
-- GET CUSTOMER/ENTITY UTILITY FUNCTIONS
-- ============================================================================
-- Get customer object with merged balances and subscriptions
-- Parameters:
-- orgId: Organization ID
-- env: Environment
-- customerId: Customer ID
-- skipEntityMerge: If true, only load customer's own balances (no entity merging)
-- Returns: customer object table (not JSON encoded), or nil if not found
local function getCustomerObject(orgId, env, customerId, skipEntityMerge)
-- Build versioned cache key using shared utility
local cacheKey = buildCustomerCacheKey(orgId, env, customerId)
-- Load balances based on merge mode
-- If skipEntityMerge is true, only load customer's own balances (no entity merging)
-- If skipEntityMerge is false, load merged balances (customer + entities)
local balances
if skipEntityMerge then
-- Load only customer's own balances without entity merging
balances = loadBalances(cacheKey, orgId, env, customerId, "__CUSTOMER_ONLY__")
else
-- Load merged balances (customer + entities)
balances = loadBalances(cacheKey, orgId, env, customerId)
end
if not balances then
return nil -- Customer not in cache or partial eviction detected
end
-- Get base customer JSON for subscriptions and metadata
local baseJson = redis.call("GET", cacheKey)
if not baseJson then
return nil
end
local baseCustomer = cjson.decode(baseJson)
local entityIds = baseCustomer._entityIds or {}
-- ============================================================================
-- MERGE ENTITY SUBSCRIPTIONS INTO CUSTOMER SUBSCRIPTIONS
-- ============================================================================
-- Build entity base data map for subscription access
local entityBaseData = {}
for _, entityId in ipairs(entityIds) do
local entityCacheKey = buildEntityCacheKey(orgId, env, customerId, entityId)
local entityBaseJson = redis.call("GET", entityCacheKey)
if entityBaseJson then
entityBaseData[entityId] = cjson.decode(entityBaseJson)
end
end
-- Collect all subscriptions: start with customer's subscriptions, then add all entity subscriptions
local allSubscriptions = {}
if baseCustomer.subscriptions then
for _, subscription in ipairs(baseCustomer.subscriptions) do
table.insert(allSubscriptions, subscription)
end
end
-- Add subscriptions from each entity
for _, entityId in ipairs(entityIds) do
local entityBase = entityBaseData[entityId]
if entityBase and entityBase.subscriptions then
for _, subscription in ipairs(entityBase.subscriptions) do
table.insert(allSubscriptions, subscription)
end
end
end
-- Merge subscriptions by plan ID and normalized status
baseCustomer.subscriptions = mergeSubscriptions(allSubscriptions)
-- Merge invoices
-- Build final customer object
baseCustomer.invoices = baseCustomer.invoices or nil
baseCustomer._balanceFeatureIds = nil -- Remove tracking field
baseCustomer._entityIds = nil -- Remove tracking field
baseCustomer.balances = balances
return baseCustomer
end
-- Get entity object with merged balances and subscriptions
-- Parameters:
-- orgId: Organization ID
-- env: Environment
-- customerId: Customer ID
-- entityId: Entity ID
-- skipCustomerMerge: If true, only load entity's own balances (no customer merging)
-- Returns: entity object table (not JSON encoded), or nil if not found
local function getEntityObject(orgId, env, customerId, entityId, skipCustomerMerge)
-- Build versioned entity cache key using shared utility
local entityCacheKey = buildEntityCacheKey(orgId, env, customerId, entityId)
-- Get base entity JSON
local baseJson = redis.call("GET", entityCacheKey)
if not baseJson then
return nil
end
local baseEntity = cjson.decode(baseJson)
-- Build customer cache key for balance loading
local customerCacheKey = buildCustomerCacheKey(orgId, env, customerId)
-- ============================================================================
-- LOAD BALANCES USING loadBalances
-- ============================================================================
local mergedBalances
if skipCustomerMerge then
-- Load only entity's own balances (no customer merging)
-- We'll use loadBalances with "__CUSTOMER_ONLY__" mode on the entity cache key
-- This is a bit of a hack but works with the current structure
mergedBalances = loadBalances(entityCacheKey, orgId, env, customerId, "__CUSTOMER_ONLY__")
else
-- Load entity-level merged balances (entity + customer)
-- loadBalances handles this when entityId is provided
mergedBalances = loadBalances(customerCacheKey, orgId, env, customerId, entityId)
end
-- If balances loading failed (partial eviction), return nil
if not mergedBalances then
return nil
end
-- ============================================================================
-- MERGE CUSTOMER SUBSCRIPTIONS INTO ENTITY SUBSCRIPTIONS
-- Skip if skipCustomerMerge is true
-- ============================================================================
-- Get entity subscriptions (start with entity's own subscriptions)
local entitySubscriptions = baseEntity.subscriptions or {}
if not skipCustomerMerge then
-- Get customer subscriptions
local customerSubscriptions = nil
local customerBaseJson = redis.call("GET", customerCacheKey)
if customerBaseJson then
local customerBase = cjson.decode(customerBaseJson)
customerSubscriptions = customerBase.subscriptions
end
-- Merge customer subscriptions into entity subscriptions (only add if not exists)
baseEntity.subscriptions = mergeCustomerSubscriptionsIntoEntity(entitySubscriptions, customerSubscriptions)
else
-- No merging - just use entity's own subscriptions
baseEntity.subscriptions = entitySubscriptions
end
-- Build final entity object
baseEntity._balanceFeatureIds = nil -- Remove tracking field
baseEntity.balances = mergedBalances
return baseEntity
end

View File

@@ -0,0 +1,708 @@
-- loadBalances.lua
-- Shared function to load customer balances with merged entity balances (customer + entities)
-- Returns: { [featureId] = { granted_balance, purchased_balance, current_balance, usage, ... } } or nil if not in cache
-- Helper function to safely convert values to numbers for arithmetic
local function toNum(value)
return type(value) == "number" and value or 0
end
-- Helper function to parse HGETALL result into balance data object
local function parseBalanceHash(balanceHash)
local balanceData = {}
-- Define field types for parsing
local numericFields = {
granted_balance = true,
purchased_balance = true,
current_balance = true,
usage = true,
max_purchase = true,
_breakdown_count = true,
_rollover_count = true
}
local booleanFields = {
unlimited = true,
overage_allowed = true
}
local jsonFields = {
feature = true,
reset = true
}
for i = 1, #balanceHash, 2 do
local key = balanceHash[i]
local value = balanceHash[i + 1]
-- Check for null first before parsing
if value == "null" then
balanceData[key] = cjson.null
elseif numericFields[key] then
balanceData[key] = tonumber(value)
elseif booleanFields[key] then
balanceData[key] = (value == "true")
elseif jsonFields[key] then
-- Parse JSON fields (feature object and reset object)
if value ~= "null" and value ~= "" then
balanceData[key] = cjson.decode(value)
else
balanceData[key] = cjson.null
end
else
balanceData[key] = value
end
end
return balanceData
end
-- Helper function to fetch and parse rollover items
-- Returns: array of rollover data objects, or nil if any key is missing (partial eviction)
-- cacheKey: base cache key (customer or entity cache key)
-- featureId: feature ID
-- rolloverCount: number of rollover items to fetch
local function fetchRollovers(cacheKey, featureId, rolloverCount)
local rollovers = {}
for i = 0, rolloverCount - 1 do
local rolloverKey = buildRolloverCacheKey(cacheKey, featureId, i)
local rolloverHash = redis.call("HGETALL", rolloverKey)
-- If rollover key is missing, return nil (partial eviction detected)
if #rolloverHash == 0 then
return nil
end
local rolloverData = {}
for j = 1, #rolloverHash, 2 do
local key = rolloverHash[j]
local value = rolloverHash[j + 1]
if value == "null" then
rolloverData[key] = cjson.null
elseif key == "balance" or key == "expires_at" then
rolloverData[key] = tonumber(value)
else
rolloverData[key] = value
end
end
table.insert(rollovers, rolloverData)
end
return rollovers
end
-- Helper function to fetch and parse breakdown items
-- Returns: array of breakdown data objects, or nil if any key is missing (partial eviction)
-- cacheKey: base cache key (customer or entity cache key)
-- featureId: feature ID
-- breakdownCount: number of breakdown items to fetch
local function fetchBreakdown(cacheKey, featureId, breakdownCount)
local breakdown = {}
-- Define field types for parsing breakdown items
local breakdownNumericFields = {
granted_balance = true,
purchased_balance = true,
current_balance = true,
usage = true,
max_purchase = true
}
local breakdownBooleanFields = {
overage_allowed = true
}
local breakdownJsonFields = {
reset = true
}
for i = 0, breakdownCount - 1 do
local breakdownKey = buildBreakdownCacheKey(cacheKey, featureId, i)
local breakdownHash = redis.call("HGETALL", breakdownKey)
-- If breakdown key is missing, return nil (partial eviction detected)
if #breakdownHash == 0 then
return nil
end
local breakdownData = {}
for j = 1, #breakdownHash, 2 do
local key = breakdownHash[j]
local value = breakdownHash[j + 1]
if value == "null" then
breakdownData[key] = cjson.null
elseif breakdownNumericFields[key] then
breakdownData[key] = tonumber(value)
elseif breakdownBooleanFields[key] then
breakdownData[key] = (value == "true")
elseif breakdownJsonFields[key] then
-- Parse reset JSON object
if value ~= "null" and value ~= "" then
breakdownData[key] = cjson.decode(value)
else
breakdownData[key] = cjson.null
end
else
breakdownData[key] = value
end
end
table.insert(breakdown, breakdownData)
end
return breakdown
end
-- ============================================================================
-- MERGE BALANCE UTILITIES
-- ============================================================================
-- Helper function to merge numeric balance fields (sums values)
-- Mutates target by adding source's numeric fields
local function mergeBalanceNumericFields(target, source)
target.granted_balance = toNum(target.granted_balance) + toNum(source.granted_balance)
target.purchased_balance = toNum(target.purchased_balance) + toNum(source.purchased_balance)
target.current_balance = toNum(target.current_balance) + toNum(source.current_balance)
target.usage = toNum(target.usage) + toNum(source.usage)
target.max_purchase = toNum(target.max_purchase or 0) + toNum(source.max_purchase or 0)
end
-- Helper function to merge overage_allowed (true if at least one is true)
-- Mutates target
local function mergeBalanceOverageAllowed(target, source)
if source.overage_allowed == true then
target.overage_allowed = true
end
end
-- Helper function to merge reset objects (uses minimum resets_at)
-- Mutates target
local function mergeBalanceReset(target, source)
if source.reset and source.reset ~= cjson.null and type(source.reset) == "table" and source.reset.resets_at then
local sourceResetsAt = source.reset.resets_at
if type(sourceResetsAt) == "number" then
if target.reset and target.reset ~= cjson.null and type(target.reset) == "table" and target.reset.resets_at then
local targetResetsAt = target.reset.resets_at
if type(targetResetsAt) == "number" then
if sourceResetsAt < targetResetsAt then
target.reset.resets_at = sourceResetsAt
end
else
target.reset.resets_at = sourceResetsAt
end
else
-- Initialize reset object if it doesn't exist
target.reset = {
interval = source.reset.interval,
interval_count = source.reset.interval_count,
resets_at = sourceResetsAt
}
end
end
end
end
-- Helper function to generate breakdown item key for matching
-- Key format: "interval_count:interval:overage_allowed"
-- Example: "1:month:true" or "1:month:false"
local function getBreakdownItemKey(breakdownItem)
if not breakdownItem then
return nil
end
local intervalCount = 1
local interval = "none"
-- Extract interval and interval_count from reset object
if breakdownItem.reset and breakdownItem.reset ~= cjson.null and type(breakdownItem.reset) == "table" then
interval = breakdownItem.reset.interval or "none"
intervalCount = breakdownItem.reset.interval_count or 1
end
-- Get overage_allowed (usage model)
local overageAllowed = breakdownItem.overage_allowed or false
-- Return key in format: "interval_count:interval:overage_allowed"
return tostring(intervalCount) .. ":" .. interval .. ":" .. tostring(overageAllowed)
end
-- Helper function to merge source balance into target balance
-- Mutates targetBalance by adding sourceBalance's balances, usage, breakdowns, and rollovers
-- Also handles minimum resets_at (earliest reset time) and overage_allowed (true if any is true)
local function mergeFeatureBalances(targetBalance, sourceBalance)
if not sourceBalance then return end
-- Merge top-level balance fields
mergeBalanceNumericFields(targetBalance, sourceBalance)
mergeBalanceOverageAllowed(targetBalance, sourceBalance)
mergeBalanceReset(targetBalance, sourceBalance)
-- Merge breakdown balances and usage
-- Breakdown items are matched by key (interval_count:interval:overage_allowed)
-- If a matching breakdown exists, merge it; otherwise, add as new breakdown item
if sourceBalance.breakdown then
for _, sourceBreakdown in ipairs(sourceBalance.breakdown) do
local sourceKey = getBreakdownItemKey(sourceBreakdown)
local foundMatch = false
-- Try to find matching breakdown by key
if targetBalance.breakdown then
for _, targetBreakdown in ipairs(targetBalance.breakdown) do
local targetKey = getBreakdownItemKey(targetBreakdown)
if sourceKey and targetKey and sourceKey == targetKey then
-- Found matching breakdown - merge it
mergeBalanceNumericFields(targetBreakdown, sourceBreakdown)
mergeBalanceOverageAllowed(targetBreakdown, sourceBreakdown)
mergeBalanceReset(targetBreakdown, sourceBreakdown)
foundMatch = true
break
end
end
end
-- If no matching breakdown found, add as new breakdown item
if not foundMatch then
if not targetBalance.breakdown then
targetBalance.breakdown = {}
end
-- Create a copy of the source breakdown to add
local newBreakdown = {
granted_balance = sourceBreakdown.granted_balance,
purchased_balance = sourceBreakdown.purchased_balance,
current_balance = sourceBreakdown.current_balance,
usage = sourceBreakdown.usage,
max_purchase = sourceBreakdown.max_purchase,
overage_allowed = sourceBreakdown.overage_allowed,
reset = sourceBreakdown.reset
}
table.insert(targetBalance.breakdown, newBreakdown)
end
end
end
-- 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)
end
end
end
end
-- ============================================================================
-- LOAD SINGLE BALANCE (WITH _key FIELDS FOR REDIS OPERATIONS)
-- ============================================================================
-- Load a single balance from Redis cache (no merging)
-- Used by batchDeduction.lua for on-demand balance loading with Redis operation keys
-- Parameters:
-- cacheKey: Base cache key (customer or entity cache key)
-- featureId: Feature ID to load
-- Returns: balance object with _key fields for Redis operations, or nil if not found
local function loadBalance(cacheKey, featureId)
local balanceKey = buildBalanceCacheKey(cacheKey, featureId)
local balanceHash = redis.call("HGETALL", balanceKey)
if #balanceHash == 0 then
return nil
end
-- Parse balance hash using helper function
local balanceData = parseBalanceHash(balanceHash)
balanceData._key = balanceKey -- Add Redis key for operations
-- Fetch rollovers using helper function
local rolloverCount = balanceData._rollover_count or 0
balanceData._rollover_count = nil
local rollovers = fetchRollovers(cacheKey, featureId, rolloverCount)
if rollovers == nil then
return nil -- Partial eviction detected
end
-- Add _key fields to rollovers for Redis operations
if #rollovers > 0 then
for index, rollover in ipairs(rollovers) do
rollover._key = buildRolloverCacheKey(cacheKey, featureId, index - 1)
rollover._index = index - 1
end
balanceData.rollovers = rollovers
end
-- Fetch breakdown using helper function
local breakdownCount = balanceData._breakdown_count or 0
balanceData._breakdown_count = nil
local breakdown = fetchBreakdown(cacheKey, featureId, breakdownCount)
if breakdown == nil then
return nil -- Partial eviction detected
end
-- Add _key fields to breakdown items for Redis operations
if #breakdown > 0 then
for index, breakdownItem in ipairs(breakdown) do
breakdownItem._key = buildBreakdownCacheKey(cacheKey, featureId, index - 1)
breakdownItem._index = index - 1
end
balanceData.breakdown = breakdown
end
return balanceData
end
-- ============================================================================
-- LOAD BALANCES WITH MERGING
-- ============================================================================
-- Load entity-level balances (entity + customer merged)
-- Used for entity-level sync mode
-- Parameters: cacheKey (customer cache key), orgId, env, customerId, entityId
-- Returns: merged balances table (entity + customer) or nil
local function loadEntityLevelFeatures(cacheKey, orgId, env, customerId, entityId)
-- Build versioned entity cache key using shared utility
local entityCacheKey = buildEntityCacheKey(orgId, env, customerId, entityId)
-- Get entity base JSON
local entityBaseJson = redis.call("GET", entityCacheKey)
if not entityBaseJson then
return nil
end
local entityBase = cjson.decode(entityBaseJson)
local entityBalanceFeatureIds = entityBase._balanceFeatureIds or {}
-- Load entity balances
local entityBalances = {}
for _, featureId in ipairs(entityBalanceFeatureIds) do
local balanceKey = buildBalanceCacheKey(entityCacheKey, featureId)
local balanceHash = redis.call("HGETALL", balanceKey)
-- If balance key is missing, return nil (partial eviction detected)
if #balanceHash == 0 then
return nil
end
-- Parse balance hash using helper function
local balanceData = parseBalanceHash(balanceHash)
-- Fetch rollovers using helper function
local rolloverCount = balanceData._rollover_count or 0
balanceData._rollover_count = nil
local rollovers = fetchRollovers(entityCacheKey, featureId, rolloverCount)
if rollovers == nil then
return nil -- Partial eviction detected
end
if #rollovers > 0 then
balanceData.rollovers = rollovers
end
-- Fetch breakdown using helper function
local breakdownCount = balanceData._breakdown_count or 0
balanceData._breakdown_count = nil
local breakdown = fetchBreakdown(entityCacheKey, featureId, breakdownCount)
if breakdown == nil then
return nil -- Partial eviction detected
end
if #breakdown > 0 then
balanceData.breakdown = breakdown
end
entityBalances[featureId] = balanceData
end
-- Load customer balances (raw, no entity aggregation)
local customerCacheKey = cacheKey
local customerBaseJson = redis.call("GET", customerCacheKey)
local customerBalances = {}
if customerBaseJson then
local customerBase = cjson.decode(customerBaseJson)
local customerBalanceFeatureIds = customerBase._balanceFeatureIds or {}
for _, featureId in ipairs(customerBalanceFeatureIds) do
local balanceKey = buildBalanceCacheKey(customerCacheKey, featureId)
local balanceHash = redis.call("HGETALL", balanceKey)
if #balanceHash > 0 then
-- Parse balance hash using helper function
local balanceData = parseBalanceHash(balanceHash)
-- Fetch rollovers
local rolloverCount = balanceData._rollover_count or 0
balanceData._rollover_count = nil
local rollovers = fetchRollovers(customerCacheKey, featureId, rolloverCount) or {}
if #rollovers > 0 then
balanceData.rollovers = rollovers
end
-- Fetch breakdown
local breakdownCount = balanceData._breakdown_count or 0
balanceData._breakdown_count = nil
local breakdown = fetchBreakdown(customerCacheKey, featureId, breakdownCount) or {}
if #breakdown > 0 then
balanceData.breakdown = breakdown
end
customerBalances[featureId] = balanceData
end
end
end
-- Merge customer and entity balances (entity + customer)
local mergedBalances = {}
-- First, add all customer balances (inherited)
for featureId, customerBalance in pairs(customerBalances) do
mergedBalances[featureId] = customerBalance
end
-- Then, merge or add entity balances
for featureId, entityBalance in pairs(entityBalances) do
local customerBalance = customerBalances[featureId]
if customerBalance then
-- Both customer and entity have this balance - merge balances
if not entityBalance.unlimited and not customerBalance.unlimited then
mergeFeatureBalances(entityBalance, customerBalance)
end
mergedBalances[featureId] = entityBalance
else
-- Only entity has this balance - use entity's balance
mergedBalances[featureId] = entityBalance
end
end
return mergedBalances
end
-- Load customer balances with merged entity balances
-- Parameters: cacheKey, orgId, env, customerId, entityId (optional)
-- If entityId is "__CUSTOMER_ONLY__": returns ONLY customer balances (no merging)
-- If entityId is provided (string): returns entity-level merged balances (entity + customer)
-- If entityId is nil: returns customer-level merged balances (customer + all entities)
-- Returns: merged balances table or nil
local function loadBalances(cacheKey, orgId, env, customerId, entityId)
-- Special case: Customer-only mode (no entity merging)
if entityId == "__CUSTOMER_ONLY__" then
local baseJson = redis.call("GET", cacheKey)
if not baseJson then
return nil
end
local base = cjson.decode(baseJson)
local balanceFeatureIds = base._balanceFeatureIds or {}
-- Load only customer's own balances without entity merging
local customerBalances = {}
for _, featureId in ipairs(balanceFeatureIds) do
local balanceKey = buildBalanceCacheKey(cacheKey, featureId)
local balanceHash = redis.call("HGETALL", balanceKey)
if #balanceHash == 0 then
return nil -- Partial eviction detected
end
-- Parse balance hash
local balanceData = parseBalanceHash(balanceHash)
-- Fetch rollovers
local rollovers = fetchRollovers(cacheKey, featureId, balanceData._rollover_count or 0)
if rollovers == nil then
return nil -- Partial eviction
end
if #rollovers > 0 then
balanceData.rollovers = rollovers
end
-- Fetch breakdown
local breakdown = fetchBreakdown(cacheKey, featureId, balanceData._breakdown_count or 0)
if breakdown == nil then
return nil -- Partial eviction
end
if #breakdown > 0 then
balanceData.breakdown = breakdown
end
-- Remove metadata fields
balanceData._breakdown_count = nil
balanceData._rollover_count = nil
customerBalances[featureId] = balanceData
end
return customerBalances
end
-- If entityId is provided, load entity-level balances (entity + customer merged)
if entityId then
return loadEntityLevelFeatures(cacheKey, orgId, env, customerId, entityId)
end
-- Otherwise, load customer-level balances (customer + all entities merged)
-- Get base customer JSON
local baseJson = redis.call("GET", cacheKey)
if not baseJson then
return nil
end
local baseCustomer = cjson.decode(baseJson)
local balanceFeatureIds = baseCustomer._balanceFeatureIds or {}
local entityIds = baseCustomer._entityIds or {}
-- Build balances object
local balances = {}
for _, featureId in ipairs(balanceFeatureIds) do
local balanceKey = buildBalanceCacheKey(cacheKey, featureId)
local balanceHash = redis.call("HGETALL", balanceKey)
-- If balance key is missing, return nil (partial eviction detected)
if #balanceHash == 0 then
return nil
end
-- Parse balance hash using helper function
local balanceData = parseBalanceHash(balanceHash)
-- Fetch rollovers using helper function
local rolloverCount = balanceData._rollover_count or 0
balanceData._rollover_count = nil -- Remove from final output
local rollovers = fetchRollovers(cacheKey, featureId, rolloverCount)
if rollovers == nil then
return nil -- Partial eviction detected
end
if #rollovers > 0 then
balanceData.rollovers = rollovers
end
-- Fetch breakdown using helper function
local breakdownCount = balanceData._breakdown_count or 0
balanceData._breakdown_count = nil -- Remove from final output
local breakdown = fetchBreakdown(cacheKey, featureId, breakdownCount)
if breakdown == nil then
return nil -- Partial eviction detected
end
if #breakdown > 0 then
balanceData.breakdown = breakdown
end
balances[featureId] = balanceData
end
-- ============================================================================
-- FETCH AND MERGE ENTITY BALANCES
-- ============================================================================
-- Fetch all entity balances and aggregate balances
local entityBalanceData = {} -- {[entityId][featureId] = balanceData}
local entityBaseData = {} -- {[entityId] = entityBase} - Store entity base for product access
for _, entityId in ipairs(entityIds) do
local entityCacheKey = buildEntityCacheKey(orgId, env, customerId, entityId)
local entityBaseJson = redis.call("GET", entityCacheKey)
if entityBaseJson then
local entityBase = cjson.decode(entityBaseJson)
entityBaseData[entityId] = entityBase -- Store entity base for product access
local entityBalanceFeatureIds = entityBase._balanceFeatureIds or {}
entityBalanceData[entityId] = {}
for _, featureId in ipairs(entityBalanceFeatureIds) do
local balanceKey = buildBalanceCacheKey(entityCacheKey, featureId)
local balanceHash = redis.call("HGETALL", balanceKey)
if #balanceHash > 0 then
-- Parse entity balance using helper function
local entityBalance = parseBalanceHash(balanceHash)
-- Fetch breakdown items for this entity balance using helper function
local breakdownCount = entityBalance._breakdown_count or 0
entityBalance._breakdown_count = nil
entityBalance.breakdown = fetchBreakdown(entityCacheKey, featureId, breakdownCount) or {}
-- Fetch rollover items for this entity balance using helper function
local rolloverCount = entityBalance._rollover_count or 0
entityBalance._rollover_count = nil
entityBalance.rollovers = fetchRollovers(entityCacheKey, featureId, rolloverCount) or {}
entityBalanceData[entityId][featureId] = entityBalance
end
end
end
end
-- ============================================================================
-- MERGE ENTITY BALANCES INTO CUSTOMER BALANCES
-- ============================================================================
for featureId, customerBalance in pairs(balances) do
-- Skip if unlimited
if not customerBalance.unlimited then
-- Merge each entity's balances into customer balance
for entityId, entityBalances in pairs(entityBalanceData) do
local entityBalance = entityBalances[featureId]
if entityBalance then
mergeFeatureBalances(customerBalance, entityBalance)
end
end
end
end
-- Add entity-only balances (balances that exist in entities but not in customer)
for entityId, entityBalances in pairs(entityBalanceData) do
for featureId, entityBalance in pairs(entityBalances) do
if not balances[featureId] then
-- This balance doesn't exist in customer, add it with zero values
balances[featureId] = {
feature_id = featureId,
feature = entityBalance.feature,
unlimited = entityBalance.unlimited,
granted_balance = 0,
purchased_balance = 0,
current_balance = 0,
usage = 0,
max_purchase = entityBalance.max_purchase or 0,
overage_allowed = entityBalance.overage_allowed,
reset = entityBalance.reset,
breakdown = {}
}
end
end
end
-- Aggregate balances for entity-only balances using mergeFeatureBalances
for featureId, customerBalance in pairs(balances) do
-- Only process if this was an entity-only balance (all balances are still 0 from initialization)
if customerBalance.granted_balance == 0 and customerBalance.purchased_balance == 0 and customerBalance.current_balance == 0 and customerBalance.usage == 0 then
for entityId, entityBalances in pairs(entityBalanceData) do
local entityBalance = entityBalances[featureId]
if entityBalance then
mergeFeatureBalances(customerBalance, entityBalance)
end
end
end
end
-- Clean up empty rollovers arrays before returning
for featureId, balance in pairs(balances) do
if balance.rollovers and #balance.rollovers == 0 then
balance.rollovers = nil
end
end
-- Return merged balances
return balances
end

View File

@@ -0,0 +1,107 @@
-- cacheBalanceUtils.lua
-- Shared utility functions for storing balances to Redis cache
-- Used by setCustomer.lua and setEntity.lua (after migration)
-- Helper function to convert values to strings, handling cjson.null
local function toString(value)
if value == cjson.null or value == nil then
return "null"
end
return tostring(value)
end
-- Helper function to serialize reset object as JSON
local function serializeReset(reset)
if reset == nil or reset == cjson.null then
return "null"
end
return cjson.encode(reset)
end
-- Store balances to Redis cache
-- Parameters:
-- cacheKey: Base cache key (e.g., customer or entity cache key)
-- balances: Table containing balance data (record of featureId -> balanceData)
-- Returns: nothing (void function)
local function storeBalances(cacheKey, balances)
if not balances then
return
end
for featureId, balanceData in pairs(balances) do
local balanceKey = buildBalanceCacheKey(cacheKey, featureId)
-- Store breakdown count for reconstruction
local breakdownCount = 0
if balanceData.breakdown then
breakdownCount = #balanceData.breakdown
end
-- Store rollover count for reconstruction
local rolloverCount = 0
if balanceData.rollovers then
rolloverCount = #balanceData.rollovers
end
-- Serialize feature object as JSON string (optional field)
local featureJson = "null"
if balanceData.feature then
featureJson = cjson.encode(balanceData.feature)
end
-- Serialize reset object as JSON string (optional field)
local resetJson = serializeReset(balanceData.reset)
-- Store all top-level balance fields in a single HSET call with TTL
redis.call("HSET", balanceKey,
"feature_id", toString(balanceData.feature_id),
"feature", featureJson,
"unlimited", toString(balanceData.unlimited),
"granted_balance", toString(balanceData.granted_balance),
"purchased_balance", toString(balanceData.purchased_balance),
"current_balance", toString(balanceData.current_balance),
"usage", toString(balanceData.usage),
"max_purchase", toString(balanceData.max_purchase),
"overage_allowed", toString(balanceData.overage_allowed),
"reset", resetJson,
"_breakdown_count", toString(breakdownCount),
"_rollover_count", toString(rolloverCount)
)
redis.call("EXPIRE", balanceKey, CACHE_TTL_SECONDS)
-- Store each rollover item as separate HSET with TTL (single call per rollover)
if balanceData.rollovers then
for index, rolloverItem in ipairs(balanceData.rollovers) do
local rolloverKey = buildRolloverCacheKey(cacheKey, featureId, index - 1)
redis.call("HSET", rolloverKey,
"balance", toString(rolloverItem.balance),
"expires_at", toString(rolloverItem.expires_at)
)
redis.call("EXPIRE", rolloverKey, CACHE_TTL_SECONDS)
end
end
-- Store each breakdown item as separate HSET with TTL (single call per breakdown)
if balanceData.breakdown then
for index, breakdownItem in ipairs(balanceData.breakdown) do
local breakdownKey = buildBreakdownCacheKey(cacheKey, featureId, index - 1)
-- Serialize breakdown reset object as JSON
local breakdownResetJson = serializeReset(breakdownItem.reset)
redis.call("HSET", breakdownKey,
"granted_balance", toString(breakdownItem.granted_balance),
"purchased_balance", toString(breakdownItem.purchased_balance),
"current_balance", toString(breakdownItem.current_balance),
"usage", toString(breakdownItem.usage),
"max_purchase", toString(breakdownItem.max_purchase),
"overage_allowed", toString(breakdownItem.overage_allowed),
"reset", breakdownResetJson
)
redis.call("EXPIRE", breakdownKey, CACHE_TTL_SECONDS)
end
end
end
end

View File

@@ -1,92 +1,5 @@
import type { CustomerEntitlement, ResetCusEnt } from "@autumn/shared";
import { UTCDate } from "@date-fns/utc";
import { CronJob } from "cron";
import { format } from "date-fns";
import dotenv from "dotenv";
import { resetCustomerEntitlement } from "./cron/cronUtils.js";
import { runProductCron } from "./cron/productCron/runProductCron.js";
import { initDrizzle } from "./db/initDrizzle.js";
import { CusEntService } from "./internal/customers/cusProducts/cusEnts/CusEntitlementService.js";
import { OrgService } from "./internal/orgs/OrgService.js";
import { notNullish } from "./utils/genUtils.js";
import { initInfisical } from "./external/infisical/initInfisical.js";
dotenv.config();
await initInfisical();
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,
batchSize: 500,
});
const cacheEnabledOrgs = await OrgService.getCacheEnabledOrgs({ db });
const batchSize = 100;
for (let i = 0; i < cusEnts.length; i += batchSize) {
const batch = cusEnts.slice(i, i + batchSize);
const batchResets = [];
for (const cusEnt of batch) {
batchResets.push(
resetCustomerEntitlement({
db,
cusEnt: cusEnt,
cacheEnabledOrgs,
}),
);
}
const results = await Promise.all(batchResets);
const toUpsert = results.filter(notNullish);
await CusEntService.upsert({
db,
data: toUpsert as CustomerEntitlement[],
});
console.log(`Upserted ${toUpsert.length} short entitlements`);
}
console.log(
"FINISHED RESET CRON:",
format(new UTCDate(), "yyyy-MM-dd HH:mm:ss"),
);
console.log("----------------------------------\n");
} catch (error) {
console.error("Error getting entitlements for reset:", error);
return;
}
// await client.end();
};
const main = async () => {
await Promise.all([cronTask(), runProductCron()]);
};
new CronJob(
"* * * * *", // Run every minute
main,
null, // onComplete
true, // start immediately
"UTC", // timezone (adjust as needed)
);
main();
process.on("SIGTERM", async () => {
console.log("Received SIGTERM signal, closing database connection...");
await client.end();
process.exit(0);
});
process.on("SIGINT", async () => {
console.log("Received SIGINT signal, closing database connection...");
await client.end();
process.exit(0);
});
await import("./cron/cronInit.js");

View File

@@ -0,0 +1,85 @@
import type { CustomerEntitlement, ResetCusEnt } from "@autumn/shared";
import { UTCDate } from "@date-fns/utc";
import { CronJob } from "cron";
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 { 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,
batchSize: 500,
});
const batchSize = 100;
for (let i = 0; i < cusEnts.length; i += batchSize) {
const batch = cusEnts.slice(i, i + batchSize);
const batchResets = [];
for (const cusEnt of batch) {
batchResets.push(
resetCustomerEntitlement({
db,
cusEnt: cusEnt,
}),
);
}
const results = await Promise.all(batchResets);
const toUpsert = results.filter(notNullish);
await CusEntService.upsert({
db,
data: toUpsert as CustomerEntitlement[],
});
console.log(`Upserted ${toUpsert.length} short entitlements`);
}
console.log(
"FINISHED RESET CRON:",
format(new UTCDate(), "yyyy-MM-dd HH:mm:ss"),
);
console.log("----------------------------------\n");
} catch (error) {
console.error("Error getting entitlements for reset:", error);
return;
}
// await client.end();
};
const main = async () => {
await Promise.all([cronTask(), runProductCron()]);
};
new CronJob(
"* * * * *", // Run every minute
main,
null, // onComplete
true, // start immediately
"UTC", // timezone (adjust as needed)
);
main();
process.on("SIGTERM", async () => {
console.log("Received SIGTERM signal, closing database connection...");
await client.end();
process.exit(0);
});
process.on("SIGINT", async () => {
console.log("Received SIGINT signal, closing database connection...");
await client.end();
process.exit(0);
});

View File

@@ -94,11 +94,9 @@ const checkSubAnchor = async ({
const handleShortDurationCusEnt = async ({
db,
cusEnt,
cacheEnabledOrgs,
}: {
db: DrizzleCli;
cusEnt: ResetCusEnt;
cacheEnabledOrgs: any[];
}) => {
const ent = cusEnt.entitlement as FullEntitlement;
@@ -155,11 +153,9 @@ const shortDurations = [EntInterval.Minute, EntInterval.Hour, EntInterval.Day];
export const resetCustomerEntitlement = async ({
db,
cusEnt,
cacheEnabledOrgs,
}: {
db: DrizzleCli;
cusEnt: ResetCusEnt;
cacheEnabledOrgs: any[];
}) => {
try {
const ent = cusEnt.entitlement as FullEntitlement;
@@ -171,7 +167,6 @@ export const resetCustomerEntitlement = async ({
return await handleShortDurationCusEnt({
db,
cusEnt,
cacheEnabledOrgs,
});
}
@@ -299,10 +294,6 @@ export const resetCustomerEntitlement = async ({
)}`,
);
// let cacheOrg = cacheEnabledOrgs.find(
// (org) => org.id === cusEnt.customer.org_id
// );
const org = await OrgService.get({
db,
orgId: cusEnt.customer.org_id,

View File

@@ -3,9 +3,11 @@ import {
CusProductStatus,
customerPrices,
customerProducts,
customers,
} 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";
export const runProductCron = async () => {
console.log("Running product cron");
@@ -13,7 +15,10 @@ export const runProductCron = async () => {
const results = await db
.select()
.from(customerProducts)
.innerJoin(
customers,
eq(customerProducts.internal_customer_id, customers.internal_id),
)
.where(
and(
// No customer_prices exist for this customer_product
@@ -41,29 +46,7 @@ export const runProductCron = async () => {
`Found ${results.length} customer products with no prices and active trials`,
);
// fs.writeFileSync(
// `${process.cwd()}/scripts/expired_free_trials.json`,
// JSON.stringify(results, null, 2),
// );
// return;
// for (const result of results) {
// console.log(
// `Customer ${result.customers.id} product: ${result.customer_products.product_id}`,
// );
// }
// const uniqueOrgIds = [...new Set(results.map((r) => r.customers.org_id))];
// console.log("Unique org IDs:", uniqueOrgIds);
// for (const result of results) {
// console.log(
// `Customer ${result.customers.id} product: ${result.customer_products.product_id}`,
// );
// }
const expireCusProducts = async (ids: string[]) => {
// console.log("Expiring:", ids);
// Save the ids to scripts/json
await db
.update(customerProducts)
.set({
@@ -73,12 +56,25 @@ export const runProductCron = async () => {
};
const batchSize = 250;
for (let i = 0; i < results.length; i += batchSize) {
const batch = results.slice(i, i + batchSize);
await expireCusProducts(batch.map((r) => r.id));
await expireCusProducts(batch.map((r) => r.customer_products.id));
console.log(
`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);
}
return results;

View File

@@ -1,4 +1,4 @@
import type { Many, One, Relations } from "drizzle-orm";
import type { Relations } from "drizzle-orm";
import type { PgTable } from "drizzle-orm/pg-core";
export interface RelationPath {

View File

@@ -34,7 +34,6 @@ export function generateArrayAggSQL({
alias,
filter,
orderBy,
limit,
distinct = false,
}: ArrayAggregationConfig): SQL {
const tableAlias = alias || getTableAlias(table);
@@ -81,34 +80,33 @@ export function generateRowSubquerySQL({
return query;
}
/**
* Generate SQL for many-to-many join through junction table
* Example:
* SELECT json_agg(o)
* FROM member m
* INNER JOIN organizations o ON o.id = m.organization_id
* WHERE m.user_id = ${userId}
*/
export function generateJunctionJoinSQL({
junctionTable,
fromField,
toField,
fromTable,
toTable,
fromId,
}: JunctionJoinConfig): SQL {
const junctionAlias = getTableAlias(junctionTable);
const toAlias = getTableAlias(toTable);
const junctionTableName = getTableName(junctionTable);
const toTableName = getTableName(toTable);
// /**
// * Generate SQL for many-to-many join through junction table
// * Example:
// * SELECT json_agg(o)
// * FROM member m
// * INNER JOIN organizations o ON o.id = m.organization_id
// * WHERE m.user_id = ${userId}
// */
// export function generateJunctionJoinSQL({
// junctionTable,
// fromField,
// toField,
// toTable,
// fromId,
// }: JunctionJoinConfig): SQL {
// const junctionAlias = getTableAlias(junctionTable);
// const toAlias = getTableAlias(toTable);
// const junctionTableName = getTableName(junctionTable);
// const toTableName = getTableName(toTable);
return sql`
FROM ${sql.identifier(junctionTableName)} ${sql.identifier(junctionAlias)}
INNER JOIN ${sql.identifier(toTableName)} ${sql.identifier(toAlias)}
ON ${sql.identifier(toAlias)}.${sql.identifier("id")} = ${sql.identifier(junctionAlias)}.${sql.identifier(toField)}
WHERE ${sql.identifier(junctionAlias)}.${sql.identifier(fromField)} = ${fromId}
`;
}
// return sql`
// FROM ${sql.identifier(junctionTableName)} ${sql.identifier(junctionAlias)}
// INNER JOIN ${sql.identifier(toTableName)} ${sql.identifier(toAlias)}
// ON ${sql.identifier(toAlias)}.${sql.identifier("id")} = ${sql.identifier(junctionAlias)}.${sql.identifier(toField)}
// WHERE ${sql.identifier(junctionAlias)}.${sql.identifier(fromField)} = ${fromId}
// `;
// }
/**
* Generate SQL for limiting results per parent using window functions

View File

@@ -1,12 +1,7 @@
import { type SQL, sql } from "drizzle-orm";
import type { PgTable } from "drizzle-orm/pg-core";
import type { CTEConfig } from "../buildCte.js";
import {
buildRelationGraph,
getTableName,
parseJoinCondition,
type RelationNode,
} from "./relationGraph.js";
import { buildRelationGraph, type RelationNode } from "./relationGraph.js";
/**
* Build the optimized query using JOIN + GROUP BY strategy
@@ -26,7 +21,6 @@ export function buildJoinGroupByQuery({
}) => SQL | undefined;
}): SQL {
// Build relation graph
const rootTable = getSourceTable(config.from);
const graph = buildRelationGraph({
config,
relations,
@@ -34,7 +28,7 @@ export function buildJoinGroupByQuery({
});
// Step 1: Build aggregation CTEs for array (one-to-many) relations
const aggregationCTEs = buildAggregationCTEs({ graph, rootTable });
const aggregationCTEs = buildAggregationCTEs({ graph });
// Step 2: Build main query with row (one-to-one) relations as direct JOINs
const mainQuery = buildMainQuery({ graph, config });
@@ -181,10 +175,8 @@ function addNestedJoins({
*/
function buildAggregationCTEs({
graph,
rootTable,
}: {
graph: RelationNode;
rootTable: PgTable;
}): Array<{ name: string; definition: SQL }> {
const ctes: Array<{ name: string; definition: SQL }> = [];
@@ -301,13 +293,3 @@ function buildAggregationCTEs({
return ctes;
}
/**
* Get source table from config (unwrap CTEBuilder if needed)
*/
function getSourceTable(from: any): PgTable {
if (from?.config?.from) {
return getSourceTable(from.config.from);
}
return from;
}

View File

@@ -1,4 +1,4 @@
import { type SQL } from "drizzle-orm";
import type { SQL } from "drizzle-orm";
import type { PgTable } from "drizzle-orm/pg-core";
import type { CTEConfig } from "../buildCte.js";
import { CTEBuilder } from "../buildCte.js";
@@ -80,12 +80,10 @@ export function parseJoinCondition({
*/
export function buildRelationGraph({
config,
parentTable,
relations,
extractJoinCondition,
}: {
config: CTEConfig;
parentTable?: PgTable;
relations: Record<string, any>;
extractJoinCondition: (params: {
parentTable: PgTable;
@@ -128,7 +126,6 @@ export function buildRelationGraph({
// Recursively build nested nodes
const nestedNode = buildRelationGraph({
config: nested,
parentTable: table,
relations,
extractJoinCondition,
});

View File

@@ -36,11 +36,7 @@ export function inferMode(config: ModeDetectionConfig): CTEMode {
// 5. Plural field name? → array (entities, organizations, products)
// Exclude words ending in 'ss' (address, process, etc.)
if (
config.fieldName &&
config.fieldName.endsWith("s") &&
!config.fieldName.endsWith("ss")
) {
if (config.fieldName?.endsWith("s") && !config.fieldName.endsWith("ss")) {
return "array";
}

View File

@@ -1,5 +1,5 @@
import { getTableColumns, sql, SQL } from "drizzle-orm";
import { PgTable } from "drizzle-orm/pg-core";
import { getTableColumns, type SQL, sql } from "drizzle-orm";
import type { PgTable } from "drizzle-orm/pg-core";
export const buildConflictUpdateColumns = <T extends PgTable>(
table: T,

View File

@@ -1,4 +1,4 @@
import { ClickHouseClient, createClient } from "@clickhouse/client";
import { type ClickHouseClient, createClient } from "@clickhouse/client";
export const clickhouseClient: ClickHouseClient = createClient({
url: process.env.CLICKHOUSE_URL!,

View File

@@ -0,0 +1,45 @@
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { sql } from "drizzle-orm";
import { db } from "./initDrizzle.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
/**
* Initialize all database functions (stored procedures)
* Loads SQL files in dependency order: helpers first, then main functions
*/
export const initializeDatabaseFunctions = async () => {
try {
console.log("Initializing database functions...");
const deductRpcPath = join(
__dirname,
"../internal/balances/track/trackUtils/deductRpc",
);
// Load SQL files in dependency order:
// 1. Helper functions (used by main functions)
// 2. Main functions (depend on helpers)
const sqlFiles = [
// Helper functions
"deductFromRollovers.sql",
"deductFromMainBalance.sql",
"getTotalBalance.sql",
"deductFromAdditionalBalance.sql",
"performDeduction.sql",
];
for (const file of sqlFiles) {
const sqlContent = readFileSync(join(deductRpcPath, file), "utf-8");
await db.execute(sql.raw(sqlContent));
console.log(` ✓ Loaded ${file}`);
}
console.log("Database functions initialized successfully");
} catch (error) {
console.error("Failed to initialize database functions:", error);
throw error;
}
};

View File

@@ -0,0 +1,65 @@
import * as schema from "@autumn/shared";
import { is } from "drizzle-orm";
import { PgTable } from "drizzle-orm/pg-core";
import { logger } from "../external/logtail/logtailUtils";
import type { DrizzleCli } from "./initDrizzle";
const SKIP_TABLES = ["migrationErrors"];
export const validateDbSchema = async ({ db }: { db: DrizzleCli }) => {
// Dynamically get all tables from schema (exclude relations)
const tableEntries = Object.entries(schema)
.filter(([name, table]) => {
// Filter out relations and non-table exports
if (name.includes("Relations")) return false;
// Skip migrationErrors table (known issue)
if (SKIP_TABLES.includes(name)) return false;
return is(table, PgTable);
})
.map(([name, table]) => ({ name, table: table as PgTable }));
// Validate all tables by selecting all columns to ensure schema matches
// If schema mismatches, Drizzle will throw an error
const start = Date.now();
const results = await Promise.allSettled(
tableEntries.map(({ name, table }) =>
db
.select()
.from(table)
.limit(1)
.then(() => ({ name, success: true as const }))
.catch((err: Error) => ({
name,
success: false as const,
error: err.message,
})),
),
);
const elapsed = Date.now() - start;
// Check for any failures
const failures = results
.map((r) => (r.status === "fulfilled" ? r.value : null))
.filter(
(v): v is { name: string; success: false; error: string } =>
v !== null && !v.success,
);
if (failures.length > 0) {
const failureDetails = failures
.map((f) => `Table '${f.name}': ${f.error}`)
.join("; ");
logger.error(
`Health check failed - DB schema validation error: ${failureDetails}`,
);
throw new Error(
`Health check failed - DB schema validation error: ${failureDetails}`,
);
}
logger.info(
`Health check passed - DB schema validated for ${tableEntries.length} tables in ${elapsed}ms`,
);
return true;
};

View File

@@ -0,0 +1,182 @@
import { createHash } from "node:crypto";
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { sql } from "drizzle-orm";
import { logger } from "../external/logtail/logtailUtils";
import type { DrizzleCli } from "./initDrizzle";
type SqlFunction = {
name: string;
sourceFile: string;
content: string;
contentHash: string;
};
/**
* Dynamically discover SQL functions from the deductRpc folder
*/
const discoverSqlFunctions = (): SqlFunction[] => {
const __filename = fileURLToPath(import.meta.url);
const deductRpcPath = join(
__filename,
"../../internal/balances/track/trackUtils/deductRpc",
);
const sqlFiles = readdirSync(deductRpcPath).filter((file) =>
file.endsWith(".sql"),
);
return sqlFiles.map((file) => {
const filePath = join(deductRpcPath, file);
const content = readFileSync(filePath, "utf-8");
// Extract function name from CREATE FUNCTION statement
const functionNameMatch = content.match(
/CREATE\s+(?:OR\s+REPLACE\s+)?FUNCTION\s+(\w+)/i,
);
const functionName = functionNameMatch?.[1];
if (!functionName) {
throw new Error(`Could not extract function name from ${file}`);
}
// Create hash of normalized content (ignore whitespace differences)
const normalizedContent = content
.replace(/--.*$/gm, "") // Remove comments
.replace(/\s+/g, " ") // Normalize whitespace
.trim();
const contentHash = createHash("sha256")
.update(normalizedContent)
.digest("hex")
.substring(0, 16);
return {
name: functionName,
sourceFile: file,
content,
contentHash,
};
});
};
export const validateSqlFunctions = async ({
db,
validateContent = false,
}: {
db: DrizzleCli;
validateContent?: boolean;
}) => {
const start = Date.now();
// Dynamically discover SQL functions from source files
const requiredFunctions = discoverSqlFunctions();
logger.info(
`Discovered ${requiredFunctions.length} SQL functions from source files`,
);
// Query database for existing functions and their definitions
const result = await db.execute<{
function_name: string;
definition: string;
}>(sql`
SELECT
p.proname as function_name,
pg_get_functiondef(p.oid) as definition
FROM pg_proc p
JOIN pg_namespace n ON p.pronamespace = n.oid
WHERE n.nspname = 'public'
AND p.prokind = 'f'
ORDER BY p.proname;
`);
const dbFunctions = new Map(
result.map((row) => [row.function_name, row.definition]),
);
// Check for missing functions
const missingFunctions = requiredFunctions.filter(
(fn) => !dbFunctions.has(fn.name),
);
if (missingFunctions.length > 0) {
const missingDetails = missingFunctions
.map((fn) => `'${fn.name}' (from ${fn.sourceFile})`)
.join(", ");
logger.error(
`SQL function validation failed: Missing functions: ${missingDetails}`,
);
throw new Error(`Missing SQL functions: ${missingDetails}`);
}
// Optionally validate function content
const mismatchedFunctions: Array<{
name: string;
sourceFile: string;
reason: string;
}> = [];
if (validateContent) {
for (const fn of requiredFunctions) {
const dbDefinition = dbFunctions.get(fn.name);
if (!dbDefinition) continue;
// Normalize both definitions for comparison
const normalizeFunc = (str: string) =>
str
.replace(/--.*$/gm, "") // Remove comments
.replace(/\s+/g, " ") // Normalize whitespace
.toLowerCase()
.trim();
const normalizedSource = normalizeFunc(fn.content);
const normalizedDb = normalizeFunc(dbDefinition);
// Create hashes for comparison
const sourceHash = createHash("sha256")
.update(normalizedSource)
.digest("hex")
.substring(0, 16);
const dbHash = createHash("sha256")
.update(normalizedDb)
.digest("hex")
.substring(0, 16);
if (sourceHash !== dbHash) {
mismatchedFunctions.push({
name: fn.name,
sourceFile: fn.sourceFile,
reason: `Source hash: ${sourceHash}, DB hash: ${dbHash}`,
});
}
}
if (mismatchedFunctions.length > 0) {
const mismatchDetails = mismatchedFunctions
.map((fn) => `'${fn.name}' (${fn.sourceFile}): ${fn.reason}`)
.join("; ");
logger.warn(`SQL function content mismatch detected: ${mismatchDetails}`);
logger.warn(
"Functions exist but their content differs from source files. Run migrations to update.",
);
}
}
const elapsed = Date.now() - start;
logger.info(
`SQL function validation passed - ${requiredFunctions.length} functions verified in ${elapsed}ms${validateContent ? " (content validated)" : ""}`,
);
if (mismatchedFunctions.length > 0) {
logger.info(
`⚠️ ${mismatchedFunctions.length} function(s) have content differences`,
);
}
return true;
};

View File

@@ -0,0 +1,59 @@
import type { ZodError, ZodIssue } from "zod/v4";
/**
* Formats Zod validation errors into user-friendly messages
*/
export function formatZodError(error: ZodError): string {
const formatMessage = (issue: ZodIssue): string => {
const path = issue.path.length ? issue.path.join(".") : "input";
// Clean up common Zod error messages
let message = issue.message;
// Handle common patterns and make them more user-friendly
if (
message.includes("Too small") &&
message.includes("expected string to have >=1 characters")
) {
message = "cannot be empty";
} else if (message.includes("Invalid option: expected one of")) {
// Clean up enum error messages
// Example: 'Invalid option: expected one of "a"|"b"|"c"' -> 'must be one of: a, b, c'
const match = message.match(/expected one of (.+)/);
if (match) {
const options = match[1]
.split("|")
.map((opt) => opt.replace(/['"]/g, "").trim())
.join(", ");
message = `must be one of: ${options}`;
}
} else if (message.includes("Invalid string: must match pattern")) {
// Extract the pattern and make it more readable
if (message.includes("/^[a-zA-Z0-9_-]+$/")) {
message =
"must contain only letters, numbers, underscores, and hyphens";
} else {
message = "has invalid format";
}
} else if (message.includes("Invalid input: expected string, received")) {
const receivedType = message.split("received ")[1];
message = `must be a string (received ${receivedType})`;
} else if (message.includes("Invalid input: expected number, received")) {
const receivedType = message.split("received ")[1];
message = `must be a number (received ${receivedType})`;
} else if (message.includes("Invalid input: expected boolean, received")) {
const receivedType = message.split("received ")[1];
message = `must be a boolean (received ${receivedType})`;
}
return `${path}: ${message}`;
};
const formattedIssues = error.issues.map(formatMessage);
// If there are multiple issues, format them nicely
if (formattedIssues.length === 1) {
return formattedIssues[0];
}
return `[Validation Errors] ${formattedIssues.join("; ")}`;
}

View File

@@ -1,5 +1,5 @@
import { Writable } from "node:stream";
import pino from "pino";
import { Writable } from "stream";
// Custom log formatter for Bun compatibility
const createDevLogStream = () => {
@@ -53,7 +53,7 @@ const createDevLogStream = () => {
};
return new Writable({
write(chunk, encoding, callback) {
write(chunk, _encoding, callback) {
try {
const log = JSON.parse(chunk.toString());
const timestamp = new Date(log.time)

View File

@@ -4,8 +4,10 @@ import dotenv from "dotenv";
dotenv.config();
import {
type ApiEntity,
type ApiBaseEntity,
type AttachBody,
type BalancesUpdateParams,
type CheckQuery,
type CreateCustomerParams,
type CreateEntityParams,
type CreateRewardProgram,
@@ -17,6 +19,7 @@ import {
type RewardRedemption,
type TrackParams,
} from "@autumn/shared";
import { defaultApiVersion } from "@tests/constants.js";
import type {
CancelParams,
CheckoutParams,
@@ -26,7 +29,6 @@ import type {
Customer,
UsageParams,
} from "autumn-js";
import { defaultApiVersion } from "tests/constants.js";
export default class AutumnError extends Error {
message: string;
@@ -89,6 +91,32 @@ export class AutumnInt {
const response = await fetch(`${this.baseUrl}${path}`, {
headers: this.headers,
});
if (response.status !== 200) {
// Handle rate limit errors
if (response.status === 429) {
throw new AutumnError({
message: `request failed, rate limit exceeded`,
code: "rate_limit_exceeded",
});
}
let error: any;
try {
error = await response.json();
} catch (error) {
throw new AutumnError({
message: `request failed, error: ${error}`,
code: ErrCode.InternalError,
});
}
throw new AutumnError({
message: error.message,
code: error.code,
});
}
return response.json();
}
@@ -126,6 +154,40 @@ export class AutumnInt {
return response.json();
}
async patch(path: string, body: any) {
const response = await fetch(`${this.baseUrl}${path}`, {
method: "PATCH",
headers: this.headers,
body: JSON.stringify(body),
});
if (response.status !== 200) {
// Handle rate limit errors
if (response.status === 429) {
throw new AutumnError({
message: `request failed, rate limit exceeded`,
code: "rate_limit_exceeded",
});
}
let error: any;
try {
error = await response.json();
} catch (error) {
throw new AutumnError({
message: `request failed, error: ${error}`,
code: ErrCode.InternalError,
});
}
throw new AutumnError({
message: error.message,
code: error.code,
});
}
return response.json();
}
async delete(
path: string,
@@ -194,6 +256,27 @@ export class AutumnInt {
return data;
}
async updateCusEnt({
customerId,
customerEntitlementId,
updates,
}: {
customerId: string;
customerEntitlementId: string;
updates: {
balance?: number;
next_reset_at?: number;
entity_id?: string;
};
}) {
const data = await this.post(
`/customers/${customerId}/entitlements/${customerEntitlementId}`,
updates,
);
return data;
}
async checkout(
params: CheckoutParams & { invoice?: boolean; force_checkout?: boolean },
) {
@@ -267,20 +350,20 @@ export class AutumnInt {
return data;
},
get: async (
get: async <
T = Customer & {
invoices: any[];
autumn_id?: string;
entities?: ApiBaseEntity[];
},
>(
customerId: string,
params?: {
expand?: CusExpand[];
skip_cache?: string;
with_autumn_id?: boolean;
},
): Promise<
Customer & {
invoices: any[];
autumn_id?: string;
entities?: ApiEntity[];
}
> => {
): Promise<T> => {
const queryParams = new URLSearchParams();
const defaultParams = {
expand: [CusExpand.Invoices],
@@ -395,7 +478,7 @@ export class AutumnInt {
// if (product.items && typeof product.items === "object") {
// product.items = Object.values(product.items);
// }
const data = await this.post(`/products/${productId}`, product);
const data = await this.patch(`/products/${productId}`, product);
return data;
},
@@ -525,8 +608,16 @@ export class AutumnInt {
},
};
track = async (params: TrackParams) => {
const data = await this.post(`/track`, params);
track = async (
params: TrackParams,
{ skipCache = false }: { skipCache?: boolean } = {},
) => {
const queryParams = new URLSearchParams();
if (skipCache) {
queryParams.append("skip_cache", "true");
}
const data = await this.post(`/track?${queryParams.toString()}`, params);
return data;
};
@@ -535,8 +626,15 @@ export class AutumnInt {
return data;
};
check = async (params: CheckParams): Promise<CheckResult> => {
const data = await this.post(`/check`, params);
check = async <T = CheckResult>(
params: CheckParams & CheckQuery & { skip_event?: boolean },
): Promise<T> => {
const queryParams = new URLSearchParams();
if (params.skip_cache) {
queryParams.append("skip_cache", "true");
}
const data = await this.post(`/check?${queryParams.toString()}`, params);
return data;
};
@@ -560,7 +658,10 @@ export class AutumnInt {
return data;
};
initStripe = async () => {
await this.post(`/products/all/init_stripe`, {});
balances = {
update: async (params: BalancesUpdateParams) => {
const data = await this.post(`/balances/update`, params);
return data;
},
};
}

View File

@@ -0,0 +1,499 @@
/** biome-ignore-all lint/suspicious/noExplicitAny: AutumnCliV2 is used for internal testing & scripts */
import dotenv from "dotenv";
dotenv.config();
import {
type AttachBody,
type CreateEntityParams,
type CreateRewardProgram,
CusExpand,
EntityExpand,
ErrCode,
type OrgConfig,
type RewardRedemption,
} from "@autumn/shared";
import type {
CancelParams,
CheckoutParams,
CheckoutResult,
CheckParams,
CheckResult,
Customer,
TrackParams,
UsageParams,
} from "autumn-js";
export default class AutumnError extends Error {
message: string;
code: string;
constructor({ message, code }: { message: string; code: string }) {
super(message);
this.message = message;
this.code = code;
}
toString(): string {
return `${this.message} (code: ${this.code})`;
}
}
/**
* Robust Autumn API client (V2) with proper version handling
*
* Key improvements over V1:
* - Properly respects x-api-version header for ALL requests
* - No legacy v1Schema params
* - Cleaner error handling
* - Type-safe version parameter
*/
export class AutumnCliV2 {
private apiKey: string;
public headers: Record<string, string>;
public baseUrl: string;
public version?: string;
constructor({
apiKey,
secretKey,
baseUrl,
version,
orgConfig,
liveUrl = false,
}: {
apiKey?: string;
secretKey?: string;
baseUrl?: string;
version?: string;
orgConfig?: Partial<OrgConfig>;
liveUrl?: boolean;
} = {}) {
this.apiKey =
apiKey || secretKey || process.env.UNIT_TEST_AUTUMN_SECRET_KEY || "";
this.headers = {
Authorization: `Bearer ${this.apiKey}`,
"Content-Type": "application/json",
};
this.version = version;
if (version) {
this.headers["x-api-version"] = version;
}
if (orgConfig) {
this.headers["org-config"] = JSON.stringify(orgConfig);
}
this.baseUrl =
baseUrl ||
(liveUrl ? "https://api.useautumn.com/v1" : "http://localhost:8080/v1");
}
async get(path: string) {
const response = await fetch(`${this.baseUrl}${path}`, {
headers: this.headers,
});
if (response.status !== 200) {
let error: any;
try {
error = await response.json();
} catch (_e) {
throw new AutumnError({
message: `GET ${path} failed with status ${response.status}`,
code: ErrCode.InternalError,
});
}
throw new AutumnError({
message: error.message || `GET ${path} failed`,
code: error.code || ErrCode.InternalError,
});
}
return response.json();
}
async post(path: string, body: any) {
const response = await fetch(`${this.baseUrl}${path}`, {
method: "POST",
headers: this.headers,
body: JSON.stringify(body),
});
if (response.status !== 200) {
let error: any;
try {
error = await response.json();
} catch (_e) {
throw new AutumnError({
message: `POST ${path} failed with status ${response.status}`,
code: ErrCode.InternalError,
});
}
throw new AutumnError({
message: error.message || `POST ${path} failed`,
code: error.code || ErrCode.InternalError,
});
}
return response.json();
}
async delete(
path: string,
{
deleteInStripe = false,
}: {
deleteInStripe?: boolean;
} = {},
) {
const queryParams = deleteInStripe ? "?delete_in_stripe=true" : "";
const response = await fetch(`${this.baseUrl}${path}${queryParams}`, {
method: "DELETE",
headers: this.headers,
});
if (response.status !== 200) {
let error: any;
try {
error = await response.json();
} catch (_e) {
throw new AutumnError({
message: `DELETE ${path} failed with status ${response.status}`,
code: ErrCode.InternalError,
});
}
throw new AutumnError({
message: error.message || `DELETE ${path} failed`,
code: error.code || ErrCode.InternalError,
});
}
return response.json();
}
async createCustomer({
id,
email,
name,
fingerprint,
}: {
id: string;
email: string;
name: string;
fingerprint?: string;
}) {
return await this.post("/customers", {
id,
email,
name,
fingerprint,
});
}
async attach(params: AttachBody) {
return await this.post(`/attach`, params);
}
async checkout(
params: CheckoutParams & { invoice?: boolean; force_checkout?: boolean },
) {
const data = await this.post(`/checkout`, params);
return data as CheckoutResult;
}
async transfer(
customerId: string,
params: {
from_entity_id?: string;
to_entity_id: string;
product_id: string;
},
) {
const data = await this.post(`/customers/${customerId}/transfer`, params);
return data as CheckoutResult;
}
async sendEvent({
customerId,
eventName,
properties,
customer_data,
idempotency_key,
}: {
customerId: string;
eventName: string;
properties?: any;
customer_data?: any;
idempotency_key?: string;
}) {
return await this.post(`/events`, {
customer_id: customerId,
event_name: eventName,
properties,
customer_data,
idempotency_key,
});
}
async entitled({
customerId,
featureId,
quantity,
customer_data,
}: {
customerId: string;
featureId: string;
quantity?: number;
customer_data?: any;
}) {
return await this.post(`/entitled`, {
customer_id: customerId,
feature_id: featureId,
quantity,
customer_data,
});
}
customers = {
list: async (params?: { limit?: number; offset?: number }) => {
const queryString = params
? `?${new URLSearchParams(params as Record<string, string>).toString()}`
: "";
return await this.get(`/customers${queryString}`);
},
get: async (
customerId: string,
params?: {
expand?: CusExpand[];
},
): Promise<
Customer & {
invoices: any[];
}
> => {
const queryParams = new URLSearchParams();
const defaultParams = {
expand: [CusExpand.Invoices],
};
const finalParams = { ...defaultParams, ...params };
if (finalParams.expand) {
queryParams.append("expand", finalParams.expand.join(","));
}
return await this.get(
`/customers/${customerId}?${queryParams.toString()}`,
);
},
create: async (customer: { id: string; email?: string; name?: string }) => {
return await this.post(`/customers?with_autumn_id=true`, customer);
},
delete: async (
customerId: string,
{
deleteInStripe = false,
}: {
deleteInStripe?: boolean;
} = {},
) => {
return await this.delete(`/customers/${customerId}`, {
deleteInStripe,
});
},
};
entities = {
get: async (customerId: string, entityId: string) => {
return await this.get(
`/customers/${customerId}/entities/${entityId}?expand=${EntityExpand.Invoices}`,
);
},
create: async (
customerId: string,
entity: CreateEntityParams | CreateEntityParams[],
) => {
return await this.post(
`/customers/${customerId}/entities?with_autumn_id=true`,
entity,
);
},
list: async (customerId: string) => {
return await this.get(`/customers/${customerId}/entities`);
},
delete: async (customerId: string, entityId: string) => {
return await this.delete(`/customers/${customerId}/entities/${entityId}`);
},
};
products = {
/**
* Get product - respects x-api-version header set in constructor
*/
get: async (productId: string) => {
return await this.get(`/products/${productId}`);
},
/**
* Create product - respects x-api-version header
*/
create: async (product: any) => {
return await this.post(`/products`, product);
},
/**
* Update product - respects x-api-version header
*/
update: async (productId: string, product: any) => {
return await this.post(`/products/${productId}`, product);
},
/**
* Delete product
*/
delete: async (productId: string) => {
return await this.delete(`/products/${productId}`);
},
/**
* List products - respects x-api-version header
*/
list: async (params?: { limit?: number; offset?: number }) => {
const queryString = params
? `?${new URLSearchParams(params as Record<string, string>).toString()}`
: "";
return await this.get(`/products${queryString}`);
},
};
rewards = {
get: async (rewardId: string) => {
return await this.get(`/rewards/${rewardId}`);
},
create: async (reward: any) => {
return await this.post(`/rewards?legacyStripe=true`, reward);
},
delete: async (rewardId: string) => {
return await this.delete(`/rewards/${rewardId}`);
},
};
rewardPrograms = {
create: async (rewardProgram: CreateRewardProgram) => {
return await this.post(`/reward_programs`, rewardProgram);
},
};
referrals = {
createCode: async ({
customerId,
referralId,
}: {
customerId: string;
referralId: string;
}) => {
return await this.post(`/referrals/code`, {
customer_id: customerId,
program_id: referralId,
});
},
redeem: async ({
customerId,
code,
}: {
customerId: string;
code: string;
}) => {
return await this.post(`/referrals/redeem`, {
customer_id: customerId,
code,
});
},
};
redemptions = {
get: async (redemptionId: string) => {
const data = await this.get(`/redemptions/${redemptionId}`);
return data as RewardRedemption;
},
};
events = {
send: async ({
customerId,
featureId,
value,
properties,
}: {
customerId: string;
featureId: string;
value: number;
properties?: any;
}) => {
return await this.post(`/events`, {
customer_id: customerId,
feature_id: featureId,
value,
properties,
});
},
};
stripe = {
connect: async (params: {
secret_key: string;
success_url: string;
default_currency: string;
}) => {
return await this.post(`/organization/stripe`, params);
},
delete: async () => {
return await this.delete(`/organization/stripe`);
},
};
track = async (params: TrackParams & { timestamp?: number }) => {
return await this.post(`/track`, params);
};
usage = async (params: UsageParams) => {
return await this.post(`/usage`, params);
};
check = async (params: CheckParams): Promise<CheckResult> => {
return await this.post(`/check`, params);
};
attachPreview = async (params: AttachBody) => {
return await this.post(`/attach/preview`, params);
};
cancel = async (params: CancelParams) => {
return await this.post(`/cancel`, params);
};
migrate = async (params: {
from_product_id: string;
to_product_id: string;
from_version: number;
to_version: number;
}) => {
return await this.post(`/migrations`, params);
};
}

View File

@@ -1,7 +1,7 @@
import { AppEnv, ErrCode, Organization } from "@autumn/shared";
import { AppEnv, ErrCode, type Organization } from "@autumn/shared";
import { Autumn } from "autumn-js";
// import { Autumn } from "./autumnCli.js";
import RecaseError from "@/utils/errorUtils.js";
import { Autumn } from "autumn-js";
export enum FeatureId {
Products = "products",

View File

@@ -5,7 +5,7 @@ import RecaseError from "@/utils/errorUtils.js";
export const autumnWebhookRouter: Router = express.Router();
const verifyAutumnWebhook = async (req: any, res: any) => {
const verifyAutumnWebhook = async (req: any) => {
const wh = new Webhook(process.env.AUTUMN_WEBHOOK_SECRET!);
const headers = req.headers;
@@ -49,10 +49,13 @@ autumnWebhookRouter.post(
express.raw({ type: "application/json" }),
async (req, res) => {
try {
const evt = await verifyAutumnWebhook(req, res);
const evt = await verifyAutumnWebhook(req);
console.log("Received webhook from autumn");
const { type, data } = evt;
// console.log("Event:", evt);
// console.log("Data:", data);
switch (type) {
case WebhookEventType.CustomerProductsUpdated:
console.log(

View File

@@ -1,6 +1,6 @@
import fs from "fs";
import path from "path";
import { ClickHouseClient, QueryParams } from "@clickhouse/client";
import fs from "node:fs";
import path from "node:path";
import type { ClickHouseClient, QueryParams } from "@clickhouse/client";
import { clickhouseClient } from "../../db/initClickHouse.js";
export enum ClickHouseQuery {
@@ -115,6 +115,7 @@ export class ClickHouseManager {
}
}
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: Might comment this back in in the future
private async ensureQueriesExist() {
if (!this.client) {
throw new Error("ClickHouse client not initialized");

View File

@@ -40,9 +40,7 @@ export const createStripeCli = ({
const decrypted = decryptData(encrypted);
return new Stripe(decrypted, {
apiVersion: legacyVersion
? ("2025-02-24.acacia" as any)
: "2025-07-30.basil",
apiVersion: legacyVersion ? ("2025-02-24.acacia" as any) : undefined,
});
}

View File

@@ -1,34 +1,5 @@
import { join } from "node:path";
import { InfisicalSDK } from "@infisical/sdk";
import { config } from "dotenv";
export const loadLocalEnv = () => {
const processDir = process.cwd();
const serverDir = processDir.includes("server")
? processDir
: join(processDir, "server");
// Determine which env file to load based on ENV_FILE environment variable
// Defaults to .env if not specified
const envFileName = process.env.ENV_FILE || ".env";
const envPath = join(serverDir, envFileName);
// Load local .env file FIRST - these will take precedence over Infisical
const result = config({ path: envPath });
if (result.parsed) {
console.log(
`📄 Loading ${Object.keys(result.parsed).length} variables from ${envFileName}`,
);
for (const [key, value] of Object.entries(result.parsed)) {
process.env[key] = value;
}
} else {
console.log(
` No ${envFileName} file found (using only Infisical secrets)`,
);
}
};
import { loadLocalEnv } from "@/utils/envUtils.js";
/**
* Initialize Infisical and load secrets into process.env
* This allows all existing code using process.env to work seamlessly

View File

@@ -5,8 +5,6 @@ if (!process.env.CACHE_URL) {
throw new Error("CACHE_URL (redis) is not set");
}
let redis: Redis;
const regionToCacheUrl: Record<string, string | undefined> = {
"us-east-2": process.env.CACHE_URL_US_EAST,
};
@@ -23,17 +21,13 @@ const caText = await loadCaCert({
type: "cache",
});
redis = new Redis(regionalCacheUrl || process.env.CACHE_URL, {
const redis = new Redis(regionalCacheUrl || process.env.CACHE_URL, {
tls: caText ? { ca: caText } : undefined,
});
// biome-ignore lint/correctness/noUnusedFunctionParameters: Might uncomment this back in in the future
redis.on("error", (error) => {
// logger.error(`redis (cache) error: ${error.message}`);
});
export { redis };
// export const redis = new Redis(process.env.CACHE_URL, {
// tls: {
// ca: process.env.CACHE_CA,
// },
// });

View File

@@ -19,7 +19,7 @@ export const loadCaCert = async ({
const ca = Bun.file(caPath || `/etc/secrets/${type}-cert.pem`);
const caText = await ca.text();
return undefined;
return caText;
} catch (_error) {
return;
}

View File

@@ -24,7 +24,7 @@ export const sendTextEmail = async ({
try {
logger.info(`Sending email to ${to} with subject ${subject}`);
const { data, error } = await resend.emails.send({
const { error } = await resend.emails.send({
from: from,
to: to,
subject: subject,

View File

@@ -0,0 +1,21 @@
import * as Sentry from "@sentry/bun";
import type { AutumnContext } from "../../honoUtils/HonoEnv";
export const setSentryTags = ({
ctx,
customerId,
messageId,
}: {
ctx: AutumnContext;
customerId?: string;
messageId?: string;
}) => {
Sentry.setTags({
org_id: ctx.org.id,
org_slug: ctx.org.slug,
env: ctx.env,
request_id: ctx.id,
customer_id: customerId,
message_id: messageId,
});
};

View File

@@ -20,7 +20,6 @@ import { billingIntervalToStripe } from "../stripePriceUtils.js";
import { priceToInArrearTiers } from "./createStripeInArrear.js";
export interface StripeMeteredPriceParams {
db: DrizzleCli;
stripeCli: Stripe;
price: Price;
entitlements: EntitlementWithFeature[];
@@ -29,7 +28,6 @@ export interface StripeMeteredPriceParams {
}
export const createStripeMeteredPrice = async ({
db,
stripeCli,
price,
entitlements,
@@ -225,7 +223,6 @@ export const createStripeArrearProrated = async ({
// CREATE PLACEHOLDER PRICE FOR INARREAR PRORATED PRICING
if (billingType === BillingType.InArrearProrated) {
const placeholderPrice = await createStripeMeteredPrice({
db,
stripeCli,
price,
entitlements,

View File

@@ -178,7 +178,6 @@ export const createStripePriceIFNotExist = async ({
} else if (!config.stripe_placeholder_price_id) {
logger.info(`Creating stripe placeholder price`);
const placeholderPrice = await createStripeMeteredPrice({
db,
stripeCli,
price,
entitlements,

View File

@@ -1,4 +1,8 @@
import type { Organization } from "@autumn/shared";
import {
CusExpand,
type FullCustomer,
type Organization,
} from "@autumn/shared";
import chalk from "chalk";
import { Stripe } from "stripe";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
@@ -7,6 +11,8 @@ import { unsetOrgStripeKeys } from "@/internal/orgs/orgUtils.js";
import type { ExtendedRequest } from "@/utils/models/Request.js";
import type { AutumnContext } from "../../honoUtils/HonoEnv.js";
import { deleteCachedApiCustomer } from "../../internal/customers/cusUtils/apiCusCacheUtils/deleteCachedApiCustomer.js";
import { setCachedApiInvoices } from "../../internal/customers/cusUtils/apiCusCacheUtils/setCachedApiInvoices.js";
import { setCachedApiSubs } from "../../internal/customers/cusUtils/apiCusCacheUtils/setCachedApiSubs.js";
import type { Logger } from "../logtail/logtailUtils.js";
import { handleCheckoutSessionCompleted } from "./webhookHandlers/handleCheckoutCompleted.js";
import { handleCusDiscountDeleted } from "./webhookHandlers/handleCusDiscountDeleted.js";
@@ -41,6 +47,13 @@ const coreEvents = [
"checkout.session.completed",
];
const updateInvoiceEvents = [
"invoice.paid",
"invoice.updated",
"invoice.created",
"invoice.finalized",
];
const handleStripeWebhookRefresh = async ({
eventType,
data,
@@ -51,9 +64,11 @@ const handleStripeWebhookRefresh = async ({
ctx: AutumnContext;
}) => {
const { db, logger, org, env } = ctx;
if (
coreEvents.includes(eventType) ||
updateProductEvents.includes(eventType)
updateProductEvents.includes(eventType) ||
updateInvoiceEvents.includes(eventType)
) {
const stripeCusId = data.object.customer;
if (!stripeCusId) {
@@ -81,38 +96,49 @@ const handleStripeWebhookRefresh = async ({
return;
}
logger.info(`Attempting delete cached api customer! ${eventType}`);
await deleteCachedApiCustomer({
customerId: cus.id!,
orgId: org.id,
env,
source: `handleStripeWebhookRefresh: ${eventType}`,
});
// console.log(
// `Attempting to refresh cache for customer: ${cus.id}, env: ${env}`,
// );
// if (updateProductEvents.includes(eventType)) {
// const fullCus = await CusService.getFull({
// db,
// idOrInternalId: cus.id!,
// orgId: org.id,
// env,
// withEntities: true,
// withSubs: true,
// });
let fullCus: FullCustomer | undefined;
if (
updateProductEvents.includes(eventType) ||
updateInvoiceEvents.includes(eventType)
) {
fullCus = await CusService.getFull({
db,
idOrInternalId: cus.id!,
orgId: org.id,
env,
withEntities: true,
withSubs: true,
expand: [CusExpand.Invoices],
});
// await setCachedApiCusProducts({
// ctx,
// fullCus,
// customerId: cus.id!,
// });
// } else {
// logger.info(`Attempting delete cached api customer! ${eventType}`);
// await deleteCachedApiCustomer({
// customerId: cus.id!,
// orgId: org.id,
// env,
// source: `handleStripeWebhookRefresh: ${eventType}`,
// });
// }
if (updateProductEvents.includes(eventType)) {
await setCachedApiSubs({
ctx,
fullCus,
customerId: cus.id!,
});
}
if (updateInvoiceEvents.includes(eventType)) {
await setCachedApiInvoices({
ctx,
fullCus,
customerId: cus.id!,
});
}
} else {
logger.info(`Attempting delete cached api customer! ${eventType}`);
await deleteCachedApiCustomer({
customerId: cus.id!,
orgId: org.id,
env,
source: `handleStripeWebhookRefresh: ${eventType}`,
});
}
}
};
@@ -155,7 +181,7 @@ export const handleStripeWebhookEvent = async ({
case "customer.subscription.updated": {
const subscription = event.data.object;
await handleSubscriptionUpdated({
req: ctx as ExtendedRequest,
req: ctx as unknown as ExtendedRequest,
db,
org,
subscription,
@@ -168,7 +194,7 @@ export const handleStripeWebhookEvent = async ({
case "customer.subscription.deleted":
await handleSubDeleted({
req: ctx as ExtendedRequest,
req: ctx as unknown as ExtendedRequest,
stripeCli,
data: event.data.object,
logger,
@@ -178,7 +204,7 @@ export const handleStripeWebhookEvent = async ({
case "checkout.session.completed": {
const checkoutSession = event.data.object;
await handleCheckoutSessionCompleted({
req: ctx as ExtendedRequest,
req: ctx as unknown as ExtendedRequest,
db,
data: checkoutSession,
org,
@@ -196,17 +222,15 @@ export const handleStripeWebhookEvent = async ({
invoiceData: invoice,
env,
event,
req: ctx as ExtendedRequest,
req: ctx as unknown as ExtendedRequest,
});
break;
}
case "invoice.updated":
await handleInvoiceUpdated({
stripeCli,
env,
event,
req: ctx as ExtendedRequest,
req: ctx as unknown as ExtendedRequest,
});
break;
@@ -234,6 +258,18 @@ export const handleStripeWebhookEvent = async ({
break;
}
// case "invoice.payment_attempt_required": {
// const invoice = event.data.object;
// await handleInvoicePaymentAttemptRequired({
// db,
// org,
// invoice,
// env,
// logger,
// });
// break;
// }
case "subscription_schedule.canceled": {
const canceledSchedule = event.data.object;
await handleSubscriptionScheduleCanceled({
@@ -241,7 +277,6 @@ export const handleStripeWebhookEvent = async ({
org,
env,
schedule: canceledSchedule,
logger,
});
break;
}
@@ -293,7 +328,11 @@ export const handleStripeWebhookEvent = async ({
ctx,
});
} catch (error) {
logger.error(`Stripe webhook, error refreshing cache!`, { error });
logger.error(`Stripe webhook, error refreshing cache: ${error}`, {
error: {
message: error instanceof Error ? error.message : String(error),
},
});
return { success: true };
}

View File

@@ -1,8 +0,0 @@
import Stripe from "stripe";
const classifyStripePaymentMethod = (paymentMethod: Stripe.PaymentMethod) => {
let cardPaymentMethods = [];
};
// Note: us_bank_account -> ACH
// customer_balance -> Bank Account

View File

@@ -1,5 +1,4 @@
import { UsagePriceConfig } from "@autumn/shared";
import { Price } from "@autumn/shared";
import type { Price, UsagePriceConfig } from "@autumn/shared";
export const priceToInArrearProrated = ({
price,
@@ -11,9 +10,9 @@ export const priceToInArrearProrated = ({
existingUsage: number;
}) => {
const config = price.config as UsagePriceConfig;
let quantity = existingUsage || 0;
const quantity = existingUsage || 0;
if (quantity == 0 && isCheckout) {
if (quantity === 0 && isCheckout) {
return {
price: config.stripe_placeholder_price_id,
};

Some files were not shown because too many files have changed in this diff Show More