chore: removed priceToInvoiceAmount from server package. Defined line item utility funcitons

This commit is contained in:
John Yeo
2025-12-14 10:52:38 +00:00
183 changed files with 6009 additions and 2746 deletions

View File

@@ -30,6 +30,7 @@
"validate-schema": "infisical run --env=prod -- bun scripts/migrations/validate-schema.ts",
"setupci": "node scripts/setup/setupci.js",
"replicate": "bun scripts/db/replicate.ts",
"db:pull": "bun scripts/db/pull.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"

381
scripts/db/pull.ts Normal file
View File

@@ -0,0 +1,381 @@
import { exec, spawn } from "node:child_process";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import readline from "node:readline";
import { promisify } from "node:util";
const execAsync = promisify(exec);
const MAX_CUSTOMER_COUNT_LIMIT = 1000;
/**
* Execute command and show live output
*/
function execWithOutput(command: string): Promise<void> {
return new Promise((resolve, reject) => {
const child = spawn(command, {
shell: true,
stdio: "inherit",
});
child.on("close", (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`Command failed with exit code ${code}`));
}
});
child.on("error", reject);
});
}
/**
* Prompt user for confirmation
*/
function promptUser(question: string): Promise<boolean> {
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");
});
});
}
/**
* Validate PostgreSQL URL format
*/
function validatePostgresUrl(url: string): boolean {
return url.startsWith("postgresql://") || url.startsWith("postgres://");
}
/**
* Extract database name from PostgreSQL URL
*/
function extractDbName(url: string): string {
try {
const urlObj = new URL(url);
return urlObj.pathname.slice(1) || "database";
} catch {
return "database";
}
}
/**
* Build connection URL to default postgres database
* Used for creating databases and checking if they exist
*/
function buildDefaultDbUrl(url: string): string {
try {
const urlObj = new URL(url);
// Replace the database name with 'postgres' (default database)
urlObj.pathname = "/postgres";
return urlObj.toString();
} catch {
return url;
}
}
/**
* Escape single quotes in database name for SQL queries
*/
function escapeDbName(dbName: string): string {
return dbName.replace(/'/g, "''");
}
/**
* Check if a database exists
*/
async function databaseExists(dbUrl: string, dbName: string): Promise<boolean> {
try {
const defaultDbUrl = buildDefaultDbUrl(dbUrl);
const cleanedUrl = cleanUrl(defaultDbUrl);
const escapedDbName = escapeDbName(dbName);
const { stdout } = await execAsync(
`psql "${cleanedUrl}" -t -c "SELECT 1 FROM pg_database WHERE datname = '${escapedDbName}';"`,
);
return stdout.trim() === "1";
} catch {
return false;
}
}
/**
* Create a database if it doesn't exist
*/
async function ensureDatabaseExists(
dbUrl: string,
dbName: string,
): Promise<void> {
const exists = await databaseExists(dbUrl, dbName);
if (exists) {
console.log(`✅ Database "${dbName}" already exists`);
return;
}
console.log(`📝 Creating database "${dbName}"...`);
try {
const defaultDbUrl = buildDefaultDbUrl(dbUrl);
const cleanedUrl = cleanUrl(defaultDbUrl);
await execWithOutput(
`psql "${cleanedUrl}" -c "CREATE DATABASE \\"${dbName}\\";"`,
);
console.log(`✅ Database "${dbName}" created successfully`);
} catch (error) {
console.error(`❌ Failed to create database "${dbName}"`);
if (error instanceof Error) {
console.error(` ${error.message}`);
}
throw error;
}
}
/**
* Clean up PostgreSQL URL for pg_dump/psql
* - Removes query parameters
* - Changes port 6432 to 5432
*/
function cleanUrl(url: string): string {
try {
const urlObj = new URL(url);
// Remove query parameters
urlObj.search = "";
// Replace port 6432 with 5432
if (urlObj.port === "6432") {
urlObj.port = "5432";
}
return urlObj.toString();
} catch {
// Fallback to regex-based cleaning if URL parsing fails
let cleaned = url.replace(/\?.*$/, "");
cleaned = cleaned.replace(/:6432\//, ":5432/");
return cleaned;
}
}
/**
* Get customer table row count
*/
async function getCustomerCount(
url: string,
): Promise<{ count: number | null; error?: string }> {
try {
const cleanedUrl = cleanUrl(url);
const { stdout } = await execAsync(
`psql "${cleanedUrl}" -t -c "SELECT COUNT(*) FROM customers;"`,
);
const count = parseInt(stdout.trim());
if (!Number.isNaN(count)) {
return { count };
}
return { count: null, error: "Could not parse customer count" };
} catch (error) {
// Table might not exist or connection failed
return {
count: null,
error: error instanceof Error ? error.message : "Unknown error",
};
}
}
/**
* Read DATABASE_URL from server/.env file
*/
function readLocalDatabaseUrl(): string | null {
try {
const envPath = join(process.cwd(), "server", ".env");
const envContent = readFileSync(envPath, "utf-8");
const lines = envContent.split("\n");
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith("DATABASE_URL=")) {
const url = trimmed.replace(/^DATABASE_URL=/, "").trim();
// Remove quotes if present
return url.replace(/^["']|["']$/g, "");
}
}
return null;
} catch {
return null;
}
}
/**
* Pull database from remote to local
*/
async function pullDatabase({
remoteUrl,
localUrl: providedLocalUrl,
}: {
remoteUrl: string;
localUrl?: string;
}): Promise<void> {
console.log("\n📥 Pulling Remote Database to Local\n");
// Validate remote URL
if (!validatePostgresUrl(remoteUrl)) {
console.error("❌ Invalid remote database URL");
console.error(
" Expected format: postgresql://user:pass@host:port/dbname",
);
process.exit(1);
}
// Get local database URL (from argument, env var, or .env file)
const localUrl =
providedLocalUrl ||
process.env.LOCAL_DATABASE_URL ||
readLocalDatabaseUrl();
if (!localUrl) {
console.error("❌ Could not find local database URL");
console.error(" Options:");
console.error(" 1. Ensure server/.env exists and contains DATABASE_URL");
console.error(" 2. Pass local URL as second argument");
console.error(" 3. Set LOCAL_DATABASE_URL environment variable");
process.exit(1);
}
if (!validatePostgresUrl(localUrl)) {
console.error("❌ Invalid local database URL in server/.env");
console.error(
" Expected format: postgresql://user:pass@host:port/dbname",
);
process.exit(1);
}
const remoteDbName = extractDbName(remoteUrl);
const localDbName = extractDbName(localUrl);
console.log(`📤 Remote: ${remoteDbName}`);
console.log(`📥 Local: ${localDbName}`);
// Ensure local database exists
console.log("\n🔍 Checking if local database exists...");
await ensureDatabaseExists(localUrl, localDbName);
// Check local database for production data
console.log("\n🔍 Checking local database contents...");
const customerResult = await getCustomerCount(localUrl);
if (customerResult.count === null) {
// Table doesn't exist or can't query - likely a new/empty database
console.log("✅ Local database appears to be empty (no customers table)");
} else {
console.log(`📊 Local database has ${customerResult.count} customers`);
// Protection: Don't allow overwriting databases with > MAX_CUSTOMER_COUNT_LIMIT customers
if (customerResult.count > MAX_CUSTOMER_COUNT_LIMIT) {
console.error("\n❌ PROTECTION: Local database has too many customers!");
console.error(
` Customer count (${customerResult.count}) exceeds ${MAX_CUSTOMER_COUNT_LIMIT} limit.`,
);
console.error(
" This safety check prevents accidental overwrites of production databases.\n",
);
process.exit(1);
}
}
// Ask for confirmation
const confirmed = await promptUser(
"\n⚠ This will OVERWRITE your local database. Continue? (y/n): ",
);
if (!confirmed) {
console.log("\n❌ Pull cancelled\n");
process.exit(0);
}
const tempFile = `/tmp/db_dump_${Date.now()}.sql`;
// Clean URLs
const cleanedRemoteUrl = cleanUrl(remoteUrl);
const cleanedLocalUrl = cleanUrl(localUrl);
console.log(`\n📤 Using remote URL: ${cleanedRemoteUrl}`);
console.log(`📥 Using local URL: ${cleanedLocalUrl}\n`);
try {
// Step 1: Dump the remote database
console.log("📦 Dumping remote database...");
await execWithOutput(
`pg_dump "${cleanedRemoteUrl}" --no-owner --no-privileges -f "${tempFile}" 2>&1`,
);
console.log("✅ Remote database dumped successfully");
// Step 2: Restore to local database
console.log("\n📥 Restoring to local database...\n");
await execWithOutput(
`psql "${cleanedLocalUrl}" -f "${tempFile}" --set ON_ERROR_STOP=off 2>&1`,
);
console.log("\n✅ Database restored successfully");
// Step 3: Clean up temp file
console.log("\n🧹 Cleaning up temporary files...");
await execAsync(`rm "${tempFile}"`);
console.log("✅ Cleanup complete");
console.log("\n✨ Database pull completed successfully!\n");
} catch (error) {
console.error("\n❌ Pull failed:");
if (error instanceof Error) {
console.error(` ${error.message}\n`);
}
// Try to clean up temp file
try {
await execAsync(`rm "${tempFile}"`);
} catch {
// Ignore cleanup errors
}
process.exit(1);
}
}
// Parse command line arguments or environment variable
const args = process.argv.slice(2);
const remoteUrl =
args[0] || process.env.REMOTE_DATABASE_URL || process.env.DATABASE_URL_REMOTE;
const localUrl = args[1] || process.env.LOCAL_DATABASE_URL;
if (!remoteUrl) {
console.log("\n📥 Pull Remote Database to Local\n");
console.log("Usage:");
console.log(' bun db:pull "<remote-database-url>" [local-database-url]\n');
console.log("Arguments:");
console.log(
" remote-database-url (required) Remote database URL to pull from",
);
console.log(
" local-database-url (optional) Local database URL to pull into",
);
console.log(
" If not provided, reads from server/.env DATABASE_URL\n",
);
console.log("Environment variables:");
console.log(" REMOTE_DATABASE_URL Remote database URL");
console.log(" LOCAL_DATABASE_URL Local database URL (overrides .env)\n");
console.log("Examples:");
console.log(' bun db:pull "postgresql://user:pass@remote-host:5432/db"');
console.log(' bun db:pull "postgresql://remote..." "postgresql://local..."');
console.log(' REMOTE_DATABASE_URL="postgresql://..." bun db:pull\n');
console.log("⚠️ Important: URLs must be quoted to prevent shell expansion\n");
console.log(
"By default, the script reads DATABASE_URL from server/.env for the local database.\n",
);
process.exit(1);
}
pullDatabase({ remoteUrl, localUrl }).catch((error) => {
console.error("Unexpected error:", error);
process.exit(1);
});

View File

@@ -36,10 +36,10 @@ async function startDev() {
const backendUrl =
process.env.VITE_BACKEND_URL ||
getEnvVariable(viteEnvPath, "VITE_BACKEND_URL");
const isUsingRemoteBackend = backendUrl?.includes("api.useautumn.com");
const isUsingRemoteBackend = backendUrl?.includes(".useautumn.com");
if (isUsingRemoteBackend) {
console.log("\n🌐 Using remote backend (api.useautumn.com)");
console.log("\n🌐 Using remote backend (*.useautumn.com)");
console.log("⏭️ Skipping port cleanup...\n");
} else {
// Port cleanup disabled (detection is unreliable)
@@ -76,28 +76,26 @@ async function startDev() {
];
}
const concurrentlyProc = Bun.spawn(shellArgs,
{
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);
},
const concurrentlyProc = Bun.spawn(shellArgs, {
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", () => {

View File

@@ -2,14 +2,14 @@ loadLocalEnv();
import { loadLocalEnv } from "@server/utils/envUtils";
import inquirer from "inquirer";
export const migrateFunctions = async () => {
// Dynamic import to ensure env is loaded first
const { initializeDatabaseFunctions } = await import(
"@server/db/initializeDatabaseFunctions"
);
const databaseUrl = process.env.DATABASE_URL;
console.log("databaseUrl", databaseUrl);
if (databaseUrl?.includes("us-west-3")) {
const { confirm } = await inquirer.prompt([
{

366
scripts/seed/seedEvents.ts Normal file
View File

@@ -0,0 +1,366 @@
import { AppEnv, ErrCode, type EventInsert, RecaseError } from "@autumn/shared";
import { initDrizzle } from "@server/db/initDrizzle.js";
import { EventService } from "@server/internal/api/events/EventService.js";
import { loadLocalEnv } from "@server/utils/envUtils.js";
import { generateId } from "@server/utils/genUtils.js";
import chalk from "chalk";
// Load environment variables from server/.env
loadLocalEnv();
// ============================================================================
// CONFIGURATION - Edit these values to customize seed behavior
// ============================================================================
const CONFIG = {
// Default number of events to generate
defaultEventCount: 100,
// Time range configuration (in days)
// Events will be randomly distributed within this range from now
timeRangeDays: 30,
// Default environment
defaultEnv: AppEnv.Sandbox,
// Optional default properties to include in events
defaultProperties: {
source: "seed_script",
version: "1.0",
},
} as const;
// ============================================================================
// TYPES
// ============================================================================
interface CliArgs {
customer_id: string;
count?: number;
env?: AppEnv;
org_id?: string;
feature_ids?: string;
}
// ============================================================================
// CLI ARGUMENT PARSING
// ============================================================================
function parseArgs(): CliArgs {
const args = process.argv.slice(2);
const parsed: Partial<CliArgs> = {};
for (const arg of args) {
if (arg.startsWith("--")) {
const [key, value] = arg.slice(2).split("=");
if (key === "customer_id") {
parsed.customer_id = value;
} else if (key === "count") {
parsed.count = Number.parseInt(value, 10);
} else if (key === "env") {
parsed.env = value as AppEnv;
} else if (key === "org_id") {
parsed.org_id = value;
} else if (key === "feature_ids") {
parsed.feature_ids = value;
}
}
}
if (!parsed.customer_id) {
console.error(chalk.red("❌ Error: --customer_id is required"));
console.log(
chalk.yellow(
"\nUsage: bun run scripts/seed/seedEvents.ts --customer_id=<id> --feature_ids=<id1,id2,id3> [--count=<number>] [--env=<sandbox|live>] [--org_id=<id>]",
),
);
console.log(chalk.gray("\nExample:"));
console.log(
chalk.gray(
" bun run scripts/seed/seedEvents.ts --customer_id=cus_123 --feature_ids=api_call,page_view --count=50 --env=sandbox",
),
);
process.exit(1);
}
if (!parsed.feature_ids) {
console.error(chalk.red("❌ Error: --feature_ids is required"));
console.log(
chalk.yellow(
"\nUsage: bun run scripts/seed/seedEvents.ts --customer_id=<id> --feature_ids=<id1,id2,id3> [--count=<number>] [--env=<sandbox|live>] [--org_id=<id>]",
),
);
console.log(chalk.gray("\nExample:"));
console.log(
chalk.gray(
" bun run scripts/seed/seedEvents.ts --customer_id=cus_123 --feature_ids=api_call,page_view --count=50 --env=sandbox",
),
);
process.exit(1);
}
return parsed as CliArgs;
}
// ============================================================================
// VALIDATION
// ============================================================================
async function validateCustomer({
db,
customerId,
orgId,
env,
}: {
db: ReturnType<typeof initDrizzle>["db"];
customerId: string;
orgId?: string;
env: AppEnv;
}) {
// Build the where clause based on whether org_id is provided
const customer = await db.query.customers.findFirst({
where: (customers, { eq, or, and }) => {
const customerMatch = or(
eq(customers.id, customerId),
eq(customers.internal_id, customerId),
);
if (orgId) {
return and(
customerMatch,
eq(customers.org_id, orgId),
eq(customers.env, env),
);
}
return customerMatch;
},
with: {
org: true,
},
});
if (!customer) {
const errorMsg = orgId
? `Customer '${customerId}' not found in org '${orgId}' with env '${env}'`
: `Customer '${customerId}' not found`;
throw new RecaseError({
message: errorMsg,
code: ErrCode.CustomerNotFound,
statusCode: 404,
});
}
return customer;
}
// ============================================================================
// EVENT GENERATION
// ============================================================================
function generateRandomTimestamp({ daysBack }: { daysBack: number }): Date {
const now = Date.now();
const startTime = now - daysBack * 24 * 60 * 60 * 1000;
const randomTime = startTime + Math.random() * (now - startTime);
return new Date(randomTime);
}
function generateEvents({
count,
customer,
env,
featureIds,
}: {
count: number;
customer: Awaited<ReturnType<typeof validateCustomer>>;
env: AppEnv;
featureIds: string[];
}): EventInsert[] {
const events: EventInsert[] = [];
for (let i = 0; i < count; i++) {
const eventName = featureIds[Math.floor(Math.random() * featureIds.length)];
const timestamp = generateRandomTimestamp({
daysBack: CONFIG.timeRangeDays,
});
const event: EventInsert = {
id: generateId("evt"),
org_id: customer.org_id,
org_slug: customer.org?.slug || "unknown",
internal_customer_id: customer.internal_id,
customer_id: customer.id || "",
env,
event_name: eventName,
timestamp,
created_at: Date.now(),
value: null,
set_usage: false,
entity_id: null,
internal_entity_id: null,
idempotency_key: null,
properties: {
...CONFIG.defaultProperties,
event_index: i + 1,
random_value: Math.floor(Math.random() * 1000),
user: {
id: Math.floor(Math.random() * 10) + 1,
},
},
};
events.push(event);
}
// Sort events by timestamp for more realistic insertion
events.sort(
(a, b) => (a.timestamp?.getTime() || 0) - (b.timestamp?.getTime() || 0),
);
return events;
}
// ============================================================================
// MAIN EXECUTION
// ============================================================================
async function main() {
console.log(
chalk.magentaBright(
"\n================ Event Seeding Script ================\n",
),
);
// Parse CLI arguments
const args = parseArgs();
const eventCount = args.count || CONFIG.defaultEventCount;
const env = args.env || CONFIG.defaultEnv;
const featureIds = args.feature_ids!.split(",").map((id) => id.trim());
console.log(chalk.cyan("Configuration:"));
console.log(chalk.gray(` Customer ID: ${args.customer_id}`));
console.log(chalk.gray(` Event Count: ${eventCount}`));
console.log(chalk.gray(` Environment: ${env}`));
console.log(chalk.gray(` Feature IDs: ${featureIds.join(", ")}`));
if (args.org_id) {
console.log(chalk.gray(` Organization ID: ${args.org_id}`));
}
console.log();
// Initialize database connection
const { db, client } = initDrizzle();
try {
// Validate customer exists
console.log(chalk.cyan("Validating customer..."));
const customer = await validateCustomer({
db,
customerId: args.customer_id,
orgId: args.org_id,
env,
});
console.log(
chalk.green(
`✅ Customer found: ${customer.id} (${customer.name || "No name"})`,
),
);
console.log(
chalk.gray(` Organization: ${customer.org?.name || customer.org_id}`),
);
console.log();
// Generate events
console.log(chalk.cyan(`Generating ${eventCount} events...`));
const events = generateEvents({
count: eventCount,
customer,
env,
featureIds,
});
console.log(chalk.green(`✅ Generated ${events.length} events`));
console.log(
chalk.gray(
` Time range: ${events[0].timestamp?.toISOString()} to ${events[events.length - 1].timestamp?.toISOString()}`,
),
);
console.log(
chalk.gray(
` Event types: ${Array.from(new Set(events.map((e) => e.event_name))).join(", ")}`,
),
);
console.log();
// Insert events
console.log(chalk.cyan("Inserting events into database..."));
let successCount = 0;
let errorCount = 0;
// Insert in batches to avoid overwhelming the database
const batchSize = 50;
for (let i = 0; i < events.length; i += batchSize) {
const batch = events.slice(i, i + batchSize);
const batchNum = Math.floor(i / batchSize) + 1;
const totalBatches = Math.ceil(events.length / batchSize);
process.stdout.write(
chalk.gray(` Batch ${batchNum}/${totalBatches}... `),
);
for (const event of batch) {
try {
await EventService.insert({ db, event });
successCount++;
} catch (error) {
errorCount++;
if (errorCount <= 5) {
// Only show first 5 errors to avoid spam
console.log(
chalk.yellow(
`\n ⚠️ Error inserting event ${event.id}: ${error instanceof Error ? error.message : String(error)}`,
),
);
}
}
}
console.log(chalk.green("✓"));
}
console.log();
console.log(chalk.green(`✅ Successfully inserted ${successCount} events`));
if (errorCount > 0) {
console.log(chalk.yellow(`⚠️ ${errorCount} events failed to insert`));
if (errorCount > 5) {
console.log(
chalk.gray(` (Only first 5 errors shown to reduce spam)`),
);
}
}
console.log(
chalk.magentaBright(
"\n================ Seeding Complete ================\n",
),
);
} catch (error) {
console.error(chalk.red("\n❌ Error during seeding:"));
if (error instanceof RecaseError) {
console.error(chalk.red(` ${error.message}`));
} else if (error instanceof Error) {
console.error(chalk.red(` ${error.message}`));
console.error(chalk.gray(error.stack));
} else {
console.error(chalk.red(` ${String(error)}`));
}
process.exit(1);
} finally {
await client.end();
}
}
// Run the script
main();

View File

@@ -0,0 +1,81 @@
/**
* Setup readonly user for ClickHouse insights queries
* Usage: bun run scripts/setup-clickhouse-insights-user.ts
* Or: infisical run --env=dev -- bun scripts/setup-clickhouse-insights-user.ts
* Or: infisical run --env=prod -- bun scripts/setup-clickhouse-insights-user.ts
*/
import crypto from "node:crypto";
import { createClient } from "@clickhouse/client";
const ALLOWED_VIRTUAL_INSIGHTS_TABLES = ["org_events_view"] as const;
async function main() {
const required = [
"CLICKHOUSE_URL",
"CLICKHOUSE_USERNAME",
"CLICKHOUSE_PASSWORD",
"CLICKHOUSE_INSIGHTS_USERNAME",
"CLICKHOUSE_INSIGHTS_PASSWORD",
];
const missing = required.filter((key) => !process.env[key]);
if (missing.length > 0) {
console.error("Missing env vars:", missing.join(", "));
process.exit(1);
}
const insightsUser = process.env.CLICKHOUSE_INSIGHTS_USERNAME!;
const insightsPassword = process.env.CLICKHOUSE_INSIGHTS_PASSWORD!;
const client = createClient({
url: process.env.CLICKHOUSE_URL,
username: process.env.CLICKHOUSE_USERNAME,
password: process.env.CLICKHOUSE_PASSWORD,
});
try {
// Check if user exists
const result = await client.query({
query: "SELECT name FROM system.users WHERE name = {username:String}",
query_params: { username: insightsUser },
format: "JSONEachRow",
});
const users = await result.json();
if (users.length === 0) {
// Create user
const passwordHash = crypto
.createHash("sha256")
.update(insightsPassword)
.digest("hex");
await client.command({
query: `CREATE USER ${insightsUser} IDENTIFIED WITH sha256_hash BY '${passwordHash}' SETTINGS readonly = 1`,
});
console.log(`✓ Created user: ${insightsUser}`);
} else {
console.log(`✓ User exists: ${insightsUser}`);
}
// Grant SELECT on virtual tables only
// Note: Views with SQL SECURITY DEFINER execute with creator's privileges,
// so insights_query_user does NOT need access to underlying tables
for (const table of ALLOWED_VIRTUAL_INSIGHTS_TABLES) {
await client.command({
query: `GRANT SELECT ON ${table} TO ${insightsUser}`,
});
console.log(`✓ Granted SELECT on ${table}`);
}
console.log("\n✅ Setup complete");
} catch (error) {
console.error("❌ Error:", error);
process.exit(1);
} finally {
await client.close();
}
}
main();

View File

@@ -8,7 +8,7 @@ source "$(dirname "$0")/config.sh"
# Run tests using TypeScript runner with compact mode
# Adjust --max to control concurrency (default: 6)
# Adjust --max to control concurren.cy (default: 6)
BUN_PARALLEL_COMPACT \
'server/tests/balances/check/basic' \
'server/tests/balances/check/credit-systems' \
@@ -23,3 +23,4 @@ BUN_PARALLEL_COMPACT \
'server/tests/balances/track/entity-balances' \
'server/tests/balances/track/concurrency' \
'server/tests/balances/track/negative' \
'server/tests/balances/update' \

View File

@@ -28,10 +28,24 @@ export function formatZodError(error: ZodError): string {
message = `must be one of: ${options}`;
}
} else if (message.includes("Invalid string: must match pattern")) {
// Extract the pattern and make it more readable
if (message.includes("/^[a-zA-Z0-9_-]+$/")) {
message =
"must contain only letters, numbers, underscores, and hyphens";
// Try to extract character class from pattern like /^[a-zA-Z0-9_:-]+$/
const charClassMatch = message.match(/\[([a-zA-Z0-9_:\-\\]+)\]/);
if (charClassMatch) {
const charClass = charClassMatch[1];
const parts: string[] = [];
if (charClass.includes("a-z") || charClass.includes("A-Z"))
parts.push("letters");
if (charClass.includes("0-9")) parts.push("numbers");
if (charClass.includes("_")) parts.push("underscores");
if (charClass.includes("-")) parts.push("hyphens");
if (charClass.includes(":")) parts.push("colons");
if (charClass.includes(".")) parts.push("periods");
if (parts.length > 0) {
message = `must contain only ${parts.join(", ")}`;
} else {
message = "has invalid format";
}
} else {
message = "has invalid format";
}

View File

@@ -54,7 +54,6 @@ autumnWebhookRouter.post(
const { type, data } = evt;
// console.log("Event:", evt);
// console.log("Data:", data);
switch (type) {
case WebhookEventType.CustomerProductsUpdated:

View File

@@ -1,6 +1,10 @@
import fs from "node:fs";
import path from "node:path";
import type { ClickHouseClient, QueryParams } from "@clickhouse/client";
import {
type ClickHouseClient,
createClient,
type QueryParams,
} from "@clickhouse/client";
import { clickhouseClient } from "../../db/initClickHouse.js";
export enum ClickHouseQuery {
@@ -17,6 +21,7 @@ export enum ClickHouseQuery {
export class ClickHouseManager {
private static instance: ClickHouseManager | null = null;
private client: ClickHouseClient | null = clickhouseClient;
private readonlyClient: ClickHouseClient | null = null;
private initialized = false;
private initPromise: Promise<void> | null = null;
static clickhouseAvailable =
@@ -75,6 +80,27 @@ export class ClickHouseManager {
return manager.client;
}
public static async getReadonlyClient(): Promise<ClickHouseClient> {
const manager = await ClickHouseManager.getInstance();
if (!manager.readonlyClient) {
if (
!process.env.CLICKHOUSE_INSIGHTS_USERNAME ||
!process.env.CLICKHOUSE_INSIGHTS_PASSWORD
) {
throw new Error(
"CLICKHOUSE_INSIGHTS_USERNAME and CLICKHOUSE_INSIGHTS_PASSWORD must be set",
);
}
manager.readonlyClient = createClient({
url: process.env.CLICKHOUSE_URL,
username: process.env.CLICKHOUSE_INSIGHTS_USERNAME,
password: process.env.CLICKHOUSE_INSIGHTS_PASSWORD,
});
}
return manager.readonlyClient;
}
static async createDateRangeView() {}
static async createDateRangeBcView() {}
static async createOrgEventsView() {}

View File

@@ -1,4 +1,6 @@
CREATE OR REPLACE VIEW org_events_view AS
CREATE OR REPLACE VIEW org_events_view
SQL SECURITY DEFINER
AS
SELECT
customer_id,
timestamp,

View File

@@ -1,155 +1,71 @@
import { ErrCode, RecaseError } from "@autumn/shared";
import { tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js";
import { redis } from "./initRedis.js";
export const handleAttachRaceCondition = async ({
req,
res,
}: {
req: any;
res: any;
}) => {
const customerId = req.body.customer_id;
const orgId = req.orgId;
const env = req.env;
const lockKey = `attach_${customerId}_${orgId}_${env}`;
// Check if Redis is ready before attempting lock
if (redis.status !== "ready") {
req.logger.warn("❗️❗️ Redis not ready, proceeding without lock", {
status: redis.status,
customerId,
});
return null;
}
try {
const existingLock = await redis.get(lockKey);
if (existingLock) {
throw new RecaseError({
message: `Attach already runnning for customer ${customerId}, try again in a few seconds`,
code: ErrCode.InvalidRequest,
statusCode: 400,
});
}
// Create lock with 5 second timeout
await redis.set(lockKey, "1", "PX", 5000, "NX");
const originalJson = res.json;
res.json = async function (body: any) {
try {
await clearLock({ lockKey, logger: req.logger });
} catch (error) {
req.logger.warn("❗️❗️ Error clearing lock", {
error,
});
}
originalJson.call(this, body);
};
return lockKey;
} catch (error) {
// Only throw if it's a lock conflict error
if (error instanceof RecaseError) {
throw error;
}
// Redis is down - log warning but allow operation to proceed
req.logger.warn("❗️❗️ Redis unavailable, proceeding without lock", {
error,
customerId,
});
return null;
}
export const clearLock = async ({ lockKey }: { lockKey: string }) => {
await tryRedisWrite(() => redis.del(lockKey));
};
export const handleCustomerRaceCondition = async ({
action,
customerId,
orgId,
env,
res,
logger,
}: {
action: any;
customerId: string;
orgId: string;
env: string;
res: any;
logger: any;
}) => {
const lockKey = `${action}_${customerId}_${orgId}_${env}`;
interface LockData {
errorMessage: string;
}
// Check if Redis is ready before attempting lock
if (redis.status !== "ready") {
logger.warn("❗️❗️ Redis not ready, proceeding without lock", {
status: redis.status,
action,
customerId,
});
return null;
}
const DEFAULT_ERROR_MESSAGE =
"Operation already in progress, try again in a few seconds";
try {
const existingLock = await redis.get(lockKey);
if (existingLock) {
throw new RecaseError({
message: `Action ${action} already running for customer ${customerId}, try again in a few seconds`,
code: ErrCode.InvalidRequest,
statusCode: 400,
});
}
// Create lock with 5 second timeout
await redis.set(lockKey, "1", "PX", 5000, "NX");
const originalJson = res.json;
res.json = async function (body: any) {
try {
await clearLock({ lockKey, logger });
} catch (error) {
logger.warn("❗️❗️ Error clearing lock", {
error,
});
}
originalJson.call(this, body);
};
return lockKey;
} catch (error) {
// Only throw if it's a lock conflict error
if (error instanceof RecaseError) {
throw error;
}
// Redis is down - log warning but allow operation to proceed
logger.warn("❗️❗️ Redis unavailable, proceeding without lock", {
error,
action,
customerId,
});
return null;
}
};
export const clearLock = async ({
/**
* Acquire a distributed lock using Redis.
* If Redis is not ready or errors, returns true to allow the operation to proceed.
* @returns true if lock was acquired (or Redis unavailable), throws if lock already exists
*/
export const acquireLock = async ({
lockKey,
logger,
ttlMs = 10000,
errorMessage = DEFAULT_ERROR_MESSAGE,
}: {
lockKey: string;
logger: any;
}) => {
ttlMs?: number;
errorMessage?: string;
}): Promise<boolean> => {
// If Redis not ready, allow operation to proceed
if (redis.status !== "ready") {
logger.warn("❗️❗️ Redis not ready, skipping lock clear", {
status: redis.status,
lockKey,
});
return;
return true;
}
try {
await redis.del(lockKey);
// NX = only set if key doesn't exist, PX = set expiry in milliseconds
// Store as JSON for future extensibility
const lockData: LockData = { errorMessage };
const result = await redis.set(
lockKey,
JSON.stringify(lockData),
"PX",
ttlMs,
"NX",
);
// If result is null, lock already exists (NX failed)
if (result === null) {
const existingData = await redis.get(lockKey);
const parsed = existingData
? (JSON.parse(existingData) as LockData)
: null;
throw new RecaseError({
message: parsed?.errorMessage || DEFAULT_ERROR_MESSAGE,
code: ErrCode.InvalidRequest,
statusCode: 429,
});
}
return true;
} catch (error) {
logger.warn("❗️❗️ Error clearing lock");
logger.warn(error);
// Re-throw lock conflict errors
if (error instanceof RecaseError) {
throw error;
}
// Redis error - allow operation to proceed
return true;
}
};

View File

@@ -2,12 +2,12 @@ import {
type BillingType,
cusProductToPrices,
type FullCusProduct,
isFixedPrice,
type Price,
PriceType,
type UsagePriceConfig,
} from "@autumn/shared";
import type Stripe from "stripe";
import { isFixedPrice } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js";
import { getBillingType } from "@/internal/products/prices/priceUtils.js";
import { notNullish } from "@/utils/genUtils.js";
@@ -97,10 +97,10 @@ export const findStripeItemForPrice = ({
if (stripeItem) return stripeItem;
// Fallback to fixed price
if (isFixedPrice({ price })) {
if (isFixedPrice(price)) {
return stripeItems.find(
(si: Stripe.SubscriptionItem | Stripe.LineItem) => {
const config = price.config as UsagePriceConfig;
const config = price.config;
return (
config.stripe_price_id === si.price?.id ||

View File

@@ -3,6 +3,7 @@ import {
BillingType,
CusProductStatus,
type FullCusProduct,
isFixedPrice,
type Organization,
} from "@autumn/shared";
import type Stripe from "stripe";
@@ -12,7 +13,6 @@ import { CusProductService } from "@/internal/customers/cusProducts/CusProductSe
import { getRelatedCusEnt } from "@/internal/customers/cusProducts/cusPrices/cusPriceUtils.js";
import { cusProductToSub } from "@/internal/customers/cusProducts/cusProductUtils/convertCusProduct.js";
import { FeatureService } from "@/internal/features/FeatureService.js";
import { isFixedPrice } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js";
import { getBillingType } from "@/internal/products/prices/priceUtils.js";
import { deleteCachedApiCustomer } from "../../../../internal/customers/cusUtils/apiCusCacheUtils/deleteCachedApiCustomer.js";
import {
@@ -60,7 +60,7 @@ export const sendUsageAndReset = async ({
const price = cusPrice.price;
const billingType = getBillingType(price.config);
if (isFixedPrice({ price })) continue;
if (isFixedPrice(price)) continue;
const relatedCusEnt = getRelatedCusEnt({
cusPrice,

View File

@@ -2,6 +2,7 @@ import type { AffectedResource, ApiVersion } from "@autumn/shared";
import type { Context, Env, Handler, MiddlewareHandler } from "hono";
import type { ZodType, z } from "zod/v4";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { acquireLock, clearLock } from "@/external/redis/redisUtils.js";
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
import { expandMiddleware } from "./expandMiddleware.js";
import { validator } from "./validatorMiddleware.js";
@@ -95,6 +96,17 @@ export function createRoute<
c: ValidatedContext<HonoEnv, Body, Query, Params>,
) => Response | Promise<Response>;
assertIdempotence?: string | undefined;
/** Lock configuration to prevent concurrent requests */
lock?: {
/** Generate a lock key. Returns null to skip locking. */
getKey: (
c: ValidatedContext<HonoEnv, Body, Query, Params>,
) => string | null;
/** Lock TTL in milliseconds (default: 10000) */
ttlMs?: number;
/** Error message to show when lock is already held */
errorMessage?: string;
};
}) {
const middlewares: MiddlewareHandler[] = [];
@@ -142,18 +154,38 @@ export function createRoute<
) => {
c.set("validated", true);
if (opts.withTx) {
const db = c.get("ctx").db;
return await db.transaction(async (tx) => {
c.set("ctx", {
...c.get("ctx"),
db: tx as unknown as DrizzleCli,
// Acquire lock if lock config provided
let lockKey: string | null = null;
if (opts.lock) {
lockKey = opts.lock.getKey(c);
if (lockKey) {
await acquireLock({
lockKey,
ttlMs: opts.lock.ttlMs,
errorMessage: opts.lock.errorMessage,
});
}
}
try {
if (opts.withTx) {
const db = c.get("ctx").db;
return await db.transaction(async (tx) => {
c.set("ctx", {
...c.get("ctx"),
db: tx as unknown as DrizzleCli,
});
return await opts.handler(c);
});
} else {
return await opts.handler(c);
});
} else {
return await opts.handler(c);
}
} finally {
// Always release lock
if (lockKey) {
await clearLock({ lockKey });
}
}
};

View File

@@ -45,6 +45,7 @@ const init = async () => {
"http://localhost:5174",
"https://app.useautumn.com",
"https://staging.useautumn.com",
"https://dev.useautumn.com",
"https://api.staging.useautumn.com",
"https://localhost:8080",
"https://www.alphalog.ai",

View File

@@ -19,6 +19,7 @@ const ALLOWED_ORIGINS = [
"http://localhost:5174",
"https://app.useautumn.com",
"https://staging.useautumn.com",
"https://dev.useautumn.com",
"https://api.staging.useautumn.com",
"https://localhost:8080",
];

View File

@@ -1,10 +0,0 @@
import { type ActionInsert, actions } from "@autumn/shared";
import type { DrizzleCli } from "@/db/initDrizzle.js";
export class ActionService {
static async insert(db: DrizzleCli, data: ActionInsert | ActionInsert[]) {
const dataArray = Array.isArray(data) ? data : [data];
await db.insert(actions).values(dataArray);
}
}

View File

@@ -1,6 +1,4 @@
/** biome-ignore-all lint/complexity/noStaticOnlyClass: wrap it up buddy */
import { ErrCode, type FullCustomer } from "@autumn/shared";
import { ErrCode, type FullCustomer, type RangeEnum } from "@autumn/shared";
import type { ClickHouseClient } from "@clickhouse/client";
import { Decimal } from "decimal.js";
import { StatusCodes } from "http-status-codes";
@@ -194,7 +192,7 @@ WHERE org_id = {org_id:String}
req: ExtendedRequest;
params: {
event_names: string[];
interval: "24h" | "7d" | "30d" | "90d" | "1bc" | "3bc";
interval: RangeEnum;
customer_id?: string;
no_count?: boolean;
};
@@ -203,8 +201,7 @@ WHERE org_id = {org_id:String}
}) {
const { clickhouseClient, org, env, db } = req;
const intervalType: "24h" | "7d" | "30d" | "90d" | "1bc" | "3bc" =
params.interval || "24h";
const intervalType: RangeEnum = params.interval || "24h";
const isBillingCycle = intervalType === "1bc" || intervalType === "3bc";
AnalyticsService.handleEarlyExit();
@@ -394,22 +391,4 @@ order by dr.period;
return resultJson;
}
// private static async getSubscriptionsIfNeeded(
// customer: FullCustomer,
// customerHasSubscriptions: boolean,
// db: DrizzleCli
// ): Promise<Subscription[]> {
// if (customerHasSubscriptions) {
// return [];
// }
// return await SubService.getInStripeIds({
// db,
// ids:
// customer.customer_products?.flatMap(
// (product: FullCusProduct) => product.subscription_ids ?? []
// ) ?? [],
// });
// }
}

View File

@@ -1,96 +0,0 @@
import { ErrCode, type FullCustomer } from "@autumn/shared";
import { format } from "date-fns";
import { Router } from "express";
import { StatusCodes } from "http-status-codes";
import z from "zod";
import { AnalyticsService } from "@/internal/analytics/AnalyticsService.js";
import { CusService } from "@/internal/customers/CusService.js";
import RecaseError from "@/utils/errorUtils.js";
import { routeHandler } from "@/utils/routerUtils.js";
const analyticsRouter = Router();
const RangeEnum = z.enum(["24h", "7d", "30d", "90d", "last_cycle"]);
analyticsRouter.post("", (req, res) =>
routeHandler({
req,
res,
action: "api query analytics data",
handler: async (req, res) => {
const { org, db, env } = req;
const {
customer_id,
feature_id,
}: { customer_id: string; feature_id: string | string[] } = req.body;
if (!customer_id || !feature_id) {
throw new RecaseError({
message: "Fields customer_id and feature_id are required",
code: ErrCode.InvalidInputs,
statusCode: 400,
});
}
let range: any = RangeEnum.nullish().parse(req.body.range);
if (range === "last_cycle" || !range) {
range = "1bc";
}
const customer = (await CusService.getFull({
db,
orgId: org.id,
idOrInternalId: customer_id,
env,
withSubs: true,
})) as FullCustomer;
if (!customer) {
throw new RecaseError({
message: "Customer not found",
code: ErrCode.CustomerNotFound,
statusCode: StatusCodes.NOT_FOUND,
});
}
let featureIds: string[] = [];
if (Array.isArray(feature_id)) featureIds = feature_id;
else featureIds = [feature_id];
const events = await AnalyticsService.getTimeseriesEvents({
req,
params: {
interval: range,
event_names: featureIds,
customer_id: customer_id,
no_count: true,
},
customer,
});
if (!events) {
throw new RecaseError({
message: "No events found",
code: ErrCode.InternalError,
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
});
}
events.data.forEach((event: any) => {
event.period = parseInt(format(new Date(event.period), "T"));
});
const usageList = events.data.filter(
(event: any) => event.period <= Date.now(),
);
res.status(200).json({
list: usageList,
});
},
}),
);
export { analyticsRouter };

View File

@@ -0,0 +1,66 @@
import {
AffectedResource,
applyResponseVersionChanges,
InsightsQueryBodySchema,
type InsightsQueryResponse,
InsightsQueryResponseSchema,
} from "@autumn/shared";
import { ClickHouseManager } from "../../../external/clickhouse/ClickHouseManager";
import { createRoute } from "../../../honoMiddlewares/routeHandler";
const VIRTUAL_TABLES = ["org_events_view"] as const;
export const handleInsightsQuery = createRoute({
body: InsightsQueryBodySchema,
handler: async (c) => {
const ctx = c.get("ctx");
const { query } = c.req.valid("json");
const containsVirtualTable = VIRTUAL_TABLES.some((table) =>
query.includes(`from ${table}`),
);
if (containsVirtualTable) {
return c.json(
{
data: null,
error: "Virtual table not allowed in query",
},
400,
);
}
const cleanedQuery = query.replace(
/from\s+events/gi,
`from org_events_view(org_id={org_id:String}, org_slug='', env={env:String})`,
);
const readonlyClient = await ClickHouseManager.getReadonlyClient();
const result = await readonlyClient.query({
query: cleanedQuery,
query_params: {
org_id: ctx.org.id,
org_slug: "",
env: ctx.env,
limit: 1000,
},
format: "JSON",
});
const resultJson = await result.json();
const parsedResult = InsightsQueryResponseSchema.parse({
data: resultJson,
});
return c.json(
applyResponseVersionChanges<InsightsQueryResponse>({
input: parsedResult.data,
targetVersion: ctx.apiVersion,
resource: AffectedResource.Attach,
ctx,
}),
);
},
});

View File

@@ -1,8 +1,10 @@
import {
AffectedResource,
type ApiCustomer,
type ApiEntityV1,
type ApiPlan,
ApiVersion,
ApiVersionClass,
type AppEnv,
type AuthType,
addToExpand,
@@ -10,6 +12,7 @@ import {
CusExpand,
type CustomerLegacyData,
cusProductToProduct,
type EntityLegacyData,
type FullCusProduct,
type FullProduct,
type Organization,
@@ -23,6 +26,7 @@ import { FeatureService } from "@/internal/features/FeatureService.js";
import { JobName } from "@/queue/JobName.js";
import { addTaskToQueue } from "@/queue/queueUtils.js";
import { getApiCustomerBase } from "../../customers/cusUtils/apiCusUtils/getApiCustomerBase";
import { getApiEntityBase } from "../../entities/entityUtils/apiEntityUtils/getApiEntityBase";
import { getPlanResponse } from "../../products/productUtils/productResponseUtils/getPlanResponse";
interface ActionDetails {
@@ -124,8 +128,10 @@ export const handleProductsUpdated = async ({
env,
});
ctx.apiVersion = new ApiVersionClass(ApiVersion.V1_2);
if (ctx.apiVersion.lte(ApiVersion.V1_2)) {
addToExpand({
ctx = addToExpand({
ctx,
add: [
CusExpand.BalancesFeature,
@@ -166,21 +172,27 @@ export const handleProductsUpdated = async ({
ctx,
});
// console.log(`API version: ${ctx.apiVersion.value}`);
// console.log(`Versioned customer:`, versionedCustomer);
// console.log(`Versioned plan:`, versionedPlan);
let entity: unknown | undefined;
if (fullCus.entity) {
const { apiEntity, legacyData } = await getApiEntityBase({
ctx,
entity: fullCus.entity,
fullCus,
});
// let entityRes = null;
// if (notNullish(customer?.entity)) {
// entityRes = await getSingleEntityResponse({
// ctx,
// entityId: customer.entity!.id,
// fullCus: customer,
// entity: customer.entity!,
// });
// }
entity = applyResponseVersionChanges<ApiEntityV1, EntityLegacyData>({
input: apiEntity,
targetVersion: ctx.apiVersion,
resource: AffectedResource.Entity,
legacyData,
ctx,
});
}
// 2. Send Svix event
ctx.logger.info(
`sending customer.products.updated webhook, customer ID: ${data.customerId}, entity ID: ${fullCus.entity?.id || "none"}`,
);
await sendSvixEvent({
org,
env,
@@ -188,6 +200,7 @@ export const handleProductsUpdated = async ({
data: {
scenario,
customer: versionedCustomer,
entity,
updated_product: versionedPlan,
},
});

View File

@@ -0,0 +1,7 @@
import { Hono } from "hono";
import type { HonoEnv } from "../../honoUtils/HonoEnv.js";
import { handleInsightsQuery } from "./handlers/handleInsightsQuery";
export const insightsRouter = new Hono<HonoEnv>();
insightsRouter.post("/query", ...handleInsightsQuery);

View File

@@ -0,0 +1,7 @@
import { Hono } from "hono";
import type { HonoEnv } from "../../honoUtils/HonoEnv.js";
import { handleEventsAggregation } from "../events/handlers/handleEventsAggregation.js";
export const legacyAnalyticsRouter = new Hono<HonoEnv>();
legacyAnalyticsRouter.post("", ...handleEventsAggregation);

View File

@@ -4,7 +4,6 @@ import { apiAuthMiddleware } from "@/middleware/apiAuthMiddleware.js";
import { expressApiVersionMiddleware } from "@/middleware/expressApiVersionMiddleware.js";
import { pricingMiddleware } from "@/middleware/pricingMiddleware.js";
import { refreshCacheMiddleware } from "@/middleware/refreshCacheMiddleware.js";
import { analyticsRouter } from "../analytics/analyticsRouter.js";
import { attachRouter } from "../customers/attach/attachRouter.js";
import cancelRouter from "../customers/cancel/cancelRouter.js";
import { expressCusRouter } from "../customers/cusRouter.js";
@@ -35,7 +34,6 @@ apiRouter.use("", attachRouter);
apiRouter.use("/cancel", cancelRouter);
// Analytics
apiRouter.use("/query", analyticsRouter);
apiRouter.use("/platform", platformRouter);
apiRouter.use("/products", expressProductRouter);
apiRouter.use("/customers", expressCusRouter);

View File

@@ -5,12 +5,12 @@ import {
type FullCusProduct,
isCusProductTrialing,
type ProductItem,
priceToInvoiceAmount,
UsageModel,
} from "@autumn/shared";
import { Decimal } from "decimal.js";
import { featureToCusPrice } from "@/internal/customers/cusProducts/cusPrices/convertCusPriceUtils.js";
import { getProration } from "@/internal/invoices/previewItemUtils/getItemsForNewProduct.js";
import { priceToInvoiceAmount } from "@/internal/products/prices/priceUtils/priceToInvoiceAmount.js";
import { isFeaturePriceItem } from "@/internal/products/product-items/productItemUtils/getItemType.js";
import { itemToPriceOrTiers } from "@/internal/products/product-items/productItemUtils.js";
import { notNullish } from "@/utils/genUtils.js";

View File

@@ -78,7 +78,7 @@ export default async (req: any, res: any) =>
const isProductOneOff = pricesOnlyOneOff(fullProduct.prices);
const relevantPrices = isProductOneOff
? fullProduct.prices // Include all prices for one-off products
: fullProduct.prices.filter((price) => isFixedPrice({ price })); // Only fixed prices for recurring products
: fullProduct.prices.filter((price) => isFixedPrice(price)); // Only fixed prices for recurring products
await createStripeCoupon({
reward: newReward,

View File

@@ -1,20 +1,23 @@
import {
ErrCode,
FeatureNotFoundError,
notNullish,
ResetInterval,
nullish,
RecaseError,
resetIntvToEntIntv,
UpdateBalanceParamsSchema,
} from "@autumn/shared";
import { StatusCodes } from "http-status-codes";
import { z } from "zod/v4";
import { createRoute } from "../../../honoMiddlewares/routeHandler.js";
import { CusService } from "../../customers/CusService.js";
import { deleteCachedApiCustomer } from "../../customers/cusUtils/apiCusCacheUtils/deleteCachedApiCustomer.js";
import { runDeductionTx } from "../track/trackUtils/runDeductionTx.js";
import { updateGrantedBalance } from "../updateGrantedBalance/updateGrantedBalance.js";
export const handleUpdateBalance = createRoute({
body: UpdateBalanceParamsSchema.extend({
// Internal
customer_entitlement_id: z.string().optional(),
interval: z.enum(ResetInterval).optional(),
}),
handler: async (c) => {
@@ -53,6 +56,40 @@ export const handleUpdateBalance = createRoute({
});
}
if (notNullish(body.granted_balance)) {
if (nullish(body.current_balance)) {
throw new RecaseError({
message: "current_balance is required when updating granted balance",
code: ErrCode.InvalidRequest,
statusCode: StatusCodes.BAD_REQUEST,
});
}
ctx.logger.info(
`updating granted balance for feature ${feature.id} to ${body.granted_balance}`,
);
const fullCus = await CusService.getFull({
db: ctx.db,
idOrInternalId: body.customer_id,
orgId: ctx.org.id,
env: ctx.env,
entityId: body.entity_id,
});
await updateGrantedBalance({
ctx,
fullCus,
featureId: body.feature_id,
targetGrantedBalance: body.granted_balance,
sortParams: {
cusEntId: body.customer_entitlement_id,
interval: body.interval
? resetIntvToEntIntv({ resetIntv: body.interval })
: undefined,
},
});
}
await deleteCachedApiCustomer({
orgId: ctx.org.id,
env: ctx.env,

View File

@@ -40,6 +40,8 @@ export const runTrack = async ({
});
}
// Clean properties
const eventInfo: EventInfo = {
event_name: body.feature_id || body.event_name || "",
value: body.value ?? 1,

View File

@@ -26,6 +26,10 @@ DECLARE
ELSE (params->>'min_balance')::numeric
END;
alter_granted_balance boolean := COALESCE((params->>'alter_granted_balance')::boolean, false);
max_balance numeric := CASE
WHEN params->>'max_balance' IS NULL THEN NULL
ELSE (params->>'max_balance')::numeric
END;
deducted_amount numeric := 0;
result_balance numeric;
@@ -38,6 +42,11 @@ DECLARE
entity_balance numeric;
deduct_amount numeric;
new_balance numeric;
-- Variables for ceiling calculation (negative track)
entity_adjustment numeric;
ceiling numeric;
max_addable numeric;
BEGIN
-- Initialize return values
@@ -61,8 +70,21 @@ BEGIN
-- Calculate deduction respecting allow_negative and min_balance
-- Handle negative amounts (adding credits) differently
IF remaining < 0 THEN
-- Adding credits: deduct the entire negative amount (which adds)
deduct_amount := remaining;
-- Adding credits: apply ceiling if alter_granted_balance is false and max_balance exists
IF NOT alter_granted_balance AND max_balance IS NOT NULL THEN
-- Get entity-level adjustment
entity_adjustment := COALESCE((result_entities->entity_key->>'adjustment')::numeric, 0);
-- Compute ceiling: max_balance + adjustment
ceiling := max_balance + entity_adjustment;
-- Cap addition so balance doesn't exceed ceiling
max_addable := GREATEST(0, ceiling - entity_balance);
-- remaining is negative, so -remaining is the amount to add
-- deduct_amount will be negative (adding to balance)
deduct_amount := -LEAST(-remaining, max_addable);
ELSE
-- No ceiling: deduct the entire negative amount (which adds)
deduct_amount := remaining;
END IF;
ELSIF allow_negative THEN
IF min_balance IS NULL THEN
deduct_amount := remaining;
@@ -106,8 +128,21 @@ BEGIN
-- Calculate deduction respecting allow_negative and min_balance
-- Handle negative amounts (adding credits) differently
IF amount_to_deduct < 0 THEN
-- Adding credits: deduct the entire negative amount (which adds)
deducted_amount := amount_to_deduct * credit_cost;
-- Adding credits: apply ceiling if alter_granted_balance is false and max_balance exists
IF NOT alter_granted_balance AND max_balance IS NOT NULL THEN
-- Get entity-level adjustment
entity_adjustment := COALESCE((current_entities->target_entity_id->>'adjustment')::numeric, 0);
-- Compute ceiling: max_balance + adjustment
ceiling := max_balance + entity_adjustment;
-- Cap addition so balance doesn't exceed ceiling
max_addable := GREATEST(0, ceiling - entity_balance);
-- amount_to_deduct is negative, so -amount_to_deduct is the amount to add
-- deducted_amount will be negative (adding to balance)
deducted_amount := -LEAST(-amount_to_deduct * credit_cost, max_addable);
ELSE
-- No ceiling: deduct the entire negative amount (which adds)
deducted_amount := amount_to_deduct * credit_cost;
END IF;
ELSIF allow_negative THEN
IF min_balance IS NULL THEN
deducted_amount := amount_to_deduct * credit_cost;
@@ -147,8 +182,19 @@ BEGIN
-- Calculate deduction based on allow_negative flag
-- Handle negative amounts (adding credits) differently
IF amount_to_deduct < 0 THEN
-- Adding credits: deduct the entire negative amount (which adds)
deducted_amount := amount_to_deduct * credit_cost;
-- Adding credits: apply ceiling if alter_granted_balance is false and max_balance exists
IF NOT alter_granted_balance AND max_balance IS NOT NULL THEN
-- Compute ceiling: max_balance + current_adjustment (customer-level)
ceiling := max_balance + current_adjustment;
-- Cap addition so balance doesn't exceed ceiling
max_addable := GREATEST(0, ceiling - current_balance);
-- amount_to_deduct is negative, so -amount_to_deduct is the amount to add
-- deducted_amount will be negative (adding to balance)
deducted_amount := -LEAST(-amount_to_deduct * credit_cost, max_addable);
ELSE
-- No ceiling: deduct the entire negative amount (which adds)
deducted_amount := amount_to_deduct * credit_cost;
END IF;
ELSIF allow_negative THEN
-- Pass 2: Can go negative (respecting min_balance)
IF min_balance IS NULL THEN

View File

@@ -36,6 +36,7 @@ DECLARE
credit_cost numeric;
usage_allowed boolean;
min_balance numeric;
max_balance numeric;
has_entity_scope boolean;
-- Current state from DB
@@ -110,6 +111,7 @@ BEGIN
credit_cost := (ent_obj->>'credit_cost')::numeric;
usage_allowed := COALESCE((ent_obj->>'usage_allowed')::boolean, false);
min_balance := (ent_obj->>'min_balance')::numeric;
max_balance := (ent_obj->>'max_balance')::numeric;
has_entity_scope := (ent_obj->>'entity_feature_id') IS NOT NULL;
-- STEP 1: Handle rollovers (only on first entitlement)
@@ -138,7 +140,7 @@ BEGIN
FROM customer_entitlements ce
WHERE ce.id = ent_id;
-- STEP 2: Deduct from additional_balance (customer-level and entity-level)
-- STEP 2: Deduct from additional_balance (customer-level and entity-level) [TODO: add max balance here?]
SELECT * INTO additional_deducted, new_additional_balance, new_adjustment, current_entities
FROM deduct_from_additional_balance(jsonb_build_object(
'current_additional_balance', current_additional_balance,
@@ -167,6 +169,7 @@ BEGIN
'has_entity_scope', has_entity_scope,
'target_entity_id', target_entity_id,
'min_balance', min_balance,
'max_balance', max_balance,
'alter_granted_balance', alter_granted_balance
));
@@ -221,6 +224,7 @@ BEGIN
credit_cost := (ent_obj->>'credit_cost')::numeric;
usage_allowed := COALESCE((ent_obj->>'usage_allowed')::boolean, false);
min_balance := (ent_obj->>'min_balance')::numeric;
max_balance := (ent_obj->>'max_balance')::numeric;
has_entity_scope := (ent_obj->>'entity_feature_id') IS NOT NULL;
-- Skip entitlements without usage_allowed
@@ -257,6 +261,7 @@ BEGIN
'has_entity_scope', has_entity_scope,
'target_entity_id', target_entity_id,
'min_balance', min_balance,
'max_balance', max_balance,
'alter_granted_balance', alter_granted_balance
));

View File

@@ -6,12 +6,15 @@ import type {
import {
CusProductStatus,
cusEntToCusPrice,
cusEntToPrepaidQuantity,
cusProductsToCusEnts,
FeatureUsageType,
type FullCustomer,
getMaxOverage,
getRelevantFeatures,
getStartingBalance,
InternalError,
isPrepaidCusEnt,
notNullish,
nullish,
orgToInStatuses,
@@ -19,6 +22,8 @@ import {
} from "@autumn/shared";
import { Decimal } from "decimal.js";
import { sql } from "drizzle-orm";
import { getResetBalancesUpdate } from "@/internal/customers/cusProducts/cusEnts/groupByUtils.js";
import { getEntOptions } from "@/internal/products/prices/priceUtils.js";
import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js";
import { EventService } from "../../../api/events/EventService.js";
import { CusService } from "../../../customers/CusService.js";
@@ -139,6 +144,15 @@ export const deductFromCusEnts = async ({
ce.entitlement.feature.config?.usage_type ===
FeatureUsageType.Continuous && nullish(cusPrice);
const resetBalance = getStartingBalance({
entitlement: ce.entitlement,
options:
getEntOptions(ce.customer_product.options, ce.entitlement) ||
undefined,
relatedPrice: cusPrice?.price,
productQuantity: ce.customer_product.quantity,
});
return {
customer_entitlement_id: ce.id,
credit_cost: creditCost,
@@ -148,6 +162,7 @@ export const deductFromCusEnts = async ({
(isFreeAllocated && overageBehaviour !== "reject"),
min_balance: notNullish(maxOverage) ? -maxOverage : undefined,
add_to_adjustment: addToAdjustment,
max_balance: resetBalance,
};
});

View File

@@ -0,0 +1,108 @@
import type { FullCustomer, SortCusEntParams } from "@autumn/shared";
import {
cusEntsHavePrice,
cusEntsToAllowance,
cusProductsToCusEnts,
FeatureNotFoundError,
InternalError,
isEntityScopedCusEnt,
notNullish,
nullish,
orgToInStatuses,
RecaseError,
} from "@autumn/shared";
import { Decimal } from "decimal.js";
import type { AutumnContext } from "../../../honoUtils/HonoEnv.js";
import { CusEntService } from "../../customers/cusProducts/cusEnts/CusEntitlementService.js";
export const updateGrantedBalance = async ({
ctx,
fullCus,
featureId,
targetGrantedBalance,
sortParams,
}: {
ctx: AutumnContext;
fullCus: FullCustomer;
featureId: string;
targetGrantedBalance: number;
sortParams: SortCusEntParams;
}) => {
const feature = ctx.features.find((f) => f.id === featureId);
if (!feature) {
throw new FeatureNotFoundError({ featureId });
}
const cusEnts = cusProductsToCusEnts({
cusProducts: fullCus.customer_products,
featureIds: [featureId],
entity: fullCus.entity,
inStatuses: orgToInStatuses({ org: ctx.org }),
sortParams,
});
if (cusEnts.length === 0) {
throw new RecaseError({
message: `[updateGrantedBalance] No balances to update for feature ${featureId}, customer ${fullCus.id}`,
});
}
if (cusEntsHavePrice({ cusEnts })) {
throw new InternalError({
message: `This feature has a price, so you cannot update the granted balance`,
});
}
const currentAllowance = cusEntsToAllowance({
cusEnts,
entityId: fullCus.entity?.id,
withRollovers: false,
});
const requiredAdjustment = new Decimal(targetGrantedBalance)
.sub(currentAllowance)
.toNumber();
const targetCusEnt = cusEnts[0];
const isEntityScoped = isEntityScopedCusEnt({ cusEnt: targetCusEnt });
const entityId = fullCus.entity?.id;
if (isEntityScoped) {
const entityKeys = Object.keys(targetCusEnt.entities ?? {});
const targetEntityId = notNullish(entityId) ? entityId : entityKeys[0];
// Throw error if no entity balance exists
if (
nullish(targetEntityId) ||
nullish(targetCusEnt.entities?.[targetEntityId])
) {
throw new InternalError({
message: `[updateGrantedBalance] No entity balance found for feature ${featureId}, customer ${fullCus.id}`,
});
}
const currentEntity = targetCusEnt.entities[targetEntityId];
const newEntities = {
...targetCusEnt.entities,
[targetEntityId]: {
id: targetEntityId,
balance: currentEntity.balance,
adjustment: requiredAdjustment,
additional_balance: currentEntity.additional_balance,
},
};
await CusEntService.update({
db: ctx.db,
id: targetCusEnt.id,
updates: { entities: newEntities },
});
} else {
await CusEntService.update({
db: ctx.db,
id: targetCusEnt.id,
updates: { adjustment: requiredAdjustment },
});
}
};

View File

@@ -17,6 +17,19 @@ import { attachToInvoiceResponse } from "../../invoices/invoiceUtils";
export const handleAttach = createRoute({
body: AttachBodyV0Schema,
resource: AffectedResource.Attach,
lock: {
ttlMs: 5000,
errorMessage:
"Attach already in progress for this customer, try again in a few seconds",
getKey: (c) => {
const ctx = c.get("ctx");
const attachBody = c.req.valid("json");
return `lock:attach:${ctx.org.id}:${ctx.env}:${attachBody.customer_id}`;
},
},
handler: async (c) => {
// await handleAttachRaceCondition({ req, res });
const ctx = c.get("ctx");
@@ -113,12 +126,3 @@ export const handleAttach = createRoute({
);
},
});
// success: true,
// message: `Successfully purchased ${productNames} and attached to ${customerName}`,
// invoice: invoiceOnly
// ? attachToInvoiceResponse({ invoice: stripeInvoice })
// : undefined,
// code: SuccessCode.OneOffProductAttached,
// scenario: AttachScenario.New,

View File

@@ -40,7 +40,11 @@ export const enrichAttachActions = async ({
excludeOneOff: true,
});
console.log("Largest interval:", largestInterval);
// 1. Get the starts at if new product is scheduled
// 2. Get reset cycle anchor
// 3. Get usage to apply to new product
// 4. Get trial ends at (either from current subscription that we're merging with, or from new product)*
// 5. Calculate line items for new product / upgrade* [let's do this]
// From billing cycle anchor, now, and interval, calculate latest cycle start:
if (largestInterval && billingCycleAnchor) {

View File

@@ -97,6 +97,7 @@ export const previewToCheckoutRes = async ({
options: attachParams.optionsList,
fullCus: attachParams.customer,
});
const total = lines.reduce((acc, line) => acc + line.amount, 0);
let nextCycle:

View File

@@ -2,6 +2,8 @@ import {
type AttachConfig,
type AttachFunctionResponse,
AttachFunctionResponseSchema,
isFixedPrice,
priceToInvoiceAmount,
SuccessCode,
type UsagePriceConfig,
} from "@autumn/shared";
@@ -15,8 +17,6 @@ import { buildInvoiceMemoFromEntitlements } from "@/internal/invoices/invoiceMem
import { insertInvoiceFromAttach } from "@/internal/invoices/invoiceUtils.js";
import { orgToCurrency } from "@/internal/orgs/orgUtils.js";
import { priceToProduct } from "@/internal/products/prices/priceUtils/findPriceUtils.js";
import { priceToInvoiceAmount } from "@/internal/products/prices/priceUtils/priceToInvoiceAmount.js";
import { isFixedPrice } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js";
import { getPriceOptions } from "@/internal/products/prices/priceUtils.js";
import { attachToInsertParams } from "@/internal/products/productUtils.js";
import type { AutumnContext } from "../../../../../honoUtils/HonoEnv";
@@ -63,7 +63,7 @@ export const handleOneOffFunction = async ({
}
let invoiceItemData = {};
if (isFixedPrice({ price })) {
if (isFixedPrice(price)) {
quantity = 1;
invoiceItemData = {

View File

@@ -67,10 +67,15 @@ export const handlePaidProduct = async ({
const subscriptions: Stripe.Subscription[] = [];
const { sub: mergeSub, cusProduct: mergeCusProduct } = await getCustomerSub({
let { sub: mergeSub, cusProduct: mergeCusProduct } = await getCustomerSub({
attachParams,
});
if (attachParams.newBillingSubscription) {
mergeSub = undefined;
mergeCusProduct = undefined;
}
let sub: Stripe.Subscription | null = null;
let schedule: Stripe.SubscriptionSchedule | null | undefined = null;
let invoice: Stripe.Invoice | undefined;

View File

@@ -8,6 +8,7 @@ import {
type FullCustomerPrice,
getFeatureInvoiceDescription,
OnIncrease,
priceToInvoiceAmount,
type UsagePriceConfig,
} from "@autumn/shared";
import { Decimal } from "decimal.js";
@@ -20,7 +21,6 @@ import { InvoiceService } from "@/internal/invoices/InvoiceService.js";
import { constructStripeInvoiceItem } from "@/internal/invoices/invoiceItemUtils/invoiceItemUtils.js";
import { createAndFinalizeInvoice } from "@/internal/invoices/invoiceUtils/createAndFinalizeInvoice.js";
import { getInvoiceItems } from "@/internal/invoices/invoiceUtils.js";
import { priceToInvoiceAmount } from "@/internal/products/prices/priceUtils/priceToInvoiceAmount.js";
import {
shouldBillNow,
shouldProrate,

View File

@@ -87,6 +87,8 @@ export const updateStripeSub2 = async ({
payment_behavior: "error_if_incomplete",
expand: ["latest_invoice"],
cancel_at_period_end: false,
});
let latestInvoice = updatedSub.latest_invoice as Stripe.Invoice | null;

View File

@@ -2,19 +2,17 @@ import {
type EntitlementWithFeature,
type FullProduct,
formatAmount,
isFixedPrice,
isOneOffPrice,
type Organization,
type Price,
priceToInvoiceAmount,
type Reward,
} from "@autumn/shared";
import type Stripe from "stripe";
import { newPriceToInvoiceDescription } from "@/internal/invoices/invoiceFormatUtils.js";
import { getProration } from "@/internal/invoices/previewItemUtils/getItemsForNewProduct.js";
import { priceToUsageModel } from "@/internal/products/prices/priceUtils/convertPrice.js";
import { priceToInvoiceAmount } from "@/internal/products/prices/priceUtils/priceToInvoiceAmount.js";
import {
isFixedPrice,
isOneOffPrice,
} from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js";
import { getPriceEntitlement } from "@/internal/products/prices/priceUtils.js";
import {
formatReward,
@@ -48,7 +46,7 @@ export const priceToNewPreviewItem = ({
rewards?: Reward[];
subDiscounts?: Stripe.Discount[];
}) => {
if (skipOneOff && isOneOffPrice({ price })) return;
if (skipOneOff && isOneOffPrice(price)) return;
now = now ?? Date.now();
@@ -73,7 +71,7 @@ export const priceToNewPreviewItem = ({
console.log("Apply Reward", formatReward({ reward }));
}
if (isFixedPrice({ price })) {
if (isFixedPrice(price)) {
let amount = priceToInvoiceAmount({
price,
quantity: 1,

View File

@@ -5,8 +5,10 @@ import {
formatAmount,
getTotalCusProdQuantity,
isCusProductTrialing,
isFixedPrice,
type Organization,
type Price,
priceToInvoiceAmount,
type UsagePriceConfig,
} from "@autumn/shared";
import { logger } from "better-auth";
@@ -16,8 +18,6 @@ import { findStripeItemForPrice } from "@/external/stripe/stripeSubUtils/stripeS
import { priceToInvoiceDescription } from "@/internal/invoices/invoiceFormatUtils.js";
import { getProration } from "@/internal/invoices/previewItemUtils/getItemsForNewProduct.js";
import { priceToUsageModel } from "@/internal/products/prices/priceUtils/convertPrice.js";
import { priceToInvoiceAmount } from "@/internal/products/prices/priceUtils/priceToInvoiceAmount.js";
import { isFixedPrice } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js";
import {
getPriceEntitlement,
getPriceOptions,
@@ -91,7 +91,7 @@ export const priceToUnusedPreviewItem = ({
? (options?.quantity ?? 1) * (config.billing_units ?? 1)
: 1;
if (isFixedPrice({ price })) {
if (isFixedPrice(price)) {
quantity = customer
? getTotalCusProdQuantity({
cusProducts: customer.customer_products,

View File

@@ -74,6 +74,8 @@ export const getAttachParams = async ({
// Others
apiVersion: ctx.apiVersion.value,
newBillingSubscription: attachBody.new_billing_subscription || false,
};
return {

View File

@@ -1,21 +1,18 @@
import { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
import { constructPreviewItem } from "@/internal/invoices/previewItemUtils/constructPreviewItem.js";
import { Proration } from "@/internal/invoices/prorationUtils.js";
import { getUsageFromBalance } from "@/internal/products/prices/priceUtils/arrearProratedUtils/getPrevAndNewUsages.js";
import {
FullEntitlement,
FullCustomerEntitlement,
PreviewLineItem,
Price,
type FullCustomerEntitlement,
type FullEntitlement,
type PreviewLineItem,
type Price,
usageToFeatureName,
} from "@autumn/shared";
import { attachParamsToProduct } from "../convertAttachParams.js";
import { priceToInvoiceItem } from "@/internal/products/prices/priceUtils/priceToInvoiceItem.js";
import { priceToInvoiceAmount } from "@/internal/products/prices/priceUtils/priceToInvoiceAmount.js";
import { Decimal } from "decimal.js";
import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
import { constructPreviewItem } from "@/internal/invoices/previewItemUtils/constructPreviewItem.js";
import type { Proration } from "@/internal/invoices/prorationUtils.js";
import { getUsageFromBalance } from "@/internal/products/prices/priceUtils/arrearProratedUtils/getPrevAndNewUsages.js";
import { priceToInvoiceItem } from "@/internal/products/prices/priceUtils/priceToInvoiceItem.js";
import { getPrevAndNewPriceForUpgrade } from "@/trigger/arrearProratedUsage/handleProratedUpgrade.js";
import { attachParamsToProduct } from "../convertAttachParams.js";
export const getContUseUpgradeItems = async ({
price,
@@ -36,27 +33,27 @@ export const getContUseUpgradeItems = async ({
proration?: Proration;
logger: any;
}) => {
let prevInvoiceItem = curItem;
let prevBalance = prevCusEnt.entitlement.allowance! - curUsage;
let newBalance = ent.allowance! - curUsage;
let usageDiff = prevBalance - newBalance;
const prevInvoiceItem = curItem;
const prevBalance = prevCusEnt.entitlement.allowance! - curUsage;
const newBalance = ent.allowance! - curUsage;
const usageDiff = prevBalance - newBalance;
const product = attachParamsToProduct({ attachParams });
const feature = prevCusEnt.entitlement.feature;
let { usage: prevUsage } = getUsageFromBalance({
const { usage: prevUsage } = getUsageFromBalance({
ent: prevCusEnt.entitlement,
price,
balance: prevBalance,
});
let { usage: newUsage } = getUsageFromBalance({
const { usage: newUsage } = getUsageFromBalance({
ent,
price,
balance: prevBalance,
});
let { usage: totalUsage } = getUsageFromBalance({
const { usage: totalUsage } = getUsageFromBalance({
ent,
price,
balance: newBalance,

View File

@@ -1,98 +0,0 @@
// import { AttachBodyV0Schema } from "@autumn/shared";
// import { handleAttachRaceCondition } from "@/external/redis/redisUtils.js";
// import type {
// ExtendedRequest,
// ExtendedResponse,
// } from "@/utils/models/Request.js";
// import { routeHandler } from "@/utils/routerUtils.js";
// import type { AutumnContext } from "../../../honoUtils/HonoEnv.js";
// import { checkStripeConnections } from "./attachRouter.js";
// import { getAttachParams } from "./attachUtils/attachParams/getAttachParams.js";
// import { getAttachBranch } from "./attachUtils/getAttachBranch.js";
// import { getAttachConfig } from "./attachUtils/getAttachConfig.js";
// import { runAttachFunction } from "./attachUtils/getAttachFunction.js";
// import { handleAttachErrors } from "./attachUtils/handleAttachErrors.js";
// import { insertCustomItems } from "./attachUtils/insertCustomItems.js";
// export const handleAttach = async (req: any, res: any) =>
// routeHandler({
// req,
// res,
// action: "attach",
// handler: async (req: ExtendedRequest, res: ExtendedResponse) => {
// await handleAttachRaceCondition({ req, res });
// const attachBody = AttachBodyV0Schema.parse(req.body);
// const ctx = req as AutumnContext;
// const { attachParams, customPrices, customEnts } = await getAttachParams({
// ctx,
// attachBody,
// });
// // Handle existing product
// const branch = await getAttachBranch({
// ctx,
// attachBody,
// attachParams,
// });
// const { flags, config } = await getAttachConfig({
// ctx,
// attachParams,
// attachBody,
// branch,
// });
// await handleAttachErrors({
// attachParams,
// attachBody,
// branch,
// flags,
// config,
// });
// await checkStripeConnections({
// ctx,
// attachParams,
// useCheckout: config.onlyCheckout,
// });
// await insertCustomItems({
// db: req.db,
// customPrices: customPrices || [],
// customEnts: customEnts || [],
// });
// try {
// req.logger.info(`Attach params: `, {
// data: {
// products: attachParams.products.map((p) => ({
// id: p.id,
// name: p.name,
// processor: p.processor,
// version: p.version,
// })),
// prices: attachParams.prices.map((p) => ({
// id: p.id,
// config: p.config,
// })),
// entitlements: attachParams.entitlements.map((e) => ({
// internal_feature_id: e.internal_feature_id,
// feature_id: e.feature_id,
// })),
// freeTrial: attachParams.freeTrial,
// },
// });
// } catch (_error) {}
// await runAttachFunction({
// ctx,
// attachParams,
// branch,
// attachBody,
// config,
// });
// },
// });

View File

@@ -94,10 +94,15 @@ export const getNewProductPreview = async ({
const { org } = attachParams;
const newProduct = attachParamsToProduct({ attachParams });
const { sub: mergeSub, cusProduct: mergeCusProduct } = await getCustomerSub({
let { sub: mergeSub, cusProduct: mergeCusProduct } = await getCustomerSub({
attachParams,
});
if (attachParams.newBillingSubscription) {
mergeSub = undefined;
mergeCusProduct = undefined;
}
let trialEnds: number | undefined;
if (config.disableTrial) {

View File

@@ -3,12 +3,12 @@ import {
cusProductToEnts,
type Entity,
type FullCusProduct,
isFixedPrice,
type Price,
} from "@autumn/shared";
import type Stripe from "stripe";
import {
isContUsePrice,
isFixedPrice,
isPrepaidPrice,
} from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js";
import {
@@ -71,7 +71,7 @@ export const getQuantityToRemove = ({
finalQuantity = existingUsage || 0;
}
if (isFixedPrice({ price })) {
if (isFixedPrice(price)) {
finalQuantity = fixedPriceMultiplier * (finalQuantity || 1);
}
@@ -85,6 +85,8 @@ export const willMergeSub = async ({
attachParams: AttachParams;
branch: AttachBranch;
}) => {
if (attachParams.newBillingSubscription) return false;
const { subId } = await getCustomerSub({ attachParams, onlySubId: true });
if (branch === AttachBranch.MainIsTrial) return false;

View File

@@ -86,6 +86,8 @@ export type AttachParams = {
// Invoice action required
stripeInvoiceId?: string;
cusEntIds?: string[];
newBillingSubscription?: boolean;
};
export type InsertCusProductParams = {
@@ -121,6 +123,8 @@ export type InsertCusProductParams = {
fromMigration?: boolean;
apiVersion?: ApiVersion;
finalizeInvoice?: boolean;
newBillingSubscription?: boolean;
};
// export const AttachResultSchema = z.object({

View File

@@ -1,15 +1,11 @@
import { AppEnv, CusProductStatus, products } from "@autumn/shared";
import { db, DrizzleCli } from "@/db/initDrizzle.js";
import { customerProducts } from "@autumn/shared";
import {
eq,
and,
isNotNull,
sql,
countDistinct,
count,
inArray,
} from "drizzle-orm";
type AppEnv,
CusProductStatus,
customerProducts,
products,
} from "@autumn/shared";
import { and, countDistinct, eq, inArray, isNotNull, sql } from "drizzle-orm";
import type { DrizzleCli } from "@/db/initDrizzle.js";
const activeStatuses = [CusProductStatus.Active, CusProductStatus.PastDue];
export class CusProdReadService {
@@ -22,7 +18,7 @@ export class CusProdReadService {
internalProductId?: string;
productId?: string;
}) {
let result = await db
const result = await db
.select({
id: customerProducts.id,
})
@@ -47,21 +43,23 @@ export class CusProdReadService {
db: DrizzleCli;
internalProductId: string;
}) => {
let result = await db
const result = await db
.select({
active: countDistinct(
sql`CASE WHEN ${inArray(customerProducts.status, activeStatuses)} THEN ${customerProducts.internal_customer_id} END`,
).as("active"),
canceled: count(
sql`CASE WHEN ${isNotNull(customerProducts.canceled_at)} AND ${inArray(customerProducts.status, activeStatuses)} THEN 1 END`,
canceled: countDistinct(
sql`CASE WHEN ${isNotNull(customerProducts.canceled_at)} AND ${inArray(customerProducts.status, activeStatuses)} THEN ${customerProducts.internal_customer_id} END`,
).as("canceled"),
custom: count(
sql`CASE WHEN ${eq(customerProducts.is_custom, true)} AND ${inArray(customerProducts.status, activeStatuses)} THEN 1 END`,
custom: countDistinct(
sql`CASE WHEN ${eq(customerProducts.is_custom, true)} AND ${inArray(customerProducts.status, activeStatuses)} THEN ${customerProducts.internal_customer_id} END`,
).as("custom"),
trialing: count(
sql`CASE WHEN ${isNotNull(customerProducts.trial_ends_at)} AND ${sql`${customerProducts.trial_ends_at} > (EXTRACT(EPOCH FROM NOW()) * 1000)::bigint`} AND ${inArray(customerProducts.status, activeStatuses)} THEN 1 END`,
trialing: countDistinct(
sql`CASE WHEN ${isNotNull(customerProducts.trial_ends_at)} AND ${sql`${customerProducts.trial_ends_at} > (EXTRACT(EPOCH FROM NOW()) * 1000)::bigint`} AND ${inArray(customerProducts.status, activeStatuses)} THEN ${customerProducts.internal_customer_id} END`,
).as("trialing"),
all: countDistinct(customerProducts.internal_customer_id).as("all"),
all: countDistinct(
sql`CASE WHEN ${inArray(customerProducts.status, activeStatuses)} THEN ${customerProducts.internal_customer_id} END`,
).as("all"),
})
.from(customerProducts)
.where(eq(customerProducts.internal_product_id, internalProductId));
@@ -80,7 +78,7 @@ export class CusProdReadService {
orgId: string;
env: AppEnv;
}) {
let internalProductIds = await db
const internalProductIds = await db
.select({
internal_id: products.internal_id,
})
@@ -93,25 +91,27 @@ export class CusProdReadService {
),
);
let internalProductIdsArray = internalProductIds.map(
const internalProductIdsArray = internalProductIds.map(
(item) => item.internal_id,
);
let result = await db
const result = await db
.select({
active: countDistinct(
sql`CASE WHEN ${inArray(customerProducts.status, activeStatuses)} THEN ${customerProducts.internal_customer_id} END`,
).as("active"),
canceled: count(
sql`CASE WHEN ${isNotNull(customerProducts.canceled_at)} AND ${inArray(customerProducts.status, activeStatuses)} THEN 1 END`,
canceled: countDistinct(
sql`CASE WHEN ${isNotNull(customerProducts.canceled_at)} AND ${inArray(customerProducts.status, activeStatuses)} THEN ${customerProducts.internal_customer_id} END`,
).as("canceled"),
custom: count(
sql`CASE WHEN ${eq(customerProducts.is_custom, true)} AND ${inArray(customerProducts.status, activeStatuses)} THEN 1 END`,
custom: countDistinct(
sql`CASE WHEN ${eq(customerProducts.is_custom, true)} AND ${inArray(customerProducts.status, activeStatuses)} THEN ${customerProducts.internal_customer_id} END`,
).as("custom"),
trialing: count(
sql`CASE WHEN ${isNotNull(customerProducts.trial_ends_at)} AND ${sql`${customerProducts.trial_ends_at} > (EXTRACT(EPOCH FROM NOW()) * 1000)::bigint`} AND ${inArray(customerProducts.status, activeStatuses)} THEN 1 END`,
trialing: countDistinct(
sql`CASE WHEN ${isNotNull(customerProducts.trial_ends_at)} AND ${sql`${customerProducts.trial_ends_at} > (EXTRACT(EPOCH FROM NOW()) * 1000)::bigint`} AND ${inArray(customerProducts.status, activeStatuses)} THEN ${customerProducts.internal_customer_id} END`,
).as("trialing"),
all: countDistinct(customerProducts.internal_customer_id).as("all"),
all: countDistinct(
sql`CASE WHEN ${inArray(customerProducts.status, activeStatuses)} THEN ${customerProducts.internal_customer_id} END`,
).as("all"),
})
.from(customerProducts)
.where(

View File

@@ -10,10 +10,11 @@ import {
CheckExpand,
CusExpand,
cusEntMatchesFeature,
cusEntsToAdjustment,
cusEntsToAllowance,
cusEntsToBalance,
cusEntsToMaxPurchase,
cusEntToBalance,
cusEntToCusPrice,
cusEntToGrantedBalance,
cusEntToKey,
cusEntToPurchasedBalance,
dbToApiFeatureV1,
@@ -22,7 +23,6 @@ import {
FeatureType,
getCusEntBalance,
isPrepaidPrice,
notNullish,
sumValues,
} from "@autumn/shared";
import { Decimal } from "decimal.js";
@@ -202,28 +202,18 @@ export const getApiBalance = ({
const totalMaxPurchase = cusEntsToMaxPurchase({ cusEnts, entityId });
// 1. Granted balance
const totalGrantedBalanceWithRollovers = sumValues(
cusEnts.map((cusEnt) =>
cusEntToGrantedBalance({
cusEnt,
entityId,
withRollovers: includeRollovers,
}),
),
);
const totalAllowanceWithRollovers = cusEntsToAllowance({
cusEnts,
entityId,
withRollovers: includeRollovers,
});
const totalAdjustment = sumValues(
cusEnts.map((cusEnt) => {
const { adjustment } = getCusEntBalance({
cusEnt,
entityId,
});
return adjustment;
}),
);
const totalAdjustment = cusEntsToAdjustment({
cusEnts,
entityId,
});
const grantedBalance = new Decimal(totalGrantedBalanceWithRollovers)
const grantedBalance = new Decimal(totalAllowanceWithRollovers)
.add(totalAdjustment)
.toNumber();
@@ -233,17 +223,11 @@ export const getApiBalance = ({
);
// 3. Current balance
const totalBalanceWithRollovers = sumValues(
cusEnts
.map((cusEnt) =>
cusEntToBalance({
cusEnt,
entityId,
withRollovers: includeRollovers,
}),
)
.filter(notNullish),
);
const totalBalanceWithRollovers = cusEntsToBalance({
cusEnts,
entityId,
withRollovers: includeRollovers,
});
const currentBalance = new Decimal(Math.max(0, totalBalanceWithRollovers))
.add(totalAdditionalBalance)

View File

@@ -25,6 +25,7 @@ import {
import { getDefaultAttachConfig } from "../attach/attachUtils/getAttachConfig.js";
import { CusService } from "../CusService.js";
import { initStripeCusAndProducts } from "../handlers/handleCreateCustomer.js";
import { deleteCachedApiCustomer } from "./apiCusCacheUtils/deleteCachedApiCustomer.js";
export const getGroupToDefaultProd = async ({
defaultProds,
@@ -121,6 +122,10 @@ export const createNewCustomer = async ({
data: customerData,
});
console.log(
`[createNewCustomer] Creating new customer with ID: ${newCustomer?.id}`,
);
if (!newCustomer) {
throw new RecaseError({
code: ErrCode.InternalError,
@@ -138,6 +143,9 @@ export const createNewCustomer = async ({
for (const group in groupToDefaultProd) {
const defaultProd = groupToDefaultProd[group];
console.log(
`[createNewCustomer] Creating default product with ID: ${defaultProd?.id}`,
);
if (!isFreeProduct(defaultProd.prices)) {
let stripeCli = null;
@@ -188,5 +196,12 @@ export const createNewCustomer = async ({
}
}
// // Clear the customer cache here
// await deleteCachedApiCustomer({
// customerId: newCustomer.id || newCustomer.internal_id,
// orgId: ctx.org.id,
// env: ctx.env,
// });
return newCustomer;
};

View File

@@ -95,9 +95,17 @@ export const getOrCreateApiCustomer = async ({
},
createDefaultProducts: customerData?.disable_default !== true,
});
} catch (error: any) {
newCustomer = await CusService.getFull({
db: ctx.db,
idOrInternalId: customerId,
orgId: ctx.org.id,
env: ctx.env,
});
} catch (error) {
if (
error?.message?.includes(
error instanceof Error &&
error.message.includes(
"duplicate key value violates unique constraint",
) &&
customerId
@@ -105,21 +113,25 @@ export const getOrCreateApiCustomer = async ({
ctx.logger.info(
`[getOrCreateApiCustomer] Customer ${customerId} already exists, fetching existing customer`,
);
const existingCustomer = await CusService.get({
const existingCustomer = await CusService.getFull({
db: ctx.db,
idOrInternalId: customerId,
orgId: ctx.org.id,
env: ctx.env,
});
if (existingCustomer) {
newCustomer = existingCustomer;
}
// Race condition, don't set in cache
ctx.skipCache = true;
if (existingCustomer) newCustomer = existingCustomer;
} else {
throw error;
}
}
// console.log("Skipping cache:", ctx.skipCache);
// console.log("New customer:", newCustomer);
const res = await getCachedApiCustomer({
ctx,
customerId: newCustomer?.id || newCustomer?.internal_id || "",

View File

@@ -0,0 +1,351 @@
import {
type BillingCycleResult,
type CalculateDateRangeParams,
type ClickHouseResult,
type DateRangeResult,
ErrCode,
RecaseError,
type TimeseriesEventsParams,
type TotalEventsParams,
} from "@autumn/shared";
import type { ClickHouseClient } from "@clickhouse/client";
import { UTCDate } from "@date-fns/utc";
import {
differenceInDays,
format,
startOfDay,
startOfHour,
sub,
} from "date-fns";
import { Decimal } from "decimal.js";
import { StatusCodes } from "http-status-codes";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import {
generateEventCountExpressions,
getBillingCycleStartDate,
} from "../analytics/analyticsUtils.js";
export class EventsAggregationService {
private static async calculateDateRange({
ctx,
params,
}: {
ctx: AutumnContext;
params: CalculateDateRangeParams;
}): Promise<DateRangeResult> {
const { db } = ctx;
const intervalType = params.interval;
const binSize =
params.bin_size ?? (intervalType === "24h" ? "hour" : "day");
if (params.custom_range) {
return {
startDate: format(
new UTCDate(params.custom_range.start),
"yyyy-MM-dd'T'HH:mm:ss",
),
endDate: format(
new UTCDate(params.custom_range.end),
"yyyy-MM-dd'T'HH:mm:ss",
),
};
}
const isBillingCycle = intervalType === "1bc" || intervalType === "3bc";
const getBCResults =
isBillingCycle && !params.aggregateAll && params.customer
? ((await getBillingCycleStartDate(
params.customer,
db,
intervalType as "1bc" | "3bc",
)) as BillingCycleResult | null)
: null;
if (getBCResults?.startDate && getBCResults?.endDate) {
return {
startDate: getBCResults.startDate,
endDate: getBCResults.endDate,
};
}
const intervalTypeToDaysMap =
EventsAggregationService.intervalTypeToDaysMap({
gap: 0,
});
const days =
intervalTypeToDaysMap[intervalType as keyof typeof intervalTypeToDaysMap];
const now = new UTCDate();
const endDate = format(now, "yyyy-MM-dd'T'HH:mm:ss");
const startTime = sub(now, { days });
const truncatedStartTime =
binSize === "day" ? startOfDay(startTime) : startOfHour(startTime);
const startDate = format(truncatedStartTime, "yyyy-MM-dd'T'HH:mm:ss");
return { startDate, endDate };
}
static intervalTypeToDaysMap({
gap,
}: {
gap?: number;
} = {}): Record<string, number> {
return {
"24h": 1,
"7d": 7,
"30d": 30,
"90d": 90,
"1bc": (gap ?? 0) + 1,
"3bc": (gap ?? 0) + 1,
};
}
static async getTimeseriesEvents({
ctx,
params,
}: {
ctx: AutumnContext;
params: TimeseriesEventsParams;
}) {
const { clickhouseClient, org, env, db } = ctx;
const intervalType = params.interval;
const useCustomDateQuery =
intervalType === "1bc" || intervalType === "3bc" || !!params.custom_range;
// Skip billing cycle calculation if aggregating all customers or using custom_range
const getBCResults =
useCustomDateQuery &&
!params.aggregateAll &&
params.customer &&
!params.custom_range
? ((await getBillingCycleStartDate(
params.customer,
db,
intervalType as "1bc" | "3bc",
)) as BillingCycleResult | null)
: null;
const countExpressions = generateEventCountExpressions(
params.event_names,
params.no_count,
);
const getGroupByClause = () => {
if (!params.group_by)
return { select: "", groupBy: "", orderBy: "", fieldName: null };
let field: string | null = null;
// Extract property path after 'properties.' and escape single quotes for SQL safety
const propertyPath = params.group_by.replace("properties.", "");
const pathSegments = propertyPath.split(".").map((segment) => {
// Validate each segment contains only safe characters (alphanumeric, underscores)
if (!/^[a-zA-Z0-9_]+$/.test(segment)) {
throw new RecaseError({
message:
"Invalid property path. Should only contain alphanumeric and underscore characters.",
code: ErrCode.InvalidInputs,
statusCode: StatusCodes.BAD_REQUEST,
});
}
// Escape single quotes for SQL safety
return segment.replace(/'/g, "''");
});
const validSegments = pathSegments.filter(
(segment): segment is string => segment !== null,
);
if (validSegments.length === 0) {
return { select: "", groupBy: "", orderBy: "", fieldName: null };
}
// Join segments as separate arguments: 'key1', 'key2', 'key3'
// ClickHouse JSONExtractString requires each key as a separate argument
const escapedPathArgs = validSegments.map((seg) => `'${seg}'`).join(", ");
field = `JSONExtractString(e.properties, ${escapedPathArgs})`;
if (!field)
return { select: "", groupBy: "", orderBy: "", fieldName: null };
const escapedFieldName = params.group_by.replace(/`/g, "``");
const columnAlias = `\`${escapedFieldName}\``;
return {
select: `, ${field} as ${columnAlias}`,
groupBy: `, ${field}`,
orderBy: `, ${field}`,
fieldName: params.group_by,
};
};
const groupBy = getGroupByClause();
const groupByFieldName = groupBy.fieldName;
const query = `
with customer_events as (
select *
from org_events_view(org_id={org_id:String}, org_slug='', env={env:String})
${params.aggregateAll ? "" : "where customer_id = {customer_id:String}"}
)
select
dr.period${groupBy.select},
${countExpressions}
from date_range_view(bin_size={bin_size:String}, days={days:UInt32}) dr
left join customer_events e
on date_trunc({bin_size:String}, e.timestamp) = dr.period
group by dr.period${groupBy.groupBy}
order by dr.period${groupBy.orderBy};
`;
const queryBillingCycle = `
with customer_events as (
select *
from org_events_view(org_id={org_id:String}, org_slug='', env={env:String})
${params.aggregateAll ? "" : "where customer_id = {customer_id:String}"}
)
select
dr.period${groupBy.select},
${countExpressions}
from date_range_bc_view(bin_size={bin_size:String}, start_date={end_date:DateTime}, days={days:UInt32}) dr
left join customer_events e
on date_trunc({bin_size:String}, e.timestamp) = dr.period
group by dr.period${groupBy.groupBy}
order by dr.period${groupBy.orderBy};
`;
const customRangeDays = params.custom_range
? differenceInDays(
new UTCDate(params.custom_range.end),
new UTCDate(params.custom_range.start),
) + 1
: undefined;
const customRangeEndDate = params.custom_range
? format(new UTCDate(params.custom_range.end), "yyyy-MM-dd'T'HH:mm:ss")
: undefined;
const intervalTypeToDaysMap =
EventsAggregationService.intervalTypeToDaysMap({
gap: getBCResults?.gap,
});
const queryParams = {
org_id: org?.id,
env: env,
customer_id: params.customer_id,
days:
customRangeDays ??
intervalTypeToDaysMap[
intervalType as keyof typeof intervalTypeToDaysMap
],
bin_size: params.bin_size ?? (intervalType === "24h" ? "hour" : "day"),
end_date: customRangeEndDate ?? getBCResults?.endDate,
};
// Use date_range_bc_view query for billing cycles or custom ranges
const queryToUse =
useCustomDateQuery &&
!params.aggregateAll &&
(getBCResults?.startDate || params.custom_range)
? queryBillingCycle
: query;
const result = await (clickhouseClient as ClickHouseClient).query({
query: queryToUse,
query_params: queryParams,
format: "JSON",
clickhouse_settings: {
output_format_json_quote_decimals: 0,
output_format_json_quote_64bit_integers: 1,
output_format_json_quote_64bit_floats: 1,
},
});
const resultJson = (await result.json()) as ClickHouseResult;
resultJson.data.forEach((row) => {
Object.keys(row).forEach((key: string) => {
// Don't convert period or the group_by field to decimal
if (key !== "period" && key !== groupByFieldName) {
row[key] = new Decimal(row[key] as string | number)
.toDecimalPlaces(10)
.toNumber();
}
});
});
return resultJson;
}
static async getTotalEvents({
ctx,
params,
}: {
ctx: AutumnContext;
params: TotalEventsParams;
}) {
const { clickhouseClient, org, env } = ctx;
const { startDate, endDate } =
await EventsAggregationService.calculateDateRange({
ctx,
params: {
interval: params.interval,
bin_size: params.bin_size,
custom_range: params.custom_range,
customer: params.customer,
aggregateAll: params.aggregateAll,
},
});
const query = `
with customer_events as (
select *
from org_events_view(org_id={org_id:String}, org_slug='', env={env:String})
${params.aggregateAll ? "" : "where customer_id = {customer_id:String}"}
)
select
e.event_name,
COUNT(*) as count,
SUM(e.value) as sum
from customer_events e
where e.timestamp >= {start_date:DateTime}
and e.timestamp <= {end_date:DateTime}
and e.event_name IN {event_names:Array(String)}
group by e.event_name;
`;
const result = await (clickhouseClient as ClickHouseClient).query({
query,
query_params: {
org_id: org?.id,
env: env,
customer_id: params.customer_id,
start_date: startDate,
end_date: endDate,
event_names: params.event_names,
},
format: "JSON",
});
const resultJson = (await result.json()) as ClickHouseResult;
const rows = resultJson.data as Array<{
event_name: string;
count: string;
sum: string;
}>;
return rows.reduce(
(acc, row) => {
acc[row.event_name] = {
count: Number(row.count),
sum: Number(row.sum ?? 0),
};
return acc;
},
{} as Record<string, { count: number; sum: number }>,
);
}
}

View File

@@ -0,0 +1,119 @@
import { UTCDate } from "@date-fns/utc";
/**
* Convert event periods from ISO strings to epoch timestamps.
* @param events - The events to convert.
* @returns The current time as an epoch timestamp for filtering.
*/
export function convertPeriodsToEpoch(
events: Array<Record<string, string | number>>,
): number {
const currentTime = new UTCDate().getTime();
for (const event of events) {
event.period = new UTCDate(event.period as string).getTime();
}
return currentTime;
}
/**
* Normalize a group value to a string.
* @param value - The value to normalize.
* @returns The normalized value as a string or null if the value is null or empty.
*/
function normalizeGroupValue(value: unknown): string | null {
if (value == null || value === "") return null;
return String(value);
}
/**
* Collect grouping metadata from a list of rows.
* @param rows - The rows to collect metadata from.
* @param groupByField - The field to group by.
* @returns The group values and feature names.
*/
export function collectGroupingMetadata(
rows: Array<Record<string, string | number>>,
groupByField: string,
): { groupValues: Set<string>; featureNames: Set<string> } {
const groupValues = new Set<string>();
const featureNames = new Set<string>();
for (const row of rows) {
// biome-ignore lint/correctness/noUnusedVariables: period is required here but appears unused
const { [groupByField]: groupValue, period, ...metrics } = row;
const normalized = normalizeGroupValue(groupValue);
if (normalized) {
groupValues.add(normalized);
}
for (const featureName of Object.keys(metrics)) {
featureNames.add(featureName);
}
}
return { groupValues, featureNames };
}
/**
* Build a grouped timeseries from a list of rows.
* @param rows - The rows to build the grouped timeseries from.
* @param groupByField - The field to group by.
* @returns The grouped timeseries.
*/
export function buildGroupedTimeseries(
rows: Array<Record<string, string | number>>,
groupByField: string,
): Map<number, Record<string, number | Record<string, number>>> {
const grouped = new Map<
number,
Record<string, number | Record<string, number>>
>();
for (const row of rows) {
const { period, [groupByField]: groupValue, ...metrics } = row;
const periodNum = Number(period);
if (!grouped.has(periodNum)) {
grouped.set(periodNum, { period: periodNum });
}
const normalized = normalizeGroupValue(groupValue);
if (!normalized) continue;
const periodData = grouped.get(periodNum)!;
for (const [featureName, value] of Object.entries(metrics)) {
if (!periodData[featureName]) {
periodData[featureName] = {};
}
(periodData[featureName] as Record<string, number>)[normalized] =
Number(value);
}
}
return grouped;
}
/**
* Backfill missing group values in a grouped timeseries.
* @param grouped - The grouped timeseries to backfill.
* @param groupValues - The group values to backfill.
* @param featureNames - The feature names to backfill.
*/
export function backfillMissingGroupValues(
grouped: Map<number, Record<string, number | Record<string, number>>>,
groupValues: Set<string>,
featureNames: Set<string>,
): void {
for (const periodData of grouped.values()) {
for (const featureName of featureNames) {
if (!periodData[featureName]) {
periodData[featureName] = {};
}
const featureData = periodData[featureName] as Record<string, number>;
for (const groupValue of groupValues) {
if (featureData[groupValue] === undefined) {
featureData[groupValue] = 0;
}
}
}
}
}

View File

@@ -0,0 +1,7 @@
import { Hono } from "hono";
import type { HonoEnv } from "../../honoUtils/HonoEnv.js";
import { handleEventsAggregation } from "./handlers/handleEventsAggregation.js";
export const eventsRouter = new Hono<HonoEnv>();
eventsRouter.post("aggregate", ...handleEventsAggregation);

View File

@@ -0,0 +1,102 @@
import type { AggregatedEventRow, ProcessedEventRow } from "@autumn/shared";
import {
CustomerNotFoundError,
ErrCode,
EventAggregationBodySchema,
RecaseError,
} from "@autumn/shared";
import { StatusCodes } from "http-status-codes";
import { CusService } from "@/internal/customers/CusService";
import { createRoute } from "../../../honoMiddlewares/routeHandler";
import { EventsAggregationService } from "../EventsAggregationService";
import {
backfillMissingGroupValues,
buildGroupedTimeseries,
collectGroupingMetadata,
convertPeriodsToEpoch,
} from "../eventUtils.js";
export const handleEventsAggregation = createRoute({
body: EventAggregationBodySchema,
handler: async (c) => {
const ctx = c.get("ctx");
const { db, org, env } = ctx;
const { customer_id, feature_id, group_by, range, bin_size, custom_range } =
c.req.valid("json");
const customer = await CusService.getFull({
db,
orgId: org.id,
idOrInternalId: customer_id,
env,
withSubs: true,
});
if (!customer) {
throw new CustomerNotFoundError({ customerId: customer_id });
}
const featureIds = Array.isArray(feature_id) ? feature_id : [feature_id];
const [events, total] = await Promise.all([
EventsAggregationService.getTimeseriesEvents({
ctx,
params: {
aggregateAll: false,
interval: range,
event_names: featureIds,
customer_id: customer_id,
no_count: true,
customer,
group_by,
bin_size,
custom_range,
},
}),
EventsAggregationService.getTotalEvents({
ctx,
params: {
aggregateAll: false,
interval: range,
event_names: featureIds,
customer_id: customer_id,
customer,
custom_range,
bin_size,
},
}),
]);
if (!events) {
throw new RecaseError({
message: "No events found",
code: ErrCode.InternalError,
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
});
}
const currentTime = convertPeriodsToEpoch(events.data);
let usageList = (events.data as ProcessedEventRow[]).filter(
(event) => event.period <= currentTime,
) as AggregatedEventRow[];
if (group_by) {
const ungroupedData = usageList as ProcessedEventRow[];
const { groupValues, featureNames } = collectGroupingMetadata(
ungroupedData,
group_by,
);
const grouped = buildGroupedTimeseries(ungroupedData, group_by);
backfillMissingGroupValues(grouped, groupValues, featureNames);
usageList = Array.from(grouped.values()) as AggregatedEventRow[];
}
return c.json({
list: usageList,
total,
});
},
});

View File

@@ -1,18 +0,0 @@
import { handleFrontendReqError } from "@/utils/errorUtils.js";
import { createFeature } from "../featureActions/createFeature.js";
export const handleCreateFeature = async (req: any, res: any) => {
try {
console.log("Trying to create feature");
const data = req.body;
const insertedFeature = await createFeature({
ctx: req,
data,
});
res.status(200).json(insertedFeature);
} catch (error) {
handleFrontendReqError({ req, error, res, action: "Create feature" });
}
};

View File

@@ -1,67 +0,0 @@
// import { ErrCode } from "@autumn/shared";
// import { getCreditSystemsFromFeature } from "@/internal/features/creditSystemUtils.js";
// import { FeatureService } from "@/internal/features/FeatureService.js";
// import { EntitlementService } from "@/internal/products/entitlements/EntitlementService.js";
// import RecaseError from "@/utils/errorUtils.js";
// import type {
// ExtendedRequest,
// ExtendedResponse,
// } from "@/utils/models/Request.js";
// import { routeHandler } from "@/utils/routerUtils.js";
// export const handleDeleteFeature = async (req: any, res: any) =>
// routeHandler({
// req,
// res,
// action: "Delete feature",
// handler: async (req: ExtendedRequest, res: ExtendedResponse) => {
// const { db, orgId } = req;
// const { featureId } = req.params;
// const features = await FeatureService.getFromReq(req);
// const feature = features.find((f) => f.id === featureId);
// const creditSystems = getCreditSystemsFromFeature({
// featureId,
// features,
// });
// if (!feature) {
// throw new RecaseError({
// message: `Feature ${featureId} not found`,
// code: ErrCode.FeatureNotFound,
// statusCode: 404,
// });
// }
// if (creditSystems.length > 0) {
// throw new RecaseError({
// message: `Feature ${featureId} is used by credit system ${creditSystems[0].id}`,
// code: ErrCode.InvalidFeature,
// statusCode: 400,
// });
// }
// // Get prices that use this feature
// const ent = await EntitlementService.getByFeature({
// db,
// internalFeatureId: feature.internal_id!,
// });
// if (ent) {
// throw new RecaseError({
// message: `Feature ${featureId} is used in a product. You must delete the product first, or archive it instead.`,
// code: ErrCode.InvalidFeature,
// statusCode: 400,
// });
// }
// await FeatureService.delete({
// db: req.db,
// orgId,
// featureId,
// env: req.env,
// });
// res.status(200).json({ success: true });
// },
// });

View File

@@ -1,32 +0,0 @@
// import { routeHandler } from "@/utils/routerUtils.js";
// import { updateFeature } from "../featureActions/updateFeature.js";
// export const handleUpdateFeature = async (
// req: any,
// res: any,
// _fromApi: boolean = false,
// ) =>
// routeHandler({
// req,
// res,
// action: "Update feature",
// handler: async (req: any, res: any) => {
// const featureId = req.params.feature_id;
// const data = req.body;
// // Use the abstracted updateFeature function
// const updatedFeature = await updateFeature({
// ctx: req,
// featureId,
// updates: data,
// });
// res
// .status(200)
// .json(
// updatedFeature
// : undefined,
// );
// },
// });

View File

@@ -6,14 +6,13 @@ import { generateId } from "@/utils/genUtils.js";
import { FeatureService } from "../FeatureService.js";
import {
validateCreditSystem,
validateFeatureId,
validateMeteredConfig,
} from "../featureUtils.js";
export const validateFeature = (data: any) => {
const featureType = data.type;
validateFeatureId(data.id);
// validateFeatureId(data.id);
let config = data.config;
if (featureType === FeatureType.Metered) {

View File

@@ -1,13 +1,3 @@
// import {
// ApiFeatureType,
// ApiFeatureV0Schema,
// ErrCode,
// type Feature,
// FeatureType,
// type FeatureUsageType,
// UpdateFeatureParamsSchema,
// } from "@autumn/shared";
import { Hono } from "hono";
import type { HonoEnv } from "../../honoUtils/HonoEnv";
import { handleCreateFeature } from "./handlers/handleCreateFeature";
@@ -17,174 +7,6 @@ import { handleListFeatures } from "./handlers/handleListFeatures";
import { handleUpdateFeature } from "./handlers/handleUpdateFeature";
import { handleGetFeatureDeletionInfo } from "./internalHandlers/handleGetFeatureDeletionInfo";
// import express, { type Router } from "express";
// import { JobName } from "@/queue/JobName.js";
// import { addTaskToQueue } from "@/queue/queueUtils.js";
// import RecaseError from "@/utils/errorUtils.js";
// import { keyToTitle } from "@/utils/genUtils.js";
// import { routeHandler } from "@/utils/routerUtils.js";
// import { FeatureService } from "./FeatureService.js";
// import { validateFeatureId } from "./featureUtils.js";
// import { handleDeleteFeature } from "./handlers/handleDeleteFeature.js";
// import { handleGetFeatureDeletionInfo } from "./handlers/handleGetFeatureDeletionInfo.js";
// import { handleUpdateFeature } from "./handlers/handleUpdateFeature.js";
// import { fromApiFeature, toApiFeature } from "./utils/mapFeatureUtils.js";
// export const featureRouter: Router = express.Router();
// // 1. Get features...
// featureRouter.get("", async (req: any, res: any) =>
// routeHandler({
// req,
// res,
// action: "list features",
// handler: async () => {
// const includeArchived = req.query.include_archived === "true";
// const features = await FeatureService.list({
// db: req.db,
// orgId: req.orgId,
// env: req.env,
// archived: includeArchived ? undefined : false,
// // showOnlyArchived: includeArchived ? undefined : false,
// });
// res
// .status(200)
// .json({ list: features.map((feature) => toApiFeature({ feature })) });
// },
// }),
// );
// featureRouter.get("/:featureId", async (req: any, res: any) =>
// routeHandler({
// req,
// res,
// action: "Get feature",
// handler: async () => {
// const feature = req.features.find(
// (f: Feature) => f.id === req.params.featureId,
// );
// if (!feature) {
// throw new RecaseError({
// message: `Feature with id ${req.params.featureId} not found`,
// code: ErrCode.FeatureNotFound,
// statusCode: 404,
// });
// }
// res.status(200).json(toApiFeature({ feature }));
// },
// }),
// );
// featureRouter.post("", async (req: any, res: any) =>
// routeHandler({
// req,
// res,
// action: "Create feature",
// handler: async () => {
// const apiFeature = ApiFeatureV0Schema.parse(req.body);
// if (!apiFeature.name) {
// apiFeature.name = keyToTitle(apiFeature.id);
// }
// validateFeatureId(apiFeature.id);
// const feature = fromApiFeature({
// apiFeature,
// orgId: req.orgId,
// env: req.env,
// });
// const { db, logger, features: curFeatures } = req;
// const curFeature = curFeatures.find((f: Feature) => f.id === feature.id);
// if (curFeature) {
// throw new RecaseError({
// message: `Feature with id ${feature.id} already exists`,
// code: ErrCode.DuplicateFeatureId,
// statusCode: 400,
// });
// }
// await FeatureService.insert({ db, data: [feature], logger });
// await addTaskToQueue({
// jobName: JobName.GenerateFeatureDisplay,
// payload: { feature },
// });
// res.status(200).json(apiFeature);
// },
// }),
// );
// featureRouter.post("/:feature_id", async (req: any, res: any) =>
// routeHandler({
// req,
// res,
// action: "Update feature",
// handler: async (req: any, res: any) => {
// const { feature_id: featureId } = req.params;
// const { features: curFeatures } = req;
// const apiFeature = UpdateFeatureParamsSchema.parse(req.body);
// const originalFeature = curFeatures.find(
// (f: Feature) => f.id === featureId,
// );
// if (!originalFeature) {
// throw new RecaseError({
// message: `Feature with id ${featureId} not found`,
// code: ErrCode.FeatureNotFound,
// statusCode: 404,
// });
// }
// // Replace body...
// let featureType = apiFeature.type as unknown as FeatureType;
// let usageType: FeatureUsageType | undefined;
// if (
// apiFeature.type === ApiFeatureType.SingleUsage ||
// apiFeature.type === ApiFeatureType.ContinuousUse
// ) {
// featureType = FeatureType.Metered;
// usageType = apiFeature.type as unknown as FeatureUsageType;
// }
// const newConfig = originalFeature.config;
// if (usageType) {
// newConfig.usage_type = usageType;
// }
// if (apiFeature.credit_schema) {
// newConfig.schema = apiFeature.credit_schema.map((credit) => ({
// metered_feature_id: credit.metered_feature_id,
// credit_amount: credit.credit_cost,
// }));
// }
// const newBody = {
// id: req.body.id || undefined,
// name: req.body.name || undefined,
// type: featureType,
// config: newConfig,
// archived: req.body.archived ?? undefined,
// };
// req.body = newBody;
// await handleUpdateFeature(req, res, true);
// },
// }),
// );
// featureRouter.delete("/:featureId", handleDeleteFeature);
// featureRouter.get("/:feature_id/deletion_info", handleGetFeatureDeletionInfo);
export const featureRouter = new Hono<HonoEnv>();
featureRouter.get("", ...handleListFeatures);
featureRouter.post("", ...handleCreateFeature);

View File

@@ -31,9 +31,10 @@ export const handleFeatureIdChanged = async ({
newId: string;
}) => {
const { db, org, env } = ctx;
const curFeature = ctx.features.find((f) => f.id === feature.id);
const curFeature = ctx.features.find((f) => f.id === newId);
if (curFeature) {
throw new FeatureAlreadyExistsError({ featureId: feature.id });
throw new FeatureAlreadyExistsError({ featureId: newId });
}
// 1. Check if any customer entitlement linked to this feature
@@ -44,7 +45,7 @@ export const handleFeatureIdChanged = async ({
if (cusEnts.length > 0) {
throw new RecaseError({
message: `Cannot change id of feature ${feature.id} because a customer is using it`,
message: `Cannot change id of feature ${feature.id} because a customer is using it or has used it before`,
code: ErrCode.InvalidFeature,
statusCode: 400,
});

View File

@@ -7,10 +7,13 @@ import {
type FullProduct,
getFeatureInvoiceDescription,
type IntervalConfig,
isFixedPrice,
isOneOffPrice,
isUsagePrice,
type Organization,
type PreviewLineItem,
type Price,
priceToInvoiceAmount,
toProductItem,
UsageModel,
type UsagePriceConfig,
@@ -27,18 +30,13 @@ import {
priceToFeature,
priceToUsageModel,
} from "@/internal/products/prices/priceUtils/convertPrice.js";
import { priceToInvoiceAmount } from "@/internal/products/prices/priceUtils/priceToInvoiceAmount.js";
import { sortPricesByType } from "@/internal/products/prices/priceUtils/sortPriceUtils.js";
import { formatAmount } from "@/utils/formatUtils.js";
import { formatUnixToDate, notNullish } from "@/utils/genUtils.js";
import type { AttachParams } from "../../customers/cusProducts/AttachParams.js";
import { getPricecnPrice } from "../../products/pricecn/pricecnUtils.js";
import { subtractIntervalForProration } from "../../products/prices/billingIntervalUtils.js";
import {
isFixedPrice,
isOneOffPrice,
isPrepaidPrice,
} from "../../products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js";
import { isPrepaidPrice } from "../../products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js";
import {
formatPrice,
@@ -166,7 +164,7 @@ export const getItemsForNewProduct = async ({
const printLogs = false;
for (const price of newProduct.prices) {
if (skipOneOff && isOneOffPrice({ price })) continue;
if (skipOneOff && isOneOffPrice(price)) continue;
const ent = getPriceEntitlement(price, newProduct.entitlements);
const billingType = getBillingType(price.config);
@@ -194,7 +192,7 @@ export const getItemsForNewProduct = async ({
}
if (printLogs) console.log("--------------------------------");
if (isFixedPrice({ price })) {
if (isFixedPrice(price)) {
let amount = finalProration
? calculateProrationAmount({
periodEnd: finalProration.end,

View File

@@ -101,7 +101,7 @@ const findBestMatch = (oldPrice: Price, newPrices: Price[]): Price | null => {
if (candidates.length === 1) return candidates[0];
// If multiple candidates, use type-specific matching
if (isFixedPrice({ price: oldPrice })) {
if (isFixedPrice(oldPrice)) {
return findMatchingFixedPrice(oldPrice, candidates);
} else if (isUsagePrice({ price: oldPrice })) {
return findMatchingUsagePrice(oldPrice, candidates);

View File

@@ -116,79 +116,6 @@ expressProductRouter.get("/rewards", async (req: any, res) => {
}
});
// // Get single product data
// expressProductRouter.get("/:productId/data2", async (req: any, res) => {
// try {
// const { productId } = req.params;
// const { version } = req.query;
// const { db, orgId, env } = req;
// const [product, latestProduct] = await Promise.all([
// ProductService.getFull({
// db,
// idOrInternalId: productId,
// orgId,
// env,
// version: version ? parseInt(version) : undefined,
// }),
// ProductService.getFull({
// db,
// idOrInternalId: productId,
// orgId,
// env,
// }),
// ]);
// const productV2 = mapToProductV2({
// product: product,
// features: req.features,
// });
// res
// .status(200)
// .json({ product: productV2, numVersions: latestProduct.version });
// } catch (error) {
// console.error("Failed to get product", error);
// res.status(500).send(error);
// }
// });
// // Get counts for a single product
// expressProductRouter.get("/:productId/count", async (req: any, res) => {
// try {
// const { db, orgId, env } = req;
// const { productId } = req.params;
// const { version } = req.query;
// const product = await ProductService.get({
// db,
// id: productId,
// orgId,
// env,
// version: version ? parseInt(version) : undefined,
// });
// if (!product) {
// throw new ProductNotFoundError({ productId, version });
// }
// // Get counts from postgres
// const counts = await CusProdReadService.getCounts({
// db,
// internalProductId: product.internal_id,
// });
// res.status(200).send(counts);
// } catch (error) {
// handleFrontendReqError({
// error,
// req,
// res,
// action: "Get product counts (internal)",
// });
// }
// });
// Get list of migrations
expressProductRouter.get("/migrations", async (req: any, res) => {
try {

View File

@@ -6,13 +6,13 @@ import {
type FullCustomerEntitlement,
type FullCustomerPrice,
type FullProduct,
isFixedPrice,
type Price,
type ProductOptions,
UsageModel,
type UsagePriceConfig,
} from "@autumn/shared";
import { getBillingType, getPriceEntitlement } from "../priceUtils.js";
import { isFixedPrice } from "./usagePriceUtils/classifyUsagePrice.js";
export const priceToIntervalKey = (price: Price) => {
return toIntervalKey({
@@ -81,7 +81,7 @@ export const priceToFeature = ({
export const priceToUsageModel = (price: Price) => {
const billingType = getBillingType(price.config);
if (isFixedPrice({ price })) {
if (isFixedPrice(price)) {
return undefined;
}
if (billingType === BillingType.UsageInAdvance) {

View File

@@ -1,171 +0,0 @@
import {
BillingType,
Feature,
FixedPriceConfig,
Infinite,
Price,
ProductItem,
UsageModel,
UsagePriceConfig,
} from "@autumn/shared";
import { isFixedPrice } from "./usagePriceUtils/classifyUsagePrice.js";
import { getBillingType } from "../priceUtils.js";
import { Decimal } from "decimal.js";
import { nullish } from "@/utils/genUtils.js";
import {
calculateProrationAmount,
Proration,
} from "@/internal/invoices/prorationUtils.js";
import { itemToPriceAndEnt } from "../../product-items/productItemUtils/itemToPriceAndEnt.js";
import { isPriceItem } from "../../product-items/productItemUtils/getItemType.js";
export const getAmountForQuantity = ({
price,
quantity,
}: {
price: Price;
quantity: number;
}) => {
const config = price.config as UsagePriceConfig;
let billingUnits = config.billing_units || 1;
const roundedQuantity = new Decimal(quantity)
.div(billingUnits)
.ceil()
.mul(billingUnits)
.toNumber();
let lastTierTo: number = 0;
let amount = new Decimal(0);
let remainingUsage = new Decimal(roundedQuantity);
// console.log("Getting amount for quantity:", roundedQuantity);
// console.log("Usage tiers:", config.usage_tiers);
for (let i = 0; i < config.usage_tiers.length; i++) {
let tier = config.usage_tiers[i];
let usageWithinTier = new Decimal(0);
if (tier.to == Infinite || tier.to == -1) {
usageWithinTier = remainingUsage;
} else {
let tierUsage = new Decimal(tier.to).minus(lastTierTo);
usageWithinTier = Decimal.min(remainingUsage, tierUsage);
lastTierTo = tier.to;
}
let amountPerUnit = new Decimal(tier.amount).div(billingUnits);
let amountWithinTier = amountPerUnit.mul(usageWithinTier);
amount = amount.plus(amountWithinTier);
remainingUsage = remainingUsage.minus(usageWithinTier);
if (remainingUsage.lte(0)) {
break;
}
}
return amount.toDecimalPlaces(10).toNumber();
};
export const itemToInvoiceAmount = ({
item,
quantity,
overage,
}: {
item: ProductItem;
quantity?: number;
overage?: number;
}) => {
let amount = 0;
if (isPriceItem(item)) {
amount = item.price!;
}
if (!nullish(quantity) && !nullish(overage)) {
throw new Error(
`itemToInvoiceAmount: quantity or overage is required, autumn item: ${item.feature_id}`,
);
}
let price = {
config: {
usage_tiers: item.tiers || [
{
to: Infinite,
amount: item.price!,
},
],
billing_units: item.billing_units || 1,
},
} as unknown as Price;
if (item.usage_model == UsageModel.Prepaid) {
amount = getAmountForQuantity({ price, quantity: quantity! });
} else {
amount = getAmountForQuantity({ price, quantity: overage! });
}
return amount;
};
export const priceToInvoiceAmount = ({
price,
item,
quantity,
productQuantity,
overage,
proration,
now,
}: {
price?: Price;
item?: ProductItem;
quantity?: number; // quantity should be multiplied by billing units
productQuantity?: number;
overage?: number;
proration?: Proration;
now?: number;
}) => {
// 1. If fixed price, just return amount
let amount = 0;
if (price) {
if (isFixedPrice({ price })) {
amount = (price.config as FixedPriceConfig).amount;
if (productQuantity) {
amount = new Decimal(amount).mul(productQuantity).toNumber();
}
} else {
const config = price.config as UsagePriceConfig;
let billingType = getBillingType(config);
if (!nullish(quantity) && !nullish(overage)) {
throw new Error(
`getAmountForPrice: quantity or overage is required, autumn price: ${price.id}`,
);
}
if (billingType == BillingType.UsageInAdvance) {
amount = getAmountForQuantity({ price, quantity: quantity! });
} else {
amount = getAmountForQuantity({ price, quantity: overage! });
}
}
} else {
amount = itemToInvoiceAmount({ item: item!, quantity, overage });
}
if (proration) {
return calculateProrationAmount({
periodEnd: proration.end,
periodStart: proration.start,
now: now || Date.now(),
amount,
allowNegative: true,
});
}
return amount;
};

View File

@@ -1,13 +1,13 @@
import { constructPreviewItem } from "@/internal/invoices/previewItemUtils/constructPreviewItem.js";
import {
FullEntitlement,
type FullEntitlement,
getFeatureInvoiceDescription,
Organization,
Price,
UsagePriceConfig,
type Organization,
type Price,
priceToInvoiceAmount,
type UsagePriceConfig,
} from "@autumn/shared";
import { priceToInvoiceAmount } from "./priceToInvoiceAmount.js";
import { Proration } from "@/internal/invoices/prorationUtils.js";
import { constructPreviewItem } from "@/internal/invoices/previewItemUtils/constructPreviewItem.js";
import type { Proration } from "@/internal/invoices/prorationUtils.js";
import { formatUnixToDate } from "@/utils/genUtils.js";
export const priceToInvoiceItem = ({
@@ -58,7 +58,7 @@ export const priceToInvoiceItem = ({
invoiceAmount = 0;
}
let newPreviewItem = constructPreviewItem({
const newPreviewItem = constructPreviewItem({
price,
org,
amount: invoiceAmount,

View File

@@ -13,10 +13,6 @@ import { Decimal } from "decimal.js";
import type Stripe from "stripe";
import { getBillingType } from "../../priceUtils";
export const isOneOffPrice = ({ price }: { price: Price }) => {
return price.config.interval === BillingInterval.OneOff;
};
export const isArrearPrice = ({ price }: { price?: Price }) => {
if (!price) return false;
const billingType = getBillingType(price.config);
@@ -33,14 +29,6 @@ export const isPrepaidPrice = ({ price }: { price: Price }) => {
return billingType === BillingType.UsageInAdvance;
};
export const isFixedPrice = ({ price }: { price: Price }) => {
const billingType = getBillingType(price.config);
return (
billingType === BillingType.FixedCycle || billingType === BillingType.OneOff
);
};
export const hasPrepaidPrice = ({
prices,
excludeOneOff,

View File

@@ -28,7 +28,7 @@ export const addIdsToProductItems = ({
const priceIds = new Set<string>();
const basePriceItem = items.find((item) => item.price_id === null);
const baseCurPrice = curPrices.find((price) => isFixedPrice({ price }));
const baseCurPrice = curPrices.find((price) => isFixedPrice(price));
if (basePriceItem && baseCurPrice) {
basePriceItem.price_id = baseCurPrice.id;

View File

@@ -5,8 +5,13 @@ import {
type FullProduct,
isCusProductCanceled,
} from "@autumn/shared";
import { getExistingCusProducts } from "@/internal/customers/cusProducts/cusProductUtils/getExistingCusProducts";
import { isFreeProduct, isOneOff, isProductUpgrade } from "../../productUtils";
import { getExistingCusProducts } from "@/internal/customers/cusProducts/cusProductUtils/getExistingCusProducts.js";
import {
isFreeProduct,
isOneOff,
isProductUpgrade,
} from "../../productUtils.js";
export const getAttachScenario = ({
fullCus,
@@ -17,18 +22,41 @@ export const getAttachScenario = ({
}) => {
if (!fullCus) return AttachScenario.New;
const { curMainProduct, curScheduledProduct } = getExistingCusProducts({
product: fullProduct,
cusProducts: fullCus?.customer_products || [],
internalEntityId: fullCus?.entity?.internal_id,
});
if (!curMainProduct || fullProduct.is_add_on) return AttachScenario.New;
const { curMainProduct, curScheduledProduct, curSameProduct } =
getExistingCusProducts({
product: fullProduct,
cusProducts: fullCus?.customer_products || [],
internalEntityId: fullCus?.entity?.internal_id,
});
if (isOneOff(fullProduct.prices)) {
return AttachScenario.New;
}
if (
fullProduct.is_add_on &&
!isFreeProduct(fullProduct.prices) &&
!isOneOff(fullProduct.prices)
) {
// 1. If current same product is add on, and it's canceled
if (
curSameProduct &&
curSameProduct.product.id !== curScheduledProduct?.product.id
) {
if (isCusProductCanceled({ cusProduct: curSameProduct })) {
return AttachScenario.Renew;
} else {
return AttachScenario.Active;
}
}
}
if (fullProduct.is_add_on) {
return AttachScenario.New;
}
if (!curMainProduct) return AttachScenario.New;
// 1. If current product is the same as the product, return active
if (curMainProduct?.product.id === fullProduct.id) {
if (isCusProductCanceled({ cusProduct: curMainProduct })) {

View File

@@ -3,6 +3,7 @@ import {
type CreateReward,
DiscountConfigSchema,
ErrCode,
isFixedPrice,
type Organization,
type Price,
type Product,
@@ -17,7 +18,6 @@ import type { DrizzleCli } from "@/db/initDrizzle.js";
import RecaseError from "@/utils/errorUtils.js";
import { generateId, getUnique, nullish } from "@/utils/genUtils.js";
import { ProductService } from "../products/ProductService.js";
import { isFixedPrice } from "../products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js";
import { initProductInStripe } from "../products/productUtils.js";
export const constructReward = ({
@@ -248,7 +248,7 @@ export const discountAppliesToPrice = ({
if (nullish(appliesTo)) return true;
if (isFixedPrice({ price })) {
if (isFixedPrice(price)) {
return appliesTo!.some(
(stripeProdId) => stripeProdId === product.processor?.id,
);

View File

@@ -1,5 +1,7 @@
import { SQSClient } from "@aws-sdk/client-sqs";
const DEFAULT_AWS_REGION = "us-west-2";
/**
* Extracts the AWS region from a given SQS queue URL.
* Returns undefined if the URL is empty or invalid.
@@ -7,7 +9,7 @@ import { SQSClient } from "@aws-sdk/client-sqs";
export function extractRegionFromQueueUrl({
queueUrl,
}: {
queueUrl: string;
queueUrl: string | undefined;
}): string | undefined {
if (!queueUrl) return undefined;
// SQS URL format: https://sqs.<region>.amazonaws.com/<account>/<queueName>
@@ -19,11 +21,9 @@ export function extractRegionFromQueueUrl({
export const sqs = new SQSClient({
region:
process.env.NODE_ENV === "production"
? "us-west-2"
: extractRegionFromQueueUrl({
queueUrl: process.env.SQS_QUEUE_URL || "",
}),
extractRegionFromQueueUrl({
queueUrl: process.env.SQS_QUEUE_URL,
}) || DEFAULT_AWS_REGION,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID || "",
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || "",

View File

@@ -1,4 +1,7 @@
import { Hono } from "hono";
import { insightsRouter } from "@/internal/analytics/insightsRouter.js";
import { legacyAnalyticsRouter } from "@/internal/analytics/legacyAnalyticsRouter.js";
import { eventsRouter } from "@/internal/events/eventsRouter.js";
import { analyticsMiddleware } from "../honoMiddlewares/analyticsMiddleware.js";
import { apiVersionMiddleware } from "../honoMiddlewares/apiVersionMiddleware.js";
import { orgConfigMiddleware } from "../honoMiddlewares/orgConfigMiddleware.js";
@@ -52,3 +55,6 @@ apiRouter.route("/organization", honoOrgRouter);
apiRouter.route("/referrals", referralRouter);
apiRouter.route("/redemptions", redemptionRouter);
apiRouter.route("/insights", insightsRouter);
apiRouter.route("/query", legacyAnalyticsRouter);
apiRouter.route("/events", eventsRouter);

View File

@@ -8,6 +8,7 @@ import {
OnIncrease,
type Organization,
type Product,
priceToInvoiceAmount,
type UsagePriceConfig,
} from "@autumn/shared";
import { Decimal } from "decimal.js";
@@ -18,7 +19,6 @@ import { constructStripeInvoiceItem } from "@/internal/invoices/invoiceItemUtils
import { createAndFinalizeInvoice } from "@/internal/invoices/invoiceUtils/createAndFinalizeInvoice.js";
import { calculateProrationAmount } from "@/internal/invoices/prorationUtils.js";
import { getReplaceables } from "@/internal/products/prices/priceUtils/arrearProratedUtils/getContUsageDowngradeItem.js";
import { priceToInvoiceAmount } from "@/internal/products/prices/priceUtils/priceToInvoiceAmount.js";
import {
shouldProrate,
shouldProrateDowngradeNow,

View File

@@ -5,12 +5,12 @@ import {
OnIncrease,
type Organization,
type Price,
priceToInvoiceAmount,
type UsagePriceConfig,
} from "@autumn/shared";
import type Stripe from "stripe";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { RepService } from "@/internal/customers/cusProducts/cusEnts/RepService.js";
import { priceToInvoiceAmount } from "@/internal/products/prices/priceUtils/priceToInvoiceAmount.js";
import { shouldCreateInvoiceItem } from "@/internal/products/prices/priceUtils/prorationConfigUtils.js";
import { roundUsage } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js";
import { getUsageFromBalance } from "../adjustAllowance.js";

View File

@@ -9,6 +9,8 @@ import {
cusProductToProduct,
type FullCusProduct,
type FullCustomer,
isFixedPrice,
isOneOffPrice,
type Organization,
} from "@autumn/shared";
import type { DrizzleCli } from "@server/db/initDrizzle";
@@ -29,11 +31,7 @@ import {
getPriceEntitlement,
getPriceOptions,
} from "@server/internal/products/prices/priceUtils";
import {
isFixedPrice,
isOneOffPrice,
isPrepaidPrice,
} from "@server/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice";
import { isPrepaidPrice } from "@server/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice";
import { isFreeProduct } from "@server/internal/products/productUtils";
import type Stripe from "stripe";
import { formatUnixToDateTime, nullish } from "../genUtils";
@@ -150,7 +148,7 @@ const compareActualItems = async ({
return false;
});
if (isFixedPrice({ price: expectedItem.autumnPrice }) && !actualItem) {
if (isFixedPrice(expectedItem.autumnPrice) && !actualItem) {
actualItem = actualItems.find((item: any) => {
return item.stripeProdId === expectedItem.stripeProdId;
});
@@ -163,7 +161,6 @@ const compareActualItems = async ({
continue;
}
const { autumnPrice: _, ...rest } = expectedItem;
console.log(`(${type}) Missing item:`, rest);
@@ -255,7 +252,6 @@ const compareActualItems = async ({
}
};
export const checkCusSubCorrect = async ({
db,
fullCus,
@@ -271,8 +267,6 @@ export const checkCusSubCorrect = async ({
org: Organization;
env: AppEnv;
}) => {
// 1. Only 1 sub ID available
const cusProducts = fullCus.customer_products;
const subIds = cusProductToSubIds({ cusProducts });
@@ -395,7 +389,7 @@ export const checkCusSubCorrect = async ({
const addToSub = cusProduct.status !== CusProductStatus.Scheduled;
for (const price of prices) {
if (isOneOffPrice({ price })) continue;
if (isOneOffPrice(price)) continue;
const relatedEnt = getPriceEntitlement(price, ents);
const options = getPriceOptions(price, cusProduct.options);
@@ -489,7 +483,6 @@ export const checkCusSubCorrect = async ({
}
assert(!!sub, `Sub ${subId} should exist`);
if (sub) {
const actualItems = sub!.items.data.map((item: any) => ({
@@ -508,7 +501,6 @@ export const checkCusSubCorrect = async ({
subId,
});
}
// Should be canceled
@@ -540,8 +532,6 @@ export const checkCusSubCorrect = async ({
const finalShouldBeCanceled = cusSubShouldBeCanceled;
if (finalShouldBeCanceled) {
assert(!sub!.schedule, `sub ${subId} should NOT have a schedule`);
assert(subIsCanceled({ sub: sub! }), `sub ${subId} should be canceled`);
@@ -553,7 +543,6 @@ export const checkCusSubCorrect = async ({
? schedules.find((s) => s.id === sub!.schedule)
: null;
for (let i = 0; i < supposedPhases.length; i++) {
const supposedPhase = supposedPhases[i];

View File

@@ -1,7 +1,6 @@
import Stripe from "stripe";
import { type FullProduct, isFixedPrice } from "@autumn/shared";
import type Stripe from "stripe";
import { subItemToFixedPrice } from "@/internal/products/prices/priceUtils/constructPriceUtils.js";
import { isFixedPrice } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js";
import { FullProduct } from "@autumn/shared";
// Scenario 1: Replace base price with new base price
export const replaceBasePrice = async ({
@@ -13,7 +12,7 @@ export const replaceBasePrice = async ({
autumnProduct: FullProduct;
basePrice?: number;
}) => {
let prices = autumnProduct.prices.filter((p) => !isFixedPrice({ price: p }));
const prices = autumnProduct.prices.filter((p) => !isFixedPrice(p));
// Get first sub item
const subItem = subItems[0];

View File

@@ -1,96 +1,89 @@
import { beforeAll, describe } from "bun:test";
import { ApiVersion, FreeTrialDuration } from "@autumn/shared";
import { ApiVersion } 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 { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { constructFeatureItem } from "../../src/utils/scriptUtils/constructItem.js";
import { initCustomerV3 } from "../../src/utils/scriptUtils/testUtils/initCustomerV3.js";
import { initProductsV0 } from "../../src/utils/scriptUtils/testUtils/initProductsV0.js";
const pro = constructProduct({
type: "pro",
forcePaidDefault: true,
items: [
constructFeatureItem({
featureId: TestFeature.Messages,
includedUsage: 1000,
}),
],
freeTrial: {
card_required: false,
duration: FreeTrialDuration.Day,
length: 7,
unique_fingerprint: false,
},
});
const premium = constructProduct({
type: "premium",
const free = constructProduct({
type: "free",
isDefault: false,
items: [
constructFeatureItem({
featureId: TestFeature.Messages,
includedUsage: 1000,
includedUsage: 5,
}),
],
freeTrial: {
card_required: true,
duration: FreeTrialDuration.Day,
length: 7,
unique_fingerprint: false,
},
});
console.log("Pro is default", pro.is_default);
const pro = constructProduct({
type: "pro",
items: [
constructFeatureItem({
featureId: TestFeature.Messages,
includedUsage: 300,
}),
],
});
describe(`${chalk.yellowBright("temp: Testing entity prorated")}`, () => {
const customerId = "temp";
export const premium = constructProduct({
type: "premium",
items: [
constructFeatureItem({
featureId: TestFeature.Messages,
includedUsage: 100,
}),
],
});
const entity = {
id: "entity1",
name: "Entity 1",
feature_id: TestFeature.Messages,
};
describe(`${chalk.yellowBright("temp1: Testing pro product")}`, () => {
const customerId = "temp1";
const autumn: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
beforeAll(async () => {
await initCustomerV3({
ctx,
customerId,
withTestClock: true,
attachPm: "success",
});
await initProductsV0({
ctx,
products: [pro, premium],
products: [free, pro, premium],
prefix: customerId,
customerId,
// customerId,
});
// await initCustomerV3({
// ctx,
// customerId,
// customerData: {},
// attachPm: "success",
// withTestClock: true,
await autumn.entities.create(customerId, [entity]);
// await autumn.attach({
// customer_id: customerId,
// product_id: pro.id,
// entity_id: entity.id,
// });
// await autumn.attach({
// customer_id: customerId,
// product_id: free.id,
// entity_id: entity.id,
// });
// await autumn.attach({
// customer_id: customerId,
// product_id: premium.id,
// entity_id: entity.id,
// });
await autumn.customers.create({
id: customerId,
name: customerId,
});
});
// test("should create a subscription with prepaid and prorated", async () => {
// await autumn.attach({
// customer_id: customerId,
// product_id: oneOff2.id,
// });
// await autumn.products.update(oneOff2.id, {
// items: replaceItems({
// items: oneOff2.items,
// featureId: TestFeature.Messages,
// newItem: constructFeatureItem({
// featureId: TestFeature.Messages,
// includedUsage: 30,
// }),
// }),
// });
// await autumn.attach({
// customer_id: customerId,
// product_id: oneOff2.id,
// });
// });
});

View File

@@ -2,21 +2,21 @@ import { beforeAll, describe, expect, test } from "bun:test";
import {
BillingInterval,
ProductItemInterval,
priceToInvoiceAmount,
UsageModel,
} from "@autumn/shared";
import chalk from "chalk";
import { Decimal } from "decimal.js";
import type Stripe from "stripe";
import { TestFeature } from "@tests/setup/v2Features.js";
import { expectCustomerV0Correct } from "@tests/utils/expectUtils/expectCustomerV0Correct.js";
import { getSubsFromCusId } from "@tests/utils/expectUtils/expectSubUtils.js";
import { checkSubscriptionContainsProducts } from "@tests/utils/scheduleCheckUtils.js";
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
import { v1ProductToBasePrice } from "@tests/utils/testProductUtils/testProductUtils.js";
import chalk from "chalk";
import { Decimal } from "decimal.js";
import type Stripe from "stripe";
import { subToPeriodStartEnd } from "@/external/stripe/stripeSubUtils/convertSubUtils.js";
import { getCreditCost } from "@/internal/features/creditSystemUtils.js";
import { calculateProrationAmount } from "@/internal/invoices/prorationUtils.js";
import { priceToInvoiceAmount } from "@/internal/products/prices/priceUtils/priceToInvoiceAmount.js";
import { constructPriceItem } from "@/internal/products/product-items/productItemUtils.js";
import { convertProductV2ToV1 } from "@/internal/products/productUtils/productV2Utils/convertProductV2ToV1.js";
import { constructRawProduct } from "@/utils/scriptUtils/createTestProducts.js";

View File

@@ -0,0 +1,59 @@
import { beforeAll, describe, test } from "bun:test";
import { LegacyVersion } 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 } from "@/utils/scriptUtils/constructItem.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
const testCase = "attach-misc2";
const pro = constructProduct({
items: [
constructArrearItem({
featureId: TestFeature.Words,
includedUsage: 1000,
}),
],
type: "pro",
});
describe(`${chalk.yellowBright(`${testCase}: Testing attach race condition`)}`, () => {
const customerId = testCase;
const entityId = "entity-1";
const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 });
beforeAll(async () => {
// Delete customer if exists
try {
await autumn.customers.delete(customerId);
} catch {
// Ignore if customer doesn't exist
}
// Initialize products
await initProductsV0({
ctx,
products: [pro],
prefix: testCase,
});
});
test("should auto-create customer and entity when calling attach", async () => {
// Attach with customer_data and entity_data to auto-create both
const responses = await Promise.allSettled([
autumn.attach({
customer_id: customerId,
product_id: pro.id,
}),
autumn.attach({
customer_id: customerId,
product_id: pro.id,
}),
]);
console.log("Responses:", responses);
});
});

View File

@@ -4,12 +4,12 @@ import {
FreeTrialDuration,
ProductItemInterval,
} from "@autumn/shared";
import chalk from "chalk";
import { addDays } from "date-fns";
import type Stripe from "stripe";
import { TestFeature } from "@tests/setup/v2Features.js";
import { expectCustomerV0Correct } from "@tests/utils/expectUtils/expectCustomerV0Correct.js";
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
import chalk from "chalk";
import { addDays } from "date-fns";
import type Stripe from "stripe";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { constructPriceItem } from "@/internal/products/product-items/productItemUtils.js";
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";

View File

@@ -4,9 +4,9 @@ import {
FreeTrialDuration,
ProductItemInterval,
} 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 { constructPriceItem } from "@/internal/products/product-items/productItemUtils.js";
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";

View File

@@ -1,19 +1,13 @@
import { beforeAll, describe, expect, test } from "bun:test";
import {
type ApiCusFeatureV3,
ApiVersion,
type LimitedItem,
} from "@autumn/shared";
import { type ApiCustomer, ApiVersion, 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 { Decimal } from "decimal.js";
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 { timeout } from "../../utils/genUtils.js";
const messagesFeature = constructFeatureItem({
featureId: TestFeature.Messages,
@@ -30,11 +24,8 @@ const testCase = "balances-update1";
describe(`${chalk.yellowBright("balances-update1: testing update balance after track (metered feature)")}`, () => {
const customerId = testCase;
const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
const autumnV2: AutumnInt = new AutumnInt({ version: ApiVersion.V2_0 });
const usage = 20.132;
beforeAll(async () => {
await initCustomerV3({
ctx,
@@ -48,75 +39,27 @@ describe(`${chalk.yellowBright("balances-update1: testing update balance after t
prefix: testCase,
});
await autumnV1.attach({
await autumnV2.attach({
customer_id: customerId,
product_id: freeProd.id,
});
});
const usageAmount = 20.132;
test("should track usage and have correct v1 / v2 api cus feature", async () => {
await autumnV2.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: usageAmount,
});
await timeout(2000);
const customer = await autumnV2.customers.get(customerId);
const feature = customer.features[TestFeature.Messages] as any;
const currentBalance = new Decimal(messagesFeature.included_usage)
.minus(usage)
.toNumber();
expect(feature).toMatchObject({
granted_balance: messagesFeature.included_usage,
current_balance: currentBalance,
usage,
purchased_balance: 0,
});
const customerV1 = await autumnV1.customers.get(customerId);
const featureV1 = customerV1.features[
TestFeature.Messages
] as unknown as ApiCusFeatureV3;
expect(featureV1).toMatchObject({
included_usage: messagesFeature.included_usage,
balance: currentBalance,
usage,
});
expect(featureV1.next_reset_at).toBeDefined();
});
test("should update balance and have correct v1/v2 api cus feature", async () => {
// Restore back to original granted balance (new granted is 20.132)
await autumnV2.balances.update({
customer_id: customerId,
feature_id: TestFeature.Messages,
current_balance: messagesFeature.included_usage,
current_balance: messagesFeature.included_usage + 140,
});
await timeout(2000);
const customerV2 = await autumnV2.customers.get<ApiCustomer>(customerId);
const balance = customerV2.balances[TestFeature.Messages];
const customer = await autumnV2.customers.get(customerId);
const feature = customer.features[TestFeature.Messages];
expect(feature).toMatchObject({
granted_balance: messagesFeature.included_usage + usageAmount,
current_balance: messagesFeature.included_usage,
usage: usageAmount,
expect(balance).toMatchObject({
granted_balance: messagesFeature.included_usage + 140,
current_balance: messagesFeature.included_usage + 140,
usage: 0,
purchased_balance: 0,
});
const customerV1 = await autumnV1.customers.get(customerId);
const featureV1 = customerV1.features[TestFeature.Messages];
expect(featureV1).toMatchObject({
included_usage: messagesFeature.included_usage + usageAmount,
balance: messagesFeature.included_usage,
usage: usageAmount,
});
});
});

View File

@@ -1,39 +1,40 @@
import { beforeAll, describe, expect, test } from "bun:test";
import {
type ApiCusFeatureV3,
type ApiCustomer,
ApiVersion,
type LimitedItem,
ResetInterval,
} 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 { AutumnInt } from "@/external/autumn/autumnCli.js";
import { constructArrearItem } from "@/utils/scriptUtils/constructItem.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 { timeout } from "../../utils/genUtils.js";
const messagesFeature = constructArrearItem({
const monthlyMsges = constructFeatureItem({
featureId: TestFeature.Messages,
price: 0.5,
billingUnits: 1,
includedUsage: 1000,
includedUsage: 100,
}) as LimitedItem;
const lifetimeMsges = constructFeatureItem({
featureId: TestFeature.Messages,
includedUsage: 50,
interval: null,
}) as LimitedItem;
const freeProd = constructProduct({
type: "free",
id: "usage-based",
isDefault: false,
items: [messagesFeature],
items: [lifetimeMsges, monthlyMsges],
});
const testCase = "balances-update2";
describe(`${chalk.yellowBright("balances-update2: testing update balance after tracking into overage")}`, () => {
describe(`${chalk.yellowBright("balances-update2: testing update balance after track (metered feature)")}`, () => {
const customerId = testCase;
const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
const autumnV2: AutumnInt = new AutumnInt({ version: ApiVersion.V2_0 });
beforeAll(async () => {
@@ -41,7 +42,6 @@ describe(`${chalk.yellowBright("balances-update2: testing update balance after t
ctx,
customerId,
withTestClock: false,
attachPm: "success",
});
await initProductsV0({
@@ -50,102 +50,91 @@ describe(`${chalk.yellowBright("balances-update2: testing update balance after t
prefix: testCase,
});
await autumnV1.attach({
await autumnV2.attach({
customer_id: customerId,
product_id: freeProd.id,
});
});
const usageAmount = 20.132;
test("should track usage and have correct v1 / v2 api cus feature", async () => {
await autumnV2.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: usageAmount,
});
await timeout(2000);
const customer = await autumnV2.customers.get(customerId);
const feature = customer.features[TestFeature.Messages];
expect(feature).toMatchObject({
granted_balance: messagesFeature.included_usage,
purchased_balance: 0,
current_balance: messagesFeature.included_usage - usageAmount,
usage: usageAmount,
});
const customerV1 = await autumnV1.customers.get(customerId);
const featureV1 = customerV1.features[TestFeature.Messages];
expect(featureV1).toMatchObject({
included_usage: messagesFeature.included_usage,
balance: messagesFeature.included_usage - usageAmount,
usage: usageAmount,
});
});
const usageAmount2 = 1231.131;
const totalUsage = new Decimal(usageAmount).add(usageAmount2).toNumber();
test("should track into overage and have correct v1 / v2 api cus feature", async () => {
await autumnV2.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: usageAmount2,
});
await timeout(2000);
const customer = await autumnV2.customers.get(customerId);
const feature = customer.features[TestFeature.Messages];
expect(feature).toMatchObject({
granted_balance: messagesFeature.included_usage,
purchased_balance: new Decimal(totalUsage)
.sub(messagesFeature.included_usage)
.toNumber(),
current_balance: 0,
usage: totalUsage,
});
const customerV1 = await autumnV1.customers.get(customerId);
const featureV1 = customerV1.features[
TestFeature.Messages
] as unknown as ApiCusFeatureV3;
expect(featureV1).toMatchObject({
included_usage: messagesFeature.included_usage,
balance: new Decimal(messagesFeature.included_usage)
.sub(new Decimal(totalUsage))
.toNumber(),
usage: totalUsage,
});
});
return;
const newBalance = 300;
test("should update balance and have correct v1 / v2 api cus feature", async () => {
test("should update balance and have correct v2 api balance for one off interval", async () => {
await autumnV2.balances.update({
customer_id: customerId,
feature_id: TestFeature.Messages,
current_balance: newBalance,
current_balance: lifetimeMsges.included_usage + 140,
interval: ResetInterval.OneOff,
});
await timeout(2000);
const customerV2 = await autumnV2.customers.get<ApiCustomer>(customerId);
const balance = customerV2.balances[TestFeature.Messages];
const customer = await autumnV2.customers.get(customerId);
const feature = customer.features[TestFeature.Messages];
expect(balance).toMatchObject({
granted_balance:
monthlyMsges.included_usage + lifetimeMsges.included_usage + 140,
current_balance:
monthlyMsges.included_usage + lifetimeMsges.included_usage + 140,
usage: 0,
purchased_balance: 0,
});
expect(feature).toMatchObject({
granted_balance: messagesFeature.included_usage,
const lifetimeBreakdown = balance.breakdown?.find(
(b) => b.reset?.interval === ResetInterval.OneOff,
);
expect(lifetimeBreakdown).toMatchObject({
granted_balance: lifetimeMsges.included_usage + 140,
current_balance: lifetimeMsges.included_usage + 140,
purchased_balance: 0,
usage: 0,
});
purchased_balance: new Decimal(totalUsage)
.sub(messagesFeature.included_usage)
.toNumber(),
const monthlyBreakdown = balance.breakdown?.find(
(b) => b.reset?.interval === ResetInterval.Month,
);
expect(monthlyBreakdown).toMatchObject({
granted_balance: monthlyMsges.included_usage,
current_balance: monthlyMsges.included_usage,
purchased_balance: 0,
usage: 0,
});
});
current_balance: newBalance,
usage: totalUsage,
test("should update balance and have correct v2 api balance for one off interval", async () => {
await autumnV2.balances.update({
customer_id: customerId,
feature_id: TestFeature.Messages,
current_balance: monthlyMsges.included_usage + 120,
interval: ResetInterval.Month,
});
const customerV2 = await autumnV2.customers.get<ApiCustomer>(customerId);
const balance = customerV2.balances[TestFeature.Messages];
expect(balance).toMatchObject({
granted_balance:
monthlyMsges.included_usage + lifetimeMsges.included_usage + 140 + 120,
current_balance:
monthlyMsges.included_usage + lifetimeMsges.included_usage + 140 + 120,
usage: 0,
purchased_balance: 0,
});
const lifetimeBreakdown = balance.breakdown?.find(
(b) => b.reset?.interval === ResetInterval.OneOff,
);
expect(lifetimeBreakdown).toMatchObject({
granted_balance: lifetimeMsges.included_usage + 140,
current_balance: lifetimeMsges.included_usage + 140,
purchased_balance: 0,
usage: 0,
});
const monthlyBreakdown = balance.breakdown?.find(
(b) => b.reset?.interval === ResetInterval.Month,
);
expect(monthlyBreakdown).toMatchObject({
granted_balance: monthlyMsges.included_usage + 120,
current_balance: monthlyMsges.included_usage + 120,
purchased_balance: 0,
usage: 0,
});
});
});

View File

@@ -1,280 +1,280 @@
import { beforeAll, describe, expect, test } from "bun:test";
import { type ApiCustomer, ApiVersion, 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 { CusService } from "@/internal/customers/CusService.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 { timeout } from "../../utils/genUtils.js";
// import { beforeAll, describe, expect, test } from "bun:test";
// import { type ApiCustomer, ApiVersion, 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 { CusService } from "@/internal/customers/CusService.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 { timeout } from "../../utils/genUtils.js";
const usersFeature = constructArrearItem({
featureId: TestFeature.Users,
price: 10,
billingUnits: 1,
includedUsage: 0,
}) as LimitedItem;
// const usersFeature = constructArrearItem({
// featureId: TestFeature.Users,
// price: 10,
// billingUnits: 1,
// includedUsage: 0,
// }) as LimitedItem;
const freeProd = constructProduct({
type: "free",
id: "pay-per-use-users-balance-update3",
isDefault: false,
items: [usersFeature],
});
// const freeProd = constructProduct({
// type: "free",
// id: "pay-per-use-users-balance-update3",
// isDefault: false,
// items: [usersFeature],
// });
const testCase = "balances-update3";
// const testCase = "balances-update3";
describe(`${chalk.yellowBright("balances-update3: Balance decoupling tests")}`, () => {
const customerId = testCase;
const autumnV2: AutumnInt = new AutumnInt({ version: ApiVersion.V2_0 });
// describe(`${chalk.yellowBright("balances-update3: Balance decoupling tests")}`, () => {
// const customerId = testCase;
// const autumnV2: AutumnInt = new AutumnInt({ version: ApiVersion.V2_0 });
const getRawCusEnt = async () => {
const fullCus = await CusService.getFull({
db: ctx.db,
idOrInternalId: customerId,
orgId: ctx.org.id,
env: ctx.env,
});
// const getRawCusEnt = async () => {
// const fullCus = await CusService.getFull({
// db: ctx.db,
// idOrInternalId: customerId,
// orgId: ctx.org.id,
// env: ctx.env,
// });
return fullCus.customer_products
.flatMap((cp) => cp.customer_entitlements)
.find(
(ce) =>
ce.internal_feature_id ===
ctx.features.find((f) => f.id === TestFeature.Users)?.internal_id,
);
};
// return fullCus.customer_products
// .flatMap((cp) => cp.customer_entitlements)
// .find(
// (ce) =>
// ce.internal_feature_id ===
// ctx.features.find((f) => f.id === TestFeature.Users)?.internal_id,
// );
// };
const logState = async (label: string) => {
const cusEnt = await getRawCusEnt();
const customer = (await autumnV2.customers.get(
customerId,
)) as unknown as ApiCustomer;
const balance = customer.balances[TestFeature.Users];
// const logState = async (label: string) => {
// const cusEnt = await getRawCusEnt();
// const customer = (await autumnV2.customers.get(
// customerId,
// )) as unknown as ApiCustomer;
// const balance = customer.balances[TestFeature.Users];
console.log(`\n=== ${label} ===`);
console.log("DB:", {
bal: cusEnt?.balance,
add_bal: cusEnt?.additional_balance,
add_grant: cusEnt?.adjustment,
});
console.log("API:", {
granted: balance.granted_balance,
purchased: balance.purchased_balance,
current: balance.current_balance,
usage: balance.usage,
});
};
// console.log(`\n=== ${label} ===`);
// console.log("DB:", {
// bal: cusEnt?.balance,
// add_bal: cusEnt?.additional_balance,
// add_grant: cusEnt?.adjustment,
// });
// console.log("API:", {
// granted: balance.granted_balance,
// purchased: balance.purchased_balance,
// current: balance.current_balance,
// usage: balance.usage,
// });
// };
beforeAll(async () => {
await initCustomerV3({
ctx,
customerId,
withTestClock: false,
attachPm: "success",
});
// beforeAll(async () => {
// await initCustomerV3({
// ctx,
// customerId,
// withTestClock: false,
// attachPm: "success",
// });
await initProductsV0({
ctx,
products: [freeProd],
prefix: testCase,
});
// await initProductsV0({
// ctx,
// products: [freeProd],
// prefix: testCase,
// });
await autumnV2.attach({
customer_id: customerId,
product_id: freeProd.id,
});
// await autumnV2.attach({
// customer_id: customerId,
// product_id: freeProd.id,
// });
await timeout(1000);
// await timeout(1000);
await logState("INITIAL STATE");
});
// await logState("INITIAL STATE");
// });
test("CASE A: balances.update ADD balance", async () => {
// Setup: balance=0, add_bal=0, add_grant=0
// Update to 10: diff=+10
// Expected: balance=0, add_bal=10, add_grant=10
// test("CASE A: balances.update ADD balance", async () => {
// // Setup: balance=0, add_bal=0, add_grant=0
// // Update to 10: diff=+10
// // Expected: balance=0, add_bal=10, add_grant=10
await autumnV2.balances.update({
customer_id: customerId,
feature_id: TestFeature.Users,
current_balance: 10,
});
// await autumnV2.balances.update({
// customer_id: customerId,
// feature_id: TestFeature.Users,
// current_balance: 10,
// });
await timeout(2000);
// await timeout(2000);
await logState("After CASE A");
// await logState("After CASE A");
const cusEnt = await getRawCusEnt();
expect(cusEnt?.balance).toBe(0); // Unchanged
expect(cusEnt?.additional_balance).toBe(10); // Added
expect(cusEnt?.adjustment).toBe(10); // Added
// const cusEnt = await getRawCusEnt();
// expect(cusEnt?.balance).toBe(0); // Unchanged
// expect(cusEnt?.additional_balance).toBe(10); // Added
// expect(cusEnt?.adjustment).toBe(10); // Added
const customer = await autumnV2.customers.get<ApiCustomer>(customerId);
const balance = customer.balances[TestFeature.Users];
// current = 0 + 0 + 10 = 10
expect(balance.current_balance).toBe(10);
expect(balance.granted_balance).toBe(10);
});
// const customer = await autumnV2.customers.get<ApiCustomer>(customerId);
// const balance = customer.balances[TestFeature.Users];
// // current = 0 + 0 + 10 = 10
// expect(balance.current_balance).toBe(10);
// expect(balance.granted_balance).toBe(10);
// });
test("CASE B: balances.update REMOVE with sufficient additional_balance", async () => {
// Setup: balance=0, add_bal=10, add_grant=10, current=10
// Update to 5: diff=-5
// Deduct 5 from add_bal → add_bal=5, balance=0
// Expected: balance=0, add_bal=5, add_grant=5
// test("CASE B: balances.update REMOVE with sufficient additional_balance", async () => {
// // Setup: balance=0, add_bal=10, add_grant=10, current=10
// // Update to 5: diff=-5
// // Deduct 5 from add_bal → add_bal=5, balance=0
// // Expected: balance=0, add_bal=5, add_grant=5
await autumnV2.balances.update({
customer_id: customerId,
feature_id: TestFeature.Users,
current_balance: 5,
});
// await autumnV2.balances.update({
// customer_id: customerId,
// feature_id: TestFeature.Users,
// current_balance: 5,
// });
await timeout(2000);
// await timeout(2000);
await logState("After CASE B");
// await logState("After CASE B");
const cusEnt = await getRawCusEnt();
expect(cusEnt?.balance).toBe(0); // Unchanged (all came from add_bal)
expect(cusEnt?.additional_balance).toBe(5); // 10 - 5
expect(cusEnt?.adjustment).toBe(5); // 10 - 5
// const cusEnt = await getRawCusEnt();
// expect(cusEnt?.balance).toBe(0); // Unchanged (all came from add_bal)
// expect(cusEnt?.additional_balance).toBe(5); // 10 - 5
// expect(cusEnt?.adjustment).toBe(5); // 10 - 5
const customer = await autumnV2.customers.get<ApiCustomer>(customerId);
const balance = customer.balances[TestFeature.Users];
// const customer = await autumnV2.customers.get<ApiCustomer>(customerId);
// const balance = customer.balances[TestFeature.Users];
expect(balance.current_balance).toBe(5);
});
// expect(balance.current_balance).toBe(5);
// });
test("CASE C: balances.update REMOVE with insufficient additional_balance", async () => {
// Setup: balance=0, add_bal=5, add_grant=5, current=5
// Update to 0: diff=-5
// Deduct 5 from add_bal → add_bal=0, remaining=0, balance=0
// Expected: balance=0, add_bal=0, add_grant=0
// test("CASE C: balances.update REMOVE with insufficient additional_balance", async () => {
// // Setup: balance=0, add_bal=5, add_grant=5, current=5
// // Update to 0: diff=-5
// // Deduct 5 from add_bal → add_bal=0, remaining=0, balance=0
// // Expected: balance=0, add_bal=0, add_grant=0
await autumnV2.balances.update({
customer_id: customerId,
feature_id: TestFeature.Users,
current_balance: 0,
});
// await autumnV2.balances.update({
// customer_id: customerId,
// feature_id: TestFeature.Users,
// current_balance: 0,
// });
await timeout(2000);
// await timeout(2000);
await logState("After CASE C");
// await logState("After CASE C");
const cusEnt = await getRawCusEnt();
expect(cusEnt?.balance).toBe(0);
expect(cusEnt?.additional_balance).toBe(0); // Floored
expect(cusEnt?.adjustment).toBe(0); // 5 - 5
// const cusEnt = await getRawCusEnt();
// expect(cusEnt?.balance).toBe(0);
// expect(cusEnt?.additional_balance).toBe(0); // Floored
// expect(cusEnt?.adjustment).toBe(0); // 5 - 5
const customer = await autumnV2.customers.get<ApiCustomer>(customerId);
const balance = customer.balances[TestFeature.Users];
expect(balance.current_balance).toBe(0);
});
// const customer = await autumnV2.customers.get<ApiCustomer>(customerId);
// const balance = customer.balances[TestFeature.Users];
// expect(balance.current_balance).toBe(0);
// });
test("Track +5 then update REMOVE to trigger main balance deduction", async () => {
// Track +5 to create main balance
await autumnV2.track({
customer_id: customerId,
feature_id: TestFeature.Users,
value: 5,
});
// test("Track +5 then update REMOVE to trigger main balance deduction", async () => {
// // Track +5 to create main balance
// await autumnV2.track({
// customer_id: customerId,
// feature_id: TestFeature.Users,
// value: 5,
// });
await timeout(2000);
// await timeout(2000);
let cusEnt = await getRawCusEnt();
expect(cusEnt?.balance).toBe(-5); // Overage
// let cusEnt = await getRawCusEnt();
// expect(cusEnt?.balance).toBe(-5); // Overage
// Now update to add balance
await autumnV2.balances.update({
customer_id: customerId,
feature_id: TestFeature.Users,
current_balance: 10,
});
// // Now update to add balance
// await autumnV2.balances.update({
// customer_id: customerId,
// feature_id: TestFeature.Users,
// current_balance: 10,
// });
await timeout(2000);
// await timeout(2000);
await logState("After track +5 then update to 10");
// await logState("After track +5 then update to 10");
cusEnt = await getRawCusEnt();
// computed_current = Math.max(0, -5) + 0 = 0
// diff = 10 - 0 = +10
// add_bal = 0 + 10 = 10, add_grant = 0 + 10 = 10, balance = -5
expect(cusEnt?.balance).toBe(-5); // Unchanged
expect(cusEnt?.additional_balance).toBe(10);
expect(cusEnt?.adjustment).toBe(10);
// cusEnt = await getRawCusEnt();
// // computed_current = Math.max(0, -5) + 0 = 0
// // diff = 10 - 0 = +10
// // add_bal = 0 + 10 = 10, add_grant = 0 + 10 = 10, balance = -5
// expect(cusEnt?.balance).toBe(-5); // Unchanged
// expect(cusEnt?.additional_balance).toBe(10);
// expect(cusEnt?.adjustment).toBe(10);
// Now remove: update to 3
// current = Math.max(0, -5) + 10 = 10
// diff = 3 - 10 = -7
// Deduct 7: 7 from add_bal → add_bal=3, balance=-5
await autumnV2.balances.update({
customer_id: customerId,
feature_id: TestFeature.Users,
current_balance: 3,
});
// // Now remove: update to 3
// // current = Math.max(0, -5) + 10 = 10
// // diff = 3 - 10 = -7
// // Deduct 7: 7 from add_bal → add_bal=3, balance=-5
// await autumnV2.balances.update({
// customer_id: customerId,
// feature_id: TestFeature.Users,
// current_balance: 3,
// });
await timeout(2000);
// await timeout(2000);
await logState("After update to 3");
// await logState("After update to 3");
cusEnt = await getRawCusEnt();
expect(cusEnt?.balance).toBe(-5); // Unchanged
expect(cusEnt?.additional_balance).toBe(3); // 10 - 7
expect(cusEnt?.adjustment).toBe(3); // 10 - 7
});
// cusEnt = await getRawCusEnt();
// expect(cusEnt?.balance).toBe(-5); // Unchanged
// expect(cusEnt?.additional_balance).toBe(3); // 10 - 7
// expect(cusEnt?.adjustment).toBe(3); // 10 - 7
// });
test("CASE D: balances.update from negative to positive preserves paid credits", async () => {
// Setup: balance=-5, add_bal=3, add_grant=3
// Update to 0:
// current = Math.max(0,-5) + 3 = 3
// diff = 0 - 3 = -3
// Deduct 3 from add_bal → add_bal=0, balance=-5
// Result: current = Math.max(0,-5) + 0 = 0 ✅
// test("CASE D: balances.update from negative to positive preserves paid credits", async () => {
// // Setup: balance=-5, add_bal=3, add_grant=3
// // Update to 0:
// // current = Math.max(0,-5) + 3 = 3
// // diff = 0 - 3 = -3
// // Deduct 3 from add_bal → add_bal=0, balance=-5
// // Result: current = Math.max(0,-5) + 0 = 0 ✅
await autumnV2.balances.update({
customer_id: customerId,
feature_id: TestFeature.Users,
current_balance: 0,
});
// await autumnV2.balances.update({
// customer_id: customerId,
// feature_id: TestFeature.Users,
// current_balance: 0,
// });
await timeout(2000);
// await timeout(2000);
await logState("After CASE D");
// await logState("After CASE D");
const cusEnt = await getRawCusEnt();
expect(cusEnt?.balance).toBe(-5); // Unchanged (still in debt)
expect(cusEnt?.additional_balance).toBe(0); // 3 - 3
expect(cusEnt?.adjustment).toBe(0); // 3 - 3
});
// const cusEnt = await getRawCusEnt();
// expect(cusEnt?.balance).toBe(-5); // Unchanged (still in debt)
// expect(cusEnt?.additional_balance).toBe(0); // 3 - 3
// expect(cusEnt?.adjustment).toBe(0); // 3 - 3
// });
test("CASE E: Track negative to fully return debt", async () => {
// Setup: balance=-5, add_bal=0, add_grant=0 (from CASE D)
// Track -5: adds 5 to main balance
// Expected: balance=0, add_bal=0, add_grant=0
// test("CASE E: Track negative to fully return debt", async () => {
// // Setup: balance=-5, add_bal=0, add_grant=0 (from CASE D)
// // Track -5: adds 5 to main balance
// // Expected: balance=0, add_bal=0, add_grant=0
await autumnV2.track({
customer_id: customerId,
feature_id: TestFeature.Users,
value: -5,
});
// await autumnV2.track({
// customer_id: customerId,
// feature_id: TestFeature.Users,
// value: -5,
// });
await timeout(2000);
// await timeout(2000);
await logState("After CASE E");
// await logState("After CASE E");
const cusEnt = await getRawCusEnt();
expect(cusEnt?.balance).toBe(0); // -5 + 5 = 0
expect(cusEnt?.additional_balance).toBe(0); // Unchanged
expect(cusEnt?.adjustment).toBe(0); // Unchanged
// const cusEnt = await getRawCusEnt();
// expect(cusEnt?.balance).toBe(0); // -5 + 5 = 0
// expect(cusEnt?.additional_balance).toBe(0); // Unchanged
// expect(cusEnt?.adjustment).toBe(0); // Unchanged
const customer = await autumnV2.customers.get<ApiCustomer>(customerId);
const balance = customer.balances[TestFeature.Users];
// const customer = await autumnV2.customers.get<ApiCustomer>(customerId);
// const balance = customer.balances[TestFeature.Users];
expect(balance.current_balance).toBe(0);
expect(balance.purchased_balance).toBe(0);
expect(balance.granted_balance).toBe(0);
expect(balance.usage).toBe(0);
});
});
// expect(balance.current_balance).toBe(0);
// expect(balance.purchased_balance).toBe(0);
// expect(balance.granted_balance).toBe(0);
// expect(balance.usage).toBe(0);
// });
// });

View File

@@ -1,77 +1,77 @@
import { beforeAll, describe, test } from "bun:test";
import { ApiVersion } 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 {
constructArrearProratedItem,
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 { beforeAll, describe, test } from "bun:test";
// import { ApiVersion } 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 {
// constructArrearProratedItem,
// 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";
const premiumProd = constructProduct({
type: "premium",
isDefault: false,
items: [
constructFeatureItem({
featureId: TestFeature.Messages,
includedUsage: 300,
}),
// const premiumProd = constructProduct({
// type: "premium",
// isDefault: false,
// items: [
// constructFeatureItem({
// featureId: TestFeature.Messages,
// includedUsage: 300,
// }),
constructArrearProratedItem({
featureId: TestFeature.Users,
includedUsage: 1,
pricePerUnit: 10,
}),
],
});
// constructArrearProratedItem({
// featureId: TestFeature.Users,
// includedUsage: 1,
// pricePerUnit: 10,
// }),
// ],
// });
const testCase = "temp";
// const testCase = "temp";
describe(`${chalk.yellowBright("balances-update4: update balance for paid allocated")}`, () => {
const customerId = "balances-update4";
const autumnV0: AutumnInt = new AutumnInt({ version: ApiVersion.V0_2 });
const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
// describe(`${chalk.yellowBright("balances-update4: update balance for paid allocated")}`, () => {
// const customerId = "balances-update4";
// const autumnV0: AutumnInt = new AutumnInt({ version: ApiVersion.V0_2 });
// const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
beforeAll(async () => {
await initCustomerV3({
ctx,
customerId,
withTestClock: false,
attachPm: "success",
});
// beforeAll(async () => {
// await initCustomerV3({
// ctx,
// customerId,
// withTestClock: false,
// attachPm: "success",
// });
await initProductsV0({
ctx,
products: [premiumProd],
prefix: testCase,
});
// await initProductsV0({
// ctx,
// products: [premiumProd],
// prefix: testCase,
// });
await autumnV1.attach({
customer_id: customerId,
product_id: premiumProd.id,
});
});
// await autumnV1.attach({
// customer_id: customerId,
// product_id: premiumProd.id,
// });
// });
test("should have correct v1 response", async () => {
// await autumnV1.balances.update({
// customer_id: customerId,
// feature_id: TestFeature.Users,
// current_balance: 4
// });
});
// test("should have correct v1 response", async () => {
// // await autumnV1.balances.update({
// // customer_id: customerId,
// // feature_id: TestFeature.Users,
// // current_balance: 4
// // });
// });
// await autumnV1.track({
// customer_id: customerId,
// feature_id: TestFeature.Users,
// value: -2,
// });
// await autumnV1.track({
// customer_id: customerId,
// feature_id: TestFeature.Users,
// value: -1,
// });
});
// // await autumnV1.track({
// // customer_id: customerId,
// // feature_id: TestFeature.Users,
// // value: -2,
// // });
// // await autumnV1.track({
// // customer_id: customerId,
// // feature_id: TestFeature.Users,
// // value: -1,
// // });
// });

View File

@@ -0,0 +1,66 @@
import { beforeAll, describe, expect, test } from "bun:test";
import { type ApiCustomer, ApiVersion, 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 { 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";
const messagesFeature = constructFeatureItem({
featureId: TestFeature.Messages,
includedUsage: 100,
}) as LimitedItem;
const freeProd = constructProduct({
type: "free",
isDefault: false,
items: [messagesFeature],
});
const testCase = "update-granted-balance1";
describe(`${chalk.yellowBright("update-granted-balance1: testing update granted balance")}`, () => {
const customerId = testCase;
const autumnV2: AutumnInt = new AutumnInt({ version: ApiVersion.V2_0 });
beforeAll(async () => {
await initCustomerV3({
ctx,
customerId,
withTestClock: false,
});
await initProductsV0({
ctx,
products: [freeProd],
prefix: testCase,
});
await autumnV2.attach({
customer_id: customerId,
product_id: freeProd.id,
});
});
test("should update granted balance to 150", async () => {
await autumnV2.balances.update({
customer_id: customerId,
feature_id: TestFeature.Messages,
current_balance: 100,
granted_balance: 150,
});
const customerV2 = await autumnV2.customers.get<ApiCustomer>(customerId);
const balance = customerV2.balances[TestFeature.Messages];
expect(balance).toMatchObject({
granted_balance: 150,
current_balance: 100,
usage: 50,
purchased_balance: 0,
});
});
});

View File

@@ -0,0 +1,93 @@
import { beforeAll, describe, expect, test } from "bun:test";
import {
type ApiCustomer,
ApiVersion,
type LimitedItem,
ResetInterval,
} 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 { 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";
const monthlyMsges = constructFeatureItem({
featureId: TestFeature.Messages,
includedUsage: 100,
}) as LimitedItem;
const lifetimeMsges = constructFeatureItem({
featureId: TestFeature.Messages,
includedUsage: 50,
interval: null,
}) as LimitedItem;
const freeProd = constructProduct({
type: "free",
isDefault: false,
items: [monthlyMsges, lifetimeMsges],
});
const testCase = "update-granted-balance2";
describe(`${chalk.yellowBright("update-granted-balance2: testing update granted balance when there's a breakdown")}`, () => {
const customerId = testCase;
const autumnV2: AutumnInt = new AutumnInt({ version: ApiVersion.V2_0 });
beforeAll(async () => {
await initCustomerV3({
ctx,
customerId,
withTestClock: false,
});
await initProductsV0({
ctx,
products: [freeProd],
prefix: testCase,
});
await autumnV2.attach({
customer_id: customerId,
product_id: freeProd.id,
});
});
test("should update granted balance to 150 for monthly feature", async () => {
await autumnV2.balances.update({
customer_id: customerId,
feature_id: TestFeature.Messages,
current_balance: 50,
granted_balance: 75,
interval: ResetInterval.Month,
});
const customerV2 = await autumnV2.customers.get<ApiCustomer>(customerId);
const balance = customerV2.balances[TestFeature.Messages];
const monthlyBreakdown = balance.breakdown?.find(
(b) => b.reset?.interval === ResetInterval.Month,
);
const lifetimeBreakdown = balance.breakdown?.find(
(b) => b.reset?.interval === ResetInterval.OneOff,
);
expect(monthlyBreakdown).toMatchObject({
granted_balance: 75,
current_balance: 50,
usage: 25,
purchased_balance: 0,
});
expect(lifetimeBreakdown).toMatchObject({
granted_balance: 50,
current_balance: 50,
usage: 0,
purchased_balance: 0,
});
});
});

View File

@@ -0,0 +1,124 @@
import { beforeAll, describe, expect, test } from "bun:test";
import { ApiVersion, type LimitedItem, ResetInterval } 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 { 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";
const monthlyMsges = constructFeatureItem({
featureId: TestFeature.Messages,
includedUsage: 100,
entityFeatureId: TestFeature.Users,
}) as LimitedItem;
const freeProd = constructProduct({
type: "free",
isDefault: false,
items: [monthlyMsges],
});
const entities = [
{
id: "update-granted-balance3-user-1",
name: "User 1",
feature_id: TestFeature.Users,
},
{
id: "update-granted-balance3-user-2",
name: "User 2",
feature_id: TestFeature.Users,
},
];
const testCase = "update-granted-balance3";
describe(`${chalk.yellowBright("update-granted-balance3: testing update granted balance on entity balances (targetting entity)")}`, () => {
const customerId = testCase;
const autumnV2: AutumnInt = new AutumnInt({ version: ApiVersion.V2_0 });
beforeAll(async () => {
await initCustomerV3({
ctx,
customerId,
withTestClock: false,
});
await initProductsV0({
ctx,
products: [freeProd],
prefix: testCase,
});
await autumnV2.entities.create(customerId, entities);
await autumnV2.attach({
customer_id: customerId,
product_id: freeProd.id,
});
});
test("should update granted balance to 75 for monthly feature on entity balance, entity 1", async () => {
await autumnV2.balances.update({
customer_id: customerId,
feature_id: TestFeature.Messages,
entity_id: entities[0].id,
current_balance: 50,
granted_balance: 75,
interval: ResetInterval.Month,
});
const entity1 = await autumnV2.entities.get(customerId, entities[0].id);
const balance = entity1.balances[TestFeature.Messages];
expect(balance).toMatchObject({
granted_balance: 75,
current_balance: 50,
usage: 25,
purchased_balance: 0,
});
const entity2 = await autumnV2.entities.get(customerId, entities[1].id);
const balance2 = entity2.balances[TestFeature.Messages];
expect(balance2).toMatchObject({
granted_balance: 100,
current_balance: 100,
usage: 0,
purchased_balance: 0,
});
});
test("should update granted to 50 for entity 2", async () => {
await autumnV2.balances.update({
customer_id: customerId,
feature_id: TestFeature.Messages,
entity_id: entities[1].id,
current_balance: 25,
granted_balance: 50,
});
const entity2 = await autumnV2.entities.get(customerId, entities[1].id);
const balance2 = entity2.balances[TestFeature.Messages];
expect(balance2).toMatchObject({
granted_balance: 50,
current_balance: 25,
usage: 25,
purchased_balance: 0,
});
const entity1 = await autumnV2.entities.get(customerId, entities[0].id);
const balance1 = entity1.balances[TestFeature.Messages];
expect(balance1).toMatchObject({
granted_balance: 75,
current_balance: 50,
usage: 25,
purchased_balance: 0,
});
});
});

View File

@@ -0,0 +1,84 @@
import { beforeAll, describe, expect, test } from "bun:test";
import { type ApiCustomer, ApiVersion, 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 { 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";
const monthlyMsges = constructFeatureItem({
featureId: TestFeature.Messages,
includedUsage: 100,
}) as LimitedItem;
const freeProd = constructProduct({
type: "free",
isDefault: false,
items: [monthlyMsges],
});
const testCase = "update-granted-balance4";
describe(`${chalk.yellowBright("update-granted-balance4: testing update current balance, then update granted balance")}`, () => {
const customerId = testCase;
const autumnV2: AutumnInt = new AutumnInt({ version: ApiVersion.V2_0 });
beforeAll(async () => {
await initCustomerV3({
ctx,
customerId,
withTestClock: false,
});
await initProductsV0({
ctx,
products: [freeProd],
prefix: testCase,
});
await autumnV2.attach({
customer_id: customerId,
product_id: freeProd.id,
});
});
test("should update current balance to 50", async () => {
await autumnV2.balances.update({
customer_id: customerId,
feature_id: TestFeature.Messages,
current_balance: 50,
});
const customer = await autumnV2.customers.get<ApiCustomer>(customerId);
const balance = customer.balances[TestFeature.Messages];
expect(balance).toMatchObject({
granted_balance: 50,
current_balance: 50,
usage: 0,
purchased_balance: 0,
});
});
test("should update granted balance to 100", async () => {
await autumnV2.balances.update({
customer_id: customerId,
feature_id: TestFeature.Messages,
granted_balance: 100,
current_balance: 50,
});
const customer = await autumnV2.customers.get<ApiCustomer>(customerId);
const balance = customer.balances[TestFeature.Messages];
expect(balance).toMatchObject({
granted_balance: 100,
current_balance: 50,
usage: 50,
purchased_balance: 0,
});
});
});

View File

@@ -0,0 +1,144 @@
import { beforeAll, describe, expect, test } from "bun:test";
import {
type ApiCustomerV3,
ApiVersion,
BillingInterval,
getCusStripeSubCount,
} from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features.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 { CusService } from "@/internal/customers/CusService";
import { constructPriceItem } from "@/internal/products/product-items/productItemUtils.js";
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
import {
constructProduct,
constructRawProduct,
} from "@/utils/scriptUtils/createTestProducts.js";
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
import { expectProductAttached } from "../../utils/expectUtils/expectProductAttached";
const paidAddOn = constructRawProduct({
id: "addOn",
isAddOn: true,
items: [
constructPriceItem({
price: 10,
interval: BillingInterval.Month,
}),
constructFeatureItem({
featureId: TestFeature.Messages,
includedUsage: 500,
}),
],
});
const pro = constructProduct({
type: "pro",
items: [
constructFeatureItem({
featureId: TestFeature.Messages,
includedUsage: 300,
}),
],
});
const testCase = "new-billing-subscription1";
describe(`${chalk.yellowBright("new-billing-subscription: paid product with add on mid cycle. add on should create new sub")}`, () => {
const customerId = testCase;
const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
beforeAll(async () => {
const result = await initCustomerV3({
ctx,
customerId,
withTestClock: true,
attachPm: "success",
});
await initProductsV0({
ctx,
products: [pro, paidAddOn],
prefix: testCase,
});
await autumnV1.attach({
customer_id: customerId,
product_id: pro.id,
});
await advanceTestClock({
stripeCli: ctx.stripeCli,
testClockId: result.testClockId,
advanceTo: addWeeks(new Date(), 2).getTime(),
waitForSeconds: 20,
});
});
test("should attach add on and have correct sub", async () => {
await autumnV1.attach({
customer_id: customerId,
product_id: paidAddOn.id,
new_billing_subscription: true,
});
const fullCus = await CusService.getFull({
idOrInternalId: customerId,
db: ctx.db,
orgId: ctx.org.id,
env: ctx.env,
});
const subCount = getCusStripeSubCount({
fullCus,
});
expect(subCount).toBe(2);
const customer = await autumnV1.customers.get(customerId);
expectProductAttached({
customer: customer,
product: paidAddOn,
});
const invoices = customer.invoices;
expect(invoices.length).toBe(2);
expect(invoices[0].total).toBe(10);
});
test("should attach add on again and have 3 subscriptions", async () => {
await autumnV1.attach({
customer_id: customerId,
product_id: paidAddOn.id,
new_billing_subscription: true,
});
const fullCus = await CusService.getFull({
idOrInternalId: customerId,
db: ctx.db,
orgId: ctx.org.id,
env: ctx.env,
});
const subCount = getCusStripeSubCount({
fullCus,
});
expect(subCount).toBe(3);
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
const addOnProduct = customer.products.find((p) => p.id === paidAddOn.id);
expect(addOnProduct?.quantity).toBe(2);
const invoices = customer.invoices;
expect(invoices?.length).toBe(3);
expect(invoices?.[0].total).toBe(10);
});
});

View File

@@ -1,11 +1,11 @@
import { beforeAll, describe, expect, test } from "bun:test";
import { LegacyVersion } from "@autumn/shared";
import chalk from "chalk";
import { addMonths, addWeeks } from "date-fns";
import { TestFeature } from "@tests/setup/v2Features.js";
import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js";
import { advanceTestClock } from "@tests/utils/stripeUtils.js";
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
import chalk from "chalk";
import { addMonths, addWeeks } from "date-fns";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
@@ -90,7 +90,7 @@ describe(`${chalk.yellowBright("multiSubInterval1: Should attach pro and pro ann
product_id: pro.id,
entity_id: entities[1].id,
});
console.log("checkoutRes", checkoutRes);
expect(checkoutRes.next_cycle).toBeDefined();
expect(checkoutRes.next_cycle?.starts_at).toBeCloseTo(
addMonths(new Date(), 1).getTime(),
@@ -112,7 +112,7 @@ describe(`${chalk.yellowBright("multiSubInterval1: Should attach pro and pro ann
const subItem = sub!.items.data[0];
expect(subItem.current_period_end * 1000).toBeCloseTo(
checkoutRes.next_cycle?.starts_at!,
checkoutRes.next_cycle?.starts_at ?? 0,
-Math.log10(toMilliseconds.days(1)), // +- 1 day
);
});

View File

@@ -1,5 +1,8 @@
import { cusProductToPrices, type FullCusProduct } from "@autumn/shared";
import { isFixedPrice } from "@server/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice";
import {
cusProductToPrices,
type FullCusProduct,
isFixedPrice,
} from "@autumn/shared";
export const cusProductToSubIds = ({
cusProducts,
@@ -17,5 +20,5 @@ export const cpToPrice = ({
type: "base" | "arrear" | "cont" | "prepaid";
}) => {
const prices = cusProductToPrices({ cusProduct: cp });
return prices.find((p) => isFixedPrice({ price: p }));
return prices.find((p) => isFixedPrice(p));
};

View File

@@ -0,0 +1,166 @@
import { describe, expect, test } from "bun:test";
import { Infinite, type Price, tiersToLineAmount } from "@autumn/shared";
const createMockPrice = (
tiers: { to: number | typeof Infinite; amount: number }[],
): Price =>
({
id: "test-price",
internal_product_id: "test-product",
config: {
type: "usage",
usage_tiers: tiers,
},
}) as unknown as Price;
describe("tiersToLineAmount", () => {
describe("single tier (flat rate)", () => {
test("100 overage @ $0.10/unit = $10", () => {
const price = createMockPrice([{ to: Infinite, amount: 0.1 }]);
const result = tiersToLineAmount({ price, overage: 100 });
expect(result).toBe(10);
});
test("0 overage = $0", () => {
const price = createMockPrice([{ to: Infinite, amount: 0.1 }]);
const result = tiersToLineAmount({ price, overage: 0 });
expect(result).toBe(0);
});
});
describe("multiple tiers", () => {
// Tiers: 0-100 @ $0.10, 100-500 @ $0.05, 500+ @ $0.02
const tieredPrice = createMockPrice([
{ to: 100, amount: 0.1 },
{ to: 500, amount: 0.05 },
{ to: Infinite, amount: 0.02 },
]);
test("50 overage (within tier 1) = $5", () => {
const result = tiersToLineAmount({ price: tieredPrice, overage: 50 });
expect(result).toBe(5);
});
test("100 overage (exactly tier 1) = $10", () => {
const result = tiersToLineAmount({ price: tieredPrice, overage: 100 });
expect(result).toBe(10);
});
test("250 overage (tier 1 + partial tier 2) = $17.50", () => {
// 100 × $0.10 = $10
// 150 × $0.05 = $7.50
// Total = $17.50
const result = tiersToLineAmount({ price: tieredPrice, overage: 250 });
expect(result).toBe(17.5);
});
test("500 overage (tier 1 + full tier 2) = $30", () => {
// 100 × $0.10 = $10
// 400 × $0.05 = $20
// Total = $30
const result = tiersToLineAmount({ price: tieredPrice, overage: 500 });
expect(result).toBe(30);
});
test("1000 overage (all tiers) = $40", () => {
// 100 × $0.10 = $10
// 400 × $0.05 = $20
// 500 × $0.02 = $10
// Total = $40
const result = tiersToLineAmount({ price: tieredPrice, overage: 1000 });
expect(result).toBe(40);
});
});
describe("billing units", () => {
const price = createMockPrice([{ to: Infinite, amount: 1 }]); // $1 per billing unit
test("rounds up to nearest billing unit (billingUnits=10)", () => {
// 15 overage, billingUnits=10 → rounds to 20
// 20 × ($1/10) = $2
const result = tiersToLineAmount({
price,
overage: 15,
billingUnits: 10,
});
expect(result).toBe(2);
});
test("exact billing unit multiple", () => {
// 20 overage, billingUnits=10 → stays 20
// 20 × ($1/10) = $2
const result = tiersToLineAmount({
price,
overage: 20,
billingUnits: 10,
});
expect(result).toBe(2);
});
test("small overage rounds up", () => {
// 1 overage, billingUnits=10 → rounds to 10
// 10 × ($1/10) = $1
const result = tiersToLineAmount({ price, overage: 1, billingUnits: 10 });
expect(result).toBe(1);
});
});
describe("decimal precision", () => {
test("fractional rate: 7 overage @ $0.0033/unit", () => {
const price = createMockPrice([{ to: Infinite, amount: 0.0033 }]);
const result = tiersToLineAmount({ price, overage: 7 });
expect(result).toBe(0.0231);
});
test("fractional rate with many decimals: 13 overage @ $0.00123/unit", () => {
const price = createMockPrice([{ to: Infinite, amount: 0.00123 }]);
const result = tiersToLineAmount({ price, overage: 13 });
expect(result).toBe(0.01599);
});
test("large overage with small rate: 1000000 @ $0.000001/unit", () => {
const price = createMockPrice([{ to: Infinite, amount: 0.000001 }]);
const result = tiersToLineAmount({ price, overage: 1000000 });
expect(result).toBe(1);
});
test("tiered with fractional rates", () => {
// 0-50 @ $0.0075, 50+ @ $0.0025
const price = createMockPrice([
{ to: 50, amount: 0.0075 },
{ to: Infinite, amount: 0.0025 },
]);
// 75 overage: 50 × $0.0075 = $0.375, 25 × $0.0025 = $0.0625
// Total = $0.4375
const result = tiersToLineAmount({ price, overage: 75 });
expect(result).toBe(0.4375);
});
test("very small overage with fractional rate: 3 @ $0.33/unit", () => {
const price = createMockPrice([{ to: Infinite, amount: 0.33 }]);
const result = tiersToLineAmount({ price, overage: 3 });
expect(result).toBe(0.99);
});
test("floating point edge case: 0.1 + 0.2 precision", () => {
// 3 overage @ $0.1/unit = $0.3 (tests floating point handling)
const price = createMockPrice([{ to: Infinite, amount: 0.1 }]);
const result = tiersToLineAmount({ price, overage: 3 });
expect(result).toBe(0.3);
});
});
describe("edge cases", () => {
test("tier.to = -1 treated same as Infinite", () => {
const price = createMockPrice([{ to: -1, amount: 0.1 }]);
const result = tiersToLineAmount({ price, overage: 100 });
expect(result).toBe(10);
});
test("throws if no tiers", () => {
const price = { config: {} } as unknown as Price;
expect(() => tiersToLineAmount({ price, overage: 100 })).toThrow();
});
});
});

View File

@@ -2,18 +2,16 @@ import {
BillingInterval,
cusProductToEnts,
cusProductToPrices,
isFixedPrice,
type Organization,
priceToInvoiceAmount,
type UsagePriceConfig,
} from "@autumn/shared";
import type { AppEnv } from "autumn-js";
import { Decimal } from "decimal.js";
import type Stripe from "stripe";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { priceToInvoiceAmount } from "@/internal/products/prices/priceUtils/priceToInvoiceAmount.js";
import {
isArrearPrice,
isFixedPrice,
} from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js";
import { isArrearPrice } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js";
import { getPriceEntitlement } from "@/internal/products/prices/priceUtils.js";
import { getSubsFromCusId } from "./expectSubUtils.js";
@@ -65,7 +63,7 @@ export const getExpectedInvoiceTotal = async ({
continue;
}
if (onlyIncludeUsage && isFixedPrice({ price })) continue;
if (onlyIncludeUsage && isFixedPrice(price)) continue;
if (onlyIncludeArrear && !isArrearPrice({ price })) continue;

View File

@@ -1,11 +1,11 @@
import type {
BillingInterval,
FixedPriceConfig,
Price,
ProductItem,
ProductV2,
import {
type BillingInterval,
type FixedPriceConfig,
isFixedPrice,
type Price,
type ProductItem,
type ProductV2,
} from "@autumn/shared";
import { isFixedPrice } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js";
import { isPriceItem } from "@/internal/products/product-items/productItemUtils/getItemType.js";
import { nullish } from "@/utils/genUtils.js";
@@ -68,7 +68,7 @@ export const getBasePrice = ({ product }: { product: ProductV2 }) => {
};
export const v1ProductToBasePrice = ({ prices }: { prices: Price[] }) => {
const fixedPrice = prices.find((price) => isFixedPrice({ price }));
const fixedPrice = prices.find((price) => isFixedPrice(price));
if (fixedPrice) {
return (fixedPrice.config as FixedPriceConfig).amount;
} else return 0;

View File

@@ -0,0 +1,30 @@
import {
EventAggregationBodySchema,
EventAggregationResponseSchema,
} from "../../../events/aggregation/eventAggregationSchema.js";
export const analyticsOpenApi = {
"/query": {
post: {
summary: "Query Analytics Aggregation",
tags: ["analytics"],
requestBody: {
content: {
"application/json": {
schema: EventAggregationBodySchema,
},
},
},
responses: {
"200": {
description: "Analytics aggregation results",
content: {
"application/json": {
schema: EventAggregationResponseSchema,
},
},
},
},
},
},
};

View File

@@ -5,6 +5,7 @@ import { SuccessResponseSchema } from "../../../common/commonResponses.js";
import { queryStringArray } from "../../../common/queryHelpers.js";
import {
API_CUSTOMER_V3_EXAMPLE,
ApiCusExpandV3Schema,
ApiCustomerV3Schema,
BillingPortalParamsSchema,
BillingPortalResultSchema,
@@ -33,7 +34,15 @@ export const customersOpenApi = {
content: {
"application/json": {
schema: ListCustomersResponseSchema.extend({
list: z.array(ApiCustomerWithMeta),
list: z.array(
ApiCustomerWithMeta.omit({
entities: true,
invoices: true,
trials_used: true,
referrals: true,
payment_method: true,
}),
),
}),
},
},

View File

@@ -10,6 +10,7 @@ import {
ApiProductItemSchema,
} from "../../../models.js";
import { ApiEntityWithMeta, entitiesOpenApi } from "../entitiesOpenApi.js";
import { analyticsOpenApi } from "./analyticsOpenApi.js";
import { coreOpenApi } from "./coreOpenApi.js";
import { ApiCustomerWithMeta, customersOpenApi } from "./customersOpenApi.js";
import { ApiFeatureWithMeta, featuresOpenApi } from "./featuresOpenApi.js";
@@ -70,6 +71,7 @@ const OPENAPI_1_2_0 = createDocument(
...coreOpenApi,
...customersOpenApi,
...entitiesOpenApi,
...analyticsOpenApi,
},
},
{

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