From 3d47e1effa00e92930dd9c393d7a2cac12ad8fc5 Mon Sep 17 00:00:00 2001 From: John Yeo Date: Wed, 12 Nov 2025 11:01:19 +0000 Subject: [PATCH] wip --- package.json | 14 +- scripts/cleanup-ports.js | 52 ---- scripts/{ => db}/replicate.ts | 0 scripts/detect-ports.js | 248 --------------- scripts/dev.ts | 107 +++++++ scripts/package.json | 6 +- scripts/{ => setup}/setup-test.ts | 45 ++- scripts/{ => setup}/setup.js | 0 scripts/{ => setup}/setupci.js | 0 scripts/setupTestUtils/updateEnvFile.ts | 2 +- scripts/start-dev.js | 214 ------------- scripts/testGroups/g1.sh | 10 +- server/nodemon.json | 16 + server/package.json | 4 +- .../deductionLuaScripts/batchDeduction.lua | 61 ++-- server/src/external/autumn/autumnCli.ts | 11 +- server/src/honoMiddlewares/baseMiddleware.ts | 1 + .../honoMiddlewares/errorSkipMiddleware.ts | 22 +- .../src/honoMiddlewares/expandMiddleware.ts | 20 +- .../honoMiddlewares/refreshCacheMiddleware.ts | 2 +- .../balances/track/syncUtils/syncItem.ts | 54 +++- .../cusEnts/cusEntUtils/findCusEntUtils.ts | 10 - .../getApiBalance/apiBalanceUtils.ts | 28 +- .../getApiBalance/getApiBalance.ts | 23 +- .../cusUtils/apiCusUtils/getApiCustomer.ts | 3 - .../customers/handlers/handleGetCustomerV2.ts | 4 +- server/src/utils/cacheUtils/cacheUtils.ts | 12 +- .../tests/balances/check/basic/check1.test.ts | 2 +- .../tests/balances/check/basic/check2.test.ts | 4 +- .../tests/balances/check/basic/check3.test.ts | 4 +- .../tests/balances/check/basic/check5.test.ts | 4 +- .../tests/balances/check/basic/check6.test.ts | 9 +- .../credit-systems/credit-systems1.test.ts | 4 +- .../credit-systems/credit-systems2.test.ts | 2 +- .../balances/check/misc/check-misc1.test.ts | 19 +- .../balances/check/misc/check-misc2.test.ts | 2 +- .../check/prepaid/check-prepaid1.test.ts | 39 +-- .../check/prepaid/check-prepaid2.test.ts | 288 ++++++++++++++++++ .../balances/track/basic/track-basic1.test.ts | 2 +- .../track/basic/track-basic10.test.ts | 89 +++++- .../track/basic/track-basic11.test.ts | 2 +- .../balances/track/basic/track-basic8.test.ts | 60 ++-- .../balances/track/basic/track-basic9.test.ts | 27 +- .../concurrency/concurrent-track1.test.ts | 7 +- .../concurrency/concurrent-track2.test.ts | 12 +- .../concurrency/concurrent-track5.test.ts | 2 +- .../track-credit-system1.test.ts | 2 +- server/tests/contUse/track/track1.test.ts | 5 +- .../tests/plan-schema/01-basic-create.test.ts | 14 +- .../check/changes/V1.2_CheckChange.ts | 2 +- .../check/changes/V1.2_CheckQueryChange.ts | 8 +- shared/api/balances/check/checkParams.ts | 4 +- .../customers/changes/V1.2_CustomerChange.ts | 1 + .../api/customers/cusFeatures/apiBalance.ts | 11 +- .../changes/V1.2_CusFeatureChange.ts | 2 +- .../balanceUtils/cusEntToPrepaidQuantity.ts | 29 ++ .../utils/cusEntUtils/convertCusEntUtils.ts | 36 +-- .../cusEntsToMaxPurchase.ts | 59 ++++ shared/utils/cusEntUtils/cusEntUtils.ts | 65 ++-- shared/utils/cusEntUtils/filterCusEntUtils.ts | 11 + .../cusEntUtils/sortCusEntsForDeduction.ts | 9 + shared/utils/index.ts | 4 +- .../plan/components/SelectFeatureSheet.tsx | 1 - .../components/plan-card/PlanFeatureList.tsx | 2 - vite/vite.config.ts | 2 + 65 files changed, 978 insertions(+), 836 deletions(-) delete mode 100644 scripts/cleanup-ports.js rename scripts/{ => db}/replicate.ts (100%) delete mode 100644 scripts/detect-ports.js create mode 100644 scripts/dev.ts rename scripts/{ => setup}/setup-test.ts (78%) rename scripts/{ => setup}/setup.js (100%) rename scripts/{ => setup}/setupci.js (100%) delete mode 100644 scripts/start-dev.js create mode 100644 server/nodemon.json create mode 100644 server/tests/balances/check/prepaid/check-prepaid2.test.ts create mode 100644 shared/utils/cusEntUtils/balanceUtils/cusEntToPrepaidQuantity.ts create mode 100644 shared/utils/cusEntUtils/convertCusEntUtils/cusEntsToMaxPurchase.ts diff --git a/package.json b/package.json index 48ffcd851..3e2c4f44b 100644 --- a/package.json +++ b/package.json @@ -16,16 +16,16 @@ }, "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", + "dev": "bun scripts/dev.ts", + "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.js", - "setup:test": "bun scripts/setup-test.ts", + "setup": "node scripts/setup/setup.js", + "setup:test": "bun scripts/setup/setup-test.ts", "tests": "bun scripts/test.ts", - "setupci": "node scripts/setupci.js", - "replicate": "bun scripts/replicate.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" diff --git a/scripts/cleanup-ports.js b/scripts/cleanup-ports.js deleted file mode 100644 index 1cee993aa..000000000 --- a/scripts/cleanup-ports.js +++ /dev/null @@ -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); -}); diff --git a/scripts/replicate.ts b/scripts/db/replicate.ts similarity index 100% rename from scripts/replicate.ts rename to scripts/db/replicate.ts diff --git a/scripts/detect-ports.js b/scripts/detect-ports.js deleted file mode 100644 index b97431a8a..000000000 --- a/scripts/detect-ports.js +++ /dev/null @@ -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, -}; diff --git a/scripts/dev.ts b/scripts/dev.ts new file mode 100644 index 000000000..a4e8d4ecf --- /dev/null +++ b/scripts/dev.ts @@ -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(); diff --git a/scripts/package.json b/scripts/package.json index 9031e728c..43ce88d63 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -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:*", diff --git a/scripts/setup-test.ts b/scripts/setup/setup-test.ts similarity index 78% rename from scripts/setup-test.ts rename to scripts/setup/setup-test.ts index d4ddec866..ef6189001 100644 --- a/scripts/setup-test.ts +++ b/scripts/setup/setup-test.ts @@ -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 }); diff --git a/scripts/setup.js b/scripts/setup/setup.js similarity index 100% rename from scripts/setup.js rename to scripts/setup/setup.js diff --git a/scripts/setupci.js b/scripts/setup/setupci.js similarity index 100% rename from scripts/setupci.js rename to scripts/setup/setupci.js diff --git a/scripts/setupTestUtils/updateEnvFile.ts b/scripts/setupTestUtils/updateEnvFile.ts index e34af0215..05177f5c5 100644 --- a/scripts/setupTestUtils/updateEnvFile.ts +++ b/scripts/setupTestUtils/updateEnvFile.ts @@ -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; diff --git a/scripts/start-dev.js b/scripts/start-dev.js deleted file mode 100644 index 97fcefa2a..000000000 --- a/scripts/start-dev.js +++ /dev/null @@ -1,214 +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(); - } - - // 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"); - - // Start server, workers, and vite (they'll use the shared package source files directly) - const concurrentlyCmd = spawn( - "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"`, - ], - { - 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(); diff --git a/scripts/testGroups/g1.sh b/scripts/testGroups/g1.sh index 59d1aa596..8d42367b8 100755 --- a/scripts/testGroups/g1.sh +++ b/scripts/testGroups/g1.sh @@ -15,16 +15,16 @@ 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/concurrency' \ # 'server/tests/balances/track/allocated' \ + # 'server/tests/balances/check/basic' \ + # 'server/tests/balances/check/credit-systems' \ + # 'server/tests/balances/check/misc' \ + # 'server/tests/balances/track/basic' \ # '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' \ diff --git a/server/nodemon.json b/server/nodemon.json new file mode 100644 index 000000000..573078d83 --- /dev/null +++ b/server/nodemon.json @@ -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" + } +} + diff --git a/server/package.json b/server/package.json index 98ab2342a..40f9ce5ff 100644 --- a/server/package.json +++ b/server/package.json @@ -11,8 +11,8 @@ "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 --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", + "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", "workers": "bun src/workers.ts", "cron": "bun src/cron.ts", "check": "bun src/check.ts", diff --git a/server/src/_luaScripts/deductionLuaScripts/batchDeduction.lua b/server/src/_luaScripts/deductionLuaScripts/batchDeduction.lua index 3590df85c..8d79661c5 100644 --- a/server/src/_luaScripts/deductionLuaScripts/batchDeduction.lua +++ b/server/src/_luaScripts/deductionLuaScripts/batchDeduction.lua @@ -359,24 +359,30 @@ local function deductFromOverage(cusFeature, amount, adjustGrantedBalance) for index, breakdown in ipairs(cusFeature.breakdown) do if remaining <= 0 then break end - -- Check if this breakdown allows overage - local breakdownAllowOverage = breakdown.overage_allowed or allowOverage + -- Check if this breakdown explicitly allows overage + -- Only deduct from breakdowns that have overage_allowed=true + -- Don't fall back to top-level allowOverage - each breakdown controls its own overage + local breakdownAllowOverage = breakdown.overage_allowed == true if breakdownAllowOverage then local breakdownPurchasedBalance = breakdown.purchased_balance or 0 - local breakdownMaxPurchase = breakdown.max_purchase or (cusFeature.max_purchase or 0) + -- Calculate availableCapacity: nil if breakdown.max_purchase is nil/null (unlimited), otherwise max_purchase - purchased_balance + local availableCapacity + if breakdown.max_purchase == nil or breakdown.max_purchase == cjson.null then + -- No max_purchase limit - unlimited capacity + availableCapacity = nil + else + -- Use breakdown max_purchase limit + local breakdownMaxPurchase = toNum(breakdown.max_purchase) + availableCapacity = breakdownMaxPurchase - breakdownPurchasedBalance + end - -- Calculate how much we can increment purchased_balance (up to max_purchase) - local availableCapacity = breakdownMaxPurchase - breakdownPurchasedBalance - - if availableCapacity > 0 then - local toIncrement = math.min(remaining, availableCapacity) + if availableCapacity == nil or availableCapacity > 0 then + local toIncrement = availableCapacity == nil and remaining or math.min(remaining, availableCapacity) -- Collect Redis deltas table.insert(deltas, {key = breakdown._key, field = "purchased_balance", delta = toIncrement}) - table.insert(deltas, {key = breakdown._key, field = "current_balance", delta = toIncrement}) table.insert(deltas, {key = cusFeature._key, field = "purchased_balance", delta = toIncrement}) - table.insert(deltas, {key = cusFeature._key, field = "current_balance", delta = toIncrement}) -- Either increment usage or decrement granted_balance based on flag if adjustGrantedBalance then @@ -394,12 +400,6 @@ local function deductFromOverage(cusFeature, amount, adjustGrantedBalance) field = "purchased_balance", delta = toIncrement }) - table.insert(stateChanges, { - type = "breakdown", - index = index, - field = "current_balance", - delta = toIncrement - }) if adjustGrantedBalance then table.insert(stateChanges, { type = "breakdown", @@ -430,11 +430,6 @@ local function deductFromOverage(cusFeature, amount, adjustGrantedBalance) field = "purchased_balance", delta = toIncrement }) - table.insert(stateChanges, { - type = "cusFeature", - field = "current_balance", - delta = toIncrement - }) remaining = remaining - toIncrement end @@ -443,17 +438,22 @@ local function deductFromOverage(cusFeature, amount, adjustGrantedBalance) else -- No breakdowns: deduct from top-level overage local topLevelPurchasedBalance = cusFeature.purchased_balance or 0 - local topLevelMaxPurchase = cusFeature.max_purchase or 0 + -- Calculate availableCapacity: nil if max_purchase is nil/null (unlimited), otherwise max_purchase - purchased_balance + local availableCapacity + if cusFeature.max_purchase == nil or cusFeature.max_purchase == cjson.null then + -- No max_purchase limit - unlimited capacity + availableCapacity = nil + else + -- Use max_purchase limit + local topLevelMaxPurchase = toNum(cusFeature.max_purchase) + availableCapacity = topLevelMaxPurchase - topLevelPurchasedBalance + end - -- Calculate how much we can increment purchased_balance (up to max_purchase) - local availableCapacity = topLevelMaxPurchase - topLevelPurchasedBalance - - if availableCapacity > 0 then - local toIncrement = math.min(remaining, availableCapacity) + if availableCapacity == nil or availableCapacity > 0 then + local toIncrement = availableCapacity == nil and remaining or math.min(remaining, availableCapacity) -- Collect Redis deltas table.insert(deltas, {key = cusFeature._key, field = "purchased_balance", delta = toIncrement}) - table.insert(deltas, {key = cusFeature._key, field = "current_balance", delta = toIncrement}) -- Either increment usage or decrement granted_balance based on flag if adjustGrantedBalance then @@ -468,11 +468,6 @@ local function deductFromOverage(cusFeature, amount, adjustGrantedBalance) field = "purchased_balance", delta = toIncrement }) - table.insert(stateChanges, { - type = "cusFeature", - field = "current_balance", - delta = toIncrement - }) if adjustGrantedBalance then table.insert(stateChanges, { type = "cusFeature", diff --git a/server/src/external/autumn/autumnCli.ts b/server/src/external/autumn/autumnCli.ts index c3ea0e807..0eb163ce4 100644 --- a/server/src/external/autumn/autumnCli.ts +++ b/server/src/external/autumn/autumnCli.ts @@ -7,6 +7,7 @@ import { type ApiEntity, type AttachBody, type BalancesUpdateParams, + type CheckQuery, type CreateCustomerParams, type CreateEntityParams, type CreateRewardProgram, @@ -557,8 +558,14 @@ export class AutumnInt { return data; }; - check = async (params: CheckParams): Promise => { - const data = await this.post(`/check`, params); + check = async ( + params: CheckParams & CheckQuery, + ): Promise => { + const queryParams = new URLSearchParams(); + if (params.skip_cache) { + queryParams.append("skip_cache", "true"); + } + const data = await this.post(`/check?${queryParams.toString()}`, params); return data; }; diff --git a/server/src/honoMiddlewares/baseMiddleware.ts b/server/src/honoMiddlewares/baseMiddleware.ts index 86342daa2..b4c632518 100644 --- a/server/src/honoMiddlewares/baseMiddleware.ts +++ b/server/src/honoMiddlewares/baseMiddleware.ts @@ -71,6 +71,7 @@ export const baseMiddleware = async (c: Context, next: Next) => { // Query params expand: [], + skipCache: false, }); // childLogger.info(`${method} ${path}`); diff --git a/server/src/honoMiddlewares/errorSkipMiddleware.ts b/server/src/honoMiddlewares/errorSkipMiddleware.ts index c7d35e06d..b61926dab 100644 --- a/server/src/honoMiddlewares/errorSkipMiddleware.ts +++ b/server/src/honoMiddlewares/errorSkipMiddleware.ts @@ -47,7 +47,16 @@ const ROUTE_SPECIFIC_RULES: Array<{ name: string; match: (err: Error, c: Context) => boolean; statusCode: ContentfulStatusCode; -}> = []; +}> = [ + { + name: "Stripe webhook secret not set in development", + match: (err: Error) => + err.message.includes( + "STRIPE_WEBHOOK_SECRET env variable is not set (live)", + ) && process.env.NODE_ENV === "development", + statusCode: 500, + }, +]; /** Stripe-specific error handling rules */ const STRIPE_RULES = [ @@ -224,13 +233,16 @@ export const handleErrorSkip = (err: Error, c: Context) => { // 3. Check advanced route-specific rules for (const rule of ROUTE_SPECIFIC_RULES) { if (rule.match(err, c)) { - const recaseErr = err as RecaseError; - logger.warn(`${recaseErr.message}, org: ${ctx.org?.slug || "unknown"}`); + const errorCode = + err instanceof RecaseError || err instanceof SharedRecaseError + ? err.code + : ErrCode.InternalError; + logger.warn(`${rule.name}, org: ${ctx.org?.slug || "unknown"}`); return createErrorResponse({ c, ctx, - message: recaseErr.message, - code: recaseErr.code, + message: err.message, + code: errorCode, statusCode: rule.statusCode, }); } diff --git a/server/src/honoMiddlewares/expandMiddleware.ts b/server/src/honoMiddlewares/expandMiddleware.ts index 040e1e223..a76138676 100644 --- a/server/src/honoMiddlewares/expandMiddleware.ts +++ b/server/src/honoMiddlewares/expandMiddleware.ts @@ -4,17 +4,21 @@ import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; /** * Extracts expand from validated query and sets it in context. * Must run AFTER versionedValidator so expand has been transformed. - * Uses c.req.query("expand") to access the parsed expand field. + * Uses c.req.valid("query") to access the transformed/validated expand field. */ export const expandMiddleware = (): MiddlewareHandler => { return async (c, next) => { - // Query is parsed by queryMiddleware and validated by versionedValidator/validator - // queryStringArray normalizes expand to an array during validation, but we access - // the parsed query which may still be a string for single values - const expandValue = c.req.query("expand"); - // queryMiddleware converts "true"/"false" strings to boolean values - // Handle both boolean (from queryMiddleware) and string (fallback) cases - const skipCacheQuery = c.req.query("skip_cache"); + // Get validated query (which includes transformed values from version changes) + // Fallback to raw query if validation hasn't happened yet + const validatedQuery = (c.req as any).valid?.("query") as + | { expand?: string | string[]; skip_cache?: boolean } + | undefined; + const rawQuery = c.req.query(); + + // Prefer validated query (transformed), fallback to raw query + const expandValue = validatedQuery?.expand ?? rawQuery?.expand; + const skipCacheQuery = validatedQuery?.skip_cache ?? rawQuery?.skip_cache; + const skipCacheValue = (typeof skipCacheQuery === "boolean" && skipCacheQuery === true) || (typeof skipCacheQuery === "string" && skipCacheQuery === "true"); diff --git a/server/src/honoMiddlewares/refreshCacheMiddleware.ts b/server/src/honoMiddlewares/refreshCacheMiddleware.ts index 774743b76..0390c3c82 100644 --- a/server/src/honoMiddlewares/refreshCacheMiddleware.ts +++ b/server/src/honoMiddlewares/refreshCacheMiddleware.ts @@ -72,7 +72,7 @@ export const refreshCacheMiddleware = async ( } const ctx = c.get("ctx"); - const { logger, db, org, env } = ctx; + const { logger, org, env } = ctx; const pathname = new URL(c.req.url).pathname.replace("/v1", ""); const method = c.req.method; diff --git a/server/src/internal/balances/track/syncUtils/syncItem.ts b/server/src/internal/balances/track/syncUtils/syncItem.ts index 6cb3fce95..1f3ac9a8a 100644 --- a/server/src/internal/balances/track/syncUtils/syncItem.ts +++ b/server/src/internal/balances/track/syncUtils/syncItem.ts @@ -1,12 +1,18 @@ +import type { Feature, FullCusEntWithFullCusProduct } from "@autumn/shared"; import { type ApiBalance, type ApiCustomer, type ApiEntity, + cusEntToPrepaidQuantity, + cusProductsToCusEnts, filterEntityLevelCusProducts, filterOutEntitiesFromCusProducts, getRelevantFeatures, + orgToInStatuses, + sumValues, } from "@autumn/shared"; import chalk from "chalk"; +import { Decimal } from "decimal.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { CusService } from "@/internal/customers/CusService.js"; import { RELEVANT_STATUSES } from "@/internal/customers/cusProducts/CusProductService.js"; @@ -24,12 +30,35 @@ export interface SyncItem { timestamp: number; } -const apiToBackendBalance = ({ apiBalance }: { apiBalance?: ApiBalance }) => { - if (!apiBalance) { - return undefined; - } +const apiToBackendBalance = ({ + cusEnts, + features, + apiBalance, +}: { + cusEnts: FullCusEntWithFullCusProduct[]; + features: Feature[]; + apiBalance?: ApiBalance; +}) => { + const feature = features.find((f) => f.id === apiBalance?.feature_id); + if (!apiBalance || !feature) return 0; + + const totalPrepaidQuantity = sumValues( + cusEnts.map((cusEnt) => cusEntToPrepaidQuantity({ cusEnt })), + ); + + const backendBalance = new Decimal(totalPrepaidQuantity) + .add(apiBalance.current_balance) + .sub(apiBalance.purchased_balance) + .toNumber(); + + // console.log("Converting api balance to backend balance"); + // console.log(`Current balance: ${apiBalance.current_balance}`); + // console.log(`Purchased balance: ${apiBalance.purchased_balance}`); + // console.log(`Total prepaid quantity: ${totalPrepaidQuantity}`); + // console.log(`Backend balance: ${backendBalance}`); + // 1. Current balance = granted balance + purchased balance - usage - return apiBalance.current_balance - apiBalance.purchased_balance; + return backendBalance; }; /** @@ -49,6 +78,7 @@ export const syncItem = async ({ // Get cached customer/entity from Redis WITHOUT merging // For sync, we need the raw balance for that specific scope (not merged) let redisEntity: ApiCustomer | ApiEntity; + if (entityId) { const { apiEntity } = await getCachedApiEntity({ ctx, @@ -103,7 +133,19 @@ export const syncItem = async ({ const redisBalance = redisEntity.balances?.[relevantFeature.id]; if (!redisBalance) continue; - const backendBalance = apiToBackendBalance({ apiBalance: redisBalance }); + const cusEnts = cusProductsToCusEnts({ + cusProducts: fullCus.customer_products, + featureIds: relevantFeatures.map((f) => f.id), + reverseOrder: org.config?.reverse_deduction_order, + entity: fullCus.entity, + inStatuses: orgToInStatuses({ org }), + }); + + const backendBalance = apiToBackendBalance({ + apiBalance: redisBalance, + cusEnts, + features: relevantFeatures, + }); featureDeductions.push({ feature: relevantFeature, diff --git a/server/src/internal/customers/cusProducts/cusEnts/cusEntUtils/findCusEntUtils.ts b/server/src/internal/customers/cusProducts/cusEnts/cusEntUtils/findCusEntUtils.ts index 75733bae9..44817d59e 100644 --- a/server/src/internal/customers/cusProducts/cusEnts/cusEntUtils/findCusEntUtils.ts +++ b/server/src/internal/customers/cusProducts/cusEnts/cusEntUtils/findCusEntUtils.ts @@ -32,16 +32,6 @@ export const cusEntMatchesEntity = ({ return cusProductMatch && entityFeatureIdMatch; }; -export const cusEntMatchesFeature = ({ - cusEnt, - feature, -}: { - cusEnt: FullCustomerEntitlement; - feature: Feature; -}) => { - return cusEnt.entitlement.feature.internal_id === feature.internal_id; -}; - export const findMainCusEntForFeature = ({ cusEnts, feature, diff --git a/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/apiBalanceUtils.ts b/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/apiBalanceUtils.ts index 871e8684e..aa932fbd6 100644 --- a/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/apiBalanceUtils.ts +++ b/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/apiBalanceUtils.ts @@ -3,7 +3,6 @@ import { type ApiBalanceReset, type ApiBalanceRollover, type ApiFeature, - cusEntToKey, entIntvToResetIntv, type Feature, type FullCusEntWithFullCusProduct, @@ -36,15 +35,16 @@ export const cusEntsToReset = ({ }: { cusEnts: FullCusEntWithFullCusProduct[]; feature: Feature; -}): ApiBalanceReset | undefined => { - // 1. If feature is allocated, undefined - if (isContUseFeature({ feature })) return undefined; +}): ApiBalanceReset | null => { + // 1. If feature is allocated, null + if (isContUseFeature({ feature })) return null; - const cusEntKeys = cusEnts.map((cusEnt) => cusEntToKey({ cusEnt })); - const uniqueCusEntKeys = [...new Set(cusEntKeys)]; + // Check if there are multiple intervals + const uniqueIntervals = [ + ...new Set(cusEnts.map((cusEnt) => cusEnt.entitlement.interval)), + ]; - // 2. If > 1 cus ent key, return multiple - if (uniqueCusEntKeys.length > 1) { + if (uniqueIntervals.length > 1) { return { interval: "multiple", interval_count: undefined, resets_at: null }; } @@ -108,10 +108,10 @@ export const getBooleanApiBalance = ({ current_balance: 0, usage: 0, - max_purchase: 0, overage_allowed: false, + max_purchase: null, + reset: null, - reset: undefined, breakdown: undefined, rollovers: undefined, } satisfies ApiBalance; @@ -137,10 +137,10 @@ export const getUnlimitedApiBalance = ({ current_balance: 0, usage: 0, - max_purchase: 0, + reset: null, + max_purchase: null, overage_allowed: false, - reset: undefined, breakdown: undefined, rollovers: undefined, }; @@ -163,10 +163,10 @@ export const getNoCusEntsApiBalance = ({ current_balance: 0, usage: 0, - max_purchase: 0, + reset: null, + max_purchase: null, overage_allowed: false, - reset: undefined, breakdown: undefined, rollovers: undefined, }; diff --git a/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalance.ts b/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalance.ts index 9feca6894..5e2a6b1db 100644 --- a/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalance.ts +++ b/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalance.ts @@ -8,11 +8,12 @@ import { ApiBalanceBreakdownSchema, ApiBalanceSchema, CusExpand, + cusEntMatchesFeature, + cusEntsToMaxPurchase, cusEntToBalance, cusEntToCusPrice, cusEntToGrantedBalance, cusEntToKey, - cusEntToMaxPurchase, cusEntToPurchasedBalance, type Feature, FeatureType, @@ -26,7 +27,6 @@ import { Decimal } from "decimal.js"; import type { RequestContext } from "@/honoUtils/HonoEnv.js"; import { getUnlimitedAndUsageAllowed } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils.js"; import type { CusFeatureLegacyData } from "../../../../../../../shared/api/customers/cusFeatures/cusFeatureLegacyData.js"; -import { cusEntMatchesFeature } from "../../../cusProducts/cusEnts/cusEntUtils/findCusEntUtils.js"; import { cusEntsToReset, cusEntsToRollovers, @@ -85,14 +85,14 @@ const cusEntsToBreakdown = ({ return breakdown; }; -const cusEntsToPrepaidQuantity = ({ +export const cusEntsToPrepaidQuantity = ({ cusEnts, feature, }: { cusEnts: FullCusEntWithFullCusProduct[]; feature: Feature; }) => { - const prepaidQuantity = new Decimal(0); + let prepaidQuantity = new Decimal(0); for (const cusEnt of cusEnts) { // 1. if cus ent doesn't match feature, skip @@ -100,18 +100,21 @@ const cusEntsToPrepaidQuantity = ({ // 2. If cus ent is not prepaid, skip const cusPrice = cusEntToCusPrice({ cusEnt }); + if (!cusPrice || !isPrepaidPrice({ price: cusPrice.price })) continue; // 3. Get quantity const options = cusEnt.customer_product.options.find( (option) => option.internal_feature_id === feature.internal_id, ); + if (!options) continue; const quantityWithUnits = new Decimal(options.quantity) .mul(cusPrice.price.config.billing_units ?? 1) .toNumber(); - prepaidQuantity.add(quantityWithUnits); + + prepaidQuantity = prepaidQuantity.add(quantityWithUnits); } return prepaidQuantity.toNumber(); @@ -180,9 +183,7 @@ export const getApiBalance = ({ }), ); - const totalMaxPurchase = sumValues( - cusEnts.map((cusEnt) => cusEntToMaxPurchase({ cusEnt })), - ); + const totalMaxPurchase = cusEntsToMaxPurchase({ cusEnts, entityId }); // 1. Granted balance const totalGrantedBalanceWithRollovers = sumValues( @@ -260,19 +261,19 @@ export const getApiBalance = ({ usage: totalUsage, // Max purchase... - max_purchase: totalMaxPurchase ?? 0, overage_allowed: usageAllowed ?? false, + max_purchase: totalMaxPurchase, reset: reset, breakdown: cusEntsToBreakdown({ ctx, fullCus, cusEnts }), rollovers, - }); + } satisfies ApiBalance); if (error) throw error; // Return in latest format - version transformation happens at Customer level const totalPrepaidQuantity = cusEntsToPrepaidQuantity({ cusEnts, feature }); - console.log("Total prepaid quantity:", totalPrepaidQuantity); + return { data: apiBalance, legacyData: { diff --git a/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomer.ts b/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomer.ts index a660b4b3b..80016e05d 100644 --- a/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomer.ts +++ b/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomer.ts @@ -17,14 +17,12 @@ export const getApiCustomer = async ({ withAutumnId = false, customerId, fullCus, - skipCache = false, baseData, }: { ctx: RequestContext; withAutumnId?: boolean; customerId?: string; fullCus?: FullCustomer; - skipCache?: boolean; baseData?: { apiCustomer: ApiCustomer; legacyData: CustomerLegacyData }; }) => { let baseCustomer: ApiCustomer; @@ -34,7 +32,6 @@ export const getApiCustomer = async ({ const { apiCustomer, legacyData } = await getCachedApiCustomer({ ctx, customerId: customerId || "", - skipCache, }); baseCustomer = apiCustomer; cusLegacyData = legacyData; diff --git a/server/src/internal/customers/handlers/handleGetCustomerV2.ts b/server/src/internal/customers/handlers/handleGetCustomerV2.ts index c3427c37b..6d61adc92 100644 --- a/server/src/internal/customers/handlers/handleGetCustomerV2.ts +++ b/server/src/internal/customers/handlers/handleGetCustomerV2.ts @@ -18,7 +18,7 @@ export const handleGetCustomerV2 = createRoute({ handler: async (c) => { const ctx = c.get("ctx"); const customerId = c.req.param("customer_id"); - const { env, db, logger, org, expand } = ctx; + const { expand } = ctx; const { skip_cache = false, with_autumn_id } = c.req.valid("query"); // SIDE EFFECT @@ -34,8 +34,6 @@ export const handleGetCustomerV2 = createRoute({ const customer = await getApiCustomer({ ctx, customerId, - - skipCache: skip_cache, withAutumnId: with_autumn_id, }); diff --git a/server/src/utils/cacheUtils/cacheUtils.ts b/server/src/utils/cacheUtils/cacheUtils.ts index 6da221beb..7c7b87bb8 100644 --- a/server/src/utils/cacheUtils/cacheUtils.ts +++ b/server/src/utils/cacheUtils/cacheUtils.ts @@ -129,15 +129,15 @@ export const normalizeCachedData = ( for (const featureId in data.balances) { const feature = data.balances[featureId]; - if (!feature.reset) { - feature.reset = undefined; - } + // if (!feature.reset) { + // feature.reset = null; + // } if (feature.breakdown) { for (const breakdown of feature.breakdown) { - if (!breakdown.reset) { - breakdown.reset = undefined; - } + // if (!breakdown.reset) { + // breakdown.reset = null; + // } } } diff --git a/server/tests/balances/check/basic/check1.test.ts b/server/tests/balances/check/basic/check1.test.ts index 0c0252875..1ec9e11af 100644 --- a/server/tests/balances/check/basic/check1.test.ts +++ b/server/tests/balances/check/basic/check1.test.ts @@ -6,9 +6,9 @@ import { type CheckResponseV2, SuccessCode, } from "@autumn/shared"; -import chalk from "chalk"; import { TestFeature } from "@tests/setup/v2Features.js"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; diff --git a/server/tests/balances/check/basic/check2.test.ts b/server/tests/balances/check/basic/check2.test.ts index a629ebf5b..34a2ccf9c 100644 --- a/server/tests/balances/check/basic/check2.test.ts +++ b/server/tests/balances/check/basic/check2.test.ts @@ -6,9 +6,9 @@ import { type CheckResponseV2, SuccessCode, } from "@autumn/shared"; -import chalk from "chalk"; import { TestFeature } from "@tests/setup/v2Features.js"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; @@ -75,7 +75,7 @@ describe(`${chalk.yellowBright("check2: test /check on boolean feature")}`, () = purchased_balance: 0, current_balance: 0, usage: 0, - max_purchase: 0, + max_purchase: null, overage_allowed: false, }, }); diff --git a/server/tests/balances/check/basic/check3.test.ts b/server/tests/balances/check/basic/check3.test.ts index 376f317f9..def4475ff 100644 --- a/server/tests/balances/check/basic/check3.test.ts +++ b/server/tests/balances/check/basic/check3.test.ts @@ -8,9 +8,9 @@ import { ResetInterval, SuccessCode, } from "@autumn/shared"; -import chalk from "chalk"; import { TestFeature } from "@tests/setup/v2Features.js"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; @@ -72,7 +72,7 @@ describe(`${chalk.yellowBright("check3: test /check on metered feature")}`, () = purchased_balance: 0, current_balance: 1000, usage: 0, - max_purchase: 0, + max_purchase: null, overage_allowed: false, reset: { interval: ResetInterval.Month, diff --git a/server/tests/balances/check/basic/check5.test.ts b/server/tests/balances/check/basic/check5.test.ts index f9642085c..ccba8532d 100644 --- a/server/tests/balances/check/basic/check5.test.ts +++ b/server/tests/balances/check/basic/check5.test.ts @@ -8,9 +8,9 @@ import { ResetInterval, SuccessCode, } from "@autumn/shared"; -import chalk from "chalk"; import { TestFeature } from "@tests/setup/v2Features.js"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { constructArrearItem } from "@/utils/scriptUtils/constructItem.js"; import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; @@ -73,7 +73,7 @@ describe(`${chalk.yellowBright("check5: test /check on usage-based feature")}`, purchased_balance: 0, current_balance: messagesFeature.included_usage, usage: 0, - max_purchase: 0, + max_purchase: null, overage_allowed: true, reset: { interval: ResetInterval.Month, diff --git a/server/tests/balances/check/basic/check6.test.ts b/server/tests/balances/check/basic/check6.test.ts index d87ac6754..4a9674867 100644 --- a/server/tests/balances/check/basic/check6.test.ts +++ b/server/tests/balances/check/basic/check6.test.ts @@ -9,9 +9,9 @@ import { ResetInterval, SuccessCode, } from "@autumn/shared"; -import chalk from "chalk"; import { TestFeature } from "@tests/setup/v2Features.js"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { constructArrearItem, @@ -77,7 +77,8 @@ describe(`${chalk.yellowBright("check6: test /check on feature with multiple bal purchased_balance: 0, current_balance: 1000, usage: 0, - max_purchase: 0, + max_purchase: null, + overage_allowed: false, reset: { interval: ResetInterval.OneOff, resets_at: null, @@ -89,7 +90,7 @@ describe(`${chalk.yellowBright("check6: test /check on feature with multiple bal purchased_balance: 0, current_balance: 100, usage: 0, - max_purchase: 0, + max_purchase: null, reset: { interval: ResetInterval.Month, }, @@ -115,7 +116,7 @@ describe(`${chalk.yellowBright("check6: test /check on feature with multiple bal current_balance: monthlyMessages.included_usage + lifetimeMessages.included_usage, usage: 0, - max_purchase: 0, + max_purchase: null, overage_allowed: true, reset: { interval: "multiple", diff --git a/server/tests/balances/check/credit-systems/credit-systems1.test.ts b/server/tests/balances/check/credit-systems/credit-systems1.test.ts index 995df7db4..22f1a2fef 100644 --- a/server/tests/balances/check/credit-systems/credit-systems1.test.ts +++ b/server/tests/balances/check/credit-systems/credit-systems1.test.ts @@ -7,9 +7,9 @@ import { type LimitedItem, SuccessCode, } from "@autumn/shared"; -import chalk from "chalk"; import { TestFeature } from "@tests/setup/v2Features.js"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { featureToCreditSystem, @@ -88,7 +88,7 @@ describe(`${chalk.yellowBright("credit-systems1: test /check on action that uses purchased_balance: 0, current_balance: creditsFeature.included_usage, usage: 0, - max_purchase: 0, + max_purchase: null, overage_allowed: false, reset: { interval: "month", diff --git a/server/tests/balances/check/credit-systems/credit-systems2.test.ts b/server/tests/balances/check/credit-systems/credit-systems2.test.ts index ee189f8a0..bbfbae9ce 100644 --- a/server/tests/balances/check/credit-systems/credit-systems2.test.ts +++ b/server/tests/balances/check/credit-systems/credit-systems2.test.ts @@ -6,9 +6,9 @@ import { type LimitedItem, SuccessCode, } from "@autumn/shared"; -import chalk from "chalk"; import { TestFeature } from "@tests/setup/v2Features.js"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; diff --git a/server/tests/balances/check/misc/check-misc1.test.ts b/server/tests/balances/check/misc/check-misc1.test.ts index 5b6c3f219..cc8439b45 100644 --- a/server/tests/balances/check/misc/check-misc1.test.ts +++ b/server/tests/balances/check/misc/check-misc1.test.ts @@ -1,9 +1,9 @@ import { beforeAll, describe, expect, test } from "bun:test"; import { LegacyVersion, type LimitedItem } from "@autumn/shared"; -import chalk from "chalk"; -import { Decimal } from "decimal.js"; import { TestFeature } from "@tests/setup/v2Features.js"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; +import { Decimal } from "decimal.js"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { timeout } from "@/utils/genUtils.js"; import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; @@ -35,14 +35,13 @@ describe(`${chalk.yellowBright("check-misc1: Checking credit systems")}`, () => const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 }); beforeAll(async () => { - const { customer: customer_, testClockId: testClockId_ } = - await initCustomerV3({ - ctx, - customerId, - customerData: {}, - attachPm: "success", - withTestClock: true, - }); + await initCustomerV3({ + ctx, + customerId, + customerData: {}, + attachPm: "success", + withTestClock: true, + }); await initProductsV0({ ctx, diff --git a/server/tests/balances/check/misc/check-misc2.test.ts b/server/tests/balances/check/misc/check-misc2.test.ts index b59d4f693..844e712c5 100644 --- a/server/tests/balances/check/misc/check-misc2.test.ts +++ b/server/tests/balances/check/misc/check-misc2.test.ts @@ -1,8 +1,8 @@ import { beforeAll, describe, expect, test } from "bun:test"; import { ApiVersion, CusExpand } from "@autumn/shared"; -import chalk from "chalk"; import { TestFeature } from "@tests/setup/v2Features.js"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; diff --git a/server/tests/balances/check/prepaid/check-prepaid1.test.ts b/server/tests/balances/check/prepaid/check-prepaid1.test.ts index 1d02b4d36..82c027317 100644 --- a/server/tests/balances/check/prepaid/check-prepaid1.test.ts +++ b/server/tests/balances/check/prepaid/check-prepaid1.test.ts @@ -1,7 +1,6 @@ import { beforeAll, describe, expect, test } from "bun:test"; import { ApiVersion, - type CheckResponseV0, type CheckResponseV1, type CheckResponseV2, type LimitedItem, @@ -32,7 +31,6 @@ const testCase = "check-prepaid1"; describe(`${chalk.yellowBright("check-prepaid1: test /check when prepaid feature attached")}`, () => { const customerId = testCase; - const autumnV0: AutumnInt = new AutumnInt({ version: ApiVersion.V0_2 }); const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 }); const autumnV2: AutumnInt = new AutumnInt({ version: ApiVersion.V2_0 }); @@ -81,14 +79,16 @@ describe(`${chalk.yellowBright("check-prepaid1: test /check when prepaid feature current_balance: prepaidQuantity + prepaidMessagesFeature.included_usage, usage: 0, - max_purchase: 0, + + max_purchase: null, overage_allowed: false, reset: { interval: "month", - // resets_at: 1765462114000, }, }, }); + + expect(res.balance?.reset?.resets_at).toBeDefined(); }); test("should have allowed true if value is less than current balance", async () => { @@ -119,26 +119,19 @@ describe(`${chalk.yellowBright("check-prepaid1: test /check when prepaid feature feature_id: TestFeature.Messages, })) as unknown as CheckResponseV1; - console.log("Res:", res); - - // expect(res).toStrictEqual({ - // allowed: false, - // customer_id: customerId, - // feature_id: TestFeature.Messages, - // required_balance: 1, - // code: SuccessCode.FeatureFound, - // }); - }); - return; - - test("should have correct v0 response", async () => { - const res = (await autumnV0.check({ + expect(res).toMatchObject({ + allowed: true, + code: "feature_found", customer_id: customerId, feature_id: TestFeature.Messages, - })) as unknown as CheckResponseV0; - - expect(res.allowed).toBe(false); - expect(res.balances).toBeDefined(); - expect(res.balances).toHaveLength(0); + required_balance: 1, + interval: "month", + interval_count: 1, + unlimited: false, + balance: prepaidQuantity + prepaidMessagesFeature.included_usage, + usage: 0, + included_usage: prepaidQuantity + prepaidMessagesFeature.included_usage, + overage_allowed: false, + }); }); }); diff --git a/server/tests/balances/check/prepaid/check-prepaid2.test.ts b/server/tests/balances/check/prepaid/check-prepaid2.test.ts new file mode 100644 index 000000000..7a020b8c8 --- /dev/null +++ b/server/tests/balances/check/prepaid/check-prepaid2.test.ts @@ -0,0 +1,288 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import { + ApiVersion, + type CheckResponseV1, + type CheckResponseV2, + type LimitedItem, +} from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { + constructArrearItem, + constructPrepaidItem, +} from "@/utils/scriptUtils/constructItem.js"; +import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; +import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; +import { timeout } from "../../../utils/genUtils"; + +const prepaidItem = constructPrepaidItem({ + featureId: TestFeature.Messages, + includedUsage: 100, + billingUnits: 100, + price: 8.5, +}) as LimitedItem; + +const usageItem = constructArrearItem({ + featureId: TestFeature.Messages, + includedUsage: 200, + price: 0.5, + billingUnits: 1, + usageLimit: 500, +}) as LimitedItem; + +const prod = constructProduct({ + type: "free", + isDefault: false, + items: [prepaidItem, usageItem], +}); + +const testCase = "check-prepaid2"; + +describe(`${chalk.yellowBright("check-prepaid2: test /check on prepaid + pay per use feature")}`, () => { + const customerId = testCase; + const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 }); + const autumnV2: AutumnInt = new AutumnInt({ version: ApiVersion.V2_0 }); + + const prepaidQuantity = 500; + const grantedBalance = prepaidItem.included_usage + usageItem.included_usage; + + beforeAll(async () => { + await initCustomerV3({ + ctx, + customerId, + withTestClock: false, + attachPm: "success", + }); + + await initProductsV0({ + ctx, + products: [prod], + prefix: testCase, + }); + + await autumnV1.attach({ + customer_id: customerId, + product_id: prod.id, + options: [ + { + feature_id: TestFeature.Messages, + quantity: prepaidQuantity, + }, + ], + }); + }); + + test("should have correct v2 response for empty usage", async () => { + const res = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponseV2; + + expect(res).toMatchObject({ + allowed: true, + customer_id: customerId, + required_balance: 1, + balance: { + feature_id: TestFeature.Messages, + unlimited: false, + granted_balance: grantedBalance, + purchased_balance: prepaidQuantity, + current_balance: prepaidQuantity + grantedBalance, + usage: 0, + max_purchase: null, + overage_allowed: true, + reset: { + interval: prepaidItem.interval, + }, + }, + }); + + expect(res.balance?.reset?.resets_at).toBeDefined(); + + const expectedPrepaidBreakdown = { + granted_balance: prepaidItem.included_usage, + purchased_balance: prepaidQuantity, + current_balance: prepaidQuantity + prepaidItem.included_usage, + usage: 0, + max_purchase: null, + overage_allowed: false, + reset: { + interval: "month", + }, + }; + + const expectedUsageBreakdown = { + granted_balance: usageItem.included_usage, + purchased_balance: 0, + current_balance: usageItem.included_usage, + usage: 0, + max_purchase: 300, + overage_allowed: true, + reset: { + interval: "month", + }, + }; + + expect(res.balance?.breakdown).toHaveLength(2); + expect(res.balance?.breakdown?.[0]).toMatchObject(expectedPrepaidBreakdown); + expect(res.balance?.breakdown?.[1]).toMatchObject(expectedUsageBreakdown); + }); + + test("should have correct v1 response for empty usage", async () => { + const res = (await autumnV1.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponseV1; + + expect(res).toMatchObject({ + allowed: true, + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: 1, + interval: prepaidItem.interval, + unlimited: false, + included_usage: prepaidQuantity + grantedBalance, + balance: prepaidQuantity + grantedBalance, + usage: 0, + }); + }); + + let curUsage = 0; + test("should track 500 and verify check response uses prepaid balance first", async () => { + await autumnV2.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 500, + }); + + const res = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponseV2; + + const expectedCurrentBalance = prepaidQuantity + grantedBalance - 500; + curUsage = 500; + + const balance = res.balance; + + expect(balance?.granted_balance).toBe(grantedBalance); + expect(balance?.current_balance).toBe(expectedCurrentBalance); + expect(balance?.usage).toBe(curUsage); + expect(balance?.purchased_balance).toBe(500); + + const prepaidBreakdown = res.balance?.breakdown?.[0]; + expect(prepaidBreakdown).toMatchObject({ + granted_balance: prepaidItem.included_usage, + purchased_balance: prepaidQuantity, + current_balance: prepaidQuantity + prepaidItem.included_usage - curUsage, + usage: curUsage, + }); + }); + + // Balances at this point: + test("should track another 500 -- 100 from prepaid, 200 from usage-based granted, 200 paid", async () => { + await autumnV2.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 500, + }); + + const res = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponseV2; + + curUsage = curUsage + 500; + + const balance = res.balance; + + expect(balance).toMatchObject({ + granted_balance: grantedBalance, + current_balance: 0, + usage: curUsage, + purchased_balance: prepaidQuantity + 200, + }); + + const prepaidBreakdown = res.balance?.breakdown?.[0]; + expect(prepaidBreakdown).toMatchObject({ + granted_balance: prepaidItem.included_usage, + purchased_balance: prepaidQuantity, + current_balance: 0, + usage: 600, + }); + + const usageBreakdown = res.balance?.breakdown?.[1]; + expect(usageBreakdown).toMatchObject({ + granted_balance: usageItem.included_usage, + purchased_balance: 200, + current_balance: 0, + usage: 400, + }); + }); + + test("should track another 200 and only 100 used due to usage limit", async () => { + await autumnV2.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 200, + }); + + const res = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponseV2; + + curUsage = curUsage + 100; + + const balance = res.balance; + expect(balance).toMatchObject({ + usage: curUsage, + purchased_balance: prepaidQuantity + 300, + }); + + const usageBreakdown = res.balance?.breakdown?.[1]; + expect(usageBreakdown).toMatchObject({ + granted_balance: usageItem.included_usage, + purchased_balance: 300, + current_balance: 0, + usage: 500, + max_purchase: 300, + }); + }); + + test("should check that non-cached customer returns correct response", async () => { + await timeout(2000); + const res = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + skip_cache: true, + })) as unknown as CheckResponseV2; + + expect(res.balance).toMatchObject({ + granted_balance: grantedBalance, + current_balance: 0, + usage: curUsage, + purchased_balance: prepaidQuantity + 300, + }); + + const prepaidBreakdown = res.balance?.breakdown?.[0]; + expect(prepaidBreakdown).toMatchObject({ + granted_balance: prepaidItem.included_usage, + purchased_balance: prepaidQuantity, + current_balance: 0, + usage: 600, + }); + + const usageBreakdown = res.balance?.breakdown?.[1]; + expect(usageBreakdown).toMatchObject({ + granted_balance: usageItem.included_usage, + purchased_balance: 300, + current_balance: 0, + usage: 500, + max_purchase: 300, + }); + }); +}); diff --git a/server/tests/balances/track/basic/track-basic1.test.ts b/server/tests/balances/track/basic/track-basic1.test.ts index a3b30157a..81e3cf03f 100644 --- a/server/tests/balances/track/basic/track-basic1.test.ts +++ b/server/tests/balances/track/basic/track-basic1.test.ts @@ -1,9 +1,9 @@ import { beforeAll, describe, expect, test } from "bun:test"; import { ApiVersion, type TrackResponseV2 } from "@autumn/shared"; -import chalk from "chalk"; import { TestFeature } from "@tests/setup/v2Features.js"; import { timeout } from "@tests/utils/genUtils.js"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; diff --git a/server/tests/balances/track/basic/track-basic10.test.ts b/server/tests/balances/track/basic/track-basic10.test.ts index a50e04a59..cc0b53e80 100644 --- a/server/tests/balances/track/basic/track-basic10.test.ts +++ b/server/tests/balances/track/basic/track-basic10.test.ts @@ -1,9 +1,13 @@ import { beforeAll, describe, expect, test } from "bun:test"; -import { ApiVersion, type LimitedItem } from "@autumn/shared"; -import chalk from "chalk"; +import { + ApiVersion, + type LimitedItem, + type TrackResponseV2, +} from "@autumn/shared"; import { TestFeature } from "@tests/setup/v2Features.js"; import { timeout } from "@tests/utils/genUtils.js"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { constructArrearItem, @@ -40,6 +44,7 @@ const monthlyProduct = constructProduct({ describe(`${chalk.yellowBright(`${testCase}: Testing deduction order with monthly pay-per-use and lifetime one-off`)}`, () => { const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 }); + const autumnV2: AutumnInt = new AutumnInt({ version: ApiVersion.V2_0 }); beforeAll(async () => { await initProductsV0({ @@ -93,23 +98,46 @@ describe(`${chalk.yellowBright(`${testCase}: Testing deduction order with monthl const currentUsage = 40; test("should deduct from monthly first", async () => { - await autumnV1.track({ + const trackRes: TrackResponseV2 = await autumnV2.track({ customer_id: customerId, feature_id: TestFeature.Messages, value: currentUsage, overage_behavior: "reject", }); + // 1. Verify track response + const totalBalance = + monthlyMsges.included_usage + lifetimeMsges.included_usage; + expect(trackRes.balance).toMatchObject({ + granted_balance: totalBalance, + purchased_balance: 0, + current_balance: totalBalance - currentUsage, + usage: currentUsage, + }); + const trackMonthlyBreakdown = trackRes.balance?.breakdown?.[0]; + expect(trackMonthlyBreakdown).toMatchObject({ + granted_balance: monthlyMsges.included_usage, + purchased_balance: 0, + current_balance: monthlyMsges.included_usage - currentUsage, + usage: currentUsage, + }); + const trackLifetimeBreakdown = trackRes.balance?.breakdown?.[1]; + expect(trackLifetimeBreakdown).toMatchObject({ + granted_balance: lifetimeMsges.included_usage, + purchased_balance: 0, + current_balance: lifetimeMsges.included_usage, + usage: 0, + }); + + // Check top-level balance and usage const customer = await autumnV1.customers.get(customerId); const msgesFeature = customer.features[TestFeature.Messages]; - // Check top-level balance and usage expect(msgesFeature.balance).toBe( monthlyMsges.included_usage + lifetimeMsges.included_usage - currentUsage, ); expect(msgesFeature.usage).toBe(currentUsage); - // Check breakdown balances and usage const monthlyBreakdown = msgesFeature.breakdown?.find( (b: any) => b.interval === "month", ); @@ -132,30 +160,43 @@ describe(`${chalk.yellowBright(`${testCase}: Testing deduction order with monthl const usage2 = 50; // 10 from monthly, 40 from lifetime test("should deduct from monthly and lifetime in correct order", async () => { - await autumnV1.track({ + const trackRes: TrackResponseV2 = await autumnV2.track({ customer_id: customerId, feature_id: TestFeature.Messages, value: usage2, overage_behavior: "reject", }); + // 1. Verify track response + const trackMonthlyBreakdown = trackRes.balance?.breakdown?.[0]; + expect(trackMonthlyBreakdown).toMatchObject({ + granted_balance: monthlyMsges.included_usage, + purchased_balance: 0, + current_balance: 0, + usage: monthlyMsges.included_usage, + }); + const trackLifetimeBreakdown = trackRes.balance?.breakdown?.[1]; + expect(trackLifetimeBreakdown).toMatchObject({ + granted_balance: lifetimeMsges.included_usage, + purchased_balance: 0, + current_balance: 10, + usage: 40, + }); + + // 2. Verify customer balances const customer = await autumnV1.customers.get(customerId); const msgesFeature = customer.features[TestFeature.Messages]; - - // Check top-level balance and usage expect(msgesFeature.balance).toBe(10); expect(msgesFeature.usage).toBe(currentUsage + usage2); - - // Check breakdown balances and usage const monthlyBreakdown = msgesFeature.breakdown?.find( (b: any) => b.interval === "month", ); + expect(monthlyBreakdown?.balance).toBe(0); + expect(monthlyBreakdown?.usage).toBe(monthlyMsges.included_usage); + const lifetimeBreakdown = msgesFeature.breakdown?.find( (b: any) => b.interval === "lifetime", ); - - expect(monthlyBreakdown?.balance).toBe(0); - expect(monthlyBreakdown?.usage).toBe(monthlyMsges.included_usage); expect(lifetimeBreakdown?.balance).toBe(10); expect(lifetimeBreakdown?.usage).toBe(40); @@ -167,21 +208,37 @@ describe(`${chalk.yellowBright(`${testCase}: Testing deduction order with monthl const usage3 = 50; // 10 from lifetime, 40 from monthly overage test("should deduct from lifetime and monthly in correct order", async () => { - await autumnV1.track({ + const trackRes: TrackResponseV2 = await autumnV2.track({ customer_id: customerId, feature_id: TestFeature.Messages, value: usage3, overage_behavior: "reject", }); + // 1. Verify track response + // 1. Verify track response + const trackMonthlyBreakdown = trackRes.balance?.breakdown?.[0]; + expect(trackMonthlyBreakdown).toMatchObject({ + granted_balance: monthlyMsges.included_usage, + purchased_balance: 40, + current_balance: 0, + usage: monthlyMsges.included_usage + 40, + }); + const trackLifetimeBreakdown = trackRes.balance?.breakdown?.[1]; + expect(trackLifetimeBreakdown).toMatchObject({ + granted_balance: lifetimeMsges.included_usage, + purchased_balance: 0, + current_balance: 0, + usage: 40 + 10, + }); + + // 2. Verify customer response const customer = await autumnV1.customers.get(customerId); const msgesFeature = customer.features[TestFeature.Messages]; - // Check top-level balance and usage expect(msgesFeature.balance).toBe(-40); expect(msgesFeature.usage).toBe(currentUsage + usage2 + usage3); - // Check breakdown balances and usage const monthlyBreakdown = msgesFeature.breakdown?.find( (b: any) => b.interval === "month", ); diff --git a/server/tests/balances/track/basic/track-basic11.test.ts b/server/tests/balances/track/basic/track-basic11.test.ts index 599616aa8..34ec53a30 100644 --- a/server/tests/balances/track/basic/track-basic11.test.ts +++ b/server/tests/balances/track/basic/track-basic11.test.ts @@ -1,9 +1,9 @@ import { beforeAll, describe, expect, test } from "bun:test"; import { ApiVersion } from "@autumn/shared"; -import chalk from "chalk"; import { TestFeature } from "@tests/setup/v2Features.js"; import { timeout } from "@tests/utils/genUtils.js"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; diff --git a/server/tests/balances/track/basic/track-basic8.test.ts b/server/tests/balances/track/basic/track-basic8.test.ts index 153c9164f..10aa791e1 100644 --- a/server/tests/balances/track/basic/track-basic8.test.ts +++ b/server/tests/balances/track/basic/track-basic8.test.ts @@ -1,9 +1,14 @@ import { beforeAll, describe, expect, test } from "bun:test"; -import { ApiVersion, ErrCode, type TrackResponseV2 } from "@autumn/shared"; -import chalk from "chalk"; +import { + type ApiCustomer, + ApiVersion, + ErrCode, + type TrackResponseV2, +} from "@autumn/shared"; import { TestFeature } from "@tests/setup/v2Features.js"; import { timeout } from "@tests/utils/genUtils.js"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { constructPrepaidItem } from "@/utils/scriptUtils/constructItem.js"; import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; @@ -15,11 +20,11 @@ const testCase = "track-basic8"; const customerId = testCase; // Prepaid feature: 5 included, no overage allowed -const prepaidQuantity = 500; + const prepaidItem = constructPrepaidItem({ featureId: TestFeature.Messages, - includedUsage: 0, - billingUnits: 100, + includedUsage: 2, + billingUnits: 1, price: 1, }); @@ -29,6 +34,7 @@ const prepaidProduct = constructProduct({ type: "pro", }); +const prepaidQuantity = 3; describe(`${chalk.yellowBright(`${testCase}: Testing prepaid tracking`)}`, () => { const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 }); const autumnV2: AutumnInt = new AutumnInt({ version: ApiVersion.V2_0 }); @@ -62,7 +68,7 @@ describe(`${chalk.yellowBright(`${testCase}: Testing prepaid tracking`)}`, () => const customer = await autumnV1.customers.get(customerId); const balance = customer.features[TestFeature.Messages].balance; - expect(balance).toBe(prepaidQuantity); + expect(balance).toBe(5); }); test("should reject tracking 7 units when balance is 5 (no overage)", async () => { @@ -72,7 +78,7 @@ describe(`${chalk.yellowBright(`${testCase}: Testing prepaid tracking`)}`, () => await autumnV1.track({ customer_id: customerId, feature_id: TestFeature.Messages, - value: prepaidQuantity + 1, + value: 7, overage_behavior: "reject", }); }, @@ -82,7 +88,22 @@ describe(`${chalk.yellowBright(`${testCase}: Testing prepaid tracking`)}`, () => const customer = await autumnV1.customers.get(customerId); const balance = customer.features[TestFeature.Messages].balance; - expect(balance).toBe(prepaidQuantity); + expect(balance).toBe(5); + }); + + test("should track 4 units and have correct balance", async () => { + const trackRes: TrackResponseV2 = await autumnV2.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 4, + }); + + expect(trackRes.balance).toMatchObject({ + granted_balance: 2, + purchased_balance: 3, + current_balance: 1, + usage: 4, + }); }); test("should reflect unchanged balance in non-cached customer after 2s", async () => { @@ -90,21 +111,16 @@ describe(`${chalk.yellowBright(`${testCase}: Testing prepaid tracking`)}`, () => await timeout(2000); // Fetch customer with skip_cache=true - const customer = await autumnV1.customers.get(customerId, { + const customer = (await autumnV2.customers.get(customerId, { skip_cache: "true", + })) as unknown as ApiCustomer; + const feature = customer.balances[TestFeature.Messages]; + + expect(feature).toMatchObject({ + granted_balance: 2, + purchased_balance: 3, + current_balance: 1, + usage: 4, }); - const balance = customer.features[TestFeature.Messages].balance; - - expect(balance).toBe(prepaidQuantity); - }); - - test("should track 3 units and have correct balance", async () => { - const trackRes: TrackResponseV2 = await autumnV2.track({ - customer_id: customerId, - feature_id: TestFeature.Messages, - value: 3, - }); - - console.log("Track res:", trackRes); }); }); diff --git a/server/tests/balances/track/basic/track-basic9.test.ts b/server/tests/balances/track/basic/track-basic9.test.ts index a6f99c1ca..ede700b20 100644 --- a/server/tests/balances/track/basic/track-basic9.test.ts +++ b/server/tests/balances/track/basic/track-basic9.test.ts @@ -1,15 +1,14 @@ import { beforeAll, describe, expect, test } from "bun:test"; -import { ApiVersion } from "@autumn/shared"; -import chalk from "chalk"; +import { ApiVersion, type TrackResponseV2 } from "@autumn/shared"; import { TestFeature } from "@tests/setup/v2Features.js"; import { timeout } from "@tests/utils/genUtils.js"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { constructArrearItem } from "@/utils/scriptUtils/constructItem.js"; import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; -import { trackWasSuccessful } from "../trackTestUtils.js"; const testCase = "track-basic9"; const customerId = testCase; @@ -31,7 +30,7 @@ const payPerUseProduct = constructProduct({ describe(`${chalk.yellowBright(`${testCase}: Testing pay-per-use (overage allowed) with reject behavior`)}`, () => { const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 }); - + const autumnV2: AutumnInt = new AutumnInt({ version: ApiVersion.V2_0 }); beforeAll(async () => { await initCustomerV3({ ctx, @@ -60,14 +59,20 @@ describe(`${chalk.yellowBright(`${testCase}: Testing pay-per-use (overage allowe }); test("should allow tracking 7 units when balance is 5 (overage allowed)", async () => { - const res = await autumnV1.track({ + const trackRes: TrackResponseV2 = await autumnV2.track({ customer_id: customerId, feature_id: TestFeature.Messages, value: 7, overage_behavior: "reject", }); - expect(trackWasSuccessful({ res })).toBe(true); + // Verify track response + expect(trackRes.balance).toMatchObject({ + granted_balance: 5, + purchased_balance: 2, + current_balance: 0, + usage: 7, + }); // Verify balance went negative (overage) const customer = await autumnV1.customers.get(customerId); @@ -80,14 +85,20 @@ describe(`${chalk.yellowBright(`${testCase}: Testing pay-per-use (overage allowe // Track 3 more units, should be allowed test("should allow tracking 3 units when balance is -2 (overage allowed)", async () => { - const res = await autumnV1.track({ + const trackRes: TrackResponseV2 = await autumnV2.track({ customer_id: customerId, feature_id: TestFeature.Messages, value: 3, overage_behavior: "reject", }); - expect(trackWasSuccessful({ res })).toBe(true); + // Verify track response + expect(trackRes.balance).toMatchObject({ + granted_balance: 5, + purchased_balance: 5, + current_balance: 0, + usage: 10, + }); // Verify balance went negative (overage) const customer = await autumnV1.customers.get(customerId); diff --git a/server/tests/balances/track/concurrency/concurrent-track1.test.ts b/server/tests/balances/track/concurrency/concurrent-track1.test.ts index 8589ef30e..791f647e9 100644 --- a/server/tests/balances/track/concurrency/concurrent-track1.test.ts +++ b/server/tests/balances/track/concurrency/concurrent-track1.test.ts @@ -1,14 +1,13 @@ import { beforeAll, describe, expect, test } from "bun:test"; import { ApiVersion } from "@autumn/shared"; -import chalk from "chalk"; import { TestFeature } from "@tests/setup/v2Features.js"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; -import { trackWasSuccessful } from "../trackTestUtils.js"; const testCase = "concurrentTrack1"; const customerId = testCase; @@ -85,10 +84,10 @@ describe(`${chalk.yellowBright(`concurrentTrack1: Testing track with concurrent }), ]; - const results = await Promise.all(promises); + const results = await Promise.allSettled(promises); // With cap behavior, all requests pass (cap at 0 instead of rejecting) - const allFulfilled = results.every((r) => trackWasSuccessful({ res: r })); + const allFulfilled = results.every((r) => r.status === "fulfilled"); expect(allFulfilled).toBe(true); // Check final balance diff --git a/server/tests/balances/track/concurrency/concurrent-track2.test.ts b/server/tests/balances/track/concurrency/concurrent-track2.test.ts index 0bedaaf5d..ce9084c62 100644 --- a/server/tests/balances/track/concurrency/concurrent-track2.test.ts +++ b/server/tests/balances/track/concurrency/concurrent-track2.test.ts @@ -1,8 +1,8 @@ import { beforeAll, describe, expect, test } from "bun:test"; import { ApiVersion, ProductItemFeatureType } from "@autumn/shared"; -import chalk from "chalk"; import { TestFeature } from "@tests/setup/v2Features.js"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; @@ -85,16 +85,6 @@ describe(`${chalk.yellowBright(`concurrentTrack2: Testing concurrent track, allo await Promise.all(promises); - // console.log(results); - // return; - - // const successCount = results.filter((r) => r.status === "fulfilled").length; - // const rejectedCount = results.filter((r) => r.status === "rejected").length; - - // // Only 1 should succeed, 4 should be rejected due to insufficient balance - // expect(successCount).toBe(1); - // expect(rejectedCount).toBe(4); - // Check final balance const customer = await autumnV1.customers.get(customerId); const finalBalance = customer.features[TestFeature.Users].balance; diff --git a/server/tests/balances/track/concurrency/concurrent-track5.test.ts b/server/tests/balances/track/concurrency/concurrent-track5.test.ts index 3579e8ea0..509037625 100644 --- a/server/tests/balances/track/concurrency/concurrent-track5.test.ts +++ b/server/tests/balances/track/concurrency/concurrent-track5.test.ts @@ -1,8 +1,8 @@ import { beforeAll, describe, expect, test } from "bun:test"; import { ApiVersion, ProductItemFeatureType } from "@autumn/shared"; -import chalk from "chalk"; import { TestFeature } from "@tests/setup/v2Features.js"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { constructArrearItem, diff --git a/server/tests/balances/track/credit-systems/track-credit-system1.test.ts b/server/tests/balances/track/credit-systems/track-credit-system1.test.ts index a41472bdf..fdf68277e 100644 --- a/server/tests/balances/track/credit-systems/track-credit-system1.test.ts +++ b/server/tests/balances/track/credit-systems/track-credit-system1.test.ts @@ -1,9 +1,9 @@ import { beforeAll, describe, expect, test } from "bun:test"; import { ApiVersion, type LimitedItem } from "@autumn/shared"; -import chalk from "chalk"; import { TestFeature } from "@tests/setup/v2Features.js"; import { timeout } from "@tests/utils/genUtils.js"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; diff --git a/server/tests/contUse/track/track1.test.ts b/server/tests/contUse/track/track1.test.ts index 1f84899ee..e2ccabdb6 100644 --- a/server/tests/contUse/track/track1.test.ts +++ b/server/tests/contUse/track/track1.test.ts @@ -1,12 +1,11 @@ -import { beforeAll, describe, expect, test } from "bun:test"; import { LegacyVersion, OnDecrease, OnIncrease } from "@autumn/shared"; -import chalk from "chalk"; -import { addWeeks } from "date-fns"; import { TestFeature } from "@tests/setup/v2Features.js"; import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js"; import { expectSubQuantityCorrect } from "@tests/utils/expectUtils/expectContUseUtils.js"; import { advanceTestClock } from "@tests/utils/stripeUtils.js"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; +import { addWeeks } from "date-fns"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { timeout } from "@/utils/genUtils.js"; import { constructArrearProratedItem } from "@/utils/scriptUtils/constructItem.js"; diff --git a/server/tests/plan-schema/01-basic-create.test.ts b/server/tests/plan-schema/01-basic-create.test.ts index e6f1c16df..aa3077603 100644 --- a/server/tests/plan-schema/01-basic-create.test.ts +++ b/server/tests/plan-schema/01-basic-create.test.ts @@ -141,25 +141,25 @@ describe(chalk.yellowBright("Plan V2 - Basic CREATE Tests"), () => { } catch (_error) {} const created = (await autumnV2.products.create({ - id: "metered_monthly", + id: productId, name: "Metered Monthly", features: [ { feature_id: features.metered1.id, - granted: 1000, - reset_interval: ResetInterval.Month, + granted_balance: 1000, + reset: { + interval: ResetInterval.Month, + }, }, ], } as CreatePlanParams)) as ApiPlan; // V2 response validation expect(created.features).to.have.lengthOf(1); - expect(created.features[0].granted).to.equal(1000); + expect(created.features[0].granted_balance).to.equal(1000); // V1.2 validation (items format) - const v1_2 = (await autumnV1_2.products.get( - "metered_monthly", - )) as ApiProduct; + const v1_2 = (await autumnV1_2.products.get(productId)) as ApiProduct; expect(v1_2.items[0].included_usage).to.equal(1000); expect(v1_2.items[0].interval).to.equal("month"); }); diff --git a/shared/api/balances/check/changes/V1.2_CheckChange.ts b/shared/api/balances/check/changes/V1.2_CheckChange.ts index ab4220527..d2413c38c 100644 --- a/shared/api/balances/check/changes/V1.2_CheckChange.ts +++ b/shared/api/balances/check/changes/V1.2_CheckChange.ts @@ -24,7 +24,7 @@ import { CheckResponseV1Schema } from "../prevVersions/CheckResponseV1.js"; export const V1_2_CheckChange = defineVersionChange({ name: "V1_2 Check Change", newVersion: ApiVersion.V2_0, - oldVersion: ApiVersion.V1_2, + oldVersion: ApiVersion.V1_Beta, description: ["Check response transformed to V2.0 format"], affectedResources: [AffectedResource.Check], newSchema: CheckResponseV2Schema, diff --git a/shared/api/balances/check/changes/V1.2_CheckQueryChange.ts b/shared/api/balances/check/changes/V1.2_CheckQueryChange.ts index 2b301ab9c..cf63dc588 100644 --- a/shared/api/balances/check/changes/V1.2_CheckQueryChange.ts +++ b/shared/api/balances/check/changes/V1.2_CheckQueryChange.ts @@ -4,8 +4,8 @@ import { defineVersionChange, } from "@api/versionUtils/versionChangeUtils/VersionChange.js"; import type { z } from "zod/v4"; +import { CusExpand } from "../../../../models/cusModels/cusExpand.js"; import { CheckQuerySchema } from "../checkParams.js"; -import { CheckExpand } from "../enums/CheckExpand.js"; /** * V1_2_CheckQueryChange: Transforms check query TO latest format @@ -49,7 +49,11 @@ export const V1_2_CheckQueryChange = defineVersionChange({ const existingExpand = input.expand || []; // Add `balance.feature` to expand array - const newExpand = [...existingExpand, CheckExpand.BalanceFeature]; + // Type assertion needed because schema expects CusExpand.BalanceFeature[] specifically + const newExpand: CusExpand.BalanceFeature[] = [ + ...(existingExpand as CusExpand.BalanceFeature[]), + CusExpand.BalanceFeature, + ]; return { ...input, diff --git a/shared/api/balances/check/checkParams.ts b/shared/api/balances/check/checkParams.ts index 566b13cc4..5d78b0ec4 100644 --- a/shared/api/balances/check/checkParams.ts +++ b/shared/api/balances/check/checkParams.ts @@ -1,12 +1,12 @@ import { z } from "zod/v4"; +import { CusExpand } from "../../../models/cusModels/cusExpand.js"; import { CustomerDataSchema } from "../../common/customerData.js"; import { EntityDataSchema } from "../../common/entityData.js"; import { queryStringArray } from "../../common/queryHelpers.js"; -import { CheckExpand } from "./enums/CheckExpand.js"; export const CheckQuerySchema = z.object({ skip_cache: z.boolean().optional(), - expand: queryStringArray(z.enum(CheckExpand)).optional(), + expand: queryStringArray(z.enum([CusExpand.BalanceFeature])).optional(), }); // Check Feature Schemas diff --git a/shared/api/customers/changes/V1.2_CustomerChange.ts b/shared/api/customers/changes/V1.2_CustomerChange.ts index 4b469e9ba..429ac8e35 100644 --- a/shared/api/customers/changes/V1.2_CustomerChange.ts +++ b/shared/api/customers/changes/V1.2_CustomerChange.ts @@ -63,6 +63,7 @@ export const V1_2_CustomerChange = defineVersionChange({ for (const [featureId, feature] of Object.entries(input.balances)) { v3_features[featureId] = transformBalanceToCusFeatureV3({ input: feature, + legacyData: legacyData?.cusFeatureLegacyData[featureId], }); } diff --git a/shared/api/customers/cusFeatures/apiBalance.ts b/shared/api/customers/cusFeatures/apiBalance.ts index ab7051c48..631919fa8 100644 --- a/shared/api/customers/cusFeatures/apiBalance.ts +++ b/shared/api/customers/cusFeatures/apiBalance.ts @@ -19,10 +19,9 @@ export const ApiBalanceBreakdownSchema = z.object({ current_balance: z.number(), usage: z.number(), - max_purchase: z.number().optional(), - overage_allowed: z.boolean().optional(), - - reset: ApiBalanceResetSchema.optional(), + overage_allowed: z.boolean(), + max_purchase: z.number().nullable(), + reset: ApiBalanceResetSchema.nullable(), }); export const ApiBalanceSchema = z.object({ @@ -35,10 +34,10 @@ export const ApiBalanceSchema = z.object({ current_balance: z.number(), usage: z.number(), - max_purchase: z.number(), overage_allowed: z.boolean(), + max_purchase: z.number().nullable(), + reset: ApiBalanceResetSchema.nullable(), - reset: ApiBalanceResetSchema.optional(), breakdown: z.array(ApiBalanceBreakdownSchema).nullish(), rollovers: z.array(ApiBalanceRolloverSchema).nullish(), }); diff --git a/shared/api/customers/cusFeatures/changes/V1.2_CusFeatureChange.ts b/shared/api/customers/cusFeatures/changes/V1.2_CusFeatureChange.ts index e4965af18..147be456d 100644 --- a/shared/api/customers/cusFeatures/changes/V1.2_CusFeatureChange.ts +++ b/shared/api/customers/cusFeatures/changes/V1.2_CusFeatureChange.ts @@ -124,7 +124,6 @@ const toV3BalanceParams = ({ } // 5. Usage limit - const usageLimit = input.max_purchase ? new Decimal(input.max_purchase).add(includedUsage).toNumber() : undefined; @@ -150,6 +149,7 @@ export function transformBalanceToCusFeatureV3({ unlimited: isUnlimited, }); + console.log("Legacy data:", legacyData); const { includedUsage, balance, usage, overageAllowed, usageLimit } = toV3BalanceParams({ input, diff --git a/shared/utils/cusEntUtils/balanceUtils/cusEntToPrepaidQuantity.ts b/shared/utils/cusEntUtils/balanceUtils/cusEntToPrepaidQuantity.ts new file mode 100644 index 000000000..6e83cc2fe --- /dev/null +++ b/shared/utils/cusEntUtils/balanceUtils/cusEntToPrepaidQuantity.ts @@ -0,0 +1,29 @@ +import { Decimal } from "decimal.js"; +import type { FullCusEntWithFullCusProduct } from "../../../models/cusProductModels/cusEntModels/cusEntWithProduct.js"; +import { cusEntToCusPrice } from "../../productUtils/convertUtils.js"; +import { isPrepaidPrice } from "../../productUtils/priceUtils.js"; + +export const cusEntToPrepaidQuantity = ({ + cusEnt, +}: { + cusEnt: FullCusEntWithFullCusProduct; +}) => { + // 2. If cus ent is not prepaid, skip + const cusPrice = cusEntToCusPrice({ cusEnt }); + + if (!cusPrice || !isPrepaidPrice({ price: cusPrice.price })) return 0; + + // 3. Get quantity + const options = cusEnt.customer_product.options.find( + (option) => + option.internal_feature_id === cusEnt.entitlement.internal_feature_id, + ); + + if (!options) return 0; + + const quantityWithUnits = new Decimal(options.quantity) + .mul(cusPrice.price.config.billing_units ?? 1) + .toNumber(); + + return quantityWithUnits; +}; diff --git a/shared/utils/cusEntUtils/convertCusEntUtils.ts b/shared/utils/cusEntUtils/convertCusEntUtils.ts index 7ea08b569..b856c1794 100644 --- a/shared/utils/cusEntUtils/convertCusEntUtils.ts +++ b/shared/utils/cusEntUtils/convertCusEntUtils.ts @@ -5,7 +5,6 @@ import { type FullCustomerEntitlement, getCusEntBalance, getStartingBalance, - notNullish, } from "../../index.js"; import { cusEntToCusPrice } from "../productUtils/convertUtils.js"; import { getRolloverFields } from "./getRolloverFields.js"; @@ -13,10 +12,16 @@ import { getRolloverFields } from "./getRolloverFields.js"; export const cusEntToKey = ({ cusEnt, }: { - cusEnt: FullCustomerEntitlement; + cusEnt: FullCusEntWithFullCusProduct; }) => { - const ent = cusEnt.entitlement; - return `${ent.interval || "null"}-${ent.interval_count || 1}-${ent.feature.id}`; + // Interval + const interval = `${cusEnt.entitlement.interval_count ?? 1}:${cusEnt.entitlement.interval}`; + + const planId = `${cusEnt.customer_product.product_id}`; + + const usageModel = `${cusEnt.usage_allowed}`; + + return `${interval}:${planId}:${usageModel}`; }; export const cusEntToBalance = ({ @@ -94,29 +99,6 @@ export const cusEntToIncludedUsage = ({ // } }; -export const cusEntToMaxPurchase = ({ - cusEnt, - entityId, -}: { - cusEnt: FullCusEntWithFullCusProduct; - entityId?: string; -}) => { - const startingBalance = cusEntToIncludedUsage({ - cusEnt, - entityId, - }); - - const usageLimit = cusEnt.entitlement.usage_limit; - // if (cusEnt.entitlement.usage_limit) return cusEnt.entitlement.usage_limit; - // return startingBalance; - - if (notNullish(usageLimit) && notNullish(startingBalance)) { - return new Decimal(usageLimit).sub(startingBalance).toNumber(); - } - - return 0; -}; - // NEW CUS ENT UTILS export const cusEntToGrantedBalance = ({ cusEnt, diff --git a/shared/utils/cusEntUtils/convertCusEntUtils/cusEntsToMaxPurchase.ts b/shared/utils/cusEntUtils/convertCusEntUtils/cusEntsToMaxPurchase.ts new file mode 100644 index 000000000..55ebdd6e4 --- /dev/null +++ b/shared/utils/cusEntUtils/convertCusEntUtils/cusEntsToMaxPurchase.ts @@ -0,0 +1,59 @@ +import { Decimal } from "decimal.js"; +import { + cusEntToIncludedUsage, + type FullCusEntWithFullCusProduct, + isPrepaidCusEnt, + notNullish, + nullish, +} from "../../../index.js"; + +export const cusEntsToMaxPurchase = ({ + cusEnts, + entityId, +}: { + cusEnts: FullCusEntWithFullCusProduct[]; + entityId?: string; +}): number | null => { + // 1. If there's usage-based cus ent, return undefined + if ( + cusEnts.some( + (cusEnt) => + cusEnt.usage_allowed && nullish(cusEnt.entitlement.usage_limit), + ) + ) { + return null; + } + + const hasPrepaidNoLimit = cusEnts.some( + (ce) => + isPrepaidCusEnt({ cusEnt: ce }) && nullish(ce.entitlement.usage_limit), + ); + const hasPrepaid = cusEnts.some((ce) => isPrepaidCusEnt({ cusEnt: ce })); + const hasUsageBased = cusEnts.some((ce) => ce.usage_allowed); + + // 2. If there's usage-based cus ent, and prepaid no limit + if (hasUsageBased && hasPrepaidNoLimit) return null; + + // 3. If there's prepaid + if (hasPrepaidNoLimit) return null; + + // 3. If there's no prepaid and no usage-based, return undefined (free feature) + if (!hasPrepaid && !hasUsageBased) return null; + + let maxPurchase = new Decimal(0); + for (const cusEnt of cusEnts) { + const startingBalance = cusEntToIncludedUsage({ + cusEnt, + entityId, + }); + const usageLimit = cusEnt.entitlement.usage_limit; + + if (notNullish(usageLimit) && notNullish(startingBalance)) { + maxPurchase = maxPurchase.add( + new Decimal(usageLimit).sub(startingBalance), + ); + } + } + + return maxPurchase.toNumber(); +}; diff --git a/shared/utils/cusEntUtils/cusEntUtils.ts b/shared/utils/cusEntUtils/cusEntUtils.ts index 61ae56722..91c753551 100644 --- a/shared/utils/cusEntUtils/cusEntUtils.ts +++ b/shared/utils/cusEntUtils/cusEntUtils.ts @@ -2,10 +2,10 @@ import type { EntityBalance, FullCustomerEntitlement, } from "@models/cusProductModels/cusEntModels/cusEntModels.js"; -import type { Entity } from "../../models/cusModels/entityModels/entityModels.js"; import type { FullCustomer } from "../../models/cusModels/fullCusModel.js"; import type { FullCusEntWithFullCusProduct } from "../../models/cusProductModels/cusEntModels/cusEntWithProduct.js"; -import { notNullish } from "../utils.js"; +import { cusEntToCusPrice } from "../productUtils/convertUtils.js"; +import { isPrepaidPrice } from "../productUtils/priceUtils.js"; export const formatCusEnt = ({ cusEnt, @@ -57,31 +57,52 @@ export const updateCusEntInFullCus = ({ } } }; -export const cusEntMatchesEntity = ({ + +// export const cusEntMatchesEntity = ({ +// cusEnt, +// entity, +// }: { +// cusEnt: FullCusEntWithFullCusProduct; +// entity?: Entity; +// }) => { +// if (!entity) return true; + +// let cusProductMatch = true; + +// if (notNullish(cusEnt.customer_product?.internal_entity_id)) { +// cusProductMatch = +// cusEnt.customer_product.internal_entity_id === entity.internal_id; +// } + +// let entityFeatureIdMatch = true; +// // let feature = features?.find( +// // (f) => f.id == cusEnt.entitlement.entity_feature_id, +// // ); + +// if (notNullish(cusEnt.entitlement.entity_feature_id)) { +// entityFeatureIdMatch = +// cusEnt.entitlement.entity_feature_id === entity.feature_id; +// } + +// return cusProductMatch && entityFeatureIdMatch; +// }; + +export const isPrepaidCusEnt = ({ cusEnt, - entity, }: { cusEnt: FullCusEntWithFullCusProduct; - entity?: Entity; }) => { - if (!entity) return true; + // 2. If cus ent is not prepaid, skip + const cusPrice = cusEntToCusPrice({ cusEnt }); + if (!cusPrice || !isPrepaidPrice({ price: cusPrice.price })) return false; - let cusProductMatch = true; + // 3. Get quantity + const options = cusEnt.customer_product.options.find( + (option) => + option.internal_feature_id === cusEnt.entitlement.internal_feature_id, + ); - if (notNullish(cusEnt.customer_product?.internal_entity_id)) { - cusProductMatch = - cusEnt.customer_product.internal_entity_id === entity.internal_id; - } + if (!options) return false; - let entityFeatureIdMatch = true; - // let feature = features?.find( - // (f) => f.id == cusEnt.entitlement.entity_feature_id, - // ); - - if (notNullish(cusEnt.entitlement.entity_feature_id)) { - entityFeatureIdMatch = - cusEnt.entitlement.entity_feature_id === entity.feature_id; - } - - return cusProductMatch && entityFeatureIdMatch; + return true; }; diff --git a/shared/utils/cusEntUtils/filterCusEntUtils.ts b/shared/utils/cusEntUtils/filterCusEntUtils.ts index f1cbcdc13..f5eb8a17b 100644 --- a/shared/utils/cusEntUtils/filterCusEntUtils.ts +++ b/shared/utils/cusEntUtils/filterCusEntUtils.ts @@ -1,4 +1,5 @@ import type { Entity } from "../../models/cusModels/entityModels/entityModels.js"; +import type { FullCustomerEntitlement } from "../../models/cusProductModels/cusEntModels/cusEntModels.js"; import type { FullCusEntWithFullCusProduct } from "../../models/cusProductModels/cusEntModels/cusEntWithProduct.js"; import type { Feature } from "../../models/featureModels/featureModels.js"; import { notNullish, nullish } from "../utils.js"; @@ -62,3 +63,13 @@ export const filterEntityProductCusEnts = ({ notNullish(ce.customer_product?.internal_entity_id), ); }; + +export const cusEntMatchesFeature = ({ + cusEnt, + feature, +}: { + cusEnt: FullCustomerEntitlement; + feature: Feature; +}) => { + return cusEnt.entitlement.feature.internal_id === feature.internal_id; +}; diff --git a/shared/utils/cusEntUtils/sortCusEntsForDeduction.ts b/shared/utils/cusEntUtils/sortCusEntsForDeduction.ts index 9181ac904..af1efd73c 100644 --- a/shared/utils/cusEntUtils/sortCusEntsForDeduction.ts +++ b/shared/utils/cusEntUtils/sortCusEntsForDeduction.ts @@ -119,6 +119,15 @@ export const sortCusEntsForDeduction = ({ } } + // If one has a usage_allowed, it should go last + if (a.usage_allowed && !b.usage_allowed) { + return 1; + } + + if (!a.usage_allowed && b.usage_allowed) { + return -1; + } + // 0a. If both are entity products (attached to entities), sort by entity_id for consistent ordering const aIsProductEntity = !!a.customer_product?.internal_entity_id; const bIsProductEntity = !!b.customer_product?.internal_entity_id; diff --git a/shared/utils/index.ts b/shared/utils/index.ts index fa61ea732..7080f9230 100644 --- a/shared/utils/index.ts +++ b/shared/utils/index.ts @@ -1,10 +1,12 @@ // Cus ent utils +export * from "./cusEntUtils/balanceUtils/cusEntToPrepaidQuantity.js"; export * from "./cusEntUtils/balanceUtils/cusEntToPurchasedBalance.js"; export * from "./cusEntUtils/balanceUtils.js"; +export * from "./cusEntUtils/convertCusEntUtils/cusEntsToMaxPurchase.js"; export * from "./cusEntUtils/convertCusEntUtils.js"; export * from "./cusEntUtils/cusEntUtils.js"; - +export * from "./cusEntUtils/filterCusEntUtils.js"; // Cus ent utils export * from "./cusEntUtils/getRolloverFields.js"; export * from "./cusEntUtils/getStartingBalance.js"; diff --git a/vite/src/views/products/plan/components/SelectFeatureSheet.tsx b/vite/src/views/products/plan/components/SelectFeatureSheet.tsx index bb7df4b05..1d056c6ec 100644 --- a/vite/src/views/products/plan/components/SelectFeatureSheet.tsx +++ b/vite/src/views/products/plan/components/SelectFeatureSheet.tsx @@ -49,7 +49,6 @@ export function SelectFeatureSheet({ const timer = setTimeout(() => setSelectOpen(true), 250); return () => clearTimeout(timer); } - // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const handleFeatureSelect = (featureId: string) => { diff --git a/vite/src/views/products/plan/components/plan-card/PlanFeatureList.tsx b/vite/src/views/products/plan/components/plan-card/PlanFeatureList.tsx index 588fdc743..55fe7d26f 100644 --- a/vite/src/views/products/plan/components/plan-card/PlanFeatureList.tsx +++ b/vite/src/views/products/plan/components/plan-card/PlanFeatureList.tsx @@ -31,8 +31,6 @@ export const PlanFeatureList = ({ const filteredItems = productV2ToFeatureItems({ items: product.items }); - console.log("Filtered items:", filteredItems); - // Group items by entity_feature_id const groupedItems = filteredItems.reduce( (acc, item) => { diff --git a/vite/vite.config.ts b/vite/vite.config.ts index aac71f6df..41226353e 100644 --- a/vite/vite.config.ts +++ b/vite/vite.config.ts @@ -39,6 +39,8 @@ export default defineConfig({ "better-auth/react", "@better-auth/stripe", "zod/v4", + "drizzle-orm/pg-core", + "drizzle-orm", ], }, // Clear cache on config change