chore: merge with dev
This commit is contained in:
@@ -1 +1 @@
|
||||
1.2.19
|
||||
1.3.2
|
||||
@@ -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.
|
||||
|
||||
11
CLAUDE.md
11
CLAUDE.md
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
@@ -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"]
|
||||
@@ -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:
|
||||
@@ -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"]
|
||||
43
package.json
43
package.json
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
107
scripts/dev.ts
Normal 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();
|
||||
27
scripts/migrations/migrate-functions.ts
Normal file
27
scripts/migrations/migrate-functions.ts
Normal 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);
|
||||
26
scripts/migrations/validate-schema.ts
Normal file
26
scripts/migrations/validate-schema.ts
Normal 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);
|
||||
}
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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 });
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
@@ -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" },
|
||||
// },
|
||||
// );
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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' \
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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' \
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
22
scripts/testGroups/g7.sh
Executable 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'
|
||||
|
||||
@@ -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") });
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
../../CLAUDE.md
|
||||
@@ -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!
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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(),
|
||||
});
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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();
|
||||
@@ -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,
|
||||
}
|
||||
]
|
||||
}
|
||||
122
server/experiments/rateLimiting.ts
Normal file
122
server/experiments/rateLimiting.ts
Normal 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();
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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
16
server/nodemon.json
Normal 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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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))
|
||||
35
server/src/_luaScripts/cusLuaScripts/setSubscriptions.lua
Normal file
35
server/src/_luaScripts/cusLuaScripts/setSubscriptions.lua
Normal 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
@@ -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)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
134
server/src/_luaScripts/luaUtils/apiSubscriptionUtils.lua
Normal file
134
server/src/_luaScripts/luaUtils/apiSubscriptionUtils.lua
Normal 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
|
||||
|
||||
37
server/src/_luaScripts/luaUtils/cacheKeyUtils.lua
Normal file
37
server/src/_luaScripts/luaUtils/cacheKeyUtils.lua
Normal 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
|
||||
|
||||
161
server/src/_luaScripts/luaUtils/getCustomerEntityUtils.lua
Normal file
161
server/src/_luaScripts/luaUtils/getCustomerEntityUtils.lua
Normal 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
|
||||
|
||||
708
server/src/_luaScripts/luaUtils/loadBalances.lua
Normal file
708
server/src/_luaScripts/luaUtils/loadBalances.lua
Normal 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
|
||||
107
server/src/_luaScripts/luaUtils/storeBalances.lua
Normal file
107
server/src/_luaScripts/luaUtils/storeBalances.lua
Normal 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
|
||||
|
||||
@@ -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");
|
||||
|
||||
85
server/src/cron/cronInit.ts
Normal file
85
server/src/cron/cronInit.ts
Normal 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);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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!,
|
||||
|
||||
45
server/src/db/initializeDatabaseFunctions.ts
Normal file
45
server/src/db/initializeDatabaseFunctions.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
65
server/src/db/validateDbSchema.ts
Normal file
65
server/src/db/validateDbSchema.ts
Normal 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;
|
||||
};
|
||||
182
server/src/db/validateSqlFunctions.ts
Normal file
182
server/src/db/validateSqlFunctions.ts
Normal 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;
|
||||
};
|
||||
59
server/src/errors/formatZodError.ts
Normal file
59
server/src/errors/formatZodError.ts
Normal 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("; ")}`;
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
135
server/src/external/autumn/autumnCli.ts
vendored
135
server/src/external/autumn/autumnCli.ts
vendored
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
499
server/src/external/autumn/autumnCliV2.ts
vendored
Normal file
499
server/src/external/autumn/autumnCliV2.ts
vendored
Normal 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);
|
||||
};
|
||||
}
|
||||
4
server/src/external/autumn/autumnUtils.ts
vendored
4
server/src/external/autumn/autumnUtils.ts
vendored
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
31
server/src/external/infisical/initInfisical.ts
vendored
31
server/src/external/infisical/initInfisical.ts
vendored
@@ -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
|
||||
|
||||
10
server/src/external/redis/initRedis.ts
vendored
10
server/src/external/redis/initRedis.ts
vendored
@@ -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,
|
||||
// },
|
||||
// });
|
||||
|
||||
2
server/src/external/redis/loadCaCert.ts
vendored
2
server/src/external/redis/loadCaCert.ts
vendored
@@ -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;
|
||||
}
|
||||
|
||||
2
server/src/external/resend/resendUtils.ts
vendored
2
server/src/external/resend/resendUtils.ts
vendored
@@ -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,
|
||||
|
||||
21
server/src/external/sentry/sentryUtils.ts
vendored
Normal file
21
server/src/external/sentry/sentryUtils.ts
vendored
Normal 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,
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import Stripe from "stripe";
|
||||
|
||||
const classifyStripePaymentMethod = (paymentMethod: Stripe.PaymentMethod) => {
|
||||
let cardPaymentMethods = [];
|
||||
};
|
||||
|
||||
// Note: us_bank_account -> ACH
|
||||
// customer_balance -> Bank Account
|
||||
@@ -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
Reference in New Issue
Block a user