This commit is contained in:
John Yeo
2025-11-12 11:01:19 +00:00
parent 6a618b67a4
commit 3d47e1effa
65 changed files with 978 additions and 836 deletions

View File

@@ -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"

View File

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

View File

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

107
scripts/dev.ts Normal file
View File

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

View File

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

View File

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

View File

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

View File

@@ -1,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();

View File

@@ -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' \

16
server/nodemon.json Normal file
View File

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

View File

@@ -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",

View File

@@ -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",

View File

@@ -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<CheckResult> => {
const data = await this.post(`/check`, params);
check = async <T = CheckResult>(
params: CheckParams & CheckQuery,
): Promise<T> => {
const queryParams = new URLSearchParams();
if (params.skip_cache) {
queryParams.append("skip_cache", "true");
}
const data = await this.post(`/check?${queryParams.toString()}`, params);
return data;
};

View File

@@ -71,6 +71,7 @@ export const baseMiddleware = async (c: Context<HonoEnv>, next: Next) => {
// Query params
expand: [],
skipCache: false,
});
// childLogger.info(`${method} ${path}`);

View File

@@ -47,7 +47,16 @@ const ROUTE_SPECIFIC_RULES: Array<{
name: string;
match: (err: Error, c: Context<HonoEnv>) => 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<HonoEnv>) => {
// 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,
});
}

View File

@@ -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<HonoEnv> => {
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");

View File

@@ -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;

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,
};

View File

@@ -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: {

View File

@@ -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;

View File

@@ -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,
});

View File

@@ -129,15 +129,15 @@ export const normalizeCachedData = <T extends ApiCustomer | ApiEntity>(
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;
// }
}
}

View File

@@ -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";

View File

@@ -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,
},
});

View File

@@ -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,

View File

@@ -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,

View File

@@ -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",

View File

@@ -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",

View File

@@ -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";

View File

@@ -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,7 +35,6 @@ 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,

View File

@@ -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";

View File

@@ -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,
});
});
});

View File

@@ -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,
});
});
});

View File

@@ -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";

View File

@@ -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",
);

View File

@@ -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";

View File

@@ -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",
});
const balance = customer.features[TestFeature.Messages].balance;
})) as unknown as ApiCustomer;
const feature = customer.balances[TestFeature.Messages];
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);
expect(feature).toMatchObject({
granted_balance: 2,
purchased_balance: 3,
current_balance: 1,
usage: 4,
});
});
});

View File

@@ -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);

View File

@@ -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

View File

@@ -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;

View File

@@ -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,

View File

@@ -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";

View File

@@ -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";

View File

@@ -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");
});

View File

@@ -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,

View File

@@ -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,

View File

@@ -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

View File

@@ -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],
});
}

View File

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

View File

@@ -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,

View File

@@ -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;
};

View File

@@ -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,

View File

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

View File

@@ -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;
};

View File

@@ -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;
};

View File

@@ -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;

View File

@@ -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";

View File

@@ -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) => {

View File

@@ -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) => {

View File

@@ -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