diff --git a/package.json b/package.json index 329690602..cfe86cb78 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/scripts/db/pull.ts b/scripts/db/pull.ts new file mode 100644 index 000000000..c26abf961 --- /dev/null +++ b/scripts/db/pull.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 "" [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); +}); diff --git a/scripts/dev.ts b/scripts/dev.ts index 41295c0e7..93bce74f3 100644 --- a/scripts/dev.ts +++ b/scripts/dev.ts @@ -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", () => { diff --git a/scripts/migrations/migrate-functions.ts b/scripts/migrations/migrate-functions.ts index 0073a24a7..bcb6da1f9 100644 --- a/scripts/migrations/migrate-functions.ts +++ b/scripts/migrations/migrate-functions.ts @@ -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([ { diff --git a/scripts/seed/seedEvents.ts b/scripts/seed/seedEvents.ts new file mode 100644 index 000000000..c78cd7ca9 --- /dev/null +++ b/scripts/seed/seedEvents.ts @@ -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 = {}; + + 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= --feature_ids= [--count=] [--env=] [--org_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= --feature_ids= [--count=] [--env=] [--org_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["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>; + 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(); diff --git a/scripts/setup/setup-clickhouse-insights-user.ts b/scripts/setup/setup-clickhouse-insights-user.ts new file mode 100644 index 000000000..053ce7eaf --- /dev/null +++ b/scripts/setup/setup-clickhouse-insights-user.ts @@ -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(); diff --git a/scripts/testGroups/g1.sh b/scripts/testGroups/g1.sh index 4f6cae0f4..63f103d17 100755 --- a/scripts/testGroups/g1.sh +++ b/scripts/testGroups/g1.sh @@ -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' \ diff --git a/server/src/errors/formatZodError.ts b/server/src/errors/formatZodError.ts index d6775c9b8..e62f7e550 100644 --- a/server/src/errors/formatZodError.ts +++ b/server/src/errors/formatZodError.ts @@ -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"; } diff --git a/server/src/external/autumn/autumnWebhookRouter.ts b/server/src/external/autumn/autumnWebhookRouter.ts index f234a640b..1aee9ea3c 100644 --- a/server/src/external/autumn/autumnWebhookRouter.ts +++ b/server/src/external/autumn/autumnWebhookRouter.ts @@ -54,7 +54,6 @@ autumnWebhookRouter.post( const { type, data } = evt; // console.log("Event:", evt); - // console.log("Data:", data); switch (type) { case WebhookEventType.CustomerProductsUpdated: diff --git a/server/src/external/clickhouse/ClickHouseManager.ts b/server/src/external/clickhouse/ClickHouseManager.ts index cd861b6b5..28858218c 100644 --- a/server/src/external/clickhouse/ClickHouseManager.ts +++ b/server/src/external/clickhouse/ClickHouseManager.ts @@ -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 | null = null; static clickhouseAvailable = @@ -75,6 +80,27 @@ export class ClickHouseManager { return manager.client; } + public static async getReadonlyClient(): Promise { + 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() {} diff --git a/server/src/external/clickhouse/queries/CREATE_ORG_EVENTS_VIEW.sql b/server/src/external/clickhouse/queries/CREATE_ORG_EVENTS_VIEW.sql index 1bd788268..4ae87860f 100644 --- a/server/src/external/clickhouse/queries/CREATE_ORG_EVENTS_VIEW.sql +++ b/server/src/external/clickhouse/queries/CREATE_ORG_EVENTS_VIEW.sql @@ -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, diff --git a/server/src/external/redis/redisUtils.ts b/server/src/external/redis/redisUtils.ts index 6cfa1537a..f92788a61 100644 --- a/server/src/external/redis/redisUtils.ts +++ b/server/src/external/redis/redisUtils.ts @@ -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 => { + // 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; } }; diff --git a/server/src/external/stripe/stripeSubUtils/stripeSubItemUtils.ts b/server/src/external/stripe/stripeSubUtils/stripeSubItemUtils.ts index fb7fbe96d..ada5c5220 100644 --- a/server/src/external/stripe/stripeSubUtils/stripeSubItemUtils.ts +++ b/server/src/external/stripe/stripeSubUtils/stripeSubItemUtils.ts @@ -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 || diff --git a/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handleInvoiceCreated.ts b/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handleInvoiceCreated.ts index ef9df9090..ed47c26a4 100644 --- a/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handleInvoiceCreated.ts +++ b/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handleInvoiceCreated.ts @@ -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, diff --git a/server/src/honoMiddlewares/routeHandler.ts b/server/src/honoMiddlewares/routeHandler.ts index d6d25a16d..3a4702b04 100644 --- a/server/src/honoMiddlewares/routeHandler.ts +++ b/server/src/honoMiddlewares/routeHandler.ts @@ -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, ) => Response | Promise; assertIdempotence?: string | undefined; + /** Lock configuration to prevent concurrent requests */ + lock?: { + /** Generate a lock key. Returns null to skip locking. */ + getKey: ( + c: ValidatedContext, + ) => 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 }); + } } }; diff --git a/server/src/init.ts b/server/src/init.ts index 140168710..ec60774fa 100644 --- a/server/src/init.ts +++ b/server/src/init.ts @@ -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", diff --git a/server/src/initHono.ts b/server/src/initHono.ts index 4bb1e4773..1a5005e0c 100644 --- a/server/src/initHono.ts +++ b/server/src/initHono.ts @@ -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", ]; diff --git a/server/src/internal/analytics/ActionService.ts b/server/src/internal/analytics/ActionService.ts deleted file mode 100644 index 5275e105c..000000000 --- a/server/src/internal/analytics/ActionService.ts +++ /dev/null @@ -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); - } -} diff --git a/server/src/internal/analytics/AnalyticsService.ts b/server/src/internal/analytics/AnalyticsService.ts index 6bd20c0e7..14278d1e7 100644 --- a/server/src/internal/analytics/AnalyticsService.ts +++ b/server/src/internal/analytics/AnalyticsService.ts @@ -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 { - // if (customerHasSubscriptions) { - // return []; - // } - - // return await SubService.getInStripeIds({ - // db, - // ids: - // customer.customer_products?.flatMap( - // (product: FullCusProduct) => product.subscription_ids ?? [] - // ) ?? [], - // }); - // } } diff --git a/server/src/internal/analytics/analyticsRouter.ts b/server/src/internal/analytics/analyticsRouter.ts deleted file mode 100644 index 456dd39cf..000000000 --- a/server/src/internal/analytics/analyticsRouter.ts +++ /dev/null @@ -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 }; diff --git a/server/src/internal/analytics/handlers/handleInsightsQuery.ts b/server/src/internal/analytics/handlers/handleInsightsQuery.ts new file mode 100644 index 000000000..8db494589 --- /dev/null +++ b/server/src/internal/analytics/handlers/handleInsightsQuery.ts @@ -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({ + input: parsedResult.data, + targetVersion: ctx.apiVersion, + resource: AffectedResource.Attach, + ctx, + }), + ); + }, +}); diff --git a/server/src/internal/analytics/handlers/handleProductsUpdated.ts b/server/src/internal/analytics/handlers/handleProductsUpdated.ts index 918f317e6..63aacb624 100644 --- a/server/src/internal/analytics/handlers/handleProductsUpdated.ts +++ b/server/src/internal/analytics/handlers/handleProductsUpdated.ts @@ -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({ + 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, }, }); diff --git a/server/src/internal/analytics/insightsRouter.ts b/server/src/internal/analytics/insightsRouter.ts new file mode 100644 index 000000000..8e3f3fc5d --- /dev/null +++ b/server/src/internal/analytics/insightsRouter.ts @@ -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(); + +insightsRouter.post("/query", ...handleInsightsQuery); diff --git a/server/src/internal/analytics/legacyAnalyticsRouter.ts b/server/src/internal/analytics/legacyAnalyticsRouter.ts new file mode 100644 index 000000000..e395c712f --- /dev/null +++ b/server/src/internal/analytics/legacyAnalyticsRouter.ts @@ -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(); + +legacyAnalyticsRouter.post("", ...handleEventsAggregation); diff --git a/server/src/internal/api/apiRouter.ts b/server/src/internal/api/apiRouter.ts index bdf964f5b..2c54d1ca7 100644 --- a/server/src/internal/api/apiRouter.ts +++ b/server/src/internal/api/apiRouter.ts @@ -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); diff --git a/server/src/internal/api/check/checkUtils.ts b/server/src/internal/api/check/checkUtils.ts index 935b8f198..b14d75077 100644 --- a/server/src/internal/api/check/checkUtils.ts +++ b/server/src/internal/api/check/checkUtils.ts @@ -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"; diff --git a/server/src/internal/api/rewards/handlers/rewards/handleCreateCoupon.ts b/server/src/internal/api/rewards/handlers/rewards/handleCreateCoupon.ts index fcb18b1fd..5130685cb 100644 --- a/server/src/internal/api/rewards/handlers/rewards/handleCreateCoupon.ts +++ b/server/src/internal/api/rewards/handlers/rewards/handleCreateCoupon.ts @@ -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, diff --git a/server/src/internal/balances/handlers/handleUpdateBalance.ts b/server/src/internal/balances/handlers/handleUpdateBalance.ts index 14ba0b15a..2c0a316a4 100644 --- a/server/src/internal/balances/handlers/handleUpdateBalance.ts +++ b/server/src/internal/balances/handlers/handleUpdateBalance.ts @@ -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, diff --git a/server/src/internal/balances/track/runTrack.ts b/server/src/internal/balances/track/runTrack.ts index e14851785..3e3c19f69 100644 --- a/server/src/internal/balances/track/runTrack.ts +++ b/server/src/internal/balances/track/runTrack.ts @@ -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, diff --git a/server/src/internal/balances/track/trackUtils/deductRpc/deductFromMainBalance.sql b/server/src/internal/balances/track/trackUtils/deductRpc/deductFromMainBalance.sql index 5347ca1f8..a5f5315e4 100644 --- a/server/src/internal/balances/track/trackUtils/deductRpc/deductFromMainBalance.sql +++ b/server/src/internal/balances/track/trackUtils/deductRpc/deductFromMainBalance.sql @@ -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 diff --git a/server/src/internal/balances/track/trackUtils/deductRpc/performDeduction.sql b/server/src/internal/balances/track/trackUtils/deductRpc/performDeduction.sql index 4584d25ff..54372c2d7 100644 --- a/server/src/internal/balances/track/trackUtils/deductRpc/performDeduction.sql +++ b/server/src/internal/balances/track/trackUtils/deductRpc/performDeduction.sql @@ -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 )); diff --git a/server/src/internal/balances/track/trackUtils/runDeductionTx.ts b/server/src/internal/balances/track/trackUtils/runDeductionTx.ts index d6c59fdad..0796f73c9 100644 --- a/server/src/internal/balances/track/trackUtils/runDeductionTx.ts +++ b/server/src/internal/balances/track/trackUtils/runDeductionTx.ts @@ -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, }; }); diff --git a/server/src/internal/balances/updateGrantedBalance/updateGrantedBalance.ts b/server/src/internal/balances/updateGrantedBalance/updateGrantedBalance.ts new file mode 100644 index 000000000..f6f3a6151 --- /dev/null +++ b/server/src/internal/balances/updateGrantedBalance/updateGrantedBalance.ts @@ -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 }, + }); + } +}; diff --git a/server/src/internal/billing/attach/handleAttach.ts b/server/src/internal/billing/attach/handleAttach.ts index 5c6f18c1c..05644968e 100644 --- a/server/src/internal/billing/attach/handleAttach.ts +++ b/server/src/internal/billing/attach/handleAttach.ts @@ -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, diff --git a/server/src/internal/billing/billingUtils/enrichAttachActions/enrichAttachActions.ts b/server/src/internal/billing/billingUtils/enrichAttachActions/enrichAttachActions.ts index 1c58caaca..e7258e47b 100644 --- a/server/src/internal/billing/billingUtils/enrichAttachActions/enrichAttachActions.ts +++ b/server/src/internal/billing/billingUtils/enrichAttachActions/enrichAttachActions.ts @@ -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) { diff --git a/server/src/internal/billing/checkout/previewToCheckoutRes.ts b/server/src/internal/billing/checkout/previewToCheckoutRes.ts index aa68eb4e0..e708a8c2e 100644 --- a/server/src/internal/billing/checkout/previewToCheckoutRes.ts +++ b/server/src/internal/billing/checkout/previewToCheckoutRes.ts @@ -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: diff --git a/server/src/internal/customers/attach/attachFunctions/addProductFlow/handleOneOffFunction.ts b/server/src/internal/customers/attach/attachFunctions/addProductFlow/handleOneOffFunction.ts index 796c3d9d0..1cf77005c 100644 --- a/server/src/internal/customers/attach/attachFunctions/addProductFlow/handleOneOffFunction.ts +++ b/server/src/internal/customers/attach/attachFunctions/addProductFlow/handleOneOffFunction.ts @@ -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 = { diff --git a/server/src/internal/customers/attach/attachFunctions/addProductFlow/handlePaidProduct.ts b/server/src/internal/customers/attach/attachFunctions/addProductFlow/handlePaidProduct.ts index 20728039a..d2aea37a8 100644 --- a/server/src/internal/customers/attach/attachFunctions/addProductFlow/handlePaidProduct.ts +++ b/server/src/internal/customers/attach/attachFunctions/addProductFlow/handlePaidProduct.ts @@ -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; diff --git a/server/src/internal/customers/attach/attachFunctions/updateQuantityFlow/handleQuantityUpgrade.ts b/server/src/internal/customers/attach/attachFunctions/updateQuantityFlow/handleQuantityUpgrade.ts index b8d6be3db..1bbb72143 100644 --- a/server/src/internal/customers/attach/attachFunctions/updateQuantityFlow/handleQuantityUpgrade.ts +++ b/server/src/internal/customers/attach/attachFunctions/updateQuantityFlow/handleQuantityUpgrade.ts @@ -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, diff --git a/server/src/internal/customers/attach/attachFunctions/upgradeFlow/updateStripeSub2.ts b/server/src/internal/customers/attach/attachFunctions/upgradeFlow/updateStripeSub2.ts index 677f3f937..0ae32f675 100644 --- a/server/src/internal/customers/attach/attachFunctions/upgradeFlow/updateStripeSub2.ts +++ b/server/src/internal/customers/attach/attachFunctions/upgradeFlow/updateStripeSub2.ts @@ -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; diff --git a/server/src/internal/customers/attach/attachPreviewUtils/priceToNewPreviewItem.ts b/server/src/internal/customers/attach/attachPreviewUtils/priceToNewPreviewItem.ts index 590472994..d25fc7f2a 100644 --- a/server/src/internal/customers/attach/attachPreviewUtils/priceToNewPreviewItem.ts +++ b/server/src/internal/customers/attach/attachPreviewUtils/priceToNewPreviewItem.ts @@ -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, diff --git a/server/src/internal/customers/attach/attachPreviewUtils/priceToUnusedPreviewItem.ts b/server/src/internal/customers/attach/attachPreviewUtils/priceToUnusedPreviewItem.ts index 0fd17ff73..e1d4dc51c 100644 --- a/server/src/internal/customers/attach/attachPreviewUtils/priceToUnusedPreviewItem.ts +++ b/server/src/internal/customers/attach/attachPreviewUtils/priceToUnusedPreviewItem.ts @@ -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, diff --git a/server/src/internal/customers/attach/attachUtils/attachParams/getAttachParams.ts b/server/src/internal/customers/attach/attachUtils/attachParams/getAttachParams.ts index 5c344b566..d2d9a3f17 100644 --- a/server/src/internal/customers/attach/attachUtils/attachParams/getAttachParams.ts +++ b/server/src/internal/customers/attach/attachUtils/attachParams/getAttachParams.ts @@ -74,6 +74,8 @@ export const getAttachParams = async ({ // Others apiVersion: ctx.apiVersion.value, + + newBillingSubscription: attachBody.new_billing_subscription || false, }; return { diff --git a/server/src/internal/customers/attach/attachUtils/getContUseItems/getContUseUpgradeItems.ts b/server/src/internal/customers/attach/attachUtils/getContUseItems/getContUseUpgradeItems.ts index 475f660fc..ecd5fdc84 100644 --- a/server/src/internal/customers/attach/attachUtils/getContUseItems/getContUseUpgradeItems.ts +++ b/server/src/internal/customers/attach/attachUtils/getContUseItems/getContUseUpgradeItems.ts @@ -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, diff --git a/server/src/internal/customers/attach/handleAttach.ts b/server/src/internal/customers/attach/handleAttach.ts deleted file mode 100644 index 60cc495ff..000000000 --- a/server/src/internal/customers/attach/handleAttach.ts +++ /dev/null @@ -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, -// }); -// }, -// }); diff --git a/server/src/internal/customers/attach/handleAttachPreview/getNewProductPreview.ts b/server/src/internal/customers/attach/handleAttachPreview/getNewProductPreview.ts index de3f95582..e417004d8 100644 --- a/server/src/internal/customers/attach/handleAttachPreview/getNewProductPreview.ts +++ b/server/src/internal/customers/attach/handleAttachPreview/getNewProductPreview.ts @@ -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) { diff --git a/server/src/internal/customers/attach/mergeUtils/mergeUtils.ts b/server/src/internal/customers/attach/mergeUtils/mergeUtils.ts index 6363c5e7a..384a5edf7 100644 --- a/server/src/internal/customers/attach/mergeUtils/mergeUtils.ts +++ b/server/src/internal/customers/attach/mergeUtils/mergeUtils.ts @@ -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; diff --git a/server/src/internal/customers/cusProducts/AttachParams.ts b/server/src/internal/customers/cusProducts/AttachParams.ts index 5d05696fe..e5a1a834a 100644 --- a/server/src/internal/customers/cusProducts/AttachParams.ts +++ b/server/src/internal/customers/cusProducts/AttachParams.ts @@ -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({ diff --git a/server/src/internal/customers/cusProducts/CusProdReadService.ts b/server/src/internal/customers/cusProducts/CusProdReadService.ts index 16ff6eed3..ec127a7e3 100644 --- a/server/src/internal/customers/cusProducts/CusProdReadService.ts +++ b/server/src/internal/customers/cusProducts/CusProdReadService.ts @@ -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( diff --git a/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalance.ts b/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalance.ts index 588db668f..ab285f22e 100644 --- a/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalance.ts +++ b/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalance.ts @@ -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) diff --git a/server/src/internal/customers/cusUtils/createNewCustomer.ts b/server/src/internal/customers/cusUtils/createNewCustomer.ts index 397bd2419..27cce28d8 100644 --- a/server/src/internal/customers/cusUtils/createNewCustomer.ts +++ b/server/src/internal/customers/cusUtils/createNewCustomer.ts @@ -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; }; diff --git a/server/src/internal/customers/cusUtils/getOrCreateApiCustomer.ts b/server/src/internal/customers/cusUtils/getOrCreateApiCustomer.ts index 260c3e984..ef9835e24 100644 --- a/server/src/internal/customers/cusUtils/getOrCreateApiCustomer.ts +++ b/server/src/internal/customers/cusUtils/getOrCreateApiCustomer.ts @@ -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 || "", diff --git a/server/src/internal/events/EventsAggregationService.ts b/server/src/internal/events/EventsAggregationService.ts new file mode 100644 index 000000000..e673a11fa --- /dev/null +++ b/server/src/internal/events/EventsAggregationService.ts @@ -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 { + 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 { + 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, + ); + } +} diff --git a/server/src/internal/events/eventUtils.ts b/server/src/internal/events/eventUtils.ts new file mode 100644 index 000000000..6d4db3941 --- /dev/null +++ b/server/src/internal/events/eventUtils.ts @@ -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>, +): 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>, + groupByField: string, +): { groupValues: Set; featureNames: Set } { + const groupValues = new Set(); + const featureNames = new Set(); + + 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>, + groupByField: string, +): Map>> { + const grouped = new Map< + number, + Record> + >(); + + 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)[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>>, + groupValues: Set, + featureNames: Set, +): void { + for (const periodData of grouped.values()) { + for (const featureName of featureNames) { + if (!periodData[featureName]) { + periodData[featureName] = {}; + } + const featureData = periodData[featureName] as Record; + for (const groupValue of groupValues) { + if (featureData[groupValue] === undefined) { + featureData[groupValue] = 0; + } + } + } + } +} diff --git a/server/src/internal/events/eventsRouter.ts b/server/src/internal/events/eventsRouter.ts new file mode 100644 index 000000000..d00293206 --- /dev/null +++ b/server/src/internal/events/eventsRouter.ts @@ -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(); + +eventsRouter.post("aggregate", ...handleEventsAggregation); diff --git a/server/src/internal/events/handlers/handleEventsAggregation.ts b/server/src/internal/events/handlers/handleEventsAggregation.ts new file mode 100644 index 000000000..cd9b78d2d --- /dev/null +++ b/server/src/internal/events/handlers/handleEventsAggregation.ts @@ -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, + }); + }, +}); diff --git a/server/src/internal/features/archives/handleCreateFeature.ts b/server/src/internal/features/archives/handleCreateFeature.ts deleted file mode 100644 index bb523ecb5..000000000 --- a/server/src/internal/features/archives/handleCreateFeature.ts +++ /dev/null @@ -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" }); - } -}; diff --git a/server/src/internal/features/archives/handleDeleteFeature.ts b/server/src/internal/features/archives/handleDeleteFeature.ts deleted file mode 100644 index 0e3a95f9d..000000000 --- a/server/src/internal/features/archives/handleDeleteFeature.ts +++ /dev/null @@ -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 }); -// }, -// }); diff --git a/server/src/internal/features/archives/handleUpdateFeature.ts b/server/src/internal/features/archives/handleUpdateFeature.ts deleted file mode 100644 index e5b876853..000000000 --- a/server/src/internal/features/archives/handleUpdateFeature.ts +++ /dev/null @@ -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, -// ); -// }, -// }); diff --git a/server/src/internal/features/featureActions/createFeature.ts b/server/src/internal/features/featureActions/createFeature.ts index ced1ff255..a07004558 100644 --- a/server/src/internal/features/featureActions/createFeature.ts +++ b/server/src/internal/features/featureActions/createFeature.ts @@ -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) { diff --git a/server/src/internal/features/featureRouter.ts b/server/src/internal/features/featureRouter.ts index 61398d400..06203f402 100644 --- a/server/src/internal/features/featureRouter.ts +++ b/server/src/internal/features/featureRouter.ts @@ -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(); featureRouter.get("", ...handleListFeatures); featureRouter.post("", ...handleCreateFeature); diff --git a/server/src/internal/features/utils/updateFeatureUtils/handleFeatureIdChanged.ts b/server/src/internal/features/utils/updateFeatureUtils/handleFeatureIdChanged.ts index 7bb1a2556..952d72fad 100644 --- a/server/src/internal/features/utils/updateFeatureUtils/handleFeatureIdChanged.ts +++ b/server/src/internal/features/utils/updateFeatureUtils/handleFeatureIdChanged.ts @@ -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, }); diff --git a/server/src/internal/invoices/previewItemUtils/getItemsForNewProduct.ts b/server/src/internal/invoices/previewItemUtils/getItemsForNewProduct.ts index cc9da72dc..1c26b5d6b 100644 --- a/server/src/internal/invoices/previewItemUtils/getItemsForNewProduct.ts +++ b/server/src/internal/invoices/previewItemUtils/getItemsForNewProduct.ts @@ -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, diff --git a/server/src/internal/migrations/runRewardMigrationTask.ts b/server/src/internal/migrations/runRewardMigrationTask.ts index c1dc2d787..76123135c 100644 --- a/server/src/internal/migrations/runRewardMigrationTask.ts +++ b/server/src/internal/migrations/runRewardMigrationTask.ts @@ -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); diff --git a/server/src/internal/products/internalProductRouter.ts b/server/src/internal/products/internalProductRouter.ts index 7579bb24f..02a489ba6 100644 --- a/server/src/internal/products/internalProductRouter.ts +++ b/server/src/internal/products/internalProductRouter.ts @@ -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 { diff --git a/server/src/internal/products/prices/priceUtils/convertPrice.ts b/server/src/internal/products/prices/priceUtils/convertPrice.ts index 633ef683e..e575de7fb 100644 --- a/server/src/internal/products/prices/priceUtils/convertPrice.ts +++ b/server/src/internal/products/prices/priceUtils/convertPrice.ts @@ -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) { diff --git a/server/src/internal/products/prices/priceUtils/priceToInvoiceAmount.ts b/server/src/internal/products/prices/priceUtils/priceToInvoiceAmount.ts deleted file mode 100644 index 8dd01303d..000000000 --- a/server/src/internal/products/prices/priceUtils/priceToInvoiceAmount.ts +++ /dev/null @@ -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; -}; diff --git a/server/src/internal/products/prices/priceUtils/priceToInvoiceItem.ts b/server/src/internal/products/prices/priceUtils/priceToInvoiceItem.ts index 3fa33d247..9bfa22434 100644 --- a/server/src/internal/products/prices/priceUtils/priceToInvoiceItem.ts +++ b/server/src/internal/products/prices/priceUtils/priceToInvoiceItem.ts @@ -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, diff --git a/server/src/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.ts b/server/src/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.ts index 0a8b5396a..cbbddd27d 100644 --- a/server/src/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.ts +++ b/server/src/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.ts @@ -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, diff --git a/server/src/internal/products/product-items/productItemUtils/addIdsToProductItems.ts b/server/src/internal/products/product-items/productItemUtils/addIdsToProductItems.ts index 62c44b05d..ff916b4ad 100644 --- a/server/src/internal/products/product-items/productItemUtils/addIdsToProductItems.ts +++ b/server/src/internal/products/product-items/productItemUtils/addIdsToProductItems.ts @@ -28,7 +28,7 @@ export const addIdsToProductItems = ({ const priceIds = new Set(); 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; diff --git a/server/src/internal/products/productUtils/productResponseUtils/getAttachScenario.ts b/server/src/internal/products/productUtils/productResponseUtils/getAttachScenario.ts index 5ac5f8852..6e7ef5f24 100644 --- a/server/src/internal/products/productUtils/productResponseUtils/getAttachScenario.ts +++ b/server/src/internal/products/productUtils/productResponseUtils/getAttachScenario.ts @@ -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 })) { diff --git a/server/src/internal/rewards/rewardUtils.ts b/server/src/internal/rewards/rewardUtils.ts index 559fa22f5..4dd18bf8c 100644 --- a/server/src/internal/rewards/rewardUtils.ts +++ b/server/src/internal/rewards/rewardUtils.ts @@ -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, ); diff --git a/server/src/queue/initSqs.ts b/server/src/queue/initSqs.ts index 018ccbf60..de81de1e7 100644 --- a/server/src/queue/initSqs.ts +++ b/server/src/queue/initSqs.ts @@ -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..amazonaws.com// @@ -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 || "", diff --git a/server/src/routers/apiRouter.ts b/server/src/routers/apiRouter.ts index 3d9080a39..ec1aeeae3 100644 --- a/server/src/routers/apiRouter.ts +++ b/server/src/routers/apiRouter.ts @@ -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); diff --git a/server/src/trigger/arrearProratedUsage/handleProratedDowngrade.ts b/server/src/trigger/arrearProratedUsage/handleProratedDowngrade.ts index 4a5c2a358..c912e6f05 100644 --- a/server/src/trigger/arrearProratedUsage/handleProratedDowngrade.ts +++ b/server/src/trigger/arrearProratedUsage/handleProratedDowngrade.ts @@ -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, diff --git a/server/src/trigger/arrearProratedUsage/handleProratedUpgrade.ts b/server/src/trigger/arrearProratedUsage/handleProratedUpgrade.ts index 50e15ed10..889198c72 100644 --- a/server/src/trigger/arrearProratedUsage/handleProratedUpgrade.ts +++ b/server/src/trigger/arrearProratedUsage/handleProratedUpgrade.ts @@ -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"; diff --git a/server/src/utils/checkUtils/checkCustomerCorrect.ts b/server/src/utils/checkUtils/checkCustomerCorrect.ts index d75d4bd6e..d90305aca 100644 --- a/server/src/utils/checkUtils/checkCustomerCorrect.ts +++ b/server/src/utils/checkUtils/checkCustomerCorrect.ts @@ -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]; diff --git a/server/src/utils/importUtils/importUtils.ts b/server/src/utils/importUtils/importUtils.ts index 6a11e4048..4f940ba9b 100644 --- a/server/src/utils/importUtils/importUtils.ts +++ b/server/src/utils/importUtils/importUtils.ts @@ -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]; diff --git a/server/tests/_temp/temp1.test.ts b/server/tests/_temp/temp1.test.ts index be0367070..bca02eaab 100644 --- a/server/tests/_temp/temp1.test.ts +++ b/server/tests/_temp/temp1.test.ts @@ -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, - // }); - // }); }); diff --git a/server/tests/advanced/usage/usage3.test.ts b/server/tests/advanced/usage/usage3.test.ts index 5696c3afa..74948eb7b 100644 --- a/server/tests/advanced/usage/usage3.test.ts +++ b/server/tests/advanced/usage/usage3.test.ts @@ -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"; diff --git a/server/tests/attach/misc/attach-misc2.test.ts b/server/tests/attach/misc/attach-misc2.test.ts index e69de29bb..f329730f1 100644 --- a/server/tests/attach/misc/attach-misc2.test.ts +++ b/server/tests/attach/misc/attach-misc2.test.ts @@ -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); + }); +}); diff --git a/server/tests/attach/upgradeOld/upgradeOld1.test.ts b/server/tests/attach/upgradeOld/upgradeOld1.test.ts index fcce2390b..a70006966 100644 --- a/server/tests/attach/upgradeOld/upgradeOld1.test.ts +++ b/server/tests/attach/upgradeOld/upgradeOld1.test.ts @@ -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"; diff --git a/server/tests/attach/upgradeOld/upgradeOld2.test.ts b/server/tests/attach/upgradeOld/upgradeOld2.test.ts index c978bddaf..c6bc13aea 100644 --- a/server/tests/attach/upgradeOld/upgradeOld2.test.ts +++ b/server/tests/attach/upgradeOld/upgradeOld2.test.ts @@ -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"; diff --git a/server/tests/balances/update/balances-update1.test.ts b/server/tests/balances/update/balances-update1.test.ts index 0fee32f9a..5c0d4137f 100644 --- a/server/tests/balances/update/balances-update1.test.ts +++ b/server/tests/balances/update/balances-update1.test.ts @@ -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(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, - }); }); }); diff --git a/server/tests/balances/update/balances-update2.test.ts b/server/tests/balances/update/balances-update2.test.ts index f2690a675..027a9230d 100644 --- a/server/tests/balances/update/balances-update2.test.ts +++ b/server/tests/balances/update/balances-update2.test.ts @@ -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(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(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, }); }); }); diff --git a/server/tests/balances/update/balances-update3.test.ts b/server/tests/balances/update/balances-update3.test.ts index 8b2378377..06de0e8dc 100644 --- a/server/tests/balances/update/balances-update3.test.ts +++ b/server/tests/balances/update/balances-update3.test.ts @@ -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(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(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(customerId); - const balance = customer.balances[TestFeature.Users]; +// const customer = await autumnV2.customers.get(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(customerId); - const balance = customer.balances[TestFeature.Users]; - expect(balance.current_balance).toBe(0); - }); +// const customer = await autumnV2.customers.get(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(customerId); - const balance = customer.balances[TestFeature.Users]; +// const customer = await autumnV2.customers.get(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); +// }); +// }); diff --git a/server/tests/balances/update/balances-update4.test.ts b/server/tests/balances/update/balances-update4.test.ts index 784c25683..a985ade4b 100644 --- a/server/tests/balances/update/balances-update4.test.ts +++ b/server/tests/balances/update/balances-update4.test.ts @@ -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, +// // }); +// }); diff --git a/server/tests/balances/update/update-granted-balance/update-granted-balance1.test.ts b/server/tests/balances/update/update-granted-balance/update-granted-balance1.test.ts new file mode 100644 index 000000000..d8c9ba8b4 --- /dev/null +++ b/server/tests/balances/update/update-granted-balance/update-granted-balance1.test.ts @@ -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(customerId); + const balance = customerV2.balances[TestFeature.Messages]; + + expect(balance).toMatchObject({ + granted_balance: 150, + current_balance: 100, + usage: 50, + purchased_balance: 0, + }); + }); +}); diff --git a/server/tests/balances/update/update-granted-balance/update-granted-balance2.test.ts b/server/tests/balances/update/update-granted-balance/update-granted-balance2.test.ts new file mode 100644 index 000000000..4f7ef8d30 --- /dev/null +++ b/server/tests/balances/update/update-granted-balance/update-granted-balance2.test.ts @@ -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(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, + }); + }); +}); diff --git a/server/tests/balances/update/update-granted-balance/update-granted-balance3.test.ts b/server/tests/balances/update/update-granted-balance/update-granted-balance3.test.ts new file mode 100644 index 000000000..0f72b2649 --- /dev/null +++ b/server/tests/balances/update/update-granted-balance/update-granted-balance3.test.ts @@ -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, + }); + }); +}); diff --git a/server/tests/balances/update/update-granted-balance/update-granted-balance4.test.ts b/server/tests/balances/update/update-granted-balance/update-granted-balance4.test.ts new file mode 100644 index 000000000..fc9f3f44c --- /dev/null +++ b/server/tests/balances/update/update-granted-balance/update-granted-balance4.test.ts @@ -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(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(customerId); + const balance = customer.balances[TestFeature.Messages]; + + expect(balance).toMatchObject({ + granted_balance: 100, + current_balance: 50, + usage: 50, + purchased_balance: 0, + }); + }); +}); diff --git a/server/tests/billing/new-billing-subscription/new-billing-subscription1.test.ts b/server/tests/billing/new-billing-subscription/new-billing-subscription1.test.ts new file mode 100644 index 000000000..4d068d30b --- /dev/null +++ b/server/tests/billing/new-billing-subscription/new-billing-subscription1.test.ts @@ -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(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); + }); +}); diff --git a/server/tests/interval/multiSub/multiSubInterval1.test.ts b/server/tests/interval/multiSub/multiSubInterval1.test.ts index 786bdf1a5..f8f0c6fa9 100644 --- a/server/tests/interval/multiSub/multiSubInterval1.test.ts +++ b/server/tests/interval/multiSub/multiSubInterval1.test.ts @@ -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 ); }); diff --git a/server/tests/merged/mergeUtils.test.ts b/server/tests/merged/mergeUtils.test.ts index c52763167..e7058d116 100644 --- a/server/tests/merged/mergeUtils.test.ts +++ b/server/tests/merged/mergeUtils.test.ts @@ -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)); }; diff --git a/server/tests/unit-tests/billing/invoicing/line-item-utils/tiers-to-line-amount.test.ts b/server/tests/unit-tests/billing/invoicing/line-item-utils/tiers-to-line-amount.test.ts new file mode 100644 index 000000000..e85e407fb --- /dev/null +++ b/server/tests/unit-tests/billing/invoicing/line-item-utils/tiers-to-line-amount.test.ts @@ -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(); + }); + }); +}); diff --git a/server/tests/utils/expectUtils/expectInvoiceUtils.ts b/server/tests/utils/expectUtils/expectInvoiceUtils.ts index 54c9e2f27..33598124a 100644 --- a/server/tests/utils/expectUtils/expectInvoiceUtils.ts +++ b/server/tests/utils/expectUtils/expectInvoiceUtils.ts @@ -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; diff --git a/server/tests/utils/testProductUtils/testProductUtils.ts b/server/tests/utils/testProductUtils/testProductUtils.ts index b77f4b35c..e9bfd7fe1 100644 --- a/server/tests/utils/testProductUtils/testProductUtils.ts +++ b/server/tests/utils/testProductUtils/testProductUtils.ts @@ -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; diff --git a/shared/api/_openapi/prevVersions/openapi1.2/analyticsOpenApi.ts b/shared/api/_openapi/prevVersions/openapi1.2/analyticsOpenApi.ts new file mode 100644 index 000000000..3ee74c5cd --- /dev/null +++ b/shared/api/_openapi/prevVersions/openapi1.2/analyticsOpenApi.ts @@ -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, + }, + }, + }, + }, + }, + }, +}; diff --git a/shared/api/_openapi/prevVersions/openapi1.2/customersOpenApi.ts b/shared/api/_openapi/prevVersions/openapi1.2/customersOpenApi.ts index 173c3f486..0d1018dfe 100644 --- a/shared/api/_openapi/prevVersions/openapi1.2/customersOpenApi.ts +++ b/shared/api/_openapi/prevVersions/openapi1.2/customersOpenApi.ts @@ -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, + }), + ), }), }, }, diff --git a/shared/api/_openapi/prevVersions/openapi1.2/openapi1.2.0.ts b/shared/api/_openapi/prevVersions/openapi1.2/openapi1.2.0.ts index 2951d3d24..c4b594b0d 100644 --- a/shared/api/_openapi/prevVersions/openapi1.2/openapi1.2.0.ts +++ b/shared/api/_openapi/prevVersions/openapi1.2/openapi1.2.0.ts @@ -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, }, }, { diff --git a/shared/api/balances/balancesUpdateModels.ts b/shared/api/balances/balancesUpdateModels.ts index 6d36a66aa..72a5aaa22 100644 --- a/shared/api/balances/balancesUpdateModels.ts +++ b/shared/api/balances/balancesUpdateModels.ts @@ -1,4 +1,4 @@ -import { notNullish } from "@autumn/shared"; +import { notNullish, ResetInterval } from "@autumn/shared"; import { z } from "zod/v4"; export const UpdateBalanceParamsSchema = z @@ -16,9 +16,15 @@ export const UpdateBalanceParamsSchema = z current_balance: z.number().optional().meta({ description: "The new balance value to set.", }), + granted_balance: z.number().optional().meta({ + description: "The new granted balance value to set.", + }), usage: z.number().optional().meta({ description: "The new usage value to set.", }), + interval: z.enum(ResetInterval).optional().meta({ + description: "The interval to update balance for.", + }), }) .refine( (data) => { diff --git a/shared/api/billing/attach/prevVersions/attachBodyV0.ts b/shared/api/billing/attach/prevVersions/attachBodyV0.ts index 013cafaa5..5ff9ca4f6 100644 --- a/shared/api/billing/attach/prevVersions/attachBodyV0.ts +++ b/shared/api/billing/attach/prevVersions/attachBodyV0.ts @@ -38,6 +38,7 @@ export const ExtAttachBodyV0Schema = z // Checkout params setup_payment: z.boolean().optional(), + new_billing_subscription: z.boolean().optional(), }) .meta({ example: { diff --git a/shared/api/errors/classes/productErrClasses.ts b/shared/api/errors/classes/productErrClasses.ts index 4b3517fbc..06d898d5c 100644 --- a/shared/api/errors/classes/productErrClasses.ts +++ b/shared/api/errors/classes/productErrClasses.ts @@ -7,7 +7,7 @@ import { ProductErrorCode } from "../codes/productErrCodes.js"; export class ProductNotFoundError extends RecaseError { constructor(opts: { productId: string; version?: string | number }) { super({ - message: `Product ${opts.productId} ${opts.version ? ` (version ${opts.version})` : ""} not found`, + message: `Product ${opts.productId} ${opts.version ? `(version ${opts.version}) ` : ""}not found`, code: ProductErrorCode.ProductNotFound, statusCode: 404, }); diff --git a/shared/api/events/aggregation/eventAggregationSchema.ts b/shared/api/events/aggregation/eventAggregationSchema.ts new file mode 100644 index 000000000..2f96ae318 --- /dev/null +++ b/shared/api/events/aggregation/eventAggregationSchema.ts @@ -0,0 +1,110 @@ +import z from "zod/v4"; + +export const RangeEnum = z.enum([ + "24h", + "7d", + "30d", + "90d", + "last_cycle", + "1bc", + "3bc", +]); + +export type RangeEnum = z.infer; + +export const BinSizeEnum = z.enum(["day", "hour"]).default("day"); + +export type BinSizeEnum = z.infer; + +export const EventAggregationBodySchema = z + .object({ + customer_id: z.string().min(1), + feature_id: z + .string() + .min(1) + .or(z.array(z.string().min(1))), + group_by: z.string().startsWith("properties.").optional(), + range: RangeEnum.optional(), + bin_size: BinSizeEnum, + custom_range: z + .object({ + start: z.number(), + end: z.number(), + }) + .refine((data) => data.start < data.end, { + message: "start must be before end", + }) + .optional(), + }) + .refine( + (data) => { + const customRangeExists = + !!data.custom_range?.end && !!data.custom_range?.start; + const rangeExists = data.range !== undefined; + return !customRangeExists || !rangeExists; + }, + { + message: "Only one of range or custom_range may be provided", + path: ["custom_range", "range"], + }, + ) + .transform((data) => { + if (!data.range && !data.custom_range) { + return { ...data, range: "1bc" as const }; + } + return data; + }); + +export type EventAggregationBody = z.infer; + +// Response without group_by: { period: number, [featureName]: number } +const EventAggregationResponseFlatSchema = z.object({ + list: z.array( + z + .object({ + period: z.number(), + }) + .catchall(z.number()), + ), +}); + +// Response with group_by: { period: number, [featureName]: { [groupValue]: number } } +const EventAggregationResponseGroupedSchema = z.object({ + list: z.array( + z + .object({ + period: z.number(), + }) + .catchall(z.record(z.string(), z.number())), + ), +}); + +const EventAggregationResponseTotalSchema = z.object({ + total: z.record( + z.string(), + z.object({ + count: z.number(), + sum: z.number(), + }), + ), +}); + +export const EventAggregationResponseSchema = z.union([ + EventAggregationResponseFlatSchema.and(EventAggregationResponseTotalSchema), + EventAggregationResponseGroupedSchema.and( + EventAggregationResponseTotalSchema, + ), +]); + +export type EventAggregationResponse = z.infer< + typeof EventAggregationResponseSchema +>; + +export const EventAggregationErrorResponseSchema = z.object({ + code: z.string(), + message: z.string(), +}); + +export type EventAggregationErrorResponse = z.infer< + typeof EventAggregationErrorResponseSchema +>; diff --git a/shared/api/events/insights/query/insightsQueryBody.ts b/shared/api/events/insights/query/insightsQueryBody.ts new file mode 100644 index 000000000..463922115 --- /dev/null +++ b/shared/api/events/insights/query/insightsQueryBody.ts @@ -0,0 +1,13 @@ +import { z } from "zod/v4"; + +export const InsightsQueryBodySchema = z.object({ + query: z.string(), +}); + +export type InsightsQueryBody = z.infer; + +export const InsightsQueryResponseSchema = z.object({ + data: z.any(), +}); + +export type InsightsQueryResponse = z.infer; diff --git a/shared/api/features/featureV1OpModels.ts b/shared/api/features/featureV1OpModels.ts index dd6389ac3..307fc2738 100644 --- a/shared/api/features/featureV1OpModels.ts +++ b/shared/api/features/featureV1OpModels.ts @@ -1,6 +1,6 @@ import { z } from "zod/v4"; -import { FeatureType } from "../../models/featureModels/featureEnums.js"; -import { nullish } from "../../utils/utils.js"; +import { FeatureType } from "../../models/featureModels/featureEnums"; +import { idRegex, nullish } from "../../utils/utils"; const featureDescriptions = { id: "The ID of the feature. This is used to refer to it in other API calls like /track or /check.", @@ -19,7 +19,11 @@ const featureDescriptions = { // Create Feature Params export const CreateFeatureV1ParamsSchema = z .object({ - id: z.string().nonempty().meta({ description: featureDescriptions.id }), + id: z + .string() + .nonempty() + .regex(idRegex) + .meta({ description: featureDescriptions.id }), name: z .string() .nonempty() diff --git a/shared/api/features/prevVersions/featureV0OpModels.ts b/shared/api/features/prevVersions/featureV0OpModels.ts index 04fb267a4..10afe2358 100644 --- a/shared/api/features/prevVersions/featureV0OpModels.ts +++ b/shared/api/features/prevVersions/featureV0OpModels.ts @@ -1,5 +1,6 @@ import { z } from "zod/v4"; -import { ApiFeatureType } from "./apiFeatureV0.js"; +import { idRegex } from "../../../utils/utils"; +import { ApiFeatureType } from "./apiFeatureV0"; const featureDescriptions = { id: "The ID of the feature. This is used to refer to it in other API calls like /track or /check.", @@ -17,7 +18,7 @@ const featureDescriptions = { // Create Feature Params export const CreateFeatureV0ParamsSchema = z.object({ - id: z.string().meta({ description: featureDescriptions.id }), + id: z.string().regex(idRegex).meta({ description: featureDescriptions.id }), name: z.string().nullish().meta({ description: featureDescriptions.name }), type: z.enum(ApiFeatureType).meta({ description: featureDescriptions.type }), display: z @@ -41,7 +42,11 @@ export const CreateFeatureV0ParamsSchema = z.object({ // Update Feature Params export const UpdateFeatureV0ParamsSchema = z.object({ - id: z.string().optional().meta({ description: featureDescriptions.id }), + id: z + .string() + .regex(idRegex) + .optional() + .meta({ description: featureDescriptions.id }), name: z.string().optional().meta({ description: featureDescriptions.name }), type: z .enum(ApiFeatureType) diff --git a/shared/drizzle.config.ts b/shared/drizzle.config.ts index 581cb7eac..1d3e26459 100644 --- a/shared/drizzle.config.ts +++ b/shared/drizzle.config.ts @@ -1,7 +1,8 @@ import { config } from "dotenv"; -config({ path: "../server/.env" }); import { defineConfig } from "drizzle-kit"; +config({ path: "../server/.env" }); + export default defineConfig({ dialect: "postgresql", out: "./drizzle", diff --git a/shared/index.ts b/shared/index.ts index dd03354fa..0671f88c2 100644 --- a/shared/index.ts +++ b/shared/index.ts @@ -71,6 +71,7 @@ export * from "./models/devModels/apiKeyTable.js"; // 5. Others: events, apiKeys export * from "./models/eventModels/eventModels.js"; export * from "./models/eventModels/eventTable.js"; +export * from "./models/eventModels/eventTypes.js"; export * from "./models/featureModels/featureConfig/creditConfig.js"; export * from "./models/featureModels/featureConfig/meteredConfig.js"; export * from "./models/featureModels/featureEnums.js"; @@ -89,6 +90,12 @@ export * from "./models/genModels/processorSchemas.js"; // Idempotency Models +// Analytics Aggregation Models +export * from "./api/events/aggregation/eventAggregationSchema.js"; + +// Insights Models +export * from "./api/events/insights/query/insightsQueryBody.js"; + // Attach Function Response export * from "./models/attachModels/attachFunctionResponse.js"; export * from "./models/billingModels/cusProductActions.js"; diff --git a/shared/models/eventModels/eventTypes.ts b/shared/models/eventModels/eventTypes.ts new file mode 100644 index 000000000..f715c6b33 --- /dev/null +++ b/shared/models/eventModels/eventTypes.ts @@ -0,0 +1,55 @@ +import type { BinSizeEnum, FullCustomer, RangeEnum } from "@autumn/shared"; + +export type ClickHouseResult = { + data: Array>; +}; + +export type TotalEventsParams = { + event_names: string[]; + customer_id?: string; + aggregateAll?: boolean; + custom_range?: { start: number; end: number }; + interval?: RangeEnum; + customer?: FullCustomer; + bin_size?: BinSizeEnum; +}; + +export type TimeseriesEventsParams = TotalEventsParams & { + group_by?: string; + no_count?: boolean; +}; + +export type CalculateDateRangeParams = Omit< + TotalEventsParams, + "event_names" | "customer_id" +>; + +export type DateRangeResult = { + startDate: string; + endDate: string; +}; + +export type BillingCycleResult = { + startDate: string; + endDate: string; + gap: number; +}; + +export type TimeseriesEventRow = Record; + +export type ProcessedEventRow = Record & { + period: number; +}; + +export type FlatAggregatedRow = { + period: number; + [featureName: string]: number; +}; + +export type GroupedAggregatedRow = { + period: number; +} & { + [featureName: string]: Record; +}; + +export type AggregatedEventRow = FlatAggregatedRow | GroupedAggregatedRow; diff --git a/shared/utils/billingUtils/INVOICING_ARCHITECTURE.md b/shared/utils/billingUtils/INVOICING_ARCHITECTURE.md new file mode 100644 index 000000000..72ba76e97 --- /dev/null +++ b/shared/utils/billingUtils/INVOICING_ARCHITECTURE.md @@ -0,0 +1,179 @@ +# Invoicing Architecture + +## Final Structure + +``` +server/src/internal/billing/ +โ”œโ”€โ”€ invoicing/ # NEW - orchestration & Stripe ops +โ”‚ โ”œโ”€โ”€ createInvoice.ts # Create Stripe invoice +โ”‚ โ”œโ”€โ”€ finalizeInvoice.ts # Finalize invoice +โ”‚ โ”œโ”€โ”€ collectPayment.ts # Pay/collect invoice +โ”‚ โ”œโ”€โ”€ addLineItems.ts # Add items to invoice +โ”‚ โ””โ”€โ”€ scenarioLineItems/ # Scenario-specific item generation +โ”‚ โ”œโ”€โ”€ newProductLineItems.ts +โ”‚ โ”œโ”€โ”€ upgradeLineItems.ts +โ”‚ โ”œโ”€โ”€ downgradeLineItems.ts +โ”‚ โ””โ”€โ”€ quantityChangeLineItems.ts + +shared/utils/billingUtils/ +โ”œโ”€โ”€ cycleUtils/ # โœ… Already exists +โ”œโ”€โ”€ invoicingUtils/ # Empty - populate with pure calcs +โ”‚ โ”œโ”€โ”€ lineItemUtils/ # Line item calculations +โ”‚ โ”‚ โ”œโ”€โ”€ calculateLineItemAmount.ts +โ”‚ โ”‚ โ”œโ”€โ”€ tieredPriceUtils.ts +โ”‚ โ”‚ โ””โ”€โ”€ types.ts +โ”‚ โ”œโ”€โ”€ prorationUtils.ts # Proration math (uses cycleUtils) +โ”‚ โ””โ”€โ”€ invoiceDisplayUtils.ts # Formatting, descriptions +``` + +--- + +## Layer 1: Pure Calculations (shared/billingUtils/invoicingUtils/) + +### 1.1 `lineItemUtils/calculateLineItemAmount.ts` + +Central amount calculation: + +```typescript +export const calculateLineItemAmount = ({ + price, + quantity, + proration, + now, +}: { + price: Price; + quantity?: number; + proration?: { start: number; end: number }; + now?: number; +}): number +``` + +### 1.2 `lineItemUtils/tieredPriceUtils.ts` + +Tiered pricing calculation (extracted from `getAmountForQuantity`): + +```typescript +export const calculateTieredAmount = ({ + tiers, + quantity, + billingUnits, +}: { + tiers: UsageTier[]; + quantity: number; + billingUnits?: number; +}): number +``` + +### 1.3 `prorationUtils.ts` + +Proration utilities integrated with `cycleUtils`: + +```typescript +import { getCycleStart, getCycleEnd } from "../cycleUtils"; + +export const getProrationPeriod = ({ + anchor, + interval, + intervalCount, + now, +}: { ... }): { start: number; end: number } + +export const applyProration = ({ + amount, + periodStart, + periodEnd, + now, +}): number +``` + +### 1.4 `invoiceDisplayUtils.ts` + +Formatting and description utilities: + +```typescript +export const formatLineItemDescription = ({ ... }): string +export const formatLineItemPrice = ({ ... }): string +``` + +--- + +## Layer 2: Orchestration (server/billing/invoicing/) + +### 2.1 `createInvoice.ts` + +```typescript +export const createInvoice = async ({ + stripeCli, + customerId, + currency, + discounts, + memo, + collectionMethod, +}: { ... }): Promise +``` + +### 2.2 `addLineItems.ts` + +```typescript +export const addLineItemsToInvoice = async ({ + stripeCli, + invoiceId, + customerId, + lineItems, +}: { + stripeCli: Stripe; + invoiceId: string; + customerId: string; + lineItems: LineItemOutput[]; +}): Promise +``` + +### 2.3 `collectPayment.ts` + +Clear replacement for `payForInvoice`: + +```typescript +export const collectPayment = async ({ + stripeCli, + invoiceId, + paymentMethod, + options, +}: { + stripeCli: Stripe; + invoiceId: string; + paymentMethod?: Stripe.PaymentMethod; + options?: { + voidOnFail?: boolean; + errorOnFail?: boolean; + }; +}): Promise<{ paid: boolean; invoice: Stripe.Invoice; error?: Error }> +``` + +### 2.4 `scenarioLineItems/` + +Each scenario uses the pure utils from `invoicingUtils`: + +```typescript +// upgradeLineItems.ts +import { calculateLineItemAmount, applyProration } from "@autumn/shared"; + +export const getUpgradeLineItems = async ({ + ctx, + curCusProduct, + newProduct, + sub, +}: { ... }): Promise +``` + +--- + +## Summary + +| Location | Purpose | Dependencies | +|----------|---------|--------------| +| `shared/.../invoicingUtils/lineItemUtils/` | Pure math (amounts, tiers) | None | +| `shared/.../invoicingUtils/prorationUtils.ts` | Proration math | cycleUtils | +| `shared/.../invoicingUtils/invoiceDisplayUtils.ts` | Formatting | None | +| `server/.../billing/invoicing/` | Stripe ops, orchestration | invoicingUtils, Stripe | +| `server/.../billing/invoicing/scenarioLineItems/` | Scenario-specific logic | invoicingUtils, Stripe | + diff --git a/shared/utils/billingUtils/index.ts b/shared/utils/billingUtils/index.ts index 3fe5c67ec..3c7a2d850 100644 --- a/shared/utils/billingUtils/index.ts +++ b/shared/utils/billingUtils/index.ts @@ -1,3 +1,6 @@ export * from "./cycleUtils/getCycleEnd.js"; export * from "./cycleUtils/getCycleStart.js"; export * from "./intervalUtils/intervalArithmetic.js"; + +export * from "./invoicingUtils/lineItemUtils/priceToLineAmount.js"; +export * from "./invoicingUtils/lineItemUtils/tiersToLineAmount.js"; diff --git a/shared/utils/billingUtils/intervalUtils/cycleUtils.ts b/shared/utils/billingUtils/intervalUtils/cycleUtils.ts deleted file mode 100644 index e69de29bb..000000000 diff --git a/shared/utils/billingUtils/invoicingUtils/lineItemUtils/priceToLineAmount.ts b/shared/utils/billingUtils/invoicingUtils/lineItemUtils/priceToLineAmount.ts new file mode 100644 index 000000000..8dff47b5c --- /dev/null +++ b/shared/utils/billingUtils/invoicingUtils/lineItemUtils/priceToLineAmount.ts @@ -0,0 +1,42 @@ +import { + isFixedPrice, + nullish, + type Price, + tiersToLineAmount, +} from "@autumn/shared"; +import { Decimal } from "decimal.js"; +/** + * Calculates the base amount for a price. + * + * @param price - The price to calculate + * @param overage - Overage amount (for prepaid or usage-in-arrear prices) + * @param multiplier - Quantity multiplier (for fixed prices, e.g. 3 seats) + */ +export const priceToLineAmount = ({ + price, + overage, + multiplier = 1, +}: { + price: Price; + overage?: number; + multiplier?: number; +}): number => { + // Fixed prices: flat amount ร— multiplier + if (isFixedPrice(price)) { + const config = price.config; + return new Decimal(config.amount).mul(multiplier).toNumber(); + } + + // Usage-based prices: tiered calculation + if (nullish(overage)) { + throw new Error( + `[priceToLineAmount] overage required for usage-based prices`, + ); + } + + return tiersToLineAmount({ + price, + overage, + billingUnits: price.config.billing_units ?? 1, + }); +}; diff --git a/shared/utils/billingUtils/invoicingUtils/lineItemUtils/tiersToLineAmount.ts b/shared/utils/billingUtils/invoicingUtils/lineItemUtils/tiersToLineAmount.ts new file mode 100644 index 000000000..1f01353f2 --- /dev/null +++ b/shared/utils/billingUtils/invoicingUtils/lineItemUtils/tiersToLineAmount.ts @@ -0,0 +1,52 @@ +import { Decimal } from "decimal.js"; +import type { Price } from "../../../../models/productModels/priceModels/priceModels"; +import { Infinite } from "../../../../models/productModels/productEnums"; +import { nullish } from "../../../utils"; + +export const tiersToLineAmount = ({ + price, + overage, + billingUnits = 1, +}: { + price: Price; + overage: number; + billingUnits?: number; +}): number => { + // Round up to billing units + const roundedOverage = new Decimal(overage) + .div(billingUnits) + .ceil() + .mul(billingUnits) + .toNumber(); + + let amount = new Decimal(0); + let remaining = new Decimal(roundedOverage); + let lastTierTo = 0; + const tiers = price.config.usage_tiers; + + if (nullish(tiers)) { + throw new Error( + `[tiersToLineAmount] usage_tiers required for usage-based prices`, + ); + } + + for (const tier of tiers) { + if (remaining.lte(0)) break; + + const isFinalTier = tier.to === Infinite || tier.to === -1; + + const tierSize = isFinalTier + ? remaining + : Decimal.min(remaining, new Decimal(tier.to).minus(lastTierTo)); + + const rate = new Decimal(tier.amount).div(billingUnits); + amount = amount.plus(rate.mul(tierSize)); + remaining = remaining.minus(tierSize); + + if (tier.to !== Infinite && tier.to !== -1) { + lastTierTo = tier.to; + } + } + + return amount.toDecimalPlaces(10).toNumber(); +}; diff --git a/shared/utils/cusEntUtils/balanceUtils/cusEntsToBalance.ts b/shared/utils/cusEntUtils/balanceUtils/cusEntsToBalance.ts new file mode 100644 index 000000000..1e25bc117 --- /dev/null +++ b/shared/utils/cusEntUtils/balanceUtils/cusEntsToBalance.ts @@ -0,0 +1,19 @@ +import type { FullCusEntWithFullCusProduct } from "../../../models/cusProductModels/cusEntModels/cusEntWithProduct"; +import { sumValues } from "../../utils"; +import { cusEntToBalance } from "../convertCusEntUtils"; + +export const cusEntsToBalance = ({ + cusEnts, + entityId, + withRollovers = false, +}: { + cusEnts: FullCusEntWithFullCusProduct[]; + entityId?: string; + withRollovers?: boolean; +}) => { + return sumValues( + cusEnts.map((cusEnt) => + cusEntToBalance({ cusEnt, entityId, withRollovers }), + ), + ); +}; diff --git a/shared/utils/cusEntUtils/balanceUtils/grantedBalanceUtils/cusEntsToAdjustment.ts b/shared/utils/cusEntUtils/balanceUtils/grantedBalanceUtils/cusEntsToAdjustment.ts new file mode 100644 index 000000000..931bd404e --- /dev/null +++ b/shared/utils/cusEntUtils/balanceUtils/grantedBalanceUtils/cusEntsToAdjustment.ts @@ -0,0 +1,21 @@ +import type { FullCusEntWithFullCusProduct } from "../../../../models/cusProductModels/cusEntModels/cusEntWithProduct"; +import { sumValues } from "../../../utils"; +import { getCusEntBalance } from "../../balanceUtils"; + +export const cusEntsToAdjustment = ({ + cusEnts, + entityId, +}: { + cusEnts: FullCusEntWithFullCusProduct[]; + entityId?: string; +}) => { + return sumValues( + cusEnts.map((cusEnt) => { + const { adjustment } = getCusEntBalance({ + cusEnt, + entityId, + }); + return adjustment; + }), + ); +}; diff --git a/shared/utils/cusEntUtils/balanceUtils/grantedBalanceUtils/cusEntsToAllowance.ts b/shared/utils/cusEntUtils/balanceUtils/grantedBalanceUtils/cusEntsToAllowance.ts new file mode 100644 index 000000000..45c7e790a --- /dev/null +++ b/shared/utils/cusEntUtils/balanceUtils/grantedBalanceUtils/cusEntsToAllowance.ts @@ -0,0 +1,56 @@ +import { Decimal } from "decimal.js"; +import type { FullCusEntWithFullCusProduct } from "../../../../models/cusProductModels/cusEntModels/cusEntWithProduct"; +import { sumValues } from "../../../utils"; +import { getCusEntBalance } from "../../balanceUtils"; +import { getRolloverFields } from "../../getRolloverFields"; + +// NEW CUS ENT UTILS +export const cusEntsToAllowance = ({ + cusEnts, + entityId, + withRollovers = false, +}: { + cusEnts: FullCusEntWithFullCusProduct[]; + entityId?: string; + withRollovers?: boolean; +}) => { + const getAllowance = ({ + cusEnt, + entityId, + withRollovers = false, + }: { + cusEnt: FullCusEntWithFullCusProduct; + entityId?: string; + withRollovers?: boolean; + }) => { + const rollover = getRolloverFields({ + cusEnt, + entityId, + }); + + const { count: entityCount } = getCusEntBalance({ + cusEnt, + entityId, + }); + + const grantedBalance = cusEnt.entitlement.allowance || 0; + + const total = new Decimal(grantedBalance) + .mul(cusEnt.customer_product.quantity ?? 1) + .mul(entityCount) + .toNumber(); + + if (withRollovers && rollover) { + return new Decimal(total) + .add(rollover.balance) + .add(rollover.usage) + .toNumber(); + } + + return total; + }; + + return sumValues( + cusEnts.map((cusEnt) => getAllowance({ cusEnt, entityId, withRollovers })), + ); +}; diff --git a/shared/utils/cusEntUtils/balanceUtils/grantedBalanceUtils/cusEntsToGrantedBalance.ts b/shared/utils/cusEntUtils/balanceUtils/grantedBalanceUtils/cusEntsToGrantedBalance.ts new file mode 100644 index 000000000..1f537e093 --- /dev/null +++ b/shared/utils/cusEntUtils/balanceUtils/grantedBalanceUtils/cusEntsToGrantedBalance.ts @@ -0,0 +1,27 @@ +import { Decimal } from "decimal.js"; +import type { FullCusEntWithFullCusProduct } from "../../../../models/cusProductModels/cusEntModels/cusEntWithProduct"; +import { cusEntsToAdjustment } from "./cusEntsToAdjustment"; +import { cusEntsToAllowance } from "./cusEntsToAllowance"; + +export const cusEntsToGrantedBalance = ({ + cusEnts, + entityId, + withRollovers = false, +}: { + cusEnts: FullCusEntWithFullCusProduct[]; + entityId?: string; + withRollovers?: boolean; +}) => { + const totalAllowance = cusEntsToAllowance({ + cusEnts, + entityId, + withRollovers, + }); + + const totalAdjustment = cusEntsToAdjustment({ + cusEnts, + entityId, + }); + + return new Decimal(totalAllowance).add(totalAdjustment).toNumber(); +}; diff --git a/shared/utils/cusEntUtils/classifyCusEntUtils.ts b/shared/utils/cusEntUtils/classifyCusEntUtils.ts index e67086535..a4e6be1dd 100644 --- a/shared/utils/cusEntUtils/classifyCusEntUtils.ts +++ b/shared/utils/cusEntUtils/classifyCusEntUtils.ts @@ -1,5 +1,8 @@ import type { FullCustomerEntitlement } from "../../models/cusProductModels/cusEntModels/cusEntModels"; +import type { FullCusEntWithFullCusProduct } from "../../models/cusProductModels/cusEntModels/cusEntWithProduct"; import { AllowanceType } from "../../models/productModels/entModels/entModels"; +import { cusEntToCusPrice } from "../productUtils/convertUtils"; +import { notNullish } from "../utils"; export const isUnlimitedCusEnt = ({ cusEnt, @@ -8,3 +11,22 @@ export const isUnlimitedCusEnt = ({ }) => { return cusEnt.entitlement.allowance_type === AllowanceType.Unlimited; }; + +export const isEntityScopedCusEnt = ({ + cusEnt, +}: { + cusEnt: FullCustomerEntitlement; +}) => { + return notNullish(cusEnt.entitlement.entity_feature_id); +}; + +export const cusEntsHavePrice = ({ + cusEnts, +}: { + cusEnts: FullCusEntWithFullCusProduct[]; +}) => { + return cusEnts.some((cusEnt) => { + const cusPrice = cusEntToCusPrice({ cusEnt }); + return notNullish(cusPrice); + }); +}; diff --git a/shared/utils/cusEntUtils/convertCusEntUtils.ts b/shared/utils/cusEntUtils/convertCusEntUtils.ts index f3e940662..c475919c3 100644 --- a/shared/utils/cusEntUtils/convertCusEntUtils.ts +++ b/shared/utils/cusEntUtils/convertCusEntUtils.ts @@ -121,41 +121,41 @@ export const cusEntToIncludedUsage = ({ }; // NEW CUS ENT UTILS -export const cusEntToGrantedBalance = ({ - cusEnt, - entityId, - withRollovers = false, -}: { - cusEnt: FullCusEntWithFullCusProduct; - entityId?: string; - withRollovers?: boolean; -}) => { - const rollover = getRolloverFields({ - cusEnt, - entityId, - }); +// export const cusEntToGrantedBalance = ({ +// cusEnt, +// entityId, +// withRollovers = false, +// }: { +// cusEnt: FullCusEntWithFullCusProduct; +// entityId?: string; +// withRollovers?: boolean; +// }) => { +// const rollover = getRolloverFields({ +// cusEnt, +// entityId, +// }); - const { count: entityCount } = getCusEntBalance({ - cusEnt, - entityId, - }); +// const { count: entityCount } = getCusEntBalance({ +// cusEnt, +// entityId, +// }); - const grantedBalance = cusEnt.entitlement.allowance || 0; +// const grantedBalance = cusEnt.entitlement.allowance || 0; - const total = new Decimal(grantedBalance) - .mul(cusEnt.customer_product.quantity ?? 1) - .mul(entityCount) - .toNumber(); +// const total = new Decimal(grantedBalance) +// .mul(cusEnt.customer_product.quantity ?? 1) +// .mul(entityCount) +// .toNumber(); - if (withRollovers && rollover) { - return new Decimal(total) - .add(rollover.balance) - .add(rollover.usage) - .toNumber(); - } +// if (withRollovers && rollover) { +// return new Decimal(total) +// .add(rollover.balance) +// .add(rollover.usage) +// .toNumber(); +// } - return total; -}; +// return total; +// }; export const apiBalanceToBreakdownKey = ({ breakdown, diff --git a/shared/utils/cusUtils/fullCusUtils/getCusStripeSubCount.ts b/shared/utils/cusUtils/fullCusUtils/getCusStripeSubCount.ts new file mode 100644 index 000000000..315995488 --- /dev/null +++ b/shared/utils/cusUtils/fullCusUtils/getCusStripeSubCount.ts @@ -0,0 +1,11 @@ +import type { FullCustomer } from "../../../models/cusModels/fullCusModel.js"; + +export const getCusStripeSubCount = ({ + fullCus, +}: { + fullCus: FullCustomer; +}) => { + return fullCus.customer_products.filter( + (cp) => cp.subscription_ids && cp.subscription_ids.length > 0, + ).length; +}; diff --git a/shared/utils/displayUtils.ts b/shared/utils/displayUtils.ts index fe94e9773..de0eeafe1 100644 --- a/shared/utils/displayUtils.ts +++ b/shared/utils/displayUtils.ts @@ -149,7 +149,7 @@ export const formatAmount = ({ org, currency, amount, - maxFractionDigits = 2, + maxFractionDigits = 10, minFractionDigits = 0, amountFormatOptions, }: { @@ -163,8 +163,8 @@ export const formatAmount = ({ return new Intl.NumberFormat(undefined, { style: "currency", currency: currency || org?.default_currency || "USD", - minimumFractionDigits: minFractionDigits || 0, - maximumFractionDigits: maxFractionDigits || 2, + minimumFractionDigits: minFractionDigits, + maximumFractionDigits: maxFractionDigits, ...amountFormatOptions, }).format(amount); }; diff --git a/shared/utils/index.ts b/shared/utils/index.ts index ddc59ba7e..e35620067 100644 --- a/shared/utils/index.ts +++ b/shared/utils/index.ts @@ -1,7 +1,5 @@ // Billing utils -// Billing utils - export * from "./billingUtils/resolveAttachUtils/getUncancelAttachActions.js"; export * from "./billingUtils/resolveAttachUtils/resolveAttachActions.js"; export * from "./billingUtils/resolveAttachUtils/resolveNewProductTiming.js"; @@ -9,16 +7,20 @@ export * from "./billingUtils/resolveAttachUtils/resolveOngoingCusProductAction. export * from "./billingUtils/resolveAttachUtils/resolveScheduledCusProductAction.js"; export * from "./common/timeUtils.js"; export * from "./common/unixUtils.js"; -export * from "./common/unixUtils.js"; + +export * from "./cusEntUtils/balanceUtils/cusEntsToBalance.js"; +export * from "./cusEntUtils/balanceUtils/cusEntToPrepaidQuantity.js"; +export * from "./cusEntUtils/balanceUtils/cusEntToPurchasedBalance.js"; // Cus ent utils -export * from "./cusEntUtils/balanceUtils"; -export * from "./cusEntUtils/balanceUtils/cusEntToPrepaidQuantity"; -export * from "./cusEntUtils/balanceUtils/cusEntToPurchasedBalance"; -export * from "./cusEntUtils/classifyCusEntUtils"; -export * from "./cusEntUtils/convertCusEntUtils"; -export * from "./cusEntUtils/convertCusEntUtils/cusEntsToMaxPurchase"; -export * from "./cusEntUtils/cusEntUtils"; -export * from "./cusEntUtils/filterCusEntUtils"; +export * from "./cusEntUtils/balanceUtils/grantedBalanceUtils/cusEntsToAdjustment.js"; +export * from "./cusEntUtils/balanceUtils/grantedBalanceUtils/cusEntsToAllowance.js"; +export * from "./cusEntUtils/balanceUtils/grantedBalanceUtils/cusEntsToGrantedBalance.js"; +export * from "./cusEntUtils/balanceUtils.js"; +export * from "./cusEntUtils/classifyCusEntUtils.js"; +export * from "./cusEntUtils/convertCusEntUtils/cusEntsToMaxPurchase.js"; +export * from "./cusEntUtils/convertCusEntUtils.js"; +export * from "./cusEntUtils/cusEntUtils.js"; +export * from "./cusEntUtils/filterCusEntUtils.js"; // Cus ent utils export * from "./cusEntUtils/getRolloverFields.js"; export * from "./cusEntUtils/getStartingBalance.js"; @@ -34,6 +36,7 @@ export * from "./cusProductUtils/getCusProductFromCustomer.js"; export * from "./cusProductUtils/productIdToCusProduct.js"; // Cus utils export * from "./cusUtils/cusPlanUtils/cusPlanUtils.js"; +export * from "./cusUtils/fullCusUtils/getCusStripeSubCount.js"; export * from "./expandUtils.js"; export * from "./featureUtils/apiFeatureToDbFeature.js"; export * from "./featureUtils/convertFeatureUtils.js"; diff --git a/shared/utils/productUtils/priceToInvoiceAmount.ts b/shared/utils/productUtils/priceToInvoiceAmount.ts index 1b6256757..025d8117f 100644 --- a/shared/utils/productUtils/priceToInvoiceAmount.ts +++ b/shared/utils/productUtils/priceToInvoiceAmount.ts @@ -1,5 +1,4 @@ import { Decimal } from "decimal.js"; -import type { FixedPriceConfig } from "../../models/productModels/priceModels/priceConfig/fixedPriceConfig.js"; import type { UsagePriceConfig } from "../../models/productModels/priceModels/priceConfig/usagePriceConfig.js"; import { BillingType } from "../../models/productModels/priceModels/priceEnums.js"; import type { Price } from "../../models/productModels/priceModels/priceModels.js"; @@ -14,7 +13,8 @@ import { type Proration, } from "../productV2Utils/productItemUtils/getProductItemRes.js"; import { nullish } from "../utils.js"; -import { getBillingType, isFixedPrice } from "./priceUtils.js"; +import { isFixedPrice } from "./priceUtils/classifyPriceUtils.js"; +import { getBillingType } from "./priceUtils.js"; export const getAmountForQuantity = ({ price, @@ -111,6 +111,7 @@ export const priceToInvoiceAmount = ({ price, item, quantity, + productQuantity, overage, proration, now, @@ -118,6 +119,7 @@ export const priceToInvoiceAmount = ({ price?: Price; item?: ProductItem; quantity?: number; // quantity should be multiplied by billing units + productQuantity?: number; overage?: number; proration?: Proration; now?: number; @@ -127,8 +129,10 @@ export const priceToInvoiceAmount = ({ let amount = 0; if (price) { - if (isFixedPrice({ price })) { - amount = (price.config as FixedPriceConfig).amount; + if (isFixedPrice(price)) { + amount = new Decimal(price.config.amount) + .mul(productQuantity ?? 1) + .toNumber(); } else { const config = price.config as UsagePriceConfig; const billingType = getBillingType(config); diff --git a/shared/utils/productUtils/priceUtils.ts b/shared/utils/productUtils/priceUtils.ts index 0e23ea26b..b7eb504a8 100644 --- a/shared/utils/productUtils/priceUtils.ts +++ b/shared/utils/productUtils/priceUtils.ts @@ -48,23 +48,11 @@ export const getBillingType = (config: FixedPriceConfig | UsagePriceConfig) => { return BillingType.UsageInArrear; }; -export const isOneOffPrice = ({ price }: { price: Price }) => { - return price.config.interval === BillingInterval.OneOff; -}; - export const isPrepaidPrice = ({ price }: { price: Price }) => { const billingType = getBillingType(price.config); 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, diff --git a/shared/utils/productUtils/priceUtils/classifyPriceUtils.ts b/shared/utils/productUtils/priceUtils/classifyPriceUtils.ts index 105e79186..c3ca985ab 100644 --- a/shared/utils/productUtils/priceUtils/classifyPriceUtils.ts +++ b/shared/utils/productUtils/priceUtils/classifyPriceUtils.ts @@ -1,7 +1,26 @@ +import { BillingInterval } from "../../../models/productModels/intervals/billingInterval"; +import type { FixedPriceConfig } from "../../../models/productModels/priceModels/priceConfig/fixedPriceConfig"; import { BillingType } from "../../../models/productModels/priceModels/priceEnums"; import type { Price } from "../../../models/productModels/priceModels/priceModels"; import { getBillingType } from "../priceUtils"; +export const isOneOffPrice = ( + price: Price, +): price is Price & { + config: FixedPriceConfig & { interval: BillingInterval.OneOff }; +} => { + return price.config.interval === BillingInterval.OneOff; +}; + +export const isFixedPrice = ( + price: Price, +): price is Price & { config: FixedPriceConfig } => { + const billingType = getBillingType(price.config); + return ( + billingType === BillingType.FixedCycle || billingType === BillingType.OneOff + ); +}; + export const isUsagePrice = ({ price, featureId, diff --git a/shared/utils/rewardUtils/rewardMigrationUtils.ts b/shared/utils/rewardUtils/rewardMigrationUtils.ts index 6b302dae0..a14f15d19 100644 --- a/shared/utils/rewardUtils/rewardMigrationUtils.ts +++ b/shared/utils/rewardUtils/rewardMigrationUtils.ts @@ -7,7 +7,8 @@ import { type UsagePriceConfig, type UsageTier, } from "../../index.js"; -import { getBillingType, isFixedPrice } from "../productUtils/priceUtils.js"; +import { getBillingType } from "../productUtils/priceUtils"; +import { isFixedPrice } from "../productUtils/priceUtils/classifyPriceUtils"; // Helper function to check if tier structures match const tiersMatch = (oldTiers: UsageTier[], newTiers: UsageTier[]): boolean => { @@ -80,7 +81,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); diff --git a/vite/src/components/forms/attach-product/attach-product-form.tsx b/vite/src/components/forms/attach-product/attach-product-form.tsx index 18c6f1297..fcdd9c108 100644 --- a/vite/src/components/forms/attach-product/attach-product-form.tsx +++ b/vite/src/components/forms/attach-product/attach-product-form.tsx @@ -1,13 +1,14 @@ -import type { Entity, FullCustomer, ProductV2 } from "@autumn/shared"; +import type { + Entity, + FrontendProduct, + FullCustomer, + ProductV2, +} from "@autumn/shared"; import { useStore } from "@tanstack/react-form"; -import { useEffect } from "react"; import { FormWrapper } from "@/components/general/form/form-wrapper"; import { SheetSection } from "@/components/v2/sheets/SharedSheetComponents"; import { useProductsQuery } from "@/hooks/queries/useProductsQuery"; -import { - usePrepaidItems, - useProductStore, -} from "@/hooks/stores/useProductStore"; +import { usePrepaidItems } from "@/hooks/stores/useProductStore"; import { useSheetStore } from "@/hooks/stores/useSheetStore"; import { useEntity } from "@/hooks/stores/useSubscriptionStore"; import { useCusQuery } from "@/views/customers/customer/hooks/useCusQuery"; @@ -33,15 +34,19 @@ function FormContent({ form, onSuccess, }: FormContentProps) { - const storeProduct = useProductStore((s) => s.product); + const sheetData = useSheetStore((s) => s.data); const productId = useStore(form.store, (state) => state.values.productId); const prepaidOptions = useStore( form.store, (state) => state.values.prepaidOptions, ); - const product = storeProduct?.id - ? storeProduct + // Use customized product from sheet data if available, otherwise find from products list + const customizedProduct = sheetData?.customizedProduct as + | FrontendProduct + | undefined; + const product = customizedProduct?.id + ? customizedProduct : products.find((p) => p.id === productId && !p.archived); const { prepaidItems } = usePrepaidItems({ product }); @@ -103,7 +108,6 @@ export function AttachProductForm({ const itemId = useSheetStore((s) => s.itemId); const form = useAttachProductForm({ initialProductId: itemId || undefined }); const { products, isLoading } = useProductsQuery(); - const resetProductStore = useProductStore((s) => s.reset); const activeProducts = products.filter((p) => !p.archived); @@ -116,14 +120,6 @@ export function AttachProductForm({ (e: Entity) => e.id === entityId || e.internal_id === entityId, ); - const productId = useStore(form.store, (state) => state.values.productId); - - useEffect(() => { - if (productId && productId !== itemId) { - resetProductStore(); - } - }, [productId, itemId, resetProductStore]); - if (isLoading) { return
Loading products...
; } diff --git a/vite/src/components/forms/attach-product/update-product-actions.tsx b/vite/src/components/forms/attach-product/update-product-actions.tsx index 2ac0ca49e..530b2d5d2 100644 --- a/vite/src/components/forms/attach-product/update-product-actions.tsx +++ b/vite/src/components/forms/attach-product/update-product-actions.tsx @@ -111,7 +111,7 @@ export function UpdateProductActions({ variant="secondary" className="w-full" isLoading={isLoading} - disabled={isLoading || !isOwnStripeAccount} + disabled={isLoading} type="button" > Send an Invoice diff --git a/vite/src/components/general/DateInputUnix.tsx b/vite/src/components/general/DateInputUnix.tsx index 2e1830d3b..05b30530f 100644 --- a/vite/src/components/general/DateInputUnix.tsx +++ b/vite/src/components/general/DateInputUnix.tsx @@ -29,14 +29,14 @@ export const DateInputUnix = ({ className={cn( // Match Select component styling "w-full rounded-lg flex items-center justify-start gap-2 text-sm outline-none disabled:cursor-not-allowed disabled:opacity-50", - "h-input input-base input-shadow-default input-state-open", + "h-input input-base input-shadow-default input-state-open truncate", // Placeholder styling !unixDate && "text-muted-foreground", )} > {unixDate ? ( - format(new Date(unixDate), "PPP") + format(new Date(unixDate), "dd MMM yyyy") ) : ( Pick a date )} diff --git a/vite/src/components/general/table/index.tsx b/vite/src/components/general/table/index.tsx index 29a203289..9ef12e5b0 100644 --- a/vite/src/components/general/table/index.tsx +++ b/vite/src/components/general/table/index.tsx @@ -2,6 +2,7 @@ import { TableActions } from "./table-actions"; import { TableBody } from "./table-body"; +import { TableColumnVisibility } from "./table-column-visibility"; import { TableContainer } from "./table-container"; import { TableContent } from "./table-content"; import { TableHeader } from "./table-header"; @@ -20,4 +21,5 @@ export const Table = { Body: TableBody, Container: TableContainer, Pagination: TablePagination, + ColumnVisibility: TableColumnVisibility, }; diff --git a/vite/src/components/general/table/table-body.tsx b/vite/src/components/general/table/table-body.tsx index 80ce0bfa6..897b99c12 100644 --- a/vite/src/components/general/table/table-body.tsx +++ b/vite/src/components/general/table/table-body.tsx @@ -27,7 +27,7 @@ export function TableBody() { if (!rows.length) { return ( - + ) : ( -
+
{emptyStateChildren || emptyStateText}
)} @@ -48,13 +48,13 @@ export function TableBody() { } return ( - + {rows.map((row) => { const isSelected = selectedItemId === (row.original as any).id; return ( ({ column }: { column: Column }) { + const header = column.columnDef.header; + const label = typeof header === "string" ? header : column.id; + + // Skip empty labels (like actions column) + if (!label || label === "actions") return null; + + const isVisible = column.getIsVisible(); + + return ( + { + e.preventDefault(); + column.toggleVisibility(!isVisible); + }} + onSelect={(e) => e.preventDefault()} + className="flex items-center gap-2 cursor-pointer text-sm" + > + + {label} + + ); +} + +/** Renders a submenu for a column group */ +function ColumnGroupSubmenu({ + group, + columns, +}: { + group: ColumnGroup; + columns: Column[]; +}) { + // Filter columns that belong to this group + const groupColumns = columns.filter((col) => + group.columnIds.includes(col.id), + ); + + // Count visible columns in this group + const visibleCount = groupColumns.filter((col) => col.getIsVisible()).length; + + if (groupColumns.length === 0) { + return null; + } + + return ( + + + {group.label} + {visibleCount > 0 && ( + + {visibleCount} + + )} + + + {groupColumns.length === 0 ? ( +
+ No columns available +
+ ) : ( +
+ {groupColumns.map((column) => ( + + ))} +
+ )} +
+
+ ); +} + +export function TableColumnVisibility() { + const [isOpen, setIsOpen] = useState(false); + + const { + table, + enableColumnVisibility, + columnVisibilityStorageKey, + columnGroups = [], + } = useTableContext(); + + // Load saved state synchronously on mount (for comparison to detect unsaved changes) + const [savedVisibility, setSavedVisibility] = + useState(() => { + if (!columnVisibilityStorageKey) return null; + return loadFromStorage(columnVisibilityStorageKey); + }); + + // Get current visibility from table for comparison + const currentVisibility = table.getState().columnVisibility; + + // Collect all column IDs that belong to any group + const groupedColumnIds = useMemo(() => { + const ids = new Set(); + for (const group of columnGroups) { + for (const colId of group.columnIds) { + ids.add(colId); + } + } + return ids; + }, [columnGroups]); + + // Check if current visibility differs from saved state + const hasUnsavedChanges = useMemo(() => { + if (!columnVisibilityStorageKey) return false; + + const currentKeys = Object.keys(currentVisibility); + + // If nothing saved yet, show save button if there are any visibility changes + if (savedVisibility === null) { + // Check if any column has explicit visibility set (not just defaults) + return currentKeys.length > 0; + } + + const savedKeys = Object.keys(savedVisibility); + + // Only compare keys that exist in both + for (const key of currentKeys) { + if ( + key in savedVisibility && + currentVisibility[key] !== savedVisibility[key] + ) { + return true; + } + } + + // Check if saved has keys not in current + for (const key of savedKeys) { + if ( + key in currentVisibility && + savedVisibility[key] !== currentVisibility[key] + ) { + return true; + } + } + + // Check if there are new keys in current that weren't in saved + for (const key of currentKeys) { + if (!(key in savedVisibility)) { + return true; + } + } + + return false; + }, [columnVisibilityStorageKey, currentVisibility, savedVisibility]); + + // Save current visibility to localStorage + const handleSave = () => { + if (columnVisibilityStorageKey) { + saveToStorage(columnVisibilityStorageKey, currentVisibility); + setSavedVisibility({ ...currentVisibility }); + } + }; + + if (!enableColumnVisibility) { + return null; + } + + // All hideable columns + const allColumns = table + .getAllColumns() + .filter((column) => column.getCanHide()); + + // Base columns (not in any group) + const baseColumns = allColumns.filter((col) => !groupedColumnIds.has(col.id)); + + return ( + + + + + + {/* Base columns (not in any group) */} + {baseColumns.map((column) => ( + + ))} + + {/* Column groups as submenus */} + {columnGroups.length > 0 && baseColumns.length > 0 && ( + + )} + {columnGroups.map((group) => ( + + ))} + + {/* Animate height using grid technique */} +
+
+
+ +
+
+
+
+
+ ); +} diff --git a/vite/src/components/general/table/table-content.tsx b/vite/src/components/general/table/table-content.tsx index 96204bbb6..b2097961d 100644 --- a/vite/src/components/general/table/table-content.tsx +++ b/vite/src/components/general/table/table-content.tsx @@ -1,6 +1,7 @@ import { Table } from "@/components/ui/table"; import { useSheetStore } from "@/hooks/stores/useSheetStore"; import { cn } from "@/lib/utils"; +import { TableColumnVisibility } from "./table-column-visibility"; import { useTableContext } from "./table-context"; export function TableContent({ @@ -10,21 +11,31 @@ export function TableContent({ children: React.ReactNode; className?: string; }) { - const { flexibleTableColumns } = useTableContext(); + const { flexibleTableColumns, enableColumnVisibility, table } = + useTableContext(); const sheetType = useSheetStore((s) => s.type); + const rows = table.getRowModel().rows; return (
+ {" "} + {enableColumnVisibility && ( +
+ +
+ )} + {/* OVERLAY */} {sheetType && ( -
+
)} {children} diff --git a/vite/src/components/general/table/table-context.tsx b/vite/src/components/general/table/table-context.tsx index 297d3c11a..a53dfd52c 100644 --- a/vite/src/components/general/table/table-context.tsx +++ b/vite/src/components/general/table/table-context.tsx @@ -1,5 +1,6 @@ import type { Table as TanstackTable } from "@tanstack/react-table"; import { createContext, type ReactNode, useContext } from "react"; +import type { ColumnGroup } from "@/hooks/useColumnVisibility"; export interface TableProps { table: TanstackTable; @@ -7,6 +8,10 @@ export interface TableProps { isLoading: boolean; enableSelection?: boolean; enableSorting?: boolean; + enableColumnVisibility?: boolean; + columnVisibilityStorageKey?: string; + /** Column groups for UI organization (renders as submenus in visibility dropdown) */ + columnGroups?: ColumnGroup[]; onRowClick?: (row: T) => void; rowClassName?: string; emptyStateChildren?: ReactNode; diff --git a/vite/src/components/general/table/table-header.tsx b/vite/src/components/general/table/table-header.tsx index d90b01481..7d300b40c 100644 --- a/vite/src/components/general/table/table-header.tsx +++ b/vite/src/components/general/table/table-header.tsx @@ -40,7 +40,11 @@ function HeaderContent({ } if (!header.column.getCanSort()) { - return flexRender(header.column.columnDef.header, header.getContext()); + return ( + + {flexRender(header.column.columnDef.header, header.getContext())} + + ); } return ( @@ -69,13 +73,23 @@ function HeaderContent({ } export function TableHeader({ className }: { className?: string }) { - const { table, enableSelection } = useTableContext(); + const { + table, + enableSelection, + enableColumnVisibility, + flexibleTableColumns, + } = useTableContext(); const headerGroups = table.getHeaderGroups(); + const rows = table.getRowModel().rows; + return ( {headerGroups.map((headerGroup) => ( {enableSelection && table && ( @@ -89,18 +103,34 @@ export function TableHeader({ className }: { className?: string }) { /> )} - {headerGroup.headers.map((header, index) => ( - - - - ))} + {headerGroup.headers.map((header, index, arr) => { + const isLast = index === arr.length - 1; + return ( + +
+ +
+
+ ); + })}
))}
diff --git a/vite/src/components/ui/table.tsx b/vite/src/components/ui/table.tsx index deb10cb12..b32ad0a30 100644 --- a/vite/src/components/ui/table.tsx +++ b/vite/src/components/ui/table.tsx @@ -10,7 +10,7 @@ function Table({ return (
) { ); diff --git a/vite/src/components/v2/LoadingShimmerText.tsx b/vite/src/components/v2/LoadingShimmerText.tsx index 7ef97f782..cb3d95dc7 100644 --- a/vite/src/components/v2/LoadingShimmerText.tsx +++ b/vite/src/components/v2/LoadingShimmerText.tsx @@ -12,7 +12,7 @@ export function LoadingShimmerText({ }: LoadingShimmerTextProps) { return (
- + {text}
); diff --git a/vite/src/components/v2/buttons/Button.tsx b/vite/src/components/v2/buttons/Button.tsx index 64d7ad77a..21a54a076 100644 --- a/vite/src/components/v2/buttons/Button.tsx +++ b/vite/src/components/v2/buttons/Button.tsx @@ -31,13 +31,13 @@ const buttonVariants = cva( `, - skeleton: `text-body border border-transparent - focus-visible:bg-muted-active focus-visible:border-primary + skeleton: `border border-transparent hover:text-primary! + focus-visible:border-primary active:bg-interactive-secondary-hover active:border-primary`, muted: `bg-muted hover:bg-interactive-secondary-hover border border-transparent - focus-visible:bg-muted-active focus-visible:border-primary - active:bg-interactive-secondary-hover active:border-primary + focus-visible:border-primary hover:interactive-secondary-hover + active:bg-interactive-secondary-hover active:text-primary! `, destructive: `bg-destructive !text-primary-foreground border-[1.2px] border-transparent diff --git a/vite/src/components/v2/dropdowns/DropdownMenu.tsx b/vite/src/components/v2/dropdowns/DropdownMenu.tsx index c3e0e6988..0901759bd 100644 --- a/vite/src/components/v2/dropdowns/DropdownMenu.tsx +++ b/vite/src/components/v2/dropdowns/DropdownMenu.tsx @@ -4,10 +4,16 @@ import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; import { cva, type VariantProps } from "class-variance-authority"; import { Check, ChevronRight, Circle } from "lucide-react"; import * as React from "react"; +import { useHotkeys } from "react-hotkeys-hook"; import SmallSpinner from "@/components/general/SmallSpinner"; import { cn } from "@/lib/utils"; +// Context for sharing menu open state with items +const DropdownMenuContext = React.createContext<{ isOpen: boolean }>({ + isOpen: false, +}); + // CVA variants for menu items const dropdownMenuItemVariants = cva( "relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm text-t2 outline-hidden transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0", @@ -38,7 +44,12 @@ type DropdownMenuItemVariantProps = VariantProps< function DropdownMenu( props: React.ComponentProps, ) { - return ; + const isOpen = props.open ?? false; + return ( + + + + ); } // Trigger @@ -177,13 +188,39 @@ type DropdownMenuItemProps = React.ComponentProps< DropdownMenuItemVariantProps & { isLoading?: boolean; shimmer?: boolean; + /** Keyboard shortcut that triggers this item when the menu is open */ + shortcut?: string; }; const DropdownMenuItem = React.forwardRef< React.ElementRef, DropdownMenuItemProps >(function DropdownMenuItem(props, ref) { - const { variant, inset, shimmer = false, isLoading, className, children, ...rest } = props; + const { + variant, + inset, + shimmer = false, + isLoading, + shortcut, + className, + children, + onClick, + ...rest + } = props; + const { isOpen } = React.useContext(DropdownMenuContext); + + useHotkeys( + shortcut ?? "", + (e) => { + e.preventDefault(); + onClick?.(e as unknown as React.MouseEvent); + }, + { + enabled: !!shortcut && isOpen, + enableOnFormTags: false, + }, + ); + return ( {isLoading ? ( @@ -202,7 +240,14 @@ const DropdownMenuItem = React.forwardRef< ) : ( - children + <> + {children} + {/* {shortcut && ( + + {shortcut} + + )} */} + )} ); diff --git a/vite/src/hooks/stores/useSheetStore.ts b/vite/src/hooks/stores/useSheetStore.ts index 5706102bb..3f8c0feda 100644 --- a/vite/src/hooks/stores/useSheetStore.ts +++ b/vite/src/hooks/stores/useSheetStore.ts @@ -23,9 +23,15 @@ interface SheetState { previousType: SheetType; // Item ID being edited (e.g., "item-0", "item-1", product.id, or "new"/"select") itemId: string | null; + // Explicit data payload for the sheet + data: Record | null; // Actions - setSheet: (params: { type: SheetType; itemId?: string | null }) => void; + setSheet: (params: { + type: SheetType; + itemId?: string | null; + data?: Record | null; + }) => void; closeSheet: () => void; reset: () => void; } @@ -35,19 +41,25 @@ const initialState = { type: null as SheetType, previousType: null as SheetType, itemId: null as string | null, + data: null as Record | null, }; export const useSheetStore = create((set) => ({ ...initialState, // Set the sheet type and optional itemId - setSheet: ({ type, itemId = null }) => { - set((state) => ({ previousType: state.type, type, itemId })); + setSheet: ({ type, itemId = null, data = null }) => { + set((state) => ({ previousType: state.type, type, itemId, data })); }, // Close the sheet closeSheet: () => { - set((state) => ({ previousType: state.type, type: null, itemId: null })); + set((state) => ({ + previousType: state.type, + type: null, + itemId: null, + data: null, + })); }, // Reset to initial state diff --git a/vite/src/hooks/useColumnVisibility.ts b/vite/src/hooks/useColumnVisibility.ts new file mode 100644 index 000000000..2a4a7ef39 --- /dev/null +++ b/vite/src/hooks/useColumnVisibility.ts @@ -0,0 +1,123 @@ +import type { ColumnDef, VisibilityState } from "@tanstack/react-table"; +import { useEffect, useState } from "react"; + +const STORAGE_PREFIX = "autumn:table-columns:"; + +type StoredColumnValue = boolean | { visible: boolean; name: string }; +type StoredVisibility = Record; + +export interface ColumnMeta { + visible: boolean; + name?: string; +} + +function loadFromStorage(storageKey: string): StoredVisibility | null { + try { + const saved = localStorage.getItem(`${STORAGE_PREFIX}${storageKey}`); + if (saved) { + return JSON.parse(saved) as StoredVisibility; + } + } catch { + // Ignore errors + } + return null; +} + +/** Parse stored value to get visibility boolean */ +function getVisibility(value: StoredColumnValue): boolean { + return typeof value === "boolean" ? value : value.visible; +} + +/** Parse stored value to get column meta (visibility + optional name) */ +export function getColumnMeta( + stored: StoredVisibility, + columnId: string, +): ColumnMeta | null { + const value = stored[columnId]; + if (value === undefined) return null; + + if (typeof value === "boolean") { + return { visible: value }; + } + return { visible: value.visible, name: value.name }; +} + +/** Get all visible usage columns with their names from storage */ +export function getVisibleUsageColumnsFromStorage( + storageKey: string, +): Array<{ featureId: string; featureName: string }> { + const stored = loadFromStorage(storageKey); + if (!stored) return []; + + return Object.entries(stored) + .filter(([key, value]) => key.startsWith("usage_") && getVisibility(value)) + .map(([key, value]) => { + const featureId = key.replace("usage_", ""); + const name = typeof value === "object" ? value.name : featureId; + return { featureId, featureName: name }; + }); +} + +/** Defines a group of columns to be rendered together in a submenu */ +export interface ColumnGroup { + key: string; + label: string; + columnIds: string[]; +} + +interface UseColumnVisibilityOptions { + columns: ColumnDef[]; + defaultVisibleColumnIds: string[]; + storageKey?: string; + columnGroups?: ColumnGroup[]; +} + +export function useColumnVisibility({ + columns, + defaultVisibleColumnIds, + storageKey, + columnGroups = [], +}: UseColumnVisibilityOptions) { + // Load from localStorage, converting to simple visibility state + const [columnVisibility, setColumnVisibility] = useState( + () => { + const stored = storageKey ? loadFromStorage(storageKey) : null; + if (!stored) return {}; + + // Convert stored format to simple visibility state + const visibility: VisibilityState = {}; + for (const [key, value] of Object.entries(stored)) { + visibility[key] = getVisibility(value); + } + return visibility; + }, + ); + + // Add defaults for any columns not yet in visibility state + useEffect(() => { + setColumnVisibility((prev) => { + let hasNewColumns = false; + const updated = { ...prev }; + + for (const col of columns) { + if (col.id && !(col.id in prev)) { + hasNewColumns = true; + updated[col.id] = defaultVisibleColumnIds.includes(col.id); + } + } + + return hasNewColumns ? updated : prev; + }); + }, [columns, defaultVisibleColumnIds]); + + const hasExtraVisibleColumns = Object.entries(columnVisibility).some( + ([key, visible]) => !defaultVisibleColumnIds.includes(key) && visible, + ); + + return { + columnVisibility, + setColumnVisibility, + hasExtraVisibleColumns, + columnGroups, + }; +} diff --git a/vite/src/hooks/useDropdownShortcut.ts b/vite/src/hooks/useDropdownShortcut.ts new file mode 100644 index 000000000..2139e4929 --- /dev/null +++ b/vite/src/hooks/useDropdownShortcut.ts @@ -0,0 +1,56 @@ +import { useHotkeys } from "react-hotkeys-hook"; + +/** + * Hook to toggle a dropdown open/closed with a keyboard shortcut + */ +export function useDropdownShortcut({ + shortcut, + isOpen, + setIsOpen, + enabled = true, +}: { + shortcut: string; + isOpen: boolean; + setIsOpen: (open: boolean) => void; + enabled?: boolean; +}) { + useHotkeys( + shortcut, + (e) => { + e.preventDefault(); + setIsOpen(!isOpen); + }, + { + enabled, + enableOnFormTags: false, + }, + ); +} + +/** + * Hook to trigger an action when a dropdown is open and a key is pressed + */ +export function useMenuItemShortcut({ + shortcut, + onTrigger, + isMenuOpen, + enabled = true, +}: { + shortcut: string; + onTrigger: () => void; + isMenuOpen: boolean; + enabled?: boolean; +}) { + useHotkeys( + shortcut, + (e) => { + e.preventDefault(); + onTrigger(); + }, + { + enabled: enabled && isMenuOpen, + enableOnFormTags: false, + }, + ); +} + diff --git a/vite/src/index.css b/vite/src/index.css index 78358d3b9..7543980f3 100644 --- a/vite/src/index.css +++ b/vite/src/index.css @@ -42,7 +42,7 @@ html { line-height: 1.5; font-weight: 500; - color-scheme: light dark; + color-scheme: light; color: rgba(255, 255, 255, 0.87); /* Editted */ @@ -163,6 +163,7 @@ html { --active-primary: #292929; --input-background: #1d1d1d; + --muted: #1c1c1d; /* --card: oklch(0.141 0.005 285.823); */ @@ -190,7 +191,6 @@ html { --primary-foreground: white; --secondary: oklch(0.274 0.006 286.033); --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.274 0.006 286.033); --muted-foreground: oklch(0.705 0.015 286.067); --accent: oklch(0.274 0.006 286.033); --accent-foreground: oklch(0.985 0 0); @@ -212,6 +212,7 @@ html { --sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-border: oklch(0.274 0.006 286.033); --sidebar-ring: oklch(0.442 0.017 285.786); + color-scheme: dark; } @theme inline { diff --git a/vite/src/views/command-bar/CommandBar.tsx b/vite/src/views/command-bar/CommandBar.tsx index b5bcc2500..695f8b101 100644 --- a/vite/src/views/command-bar/CommandBar.tsx +++ b/vite/src/views/command-bar/CommandBar.tsx @@ -179,8 +179,9 @@ const CommandBar = () => { isAdmin, }); - // Initialize hotkeys + // Initialize hotkeys (only active when command bar is open) useCommandBarHotkeys({ + isOpen: open, closeDialog, switchToOrgsPage: () => switchToPage("orgs"), switchToImpersonatePage: () => switchToPage("impersonate"), @@ -190,18 +191,6 @@ const CommandBar = () => { setOpen(true); }); - // Direct shortcut to open impersonation search (admin only) - useHotkeys( - "meta+6", - () => { - if (isAdmin) { - setOpen(true); - setCurrentPage("impersonate"); - } - }, - [isAdmin], - ); - useHotkeys( "escape", (e) => { diff --git a/vite/src/views/command-bar/useCommandBarHotkeys.ts b/vite/src/views/command-bar/useCommandBarHotkeys.ts index b7eb2ca3d..5e851871a 100644 --- a/vite/src/views/command-bar/useCommandBarHotkeys.ts +++ b/vite/src/views/command-bar/useCommandBarHotkeys.ts @@ -8,6 +8,8 @@ import { useAdmin } from "@/views/admin/hooks/useAdmin"; import { handleEnvChange } from "@/views/main-sidebar/EnvDropdown"; interface UseCommandBarHotkeysProps { + /** Whether the command bar is open */ + isOpen: boolean; /** Callback to close the command bar */ closeDialog: () => void; /** Callback to switch to orgs page */ @@ -17,9 +19,10 @@ interface UseCommandBarHotkeysProps { } /** - * Hook to manage command bar hotkeys + * Hook to manage command bar hotkeys (only active when command bar is open) */ export const useCommandBarHotkeys = ({ + isOpen, closeDialog, switchToOrgsPage, switchToImpersonatePage, @@ -29,42 +32,42 @@ export const useCommandBarHotkeys = ({ const { data: orgs, isPending: isLoadingOrgs } = useListOrganizations(); const { isAdmin } = useAdmin(); - // CMD+K: Open command bar + // CMD+K: Open command bar - handled in the component itself useHotkeys("meta+k", () => { // This is handled in the component itself }); - // CMD+1: Go to Products + // CMD+1: Go to Products (only when command bar is open) useHotkeys( "meta+1", () => { navigateTo("/products?tab=products", navigate, env); closeDialog(); }, - { enableOnFormTags: true, preventDefault: true }, + { enableOnFormTags: true, preventDefault: true, enabled: isOpen }, ); - // CMD+2: Go to Features + // CMD+2: Go to Features (only when command bar is open) useHotkeys( "meta+2", () => { navigateTo("/products?tab=features", navigate, env); closeDialog(); }, - { enableOnFormTags: true, preventDefault: true }, + { enableOnFormTags: true, preventDefault: true, enabled: isOpen }, ); - // CMD+3: Go to Customers + // CMD+3: Go to Customers (only when command bar is open) useHotkeys( "meta+3", () => { navigateTo("/customers", navigate, env); closeDialog(); }, - { enableOnFormTags: true, preventDefault: true }, + { enableOnFormTags: true, preventDefault: true, enabled: isOpen }, ); - // CMD+4: Switch Environment + // CMD+4: Switch Environment (only when command bar is open) useHotkeys( "meta+4", () => { @@ -74,10 +77,10 @@ export const useCommandBarHotkeys = ({ ); closeDialog(); }, - { enableOnFormTags: true, preventDefault: true }, + { enableOnFormTags: true, preventDefault: true, enabled: isOpen }, ); - // CMD+5: Switch Organization (only if user has multiple orgs) + // CMD+5: Switch Organization (only when command bar is open and user has multiple orgs) useHotkeys( "meta+5", () => { @@ -85,17 +88,17 @@ export const useCommandBarHotkeys = ({ switchToOrgsPage(); } }, - { enableOnFormTags: true, preventDefault: true }, + { enableOnFormTags: true, preventDefault: true, enabled: isOpen }, ); - // CMD+6: Impersonate (only if user is admin) + // CMD+I: Impersonate (only when command bar is open and user is admin) useHotkeys( - "meta+6", + "meta+i", () => { if (isAdmin) { switchToImpersonatePage(); } }, - { enableOnFormTags: true, preventDefault: true }, + { enableOnFormTags: true, preventDefault: true, enabled: isOpen }, ); }; diff --git a/vite/src/views/customers/CustomersPage.tsx b/vite/src/views/customers/CustomersPage.tsx index 6a6fea5a6..b21dd6f95 100644 --- a/vite/src/views/customers/CustomersPage.tsx +++ b/vite/src/views/customers/CustomersPage.tsx @@ -32,7 +32,6 @@ function CustomersPage() { }} >
- {/*

Customers

*/}
diff --git a/vite/src/views/customers/customer/analytics/components/RowClickDialog.tsx b/vite/src/views/customers/customer/analytics/components/RowClickDialog.tsx index a840ef21d..91c61198f 100644 --- a/vite/src/views/customers/customer/analytics/components/RowClickDialog.tsx +++ b/vite/src/views/customers/customer/analytics/components/RowClickDialog.tsx @@ -1,11 +1,11 @@ +import { CopyablePre } from "@/components/general/CopyablePre"; import { Dialog, DialogContent, DialogHeader, DialogTitle, -} from "@/components/ui/dialog"; -import { IRow } from "./AGGrid"; -import { CopyablePre } from "@/components/general/CopyablePre"; +} from "@/components/v2/dialogs/Dialog"; +import type { IRow } from "./AGGrid"; export function RowClickDialog({ event, diff --git a/vite/src/views/customers/hooks/useFullCusSearchQuery.tsx b/vite/src/views/customers/hooks/useFullCusSearchQuery.tsx index 1c5681653..040072dd7 100644 --- a/vite/src/views/customers/hooks/useFullCusSearchQuery.tsx +++ b/vite/src/views/customers/hooks/useFullCusSearchQuery.tsx @@ -18,7 +18,7 @@ export const useFullCusSearchQuery = () => { `/customers/all/full_customers`, { search: queryStates.q, - page_size: 50, + page_size: 30, page: queryStates.page, filters: { status: queryStates.status, diff --git a/vite/src/views/customers2/components/card/MeteredFeatureBalanceCard.tsx b/vite/src/views/customers2/components/card/MeteredFeatureBalanceCard.tsx deleted file mode 100644 index 01f9233be..000000000 --- a/vite/src/views/customers2/components/card/MeteredFeatureBalanceCard.tsx +++ /dev/null @@ -1,184 +0,0 @@ -import { - cusEntToBalance, - cusEntToIncludedUsage, - cusProductsToCusEnts, - FeatureUsageType, - type FullCusEntWithFullCusProduct, - type FullCusProduct, - notNullish, - sumValues, -} from "@autumn/shared"; -import { useCustomerBalanceSheetStore } from "@/hooks/stores/useCustomerBalanceSheetStore"; -import { useSheetStore } from "@/hooks/stores/useSheetStore"; -import { cn } from "@/lib/utils"; -import { formatUnixToDateTimeString } from "@/utils/formatUtils/formatDateUtils"; -import { CustomerFeatureUsageBar } from "../table/customer-feature-usage/CustomerFeatureUsageBar"; - -export const MeteredFeatureBalanceCard = ({ - ent, - filteredCustomerProducts, - featureId, - entityId, - aggregatedMap, - allEnts, -}: { - ent: FullCusEntWithFullCusProduct; - filteredCustomerProducts: FullCusProduct[]; - featureId: string; - entityId: string | null; - aggregatedMap: Map; - allEnts: FullCusEntWithFullCusProduct[]; -}) => { - const setBalanceSheet = useCustomerBalanceSheetStore((s) => s.setSheet); - const setSheet = useSheetStore((s) => s.setSheet); - const originalEnts = aggregatedMap.get(featureId); - const isAggregated = originalEnts && originalEnts.length > 1; - const balanceCount = originalEnts?.length || 1; - - const cusEnts = cusProductsToCusEnts({ - cusProducts: filteredCustomerProducts, - featureId, - }); - - const allowance = sumValues( - cusEnts.map((cusEnt) => { - const includedUsage = cusEntToIncludedUsage({ - cusEnt, - entityId: entityId ?? undefined, - }); - return includedUsage; - }), - ); - - const balance = sumValues( - cusEnts - .map((cusEnt) => - cusEntToBalance({ - cusEnt, - entityId: entityId ?? undefined, - withRollovers: true, - }), - ) - .filter(notNullish), - ); - - const shouldShowOutOfBalance = () => { - return allowance > 0 || (balance ?? 0) > 0; - }; - - const shouldShowUsed = () => { - return balance < 0 || ((balance ?? 0) === 0 && (allowance ?? 0) <= 0); - }; - - return ( -
{ - e.stopPropagation(); - const ents = aggregatedMap.get(featureId) || [ent]; - const hasMultipleBalances = ents.length > 1; - - // Set balance data in balance store - setBalanceSheet({ - type: "edit-balance", - featureId, - originalEntitlements: ents, - selectedCusEntId: hasMultipleBalances ? null : ents[0].id, - }); - - // Open the appropriate inline sheet - if (hasMultipleBalances) { - setSheet({ type: "balance-selection" }); - } else { - setSheet({ type: "balance-edit" }); - } - }} - > -
-
- - {ent.entitlement.feature.name} - - {isAggregated && ( -
- {balanceCount} -
- )} -
- {ent.next_reset_at ? ( - - Resets  - {ent.next_reset_at - ? formatUnixToDateTimeString(ent.next_reset_at) - : "-"} - - ) : ( - - )} -
-
-
- {ent.unlimited ? ( - Unlimited - ) : ( -
- {shouldShowOutOfBalance() && ( -
- - {balance && balance < 0 - ? 0 - : new Intl.NumberFormat().format(balance ?? 0)} - -

- {(allowance ?? 0) > 0 && ( - <> - / - - {new Intl.NumberFormat().format(allowance ?? 0)} - - - )}{" "} - -

-
- )} - {shouldShowUsed() && ( -

- {shouldShowOutOfBalance() && shouldShowUsed() && "+"} - {new Intl.NumberFormat().format( - balance && balance < 0 ? balance * -1 : 0, - )}{" "} - - {allowance > 0 - ? "overage" - : ent.entitlement.feature.config?.usage_type === - FeatureUsageType.Continuous - ? "in use" - : "used"} - -

- )} -
- )} -
-
0 ? "opacity-100" : "opacity-0", - )} - > - -
-
-
- ); -}; diff --git a/vite/src/views/customers2/components/sheets/AttachProductSheet.tsx b/vite/src/views/customers2/components/sheets/AttachProductSheet.tsx index 60dc3a36f..380776675 100644 --- a/vite/src/views/customers2/components/sheets/AttachProductSheet.tsx +++ b/vite/src/views/customers2/components/sheets/AttachProductSheet.tsx @@ -1,20 +1,9 @@ -import { useEffect } from "react"; import { AttachProductForm } from "@/components/forms/attach-product/attach-product-form"; import { SheetHeader } from "@/components/v2/sheets/InlineSheet"; -import { useProductStore } from "@/hooks/stores/useProductStore"; -import { useSheetStore } from "@/hooks/stores/useSheetStore"; import { useCustomerContext } from "../../customer/CustomerContext"; export function AttachProductSheet() { const { customer } = useCustomerContext(); - const sheetType = useSheetStore((s) => s.type); - const resetProductStore = useProductStore((s) => s.reset); - //remove any stale customized product data from store - useEffect(() => { - if (sheetType !== "attach-product") { - resetProductStore(); - } - }, [sheetType, resetProductStore]); return (
diff --git a/vite/src/views/customers2/components/sheets/BalanceEditSheet.tsx b/vite/src/views/customers2/components/sheets/BalanceEditSheet.tsx index 8229b654a..41bac5471 100644 --- a/vite/src/views/customers2/components/sheets/BalanceEditSheet.tsx +++ b/vite/src/views/customers2/components/sheets/BalanceEditSheet.tsx @@ -1,21 +1,24 @@ import { + cusEntsToBalance, + cusEntsToGrantedBalance, type FullCusProduct, type FullCustomerEntitlement, type FullCustomerPrice, - getCusEntBalance, isUnlimitedCusEnt, } from "@autumn/shared"; +import { LinkBreakIcon } from "@phosphor-icons/react"; import { useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import { DateInputUnix } from "@/components/general/DateInputUnix"; import { Button } from "@/components/v2/buttons/Button"; import { CopyButton } from "@/components/v2/buttons/CopyButton"; +import { IconButton } from "@/components/v2/buttons/IconButton"; import { InfoRow } from "@/components/v2/InfoRow"; +import { Input } from "@/components/v2/inputs/Input"; import { LabelInput } from "@/components/v2/inputs/LabelInput"; import { SheetHeader, SheetSection } from "@/components/v2/sheets/InlineSheet"; import { useCustomerBalanceSheetStore } from "@/hooks/stores/useCustomerBalanceSheetStore"; import { useSheetStore } from "@/hooks/stores/useSheetStore"; -import { CusService } from "@/services/customers/CusService"; import { useAxiosInstance } from "@/services/useAxiosInstance"; import { getBackendErr, notNullish } from "@/utils/genUtils"; import { useCusQuery } from "@/views/customers/customer/hooks/useCusQuery"; @@ -35,47 +38,50 @@ export function BalanceEditSheet() { const axiosInstance = useAxiosInstance(); const [updateLoading, setUpdateLoading] = useState(false); + const [isGrantedBalanceUnlinked, setIsGrantedBalanceUnlinked] = + useState(false); const hasMultipleBalances = originalEntitlements.length > 1; + const [grantedBalanceChanged, setGrantedBalanceChanged] = useState(false); + // Get the selected entitlement + const selectedCusEnt = originalEntitlements.find( + (ent) => ent.id === selectedCusEntId, + ); + + // Get the initial fields for the selected entitlement const initialFields = useMemo(() => { - if (!originalEntitlements.length) { - return new Map< - string, - { balance: number | null; next_reset_at: number | null } - >(); + if (!selectedCusEnt) { + return { + balance: null as number | null, + next_reset_at: null as number | null, + }; } - const fields = new Map< - string, - { balance: number | null; next_reset_at: number | null } - >(); + const balance = cusEntsToBalance({ + cusEnts: [selectedCusEnt], + entityId: entityId ?? undefined, + withRollovers: true, + }); - for (const cusEnt of originalEntitlements) { - const balance = getCusEntBalance({ - cusEnt, - entityId, - }).balance; + const grantedBalance = cusEntsToGrantedBalance({ + cusEnts: [selectedCusEnt], + entityId: entityId ?? undefined, + }); - const rolloverBalance = cusEnt.rollovers.reduce( - (acc, rollover) => acc + rollover.balance, - 0, - ); - - fields.set(cusEnt.id, { - balance: balance !== null ? balance + rolloverBalance : null, - next_reset_at: cusEnt.next_reset_at, - }); - } - - return fields; - }, [originalEntitlements, entityId]); + return { + balance: balance !== null ? balance : null, + grantedBalance: grantedBalance !== null ? grantedBalance : null, + next_reset_at: selectedCusEnt.next_reset_at, + }; + }, [selectedCusEnt, entityId]); const [updateFields, setUpdateFields] = useState(initialFields); - // Reset fields when feature changes + // Reset fields when selected entitlement changes useEffect(() => { setUpdateFields(initialFields); + setIsGrantedBalanceUnlinked(false); }, [initialFields]); const getCusProduct = (cusEnt: FullCustomerEntitlement) => { @@ -93,22 +99,25 @@ export function BalanceEditSheet() { const handleUpdateCusEntitlement = async ( cusEnt: FullCustomerEntitlement, ) => { - const fields = updateFields.get(cusEnt.id); - if (!fields) return; - - const balanceInt = parseFloat(String(fields.balance)); + const balanceInt = parseFloat(String(updateFields.balance)); if (Number.isNaN(balanceInt)) { toast.error("Balance not valid"); return; } + const grantedBalanceInt = parseFloat(String(updateFields.grantedBalance)); + if (Number.isNaN(grantedBalanceInt)) { + toast.error("Granted balance not valid"); + return; + } + const cusProduct = getCusProduct(cusEnt); const cusPrice = cusProduct?.customer_prices.find( (cp: FullCustomerPrice) => cp.price.entitlement_id === cusEnt.entitlement.id, ); - if (cusPrice && fields.next_reset_at !== cusEnt.next_reset_at) { + if (cusPrice && updateFields.next_reset_at !== cusEnt.next_reset_at) { toast.error("Not allowed to change reset at for paid features"); return; } @@ -116,16 +125,14 @@ export function BalanceEditSheet() { setUpdateLoading(true); try { - await CusService.updateCusEntitlement( - axiosInstance, - customer.id || customer.internal_id, - cusEnt.id, - { - balance: balanceInt, - next_reset_at: fields.next_reset_at, - entity_id: entityId, - }, - ); + await axiosInstance.post("/v1/balances/update", { + customer_id: customer.id || customer.internal_id, + feature_id: featureId, + current_balance: balanceInt, + granted_balance: grantedBalanceChanged ? grantedBalanceInt : undefined, + customer_entitlement_id: cusEnt.id, + entity_id: entityId ?? undefined, + }); toast.success("Balance updated successfully"); await refetch(); handleClose(); @@ -149,11 +156,6 @@ export function BalanceEditSheet() { const firstEnt = originalEntitlements[0]; const feature = firstEnt.entitlement.feature; - // Get the selected entitlement - const selectedCusEnt = hasMultipleBalances - ? originalEntitlements.find((ent) => ent.id === selectedCusEntId) - : originalEntitlements[0]; - const isUnlimited = selectedCusEnt ? isUnlimitedCusEnt({ cusEnt: selectedCusEnt }) : false; @@ -166,7 +168,9 @@ export function BalanceEditSheet() { ); } - const fields = updateFields.get(selectedCusEnt.id); + console.log("selectedCusEnt", selectedCusEnt); + + const fields = updateFields; if (!fields) return null; const cusProduct = getCusProduct(selectedCusEnt); @@ -214,7 +218,7 @@ export function BalanceEditSheet() { value={ {selectedCusEnt.entitlement.interval === "lifetime" - ? "never" + ? "Lifetime" : selectedCusEnt.entitlement.interval} } @@ -236,46 +240,102 @@ export function BalanceEditSheet() {
- { - const newFields = new Map(updateFields); - const current = newFields.get(selectedCusEnt.id) || { - balance: null, - next_reset_at: null, - }; - newFields.set(selectedCusEnt.id, { - ...current, - balance: e.target.value - ? parseFloat(e.target.value) - : null, - }); - setUpdateFields(newFields); - }} - /> +
+
+
+ { + const newBalance = e.target.value + ? parseFloat(e.target.value) + : null; + const newLinkedBalance = + initialFields.grantedBalance != null + ? initialFields.grantedBalance + + ((newBalance ?? 0) - + (initialFields.balance ?? 0)) + : null; -
+ setUpdateFields({ + ...updateFields, + balance: newBalance, + grantedBalance: isGrantedBalanceUnlinked + ? updateFields.grantedBalance + : newLinkedBalance, + }); + }} + /> +
+ {(initialFields.grantedBalance ?? 0) > 0 && + (isGrantedBalanceUnlinked ? ( + <> + / +
+ { + setUpdateFields({ + ...updateFields, + grantedBalance: e.target.value + ? parseFloat(e.target.value) + : null, + }); + setGrantedBalanceChanged(true); + }} + onBlur={() => setIsGrantedBalanceUnlinked(false)} + /> +
+ + ) : ( + } + onClick={() => + setIsGrantedBalanceUnlinked( + !isGrantedBalanceUnlinked, + ) + } + > +
+ + + {fields.grantedBalance} + +
+
+ ))} +
+
+
Next Reset
{ - const newFields = new Map(updateFields); - const current = newFields.get(selectedCusEnt.id) || { - balance: null, - next_reset_at: null, - }; - newFields.set(selectedCusEnt.id, { - ...current, + setUpdateFields({ + ...updateFields, next_reset_at: unixDate, }); - setUpdateFields(newFields); }} />
diff --git a/vite/src/views/customers2/components/sheets/BalanceSelectionSheet.tsx b/vite/src/views/customers2/components/sheets/BalanceSelectionSheet.tsx index db9aabc72..b277b3456 100644 --- a/vite/src/views/customers2/components/sheets/BalanceSelectionSheet.tsx +++ b/vite/src/views/customers2/components/sheets/BalanceSelectionSheet.tsx @@ -40,8 +40,6 @@ export function BalanceSelectionSheet() { ); } - console.log("originalEntitlements", originalEntitlements); - const firstEnt = originalEntitlements[0]; const feature = firstEnt.entitlement.feature; diff --git a/vite/src/views/customers2/components/sheets/SubscriptionUpdateSheet.tsx b/vite/src/views/customers2/components/sheets/SubscriptionUpdateSheet.tsx index 8e098bdd9..f0b9949fd 100644 --- a/vite/src/views/customers2/components/sheets/SubscriptionUpdateSheet.tsx +++ b/vite/src/views/customers2/components/sheets/SubscriptionUpdateSheet.tsx @@ -1,6 +1,10 @@ -import type { FullCusProduct, ProductV2 } from "@autumn/shared"; +import type { + FrontendProduct, + FullCusProduct, + ProductV2, +} from "@autumn/shared"; import { ArrowLeft } from "@phosphor-icons/react"; -import { useEffect, useMemo } from "react"; +import { useMemo } from "react"; import { UpdateProductActions } from "@/components/forms/attach-product/update-product-actions"; import { UpdateProductPrepaidOptions } from "@/components/forms/attach-product/update-product-prepaid-options"; import { UpdateProductSummary } from "@/components/forms/attach-product/update-product-summary"; @@ -12,10 +16,7 @@ import { import { FormWrapper } from "@/components/general/form/form-wrapper"; import { Button } from "@/components/v2/buttons/Button"; import { SheetHeader } from "@/components/v2/sheets/InlineSheet"; -import { - usePrepaidItems, - useProductStore, -} from "@/hooks/stores/useProductStore"; +import { usePrepaidItems } from "@/hooks/stores/useProductStore"; import { useSheetStore } from "@/hooks/stores/useSheetStore"; import { useSubscriptionById } from "@/hooks/stores/useSubscriptionStore"; import { useCusQuery } from "@/views/customers/customer/hooks/useCusQuery"; @@ -24,23 +25,24 @@ const FormContent = ({ productV2, cusProduct, form, + customizedProduct, }: { productV2: ProductV2; cusProduct: FullCusProduct; form: UseAttachProductForm; + customizedProduct: FrontendProduct | undefined; }) => { const { customer } = useCusQuery(); const customerId = customer?.id ?? customer?.internal_id; - const storeProduct = useProductStore((s) => s.product); - const product = storeProduct?.id ? storeProduct : (productV2 ?? undefined); + const product = customizedProduct?.id + ? customizedProduct + : (productV2 ?? undefined); const entityId = cusProduct?.entity_id ?? undefined; const prepaidOptions = form.state.values.prepaidOptions; const initialPrepaidOptions = form.options.defaultValues?.prepaidOptions ?? {}; - console.log("prepaidOptions", prepaidOptions); - const previewQuery = useAttachPreview({ customerId, product, @@ -74,7 +76,7 @@ const FormContent = ({ return currentQuantity !== initialQuantity; }); - if (!hasQuantityChanges && !storeProduct?.id) { + if (!hasQuantityChanges && !customizedProduct?.id) { return null; } } @@ -102,19 +104,19 @@ const FormContent = ({ function SheetContent({ cusProduct, productV2, - itemId, + customizedProduct, }: { cusProduct: FullCusProduct; productV2: ProductV2; itemId: string | null; + customizedProduct: FrontendProduct | undefined; }) { - const storeProduct = useProductStore((s) => s.product); - const product = storeProduct?.id ? storeProduct : (productV2 ?? undefined); + const product = customizedProduct?.id + ? customizedProduct + : (productV2 ?? undefined); const { prepaidItems } = usePrepaidItems({ product }); - console.log("prepaidItems", prepaidItems); - const subscriptionPrepaidValues = useMemo( () => cusProduct.options.reduce( @@ -171,14 +173,13 @@ function SheetContent({ {() => ( <> {prepaidItems.length > 0 && ( - // - // )} )} @@ -192,22 +193,13 @@ function SheetContent({ export function SubscriptionUpdateSheet() { const itemId = useSheetStore((s) => s.itemId); const setSheet = useSheetStore((s) => s.setSheet); + const sheetData = useSheetStore((s) => s.data); const { cusProduct, productV2 } = useSubscriptionById({ itemId }); - const sheetType = useSheetStore((s) => s.type); - const resetProductStore = useProductStore((s) => s.reset); - - useEffect(() => { - if ( - sheetType !== "subscription-detail" && - sheetType !== "subscription-update" - ) { - resetProductStore(); - } - }, [sheetType, resetProductStore]); - - // Load subscription's product into store on mount + const customizedProduct = sheetData?.customizedProduct as + | FrontendProduct + | undefined; if (!cusProduct) { return ( @@ -239,6 +231,7 @@ export function SubscriptionUpdateSheet() { cusProduct={cusProduct} productV2={productV2} itemId={itemId} + customizedProduct={customizedProduct} /> ); } diff --git a/vite/src/views/customers2/components/table/EmptyState.tsx b/vite/src/views/customers2/components/table/EmptyState.tsx index 25b68dd8e..2bd7a048d 100644 --- a/vite/src/views/customers2/components/table/EmptyState.tsx +++ b/vite/src/views/customers2/components/table/EmptyState.tsx @@ -1,6 +1,6 @@ -export const EmptyState = ({ text }: { text: string }) => { +export const EmptyState = ({ text }: { text: string | React.ReactNode }) => { return ( -
+

{text}

); diff --git a/vite/src/views/customers2/components/table/customer-balance/CustomerBalanceTableColumns.tsx b/vite/src/views/customers2/components/table/customer-balance/CustomerBalanceTableColumns.tsx index e403d6c2e..032d4a1a7 100644 --- a/vite/src/views/customers2/components/table/customer-balance/CustomerBalanceTableColumns.tsx +++ b/vite/src/views/customers2/components/table/customer-balance/CustomerBalanceTableColumns.tsx @@ -1,19 +1,96 @@ -import { - cusEntToBalance, - cusEntToIncludedUsage, - cusProductsToCusEnts, - FeatureUsageType, - type FullCusEntWithFullCusProduct, - type FullCusProduct, - notNullish, - sumValues, +import type { + Entity, + FullCusEntWithFullCusProduct, + FullCusProduct, } from "@autumn/shared"; import type { Row } from "@tanstack/react-table"; import { AdminHover } from "@/components/general/AdminHover"; import { cn } from "@/lib/utils"; import { formatUnixToDateTimeString } from "@/utils/formatUtils/formatDateUtils"; import { getCusEntHoverTexts } from "@/views/admin/adminUtils"; +import { useFeatureUsageBalance } from "@/views/customers2/hooks/useFeatureUsageBalance"; import { CustomerFeatureUsageBar } from "../customer-feature-usage/CustomerFeatureUsageBar"; +import { FeatureBalanceDisplay } from "../customer-feature-usage/FeatureBalanceDisplay"; + +function UsageCell({ + ent, + filteredCustomerProducts, + entityId, +}: { + ent: FullCusEntWithFullCusProduct; + filteredCustomerProducts: FullCusProduct[]; + entityId: string | null; +}) { + const { + allowance, + balance, + shouldShowOutOfBalance, + shouldShowUsed, + usageType, + initialAllowance, + } = useFeatureUsageBalance({ + cusProducts: filteredCustomerProducts, + featureId: ent.entitlement.feature.id, + entityId, + }); + + if (ent.unlimited) { + return Unlimited; + } + + return ( + + ); +} + +function BarCell({ + ent, + filteredCustomerProducts, + entityId, +}: { + ent: FullCusEntWithFullCusProduct; + filteredCustomerProducts: FullCusProduct[]; + entityId: string | null; +}) { + const { allowance, balance, quantity } = useFeatureUsageBalance({ + cusProducts: filteredCustomerProducts, + featureId: ent.entitlement.feature.id, + entityId, + }); + + return ( +
+ + Resets {formatUnixToDateTimeString(ent.next_reset_at)} + +
0 ? "opacity-100" : "opacity-0", + )} + > + +
+
+ ); +} export const CustomerBalanceTableColumns = ({ filteredCustomerProducts, @@ -24,7 +101,7 @@ export const CustomerBalanceTableColumns = ({ filteredCustomerProducts: FullCusProduct[]; entityId: string | null; aggregatedMap: Map; - entities?: any[]; + entities?: unknown[]; }) => [ { header: "Feature", @@ -43,7 +120,7 @@ export const CustomerBalanceTableColumns = ({ @@ -61,177 +138,25 @@ export const CustomerBalanceTableColumns = ({ }, { header: "Usage", - // enableResizing: true, - // size: 200, - // minSize: 100, accessorKey: "usage", - cell: ({ row }: { row: Row }) => { - const ent = row.original; - const featureId = ent.entitlement.feature.id; - - const cusEnts = cusProductsToCusEnts({ - cusProducts: filteredCustomerProducts, - featureId, - }); - - const allowance = sumValues( - cusEnts.map((cusEnt) => { - const includedUsage = cusEntToIncludedUsage({ - cusEnt, - entityId: entityId ?? undefined, - }); - return includedUsage; - }), - ); - - const balance = sumValues( - cusEnts - .map((cusEnt) => - cusEntToBalance({ - cusEnt, - entityId: entityId ?? undefined, - withRollovers: true, - }), - ) - .filter(notNullish), - ); - - const shouldShowOutOfBalance = () => { - return allowance > 0 || (balance ?? 0) > 0; - }; - - const shouldShowUsed = () => { - return balance < 0 || ((balance ?? 0) === 0 && (allowance ?? 0) <= 0); - }; - - if (ent.unlimited) { - return Unlimited; - } - - return ( -
- {shouldShowOutOfBalance() && ( -
- - {balance && balance < 0 - ? 0 - : new Intl.NumberFormat().format(balance ?? 0)} - -

- {(allowance ?? 0) > 0 && ( - <> - / - - {new Intl.NumberFormat().format(allowance ?? 0)} left - - - )}{" "} - -

-
- )} - {shouldShowUsed() && ( -

- {shouldShowOutOfBalance() && shouldShowUsed() && "+"} - {new Intl.NumberFormat().format( - balance && balance < 0 ? balance * -1 : 0, - )}{" "} - - {allowance > 0 - ? "overage" - : ent.entitlement.feature.config?.usage_type === - FeatureUsageType.Continuous - ? "in use" - : "used"} - -

- )} -
- ); - }, + cell: ({ row }: { row: Row }) => ( + + ), }, - // { - // header: "Reset Date", - // size: 120, - // accessorKey: "reset_date", - // cell: ({ row }: { row: Row }) => { - // const ent = row.original; - - // if (!ent.next_reset_at) { - // return ; - // } - - // return ( - //
- // - // Resets {formatUnixToDateTimeString(ent.next_reset_at)} - // - //
- // ); - // }, - // }, { header: "Bar", size: 220, - // maxSize: 220, - // enableResizing: true, accessorKey: "bar", - cell: ({ row }: { row: Row }) => { - const ent = row.original; - const featureId = ent.entitlement.feature.id; - - const cusEnts = cusProductsToCusEnts({ - cusProducts: filteredCustomerProducts, - featureId, - }); - - const allowance = sumValues( - cusEnts.map((cusEnt) => { - const includedUsage = cusEntToIncludedUsage({ - cusEnt, - entityId: entityId ?? undefined, - }); - return includedUsage; - }), - ); - - const balance = sumValues( - cusEnts - .map((cusEnt) => - cusEntToBalance({ - cusEnt, - entityId: entityId ?? undefined, - withRollovers: true, - }), - ) - .filter(notNullish), - ); - - return ( -
- - Resets {formatUnixToDateTimeString(ent.next_reset_at)} - -
0 ? "opacity-100" : "opacity-0", - )} - > - -
-
- ); - }, + cell: ({ row }: { row: Row }) => ( + + ), }, ]; diff --git a/vite/src/views/customers2/components/table/customer-feature-usage/FeatureBalanceDisplay.tsx b/vite/src/views/customers2/components/table/customer-feature-usage/FeatureBalanceDisplay.tsx new file mode 100644 index 000000000..fed450ff0 --- /dev/null +++ b/vite/src/views/customers2/components/table/customer-feature-usage/FeatureBalanceDisplay.tsx @@ -0,0 +1,64 @@ +import { FeatureUsageType } from "@autumn/shared"; +import { cn } from "@/lib/utils"; + +export interface FeatureBalanceDisplayProps { + allowance: number; + balance: number; + shouldShowOutOfBalance: boolean; + shouldShowUsed: boolean; + usageType?: string; + className?: string; + initialAllowance: number; + compact?: boolean; +} + +/** + * Shared display component for feature balance/usage numbers + */ +export function FeatureBalanceDisplay({ + allowance, + balance, + shouldShowOutOfBalance, + shouldShowUsed, + usageType, + className, + initialAllowance, + compact = false, +}: FeatureBalanceDisplayProps) { + const formatNumber = (num: number) => new Intl.NumberFormat().format(num); + const displayBalance = balance < 0 ? 0 : balance; + const overage = balance < 0 ? balance * -1 : 0; + + // console.log("initialAllowance", initialAllowance); + + const getUsedLabel = () => { + //change for feather + if (initialAllowance > 0) return "overage"; + if (usageType === FeatureUsageType.Continuous) return "in use"; + return "used"; + }; + + return ( +
+ {shouldShowOutOfBalance && ( + <> + {formatNumber(displayBalance)} + {allowance > 0 && ( + + {compact + ? `/ ${formatNumber(allowance)}` + : `/ ${formatNumber(allowance)} left`} + + )} + + )} + {shouldShowUsed && ( + + {shouldShowOutOfBalance && shouldShowUsed && " +"} + {formatNumber(overage)}{" "} + {getUsedLabel()} + + )} +
+ ); +} diff --git a/vite/src/views/customers2/components/table/customer-list/CustomerListColumns.tsx b/vite/src/views/customers2/components/table/customer-list/CustomerListColumns.tsx index 140ef30b7..ab63db65f 100644 --- a/vite/src/views/customers2/components/table/customer-list/CustomerListColumns.tsx +++ b/vite/src/views/customers2/components/table/customer-list/CustomerListColumns.tsx @@ -4,7 +4,7 @@ import { type FullCusProduct, isCusProductTrialing, } from "@autumn/shared"; -import type { Row } from "@tanstack/react-table"; +import type { ColumnDef, Row } from "@tanstack/react-table"; import type { z } from "zod/v4"; import { MiniCopyButton } from "@/components/v2/buttons/CopyButton"; import { @@ -16,6 +16,7 @@ import { import { formatUnixToDateTime } from "@/utils/formatUtils/formatDateUtils"; import { CustomerProductsStatus } from "../customer-products/CustomerProductsStatus"; import { CustomerListRowToolbar } from "./CustomerListRowToolbar"; +import { FeatureUsageCell } from "./FeatureUsageCell"; type CustomerWithProducts = z.infer & { customer_products?: Array<{ @@ -25,8 +26,22 @@ type CustomerWithProducts = z.infer & { trial_ends_at?: number | null; [key: string]: unknown; }>; + /** Full customer products with entitlements - merged from full_customers query */ + fullCustomerProducts?: FullCusProduct[]; + /** Whether the full customer data is still loading */ + isFullDataLoading?: boolean; }; +/** Default column IDs that are visible by default */ +export const BASE_COLUMN_IDS = [ + "name", + "customer_id", + "email", + "customer_products", + "created_at", + "actions", +]; + const getCusProductsInfo = ({ customer, }: { @@ -43,7 +58,7 @@ const getCusProductsInfo = ({ (cusProduct as FullCusProduct).status !== CusProductStatus.Scheduled, ); - //put add ons last + //put add ons last THIS DOESNT WORK ATM BECAUSE NO ADD ON PARAM EXISTS activeProducts.sort((a, b) => { const aIsAddOn = (a as FullCusProduct).product.is_add_on; const bIsAddOn = (b as FullCusProduct).product.is_add_on; @@ -54,8 +69,11 @@ const getCusProductsInfo = ({ return 0; }); + // customer.id === "e526e698-6d5d-4f0e-89e7-632f375663fb" && + // console.log("activeProducts", activeProducts, "customer", customer); + if (activeProducts.length === 0) { - return โ€”; + return ; } return ( @@ -64,9 +82,10 @@ const getCusProductsInfo = ({ .slice(0, 1) .map((cusProduct: (typeof activeProducts)[number], index: number) => { return ( -
- {(cusProduct as FullCusProduct).product.name} - +
+ + {(cusProduct as FullCusProduct).product.name} + - {activeProducts.length > 1 && ( @@ -111,8 +129,12 @@ const getCusProductsInfo = ({ ); }; -export const createCustomerListColumns = () => [ +export const createCustomerListColumns = (): ColumnDef< + CustomerWithProducts, + unknown +>[] => [ { + id: "name", header: "Name", accessorKey: "name", cell: ({ row }: { row: Row }) => { @@ -120,6 +142,7 @@ export const createCustomerListColumns = () => [ }, }, { + id: "customer_id", header: "ID", accessorKey: "id", cell: ({ row }: { row: Row }) => { @@ -136,6 +159,7 @@ export const createCustomerListColumns = () => [ }, }, { + id: "email", header: "Email", accessorKey: "email", cell: ({ row }: { row: Row }) => { @@ -143,6 +167,7 @@ export const createCustomerListColumns = () => [ }, }, { + id: "customer_products", header: "Products", accessorKey: "customer_products", cell: ({ row }: { row: Row }) => { @@ -152,9 +177,10 @@ export const createCustomerListColumns = () => [ }, }, { + id: "created_at", header: "Created At", accessorKey: "created_at", - size: 80, + size: 100, cell: ({ row }: { row: Row }) => { const { date, time } = formatUnixToDateTime(row.original.created_at); return ( @@ -165,10 +191,12 @@ export const createCustomerListColumns = () => [ }, }, { + id: "actions", header: "", accessorKey: "actions", size: 40, enableSorting: false, + enableHiding: false, cell: ({ row }: { row: Row }) => { return (
[ }, ]; +/** + * Creates a usage column for a specific metered feature + */ +export const createUsageColumn = ({ + featureId, + featureName, +}: { + featureId: string; + featureName: string; +}): ColumnDef => ({ + id: `usage_${featureId}`, + header: featureName, + size: 120, + cell: ({ row }: { row: Row }) => { + const customer = row.original; + return ( + + ); + }, +}); + export type { CustomerWithProducts }; diff --git a/vite/src/views/customers2/components/table/customer-list/CustomerListFilterButton.tsx b/vite/src/views/customers2/components/table/customer-list/CustomerListFilterButton.tsx index 2aee0f7dd..5b81ba408 100644 --- a/vite/src/views/customers2/components/table/customer-list/CustomerListFilterButton.tsx +++ b/vite/src/views/customers2/components/table/customer-list/CustomerListFilterButton.tsx @@ -1,6 +1,7 @@ import { FunnelSimpleIcon } from "@phosphor-icons/react"; import { X } from "lucide-react"; import { useState } from "react"; +import { IconButton } from "@/components/v2/buttons/IconButton"; import { DropdownMenu, DropdownMenuContent, @@ -9,7 +10,6 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/v2/dropdowns/DropdownMenu"; -import { IconButton } from "@/components/v2/buttons/IconButton"; import { cn } from "@/lib/utils"; import { FilterStatusSubMenu } from "@/views/customers/components/filter-dropdown/FilterStatusSubMenu"; import { ProductsSubMenu } from "@/views/customers/components/filter-dropdown/ProductsSubMenu"; diff --git a/vite/src/views/customers2/components/table/customer-list/CustomerListRowToolbar.tsx b/vite/src/views/customers2/components/table/customer-list/CustomerListRowToolbar.tsx index 110a35de2..de69f53f4 100644 --- a/vite/src/views/customers2/components/table/customer-list/CustomerListRowToolbar.tsx +++ b/vite/src/views/customers2/components/table/customer-list/CustomerListRowToolbar.tsx @@ -51,4 +51,3 @@ export const CustomerListRowToolbar = ({ ); }; - diff --git a/vite/src/views/customers2/components/table/customer-list/CustomerListTable.tsx b/vite/src/views/customers2/components/table/customer-list/CustomerListTable.tsx index e3f870444..509a247d2 100644 --- a/vite/src/views/customers2/components/table/customer-list/CustomerListTable.tsx +++ b/vite/src/views/customers2/components/table/customer-list/CustomerListTable.tsx @@ -1,14 +1,16 @@ +import type { FullCustomer } from "@autumn/shared"; +import { useQuery } from "@tanstack/react-query"; import { useMemo } from "react"; import { useNavigate } from "react-router"; import { Table } from "@/components/general/table"; import { EmptyState } from "@/components/v2/empty-states/EmptyState"; +import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery"; +import { useColumnVisibility } from "@/hooks/useColumnVisibility"; import { pushPage } from "@/utils/genUtils"; import { useCustomersQueryStates } from "@/views/customers/hooks/useCustomersQueryStates"; +import { useCustomerListColumns } from "@/views/customers2/hooks/useCustomerListColumns"; import { useCustomerTable } from "@/views/customers2/hooks/useCustomerTable"; -import { - type CustomerWithProducts, - createCustomerListColumns, -} from "./CustomerListColumns"; +import type { CustomerWithProducts } from "./CustomerListColumns"; import { CustomerListCreateButton } from "./CustomerListCreateButton"; import { CustomerListFilterButton } from "./CustomerListFilterButton"; import { CustomerListPagination } from "./CustomerListPagination"; @@ -20,17 +22,73 @@ export function CustomerListTable({ customers: CustomerWithProducts[]; }) { const navigate = useNavigate(); + const { features } = useFeaturesQuery(); - // Close any open sheet on mount in useEffect + // Subscribe to full_customers query to get reactive updates + const { + data: fullCustomersData, + isLoading: isFullCustomersLoading, + isFetching: isFullCustomersFetching, + } = useQuery<{ fullCustomers: FullCustomer[] }>({ + queryKey: ["full_customers"], + // Placeholder queryFn - actual fetching is done by useFullCusSearchQuery + queryFn: () => Promise.resolve({ fullCustomers: [] }), + // Don't fetch - just subscribe to existing data from useFullCusSearchQuery + enabled: false, + }); - const columns = useMemo(() => createCustomerListColumns(), []); + // Build map from full customers data for quick lookup + const fullCustomersMap = useMemo(() => { + const map = new Map(); + if (fullCustomersData?.fullCustomers) { + for (const fullCustomer of fullCustomersData.fullCustomers) { + const key = fullCustomer.id || fullCustomer.internal_id; + map.set(key, fullCustomer); + } + } + return map; + }, [fullCustomersData]); + + // Determine if full data is still loading (includes refetches for search/pagination) + const isFullDataLoading = + isFullCustomersLoading || + isFullCustomersFetching || + fullCustomersMap.size === 0; + + // Merge basic customer data with full customer data (for balance info) + const mergedCustomers = useMemo(() => { + return customers.map((customer) => { + const key = customer.id || customer.internal_id; + const fullCustomer = fullCustomersMap.get(key); + + return { + ...customer, + fullCustomerProducts: fullCustomer?.customer_products, + isFullDataLoading: !fullCustomer && isFullDataLoading, + } as CustomerWithProducts; + }); + }, [customers, fullCustomersMap, isFullDataLoading]); + + // Create columns including dynamic usage columns from metered features + const { columns, defaultVisibleColumnIds, columnGroups } = + useCustomerListColumns({ features }); + + // Column visibility management + const { columnVisibility, setColumnVisibility } = useColumnVisibility({ + columns, + defaultVisibleColumnIds, + storageKey: "customer-list", + columnGroups, + }); const table = useCustomerTable({ - data: customers, + data: mergedCustomers, columns, options: { globalFilterFn: "includesString", enableGlobalFilter: true, + state: { columnVisibility }, + onColumnVisibilityChange: setColumnVisibility, }, }); @@ -85,6 +143,9 @@ export function CustomerListTable({ onRowClick: handleRowClick, emptyStateText: "No matching results found.", rowClassName: "h-10", + enableColumnVisibility: true, + columnVisibilityStorageKey: "customer-list", + columnGroups, }} > diff --git a/vite/src/views/customers2/components/table/customer-list/FeatureUsageCell.tsx b/vite/src/views/customers2/components/table/customer-list/FeatureUsageCell.tsx new file mode 100644 index 000000000..85da782d0 --- /dev/null +++ b/vite/src/views/customers2/components/table/customer-list/FeatureUsageCell.tsx @@ -0,0 +1,78 @@ +import type { FullCusProduct } from "@autumn/shared"; +import { useFeatureUsageBalance } from "@/views/customers2/hooks/useFeatureUsageBalance"; +import { CustomerFeatureUsageBar } from "../customer-feature-usage/CustomerFeatureUsageBar"; +import { FeatureBalanceDisplay } from "../customer-feature-usage/FeatureBalanceDisplay"; + +interface FeatureUsageCellProps { + customerProducts: FullCusProduct[] | undefined; + featureId: string; + isLoading?: boolean; +} + +/** + * Displays feature usage balance and bar stacked vertically for use in the customer list table + */ +export function FeatureUsageCell({ + customerProducts, + featureId, + isLoading = false, +}: FeatureUsageCellProps) { + const { + allowance, + balance, + shouldShowOutOfBalance, + shouldShowUsed, + isUnlimited, + usageType, + quantity, + cusEntsCount, + initialAllowance, + } = useFeatureUsageBalance({ + cusProducts: customerProducts ?? [], + featureId, + }); + + if (isLoading) { + return ( +
+
+
+
+ ); + } + + if ( + !customerProducts || + customerProducts.length === 0 || + cusEntsCount === 0 + ) { + return ; + } + + if (isUnlimited) { + return Unlimited; + } + + return ( +
+ + {allowance > 0 && ( + + )} +
+ ); +} diff --git a/vite/src/views/customers2/components/table/customer-products/CustomerProductsColumns.tsx b/vite/src/views/customers2/components/table/customer-products/CustomerProductsColumns.tsx index c4cfbd672..06f5de96c 100644 --- a/vite/src/views/customers2/components/table/customer-products/CustomerProductsColumns.tsx +++ b/vite/src/views/customers2/components/table/customer-products/CustomerProductsColumns.tsx @@ -13,6 +13,8 @@ export const CustomerProductsColumns = [ { header: "Name", accessorKey: "name", + minSize: 10, + maxSize: 200, cell: ({ row }: { row: Row }) => { const quantity = row.original.quantity; const showQuantity = quantity && quantity > 1; diff --git a/vite/src/views/customers2/components/table/customer-products/CustomerProductsStatus.tsx b/vite/src/views/customers2/components/table/customer-products/CustomerProductsStatus.tsx index 18795cedd..b58025dbd 100644 --- a/vite/src/views/customers2/components/table/customer-products/CustomerProductsStatus.tsx +++ b/vite/src/views/customers2/components/table/customer-products/CustomerProductsStatus.tsx @@ -48,7 +48,7 @@ const StatusItem = ({ {trial_ends_at && ( <> - + {formatDistanceToNow(trial_ends_at)} left diff --git a/vite/src/views/customers2/components/table/customer-products/CustomerProductsTable.tsx b/vite/src/views/customers2/components/table/customer-products/CustomerProductsTable.tsx index 4e2fe8979..02de14ca1 100644 --- a/vite/src/views/customers2/components/table/customer-products/CustomerProductsTable.tsx +++ b/vite/src/views/customers2/components/table/customer-products/CustomerProductsTable.tsx @@ -1,5 +1,10 @@ -import type { Entity, FullCusProduct } from "@autumn/shared"; -import { PackageIcon, Subtract, User } from "@phosphor-icons/react"; +import { AppEnv, type Entity, type FullCusProduct } from "@autumn/shared"; +import { + ArrowSquareOutIcon, + PackageIcon, + Subtract, + User, +} from "@phosphor-icons/react"; import type { Row } from "@tanstack/react-table"; import { parseAsBoolean, useQueryState } from "nuqs"; import { useMemo, useState } from "react"; @@ -7,9 +12,10 @@ import { useLocation, useNavigate } from "react-router"; import { AdminHover } from "@/components/general/AdminHover"; import { Table } from "@/components/general/table"; import { Button } from "@/components/v2/buttons/Button"; -import { useProductStore } from "@/hooks/stores/useProductStore"; +import { IconButton } from "@/components/v2/buttons/IconButton"; import { useSheetStore } from "@/hooks/stores/useSheetStore"; import { useEntity } from "@/hooks/stores/useSubscriptionStore"; +import { useEnv } from "@/utils/envUtils"; import { getCusProductHoverTexts } from "@/views/admin/adminUtils"; import { useCusQuery } from "@/views/customers/customer/hooks/useCusQuery"; import { useFullCusSearchQuery } from "@/views/customers/hooks/useFullCusSearchQuery"; @@ -24,6 +30,7 @@ import { ShowExpiredActionButton } from "./ShowExpiredActionButton"; import { TransferProductDialog } from "./TransferProductDialog"; export function CustomerProductsTable() { + const env = useEnv(); const { customer, isLoading } = useCusQuery(); const { entityId } = useEntity(); @@ -37,7 +44,6 @@ export function CustomerProductsTable() { const [selectedProduct, setSelectedProduct] = useState( null, ); - const storeProduct = useProductStore((s) => s.product); const selectedItemId = useSheetStore((s) => s.itemId); const { setEntityId } = useEntity(); @@ -166,12 +172,6 @@ export function CustomerProductsTable() { }; const handleRowClick = (cusProduct: FullCusProduct) => { - if (storeProduct?.id) { - // If there is a product being customized, don't open another sheet - //user must close manually -- could show some notification to user here - return; - } - setSheet({ type: "subscription-detail", itemId: cusProduct.id, @@ -211,10 +211,31 @@ export function CustomerProductsTable() { const hasEntityProducts = entityProducts.length > 0; // Removed && !entityId - const emptyStateText = - entityProducts.length > 0 - ? "No customer-level plans found" - : "Enable a plan to start a subscription"; + const emptyStateChildren = + entityProducts.length > 0 ? ( + "No customer-level plans found" + ) : ( + <> + Enable a plan to start a subscription + {env === AppEnv.Sandbox && ( + } + className="px-1! ml-2" + onClick={() => + window.open( + "https://docs.useautumn.com/getting-started/setup/react", + "_blank", + ) + } + > + Docs + + )} + + ); return (
@@ -239,7 +260,7 @@ export function CustomerProductsTable() { enableSorting, isLoading, onRowClick: handleRowClick, - emptyStateText, + emptyStateChildren, flexibleTableColumns: true, selectedItemId: selectedItemId, }} diff --git a/vite/src/views/customers2/components/table/customer-usage-analytics/CustomerUsageAnalyticsColumns.tsx b/vite/src/views/customers2/components/table/customer-usage-analytics/CustomerUsageAnalyticsColumns.tsx index 6829e3203..0f3a77143 100644 --- a/vite/src/views/customers2/components/table/customer-usage-analytics/CustomerUsageAnalyticsColumns.tsx +++ b/vite/src/views/customers2/components/table/customer-usage-analytics/CustomerUsageAnalyticsColumns.tsx @@ -1,11 +1,15 @@ import type { Event } from "@autumn/shared"; -import type { Row } from "@tanstack/react-table"; +import type { ColumnDef, Row } from "@tanstack/react-table"; import { format } from "date-fns"; -export const CustomerUsageAnalyticsColumns = [ +export const BASE_COLUMN_IDS = ["event_name", "value", "timestamp"]; + +export const CustomerUsageAnalyticsColumns: ColumnDef[] = [ { + id: "event_name", header: "Feature", accessorKey: "event_name", + minSize: 80, cell: ({ row }: { row: Row }) => { return (
@@ -15,8 +19,10 @@ export const CustomerUsageAnalyticsColumns = [ }, }, { + id: "value", header: "Value", accessorKey: "value", + minSize: 50, cell: ({ row }: { row: Row }) => { const event = row.original; return ( @@ -26,22 +32,11 @@ export const CustomerUsageAnalyticsColumns = [ ); }, }, - // { - // header: "Status", - // accessorKey: "status", - // size: 60, - // cell: () => { - // return ( - //
- // POST - // 200 - //
- // ); - // }, - // }, { + id: "timestamp", header: "Timestamp", accessorKey: "timestamp", + minSize: 100, cell: ({ row }: { row: Row }) => { // type is Date but actually comes as a string const dateObj = new Date(row.original.timestamp as unknown as string); @@ -49,10 +44,47 @@ export const CustomerUsageAnalyticsColumns = [ return (
- {/* {formatUnixToDateTimeWithMs(dateAsNumber)} */} {format(new Date(dateAsNumber), "d MMM HH:mm:ss")}
); }, }, ]; + +/** Generates dynamic columns from event properties */ +export function generatePropertyColumns({ + events, +}: { + events: Event[]; +}): ColumnDef[] { + const propertyKeys = new Set(); + + for (const event of events) { + if (event.properties) { + for (const key of Object.keys(event.properties)) { + // Skip 'value' as it's already a base column + if (key !== "value") { + propertyKeys.add(key); + } + } + } + } + + return Array.from(propertyKeys).map((key) => ({ + id: `prop_${key}`, + header: key, + minSize: 70, + accessorFn: (row: Event) => { + const value = row.properties?.[key]; + if (value === undefined || value === null) return ""; + if (typeof value === "object") return JSON.stringify(value); + return String(value); + }, + cell: ({ getValue }: { getValue: () => string }) => { + const value = getValue(); + return ( +
{value}
+ ); + }, + })); +} diff --git a/vite/src/views/customers2/components/table/customer-usage-analytics/CustomerUsageAnalyticsSelectDays.tsx b/vite/src/views/customers2/components/table/customer-usage-analytics/CustomerUsageAnalyticsSelectDays.tsx index 2f98fdbb0..5d8eafda2 100644 --- a/vite/src/views/customers2/components/table/customer-usage-analytics/CustomerUsageAnalyticsSelectDays.tsx +++ b/vite/src/views/customers2/components/table/customer-usage-analytics/CustomerUsageAnalyticsSelectDays.tsx @@ -1,10 +1,12 @@ -import { Check, ChevronDown } from "lucide-react"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; +import { CaretDownIcon, CheckIcon } from "@phosphor-icons/react"; +import { useState } from "react"; import { Button } from "@/components/v2/buttons/Button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/v2/dropdowns/DropdownMenu"; import { cn } from "@/lib/utils"; const DAY_OPTIONS = [7, 30]; @@ -16,44 +18,39 @@ export function CustomerUsageAnalyticsSelectDays({ selectedDays: number; setSelectedDays: (days: number) => void; }) { + const [open, setOpen] = useState(false); const displayText = `Last ${selectedDays} days`; return ( - - + + - - + + {DAY_OPTIONS.map((days) => { const isSelected = selectedDays === days; return ( -
setSelectedDays(days)} + className="flex gap-3" > - - Last {days} days -
+ Last {days} days + ); })} -
-
+ + ); } diff --git a/vite/src/views/customers2/components/table/customer-usage-analytics/CustomerUsageAnalyticsTable.tsx b/vite/src/views/customers2/components/table/customer-usage-analytics/CustomerUsageAnalyticsTable.tsx index db39530b0..011c97eef 100644 --- a/vite/src/views/customers2/components/table/customer-usage-analytics/CustomerUsageAnalyticsTable.tsx +++ b/vite/src/views/customers2/components/table/customer-usage-analytics/CustomerUsageAnalyticsTable.tsx @@ -1,21 +1,29 @@ -import type { Event } from "@autumn/shared"; -import { ChartBar } from "@phosphor-icons/react"; +import { AppEnv, type Event } from "@autumn/shared"; +import { ArrowSquareOutIcon, ChartBarIcon } from "@phosphor-icons/react"; import { parseAsInteger, useQueryState } from "nuqs"; import { useMemo, useState } from "react"; import { Table } from "@/components/general/table"; +import { IconButton } from "@/components/v2/buttons/IconButton"; import { LoadingShimmerText } from "@/components/v2/LoadingShimmerText"; +import { useColumnVisibility } from "@/hooks/useColumnVisibility"; import { cn } from "@/lib/utils"; +import { useEnv } from "@/utils/envUtils"; import { useCusEventsQuery } from "@/views/customers/customer/hooks/useCusEventsQuery"; import { useCustomerTable } from "@/views/customers2/hooks/useCustomerTable"; import { useCustomerTimeseriesEvents } from "@/views/customers2/hooks/useCustomerTimeseriesEvents"; import { EmptyState } from "../EmptyState"; import { CustomerUsageAnalyticsChart } from "./CustomerUsageAnalyticsChart"; -import { CustomerUsageAnalyticsColumns } from "./CustomerUsageAnalyticsColumns"; +import { + BASE_COLUMN_IDS, + CustomerUsageAnalyticsColumns, + generatePropertyColumns, +} from "./CustomerUsageAnalyticsColumns"; import { CustomerUsageAnalyticsFullButton } from "./CustomerUsageAnalyticsFullButton"; import { CustomerUsageAnalyticsSelectDays } from "./CustomerUsageAnalyticsSelectDays"; import { EventDetailsDialog } from "./EventDetailsDialog"; export function CustomerUsageAnalyticsTable() { + const env = useEnv(); const [selectedEvent, setSelectedEvent] = useState(null); const [eventDialogOpen, setEventDialogOpen] = useState(false); @@ -30,33 +38,38 @@ export function CustomerUsageAnalyticsTable() { return "30d"; }, [selectedDays]); - // const [selectedFeatures, setSelectedFeatures] = useQueryState( - // "analyticsFeatures", - // parseAsArrayOf(parseAsString).withDefault([]), - // ); - - // Fetch raw events for the table via clickhouse - // const { rawEvents, isLoading: rawEventsLoading } = useCustomerRawEvents({ - // interval, - // }); - // Fetch raw events for the table via API const { events: rawEvents, isLoading: rawEventsLoading } = useCusEventsQuery(); // Fetch pre-aggregated timeseries data for the chart const { timeseriesEvents, isLoading: timeseriesLoading } = - useCustomerTimeseriesEvents({ - interval, - // eventNames: selectedFeatures || [], - }); + useCustomerTimeseriesEvents({ interval }); const isLoading = rawEventsLoading || timeseriesLoading; - const enableSorting = false; + // Generate dynamic columns from event properties + const columns = useMemo(() => { + const propertyColumns = generatePropertyColumns({ + events: rawEvents ?? [], + }); + return [...CustomerUsageAnalyticsColumns, ...propertyColumns]; + }, [rawEvents]); + + // Manage column visibility with base columns visible by default + const { columnVisibility, setColumnVisibility } = useColumnVisibility({ + columns, + defaultVisibleColumnIds: BASE_COLUMN_IDS, + storageKey: "customer-usage-analytics", + }); + const table = useCustomerTable({ - data: rawEvents, - columns: CustomerUsageAnalyticsColumns, + data: rawEvents ?? [], + columns, + options: { + state: { columnVisibility }, + onColumnVisibilityChange: setColumnVisibility, + }, }); const handleRowClick = (event: Event) => { @@ -76,27 +89,24 @@ export function CustomerUsageAnalyticsTable() { - + Usage - {/* */}
{isLoading ? ( -
+
- {/*
- {customer.name || customer.email || customer.id} -
*/}
) : hasEvents ? ( <> -
-
- - - - -
-
+ + + + +
) : ( - + + Track an event to display feature usage + {env === AppEnv.Sandbox && ( + + } + className="px-1! ml-2" + onClick={() => + window.open( + "https://docs.useautumn.com/getting-started/gating", + "_blank", + ) + } + > + Docs + + )} + + } + /> )}
diff --git a/vite/src/views/customers2/customer-plan/CustomerPlanEditorBar.tsx b/vite/src/views/customers2/customer-plan/CustomerPlanEditorBar.tsx index ea26ac038..87bf3aea8 100644 --- a/vite/src/views/customers2/customer-plan/CustomerPlanEditorBar.tsx +++ b/vite/src/views/customers2/customer-plan/CustomerPlanEditorBar.tsx @@ -1,4 +1,5 @@ import { parseAsInteger, parseAsString, useQueryStates } from "nuqs"; +import { useEffect } from "react"; import { useNavigate } from "react-router"; import { Button } from "@/components/v2/buttons/Button"; import { ShortcutButton } from "@/components/v2/buttons/ShortcutButton"; @@ -39,7 +40,13 @@ export const CustomerPlanEditorBar = () => { const changesMade = hasChanges || differentVersion; - //if no hasChanges, and version is the current cusProduct version, then return null + // Reset product store to default when component unmounts + // useEffect(() => { + // return () => { + // setProduct(DEFAULT_PRODUCT); + // setBaseProduct(null); + // }; + // }, [setProduct, setBaseProduct]); const returnToCustomer = () => { // Open the appropriate sheet based on whether we have a subscription ID @@ -47,13 +54,15 @@ export const CustomerPlanEditorBar = () => { // No subscription ID means we're attaching a new product setSheet({ type: "attach-product", - itemId: product.id, // Pass the product ID being customized + itemId: product.id, + data: changesMade ? { customizedProduct: product } : null, }); } else { // We have a subscription ID, so we're editing an existing subscription setSheet({ type: changesMade ? "subscription-update" : "subscription-detail", itemId: queryStates.id, + data: changesMade ? { customizedProduct: product } : null, }); } diff --git a/vite/src/views/customers2/customer/CustomerActions.tsx b/vite/src/views/customers2/customer/CustomerActions.tsx index 491c34738..688755f30 100644 --- a/vite/src/views/customers2/customer/CustomerActions.tsx +++ b/vite/src/views/customers2/customer/CustomerActions.tsx @@ -2,23 +2,25 @@ import type { Feature } from "@autumn/shared"; import { FeatureUsageType } from "@autumn/shared"; import { ArrowSquareOutIcon, - DotsThreeVerticalIcon, - PencilIcon, - Subtract, - Ticket, + CaretDownIcon, + PencilSimpleIcon, + SubtractIcon, + TicketIcon, TrashIcon, } from "@phosphor-icons/react"; import { useState } from "react"; +import { Button } from "@/components/v2/buttons/Button"; +import { Dialog } from "@/components/v2/dialogs/Dialog"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { Button } from "@/components/v2/buttons/Button"; -import { Dialog } from "@/components/v2/dialogs/Dialog"; +} from "@/components/v2/dropdowns/DropdownMenu"; import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery"; import { useOrgStripeQuery } from "@/hooks/queries/useOrgStripeQuery"; +import { useDropdownShortcut } from "@/hooks/useDropdownShortcut"; import { cn } from "@/lib/utils"; import { useEnv } from "@/utils/envUtils"; import { getStripeCusLink } from "@/utils/linkUtils"; @@ -33,17 +35,26 @@ export function CustomerActions() { const [deleteOpen, setDeleteOpen] = useState(false); const [createEntityOpen, setCreateEntityOpen] = useState(false); const [addCouponOpen, setAddCouponOpen] = useState(false); + const [actionsOpen, setActionsOpen] = useState(false); const { customer } = useCusQuery(); const { features } = useFeaturesQuery(); - const env = useEnv(); const { stripeAccount } = useOrgStripeQuery(); - const [ellipsisOpen, setEllipsisOpen] = useState(false); + const env = useEnv(); + + const stripeCustomerId = customer?.processor?.id; const hasContinuousUseFeatures = features?.some( (feature: Feature) => feature.config?.usage_type === FeatureUsageType.Continuous, ); + // Open dropdown with "a" key + useDropdownShortcut({ + shortcut: "a", + isOpen: actionsOpen, + setIsOpen: setActionsOpen, + }); + return (
@@ -61,71 +72,72 @@ export function CustomerActions() { /> - - + + setIsModalOpen(true)} + className="flex gap-2" + > + + Edit customer + {hasContinuousUseFeatures && ( setCreateEntityOpen(true)} - className="flex gap-3" + className="flex gap-2" > - + Create entity )} setAddCouponOpen(true)} - className="flex gap-3" + className="flex gap-2" > - + Add coupon - {customer?.processor?.id && ( + {stripeCustomerId && ( { window.open( getStripeCusLink({ - customerId: customer.processor.id, + customerId: stripeCustomerId, env, accountId: stripeAccount?.id, }), "_blank", ); }} - className="flex gap-3" + className="flex gap-2" + shortcut="s" > - + Open in Stripe )} + + setDeleteOpen(true)} + variant="destructive" + className="flex gap-2 text-red-500 !hover:bg-red-500" + > + + Delete customer + - -
); } diff --git a/vite/src/views/customers2/customer/CustomerPageDetails.tsx b/vite/src/views/customers2/customer/CustomerPageDetails.tsx index bd0a51126..bbf6ca437 100644 --- a/vite/src/views/customers2/customer/CustomerPageDetails.tsx +++ b/vite/src/views/customers2/customer/CustomerPageDetails.tsx @@ -1,6 +1,7 @@ import { FingerprintIcon, Ticket } from "@phosphor-icons/react"; import { CopyButton } from "@/components/v2/buttons/CopyButton"; import { useCusReferralQuery } from "@/views/customers/customer/hooks/useCusReferralQuery"; +import { CustomerActions } from "./CustomerActions"; import { useCustomerContext } from "./CustomerContext"; const mutedDivClassName = @@ -52,6 +53,7 @@ export const CustomerPageDetails = () => { {appliedCoupon.name}
)} +
); diff --git a/vite/src/views/customers2/customer/CustomerView2.tsx b/vite/src/views/customers2/customer/CustomerView2.tsx index eae396374..08fc81cf9 100644 --- a/vite/src/views/customers2/customer/CustomerView2.tsx +++ b/vite/src/views/customers2/customer/CustomerView2.tsx @@ -3,7 +3,7 @@ import { AnimatePresence, motion } from "motion/react"; import { createPortal } from "react-dom"; import { Link } from "react-router"; -import { useHasChanges, useProductStore } from "@/hooks/stores/useProductStore"; +import { useHasChanges } from "@/hooks/stores/useProductStore"; import { useSheetStore } from "@/hooks/stores/useSheetStore"; import { useEntity } from "@/hooks/stores/useSubscriptionStore"; import { pushPage } from "@/utils/genUtils"; @@ -15,7 +15,6 @@ import { CustomerFeatureUsageTable } from "../components/table/customer-feature- import { CustomerInvoicesTable } from "../components/table/customer-invoices/CustomerInvoicesTable"; import { CustomerProductsTable } from "../components/table/customer-products/CustomerProductsTable"; import { CustomerUsageAnalyticsTable } from "../components/table/customer-usage-analytics/CustomerUsageAnalyticsTable"; -import { CustomerActions } from "./CustomerActions"; import { CustomerBreadcrumbs } from "./CustomerBreadcrumbs2"; import { CustomerContext } from "./CustomerContext"; import { CustomerPageDetails } from "./CustomerPageDetails"; @@ -31,8 +30,9 @@ export default function CustomerView2() { const sheetType = useSheetStore((s) => s.type); const closeProductSheet = useSheetStore((s) => s.closeSheet); + const sheetData = useSheetStore((s) => s.data); const hasChanges = useHasChanges(); - const storeProduct = useProductStore((s) => s.product); + const hasCustomizedProduct = !!sheetData?.customizedProduct; // useSheetCleanup(); @@ -69,11 +69,11 @@ export default function CustomerView2() {
- + {/* */}
-
+

{ - !hasChanges && !storeProduct?.id && closeProductSheet(); + !hasCustomizedProduct && closeProductSheet(); }} /> )} diff --git a/vite/src/views/customers2/hooks/useCustomerListColumns.ts b/vite/src/views/customers2/hooks/useCustomerListColumns.ts new file mode 100644 index 000000000..8dae6d788 --- /dev/null +++ b/vite/src/views/customers2/hooks/useCustomerListColumns.ts @@ -0,0 +1,86 @@ +import { type Feature, FeatureType } from "@autumn/shared"; +import { useMemo } from "react"; +import { + type ColumnGroup, + getVisibleUsageColumnsFromStorage, +} from "@/hooks/useColumnVisibility"; +import { + BASE_COLUMN_IDS, + type CustomerWithProducts, + createCustomerListColumns, + createUsageColumn, +} from "../components/table/customer-list/CustomerListColumns"; + +interface UseCustomerListColumnsOptions { + features: Feature[]; +} + +export function useCustomerListColumns({ + features, +}: UseCustomerListColumnsOptions) { + return useMemo(() => { + const baseColumns = createCustomerListColumns(); + + // Filter to only metered features (non-boolean) + const meteredFeatures = features.filter( + (f) => + f.type === FeatureType.Metered || f.type === FeatureType.CreditSystem, + ); + + // Create usage columns for each metered feature + const usageColumnsFromFeatures = meteredFeatures.map((feature) => + createUsageColumn({ + featureId: feature.id, + featureName: feature.name, + }), + ); + + // If features haven't loaded yet, create columns from localStorage with saved names + let usageColumns = usageColumnsFromFeatures; + if (meteredFeatures.length === 0) { + const storedUsageColumns = + getVisibleUsageColumnsFromStorage("customer-list"); + usageColumns = storedUsageColumns.map(({ featureId, featureName }) => + createUsageColumn({ + featureId, + featureName, // Now uses the saved name from localStorage! + }), + ); + } + + // Build column groups for UI organization + const columnGroups: ColumnGroup[] = []; + + if (usageColumns.length > 0) { + columnGroups.push({ + key: "usage", + label: "Usage", + columnIds: usageColumns.map((col) => col.id as string), + }); + } + + // Insert usage columns before created_at (so created_at and actions stay at the end) + const createdAtIndex = baseColumns.findIndex( + (col) => col.id === "created_at", + ); + + let allColumns: typeof baseColumns; + if (createdAtIndex !== -1 && usageColumns.length > 0) { + allColumns = [ + ...baseColumns.slice(0, createdAtIndex), + ...usageColumns, + ...baseColumns.slice(createdAtIndex), + ]; + } else { + allColumns = [...baseColumns, ...usageColumns]; + } + + return { + columns: allColumns, + defaultVisibleColumnIds: BASE_COLUMN_IDS, + columnGroups, + }; + }, [features]); +} + +export type { CustomerWithProducts }; diff --git a/vite/src/views/customers2/hooks/useFeatureUsageBalance.ts b/vite/src/views/customers2/hooks/useFeatureUsageBalance.ts new file mode 100644 index 000000000..120910ded --- /dev/null +++ b/vite/src/views/customers2/hooks/useFeatureUsageBalance.ts @@ -0,0 +1,95 @@ +import { + cusEntsToAdjustment, + cusEntsToAllowance, + cusEntsToBalance, + cusEntsToGrantedBalance, + cusProductsToCusEnts, + type FullCusProduct, +} from "@autumn/shared"; + +export interface FeatureUsageBalanceParams { + cusProducts: FullCusProduct[]; + featureId: string; + entityId?: string | null; +} + +export interface FeatureUsageBalanceResult { + allowance: number; + initialAllowance: number; + balance: number; + shouldShowOutOfBalance: boolean; + shouldShowUsed: boolean; + isUnlimited: boolean; + usageType: string | undefined; + quantity: number; + cusEntsCount: number; +} + +/** + * Calculates feature usage balance metrics from customer products + */ +export function useFeatureUsageBalance({ + cusProducts, + featureId, + entityId, +}: FeatureUsageBalanceParams): FeatureUsageBalanceResult { + const cusEnts = cusProductsToCusEnts({ + cusProducts, + featureId, + }); + + //without adjustment, no rollovers + const initialAllowance = cusEntsToAllowance({ + cusEnts, + entityId: entityId ?? undefined, + withRollovers: false, + }); + + //includes adjustment + const allowance = cusEntsToGrantedBalance({ + cusEnts, + entityId: entityId ?? undefined, + withRollovers: true, + }); + + const adjustment = cusEntsToAdjustment({ + cusEnts, + entityId: entityId ?? undefined, + }); + + if (featureId === "open_ai_input_tokens_gpt_51") { + console.log("Cus ents:", cusEnts); + // console.log("allowance", allowance); + // console.log("initialAllowance", initialAllowance); + // console.log("adjustment:", adjustment); + } + + const balance = cusEntsToBalance({ + cusEnts, + entityId: entityId ?? undefined, + withRollovers: true, + }); + + const shouldShowOutOfBalance = allowance > 0 || (balance ?? 0) > 0; + const shouldShowUsed = + balance < 0 || ((balance ?? 0) === 0 && (allowance ?? 0) <= 0); + + const isUnlimited = cusEnts.some((e) => e.unlimited); + const usageType = cusEnts[0]?.entitlement?.feature?.config?.usage_type; + const quantity = cusEnts.reduce( + (sum, e) => sum + (e.customer_product.quantity ?? 1), + 0, + ); + + return { + allowance: allowance ?? 0, + initialAllowance: initialAllowance ?? 0, + balance: balance ?? 0, + shouldShowOutOfBalance, + shouldShowUsed, + isUnlimited, + usageType, + quantity, + cusEntsCount: cusEnts.length, + }; +} diff --git a/vite/src/views/developer/configure-stripe/ConfigureStripe.tsx b/vite/src/views/developer/configure-stripe/ConfigureStripe.tsx index 3d770689d..4a744cc90 100644 --- a/vite/src/views/developer/configure-stripe/ConfigureStripe.tsx +++ b/vite/src/views/developer/configure-stripe/ConfigureStripe.tsx @@ -171,7 +171,7 @@ export const ConfigureStripe = () => { description: env === AppEnv.Live ? "To start taking payments in Production, connect your Stripe live account below:" - : "You are using Autumn's default test account. To connect your own, click the button below", + : "You are using a default sandbox account managed by Autumn. You can connect your own Stipe sandbox account below.", showDisconnect: false, showConnectButtons: true, showDefaultAccountLink: false, diff --git a/vite/src/views/developer/configure-vercel/ConfigureVercel.tsx b/vite/src/views/developer/configure-vercel/ConfigureVercel.tsx index db26f0d19..8065b3e5e 100644 --- a/vite/src/views/developer/configure-vercel/ConfigureVercel.tsx +++ b/vite/src/views/developer/configure-vercel/ConfigureVercel.tsx @@ -6,7 +6,6 @@ import type { AxiosInstance } from "axios"; import { useState } from "react"; import { toast } from "sonner"; import { AppPortal } from "svix-react"; -import { PageSectionHeader } from "@/components/general/PageSectionHeader"; import { Button } from "@/components/v2/buttons/Button"; import { CodeGroup, @@ -16,8 +15,16 @@ import { CodeGroupList, CodeGroupTab, } from "@/components/v2/CodeGroup"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/v2/cards/Card"; import { FormLabel } from "@/components/v2/form/FormLabel"; import { Input } from "@/components/v2/inputs/Input"; +import { useTheme } from "@/contexts/ThemeProvider"; import { useOrg } from "@/hooks/common/useOrg"; import { useVercelQuery } from "@/hooks/queries/useVercelQuery"; import { OrgService } from "@/services/OrgService"; @@ -32,7 +39,6 @@ export const ConfigureVercel = () => { svixDashboardUrl, isLoading: isVercelLoading, error: vercelError, - refetch: vercelRefetch, } = useVercelQuery(); const env = useEnv(); const axiosInstance = useAxiosInstance(); @@ -44,6 +50,8 @@ export const ConfigureVercel = () => { marketplace_mode: "" as VercelMarketplaceMode, }); + const { isDark } = useTheme(); + const handleSaveVercelConfig = async ( axiosInstance: AxiosInstance, vercelConfig: { @@ -121,148 +129,167 @@ export const ConfigureVercel = () => { }; return !isLoading ? ( -
- -
-
- - Client (Integration) ID - -

- This is the client (integration) ID for your Vercel project in {env}{" "} - mode. -

- - setVercelConfig({ - ...vercelConfig, - client_integration_id: e.target.value, - }) - } - placeholder={ - org?.processor_configs?.vercel?.client_integration_id || - "eg. oac_2ttbjWcOQ0pyH1v9wYkROKB3" - } - /> -
+
+
+ + + + Vercel Settings ({env === "live" ? "Live" : "Sandbox"}) + + + Create an integration in the{" "} + + Integrations Console + {" "} + of the Vercel Dashboard. Then copy over the following parameters. + + + +
+
+ + Client (Integration) ID + + + setVercelConfig({ + ...vercelConfig, + client_integration_id: e.target.value, + }) + } + placeholder={ + org?.processor_configs?.vercel?.client_integration_id || + "eg. oac_2ttbjWcOQ0pyH1v9wYkROKB3" + } + /> +
+
+ + Client (Integration) Secret + + + setVercelConfig({ + ...vercelConfig, + client_secret: e.target.value, + }) + } + placeholder={ + org?.processor_configs?.vercel?.client_secret || + "eg. VAxvZFz8ST4d5b9pa2EuXkWG" + } + /> +
+
+ + + Stripe Custom Payment Method ID + + +

+ Create a custom payment method in{" "} + + Stripe + + . +

+ + setVercelConfig({ + ...vercelConfig, + custom_payment_method: e.target.value, + }) + } + placeholder={ + org?.processor_configs?.vercel?.custom_payment_method || + "eg. cpmt_Yij7OBT6Fxu0UOa12XguA0vGB" + } + /> +
+
+ +
+ +
+
+
- Client (Integration) Secret - -

- This is the client (integration) secret for your Vercel project in{" "} - {env} mode. -

- - setVercelConfig({ - ...vercelConfig, - client_secret: e.target.value, - }) - } - placeholder={ - org?.processor_configs?.vercel?.client_secret || - "eg. VAxvZFz8ST4d5b9pa2EuXkWG" - } - /> -
- -
- - Custom Payment Method ID - -

- This is the custom payment method ID for your Vercel integration in{" "} - {env} mode. -

- - setVercelConfig({ - ...vercelConfig, - custom_payment_method: e.target.value, - }) - } - placeholder={ - org?.processor_configs?.vercel?.custom_payment_method || - "eg. cpmt_Yij7OBT6Fxu0UOa12XguA0vGB" - } - /> -
- - -
- -
-
- -
-
- - Base URL + Base URL

This is the base URL for connecting to your Vercel project. You - should provide this to Vercel as the webhook URL and Partner API - Base URL. + should provide this to Vercel as the Webhook URL and Base URL.

-
- - - - {env === "live" ? "Live" : "Sandbox"} - - - navigator.clipboard.writeText( - `https://api.useautumn.com/webhooks/vercel/${org?.id}/${env}`, - ) - } - /> - - - {`https://api.useautumn.com/webhooks/vercel/${org?.id}/${env}`} - - -
+ + + + {env === "live" ? "Live" : "Sandbox"} + + + navigator.clipboard.writeText( + `https://api.useautumn.com/webhooks/vercel/${org?.id}/${env}`, + ) + } + /> + + + {`https://api.useautumn.com/webhooks/vercel/${org?.id}/${env}`} + +
-
- -
- {svixDashboardUrl && !isVercelLoading && !vercelError ? ( - - ) : ( -
Dashboard URL not found.
- )} + + + + Vercel Webhook + + Configure your Vercel webhook settings and view event logs. + + + + {svixDashboardUrl && !isVercelLoading && !vercelError ? ( + + ) : ( +
Dashboard URL not found.
+ )} +
+
) : ( diff --git a/vite/src/views/developer/publishable-key.tsx b/vite/src/views/developer/publishable-key.tsx index f83e48f89..53d351e01 100644 --- a/vite/src/views/developer/publishable-key.tsx +++ b/vite/src/views/developer/publishable-key.tsx @@ -1,16 +1,13 @@ -import { AppEnv, FrontendOrg } from "@autumn/shared"; +import { AppEnv } from "@autumn/shared"; import CopyButton from "@/components/general/CopyButton"; -import { useEnv } from "@/utils/envUtils"; import { useOrg } from "@/hooks/common/useOrg"; +import { useEnv } from "@/utils/envUtils"; export const PublishableKeySection = () => { const { org } = useOrg(); const env = useEnv(); return (
-
-

Publishable Key

-

You can safely use this from your frontend with certain endpoints, diff --git a/vite/src/views/products/features/credit-systems/components/CreditSystemSchema.tsx b/vite/src/views/products/features/credit-systems/components/CreditSystemSchema.tsx index a27d7f3c6..dc08e9af8 100644 --- a/vite/src/views/products/features/credit-systems/components/CreditSystemSchema.tsx +++ b/vite/src/views/products/features/credit-systems/components/CreditSystemSchema.tsx @@ -106,7 +106,9 @@ export function CreditSystemSchema({ ) .map((feature: Feature) => ( - {feature.name} + + {feature.name} + ))} @@ -115,6 +117,7 @@ export function CreditSystemSchema({

handleSchemaChange(index, "credit_amount", e.target.value) diff --git a/vite/src/views/products/product/utils/updateProduct.ts b/vite/src/views/products/product/utils/updateProduct.ts index d51681e90..70f3912a8 100644 --- a/vite/src/views/products/product/utils/updateProduct.ts +++ b/vite/src/views/products/product/utils/updateProduct.ts @@ -45,6 +45,14 @@ export const updateProduct = async ({ await onSuccess(); return updatedProduct; } catch (error) { + if (error instanceof Error && "issues" in error) { + // It's a ZodError + console.error( + "Zod validation failed:", + JSON.stringify((error as ZodError).issues, null, 2), + ); + } + console.error("Failed to update product", error); toast.error( getBackendErr(error as AxiosError | ZodError, "Failed to update product"), ); diff --git a/vite/src/views/products/products/components/CreateProductSheet.tsx b/vite/src/views/products/products/components/CreateProductSheet.tsx index e9b4c4172..321f781e4 100644 --- a/vite/src/views/products/products/components/CreateProductSheet.tsx +++ b/vite/src/views/products/products/components/CreateProductSheet.tsx @@ -46,12 +46,8 @@ function CreateProductSheet({ const handleCreateClicked = async () => { const productName = product.name?.trim() || ""; - if (!/^[a-zA-Z0-9 _-]+$/.test(productName)) { - toast.error( - !productName - ? "Plan name is required" - : "Plan name can only contain alphanumeric characters, dashes (-), and underscores (_)", - ); + if (!productName) { + toast.error("Plan name is required"); return; } diff --git a/vite/src/views/products/products/components/product-list/ProductListColumns.tsx b/vite/src/views/products/products/components/product-list/ProductListColumns.tsx index 221109793..a6dbec1e5 100644 --- a/vite/src/views/products/products/components/product-list/ProductListColumns.tsx +++ b/vite/src/views/products/products/components/product-list/ProductListColumns.tsx @@ -4,6 +4,7 @@ import { AdminHover } from "@/components/general/AdminHover"; import { PlanTypeBadges } from "@/components/v2/badges/PlanTypeBadges"; import { MiniCopyButton } from "@/components/v2/buttons/CopyButton"; import { getPlanHoverTexts } from "@/views/admin/adminUtils"; +import { ProductCountsTooltip } from "@/views/products/products/product-row-toolbar/ProductCountsTooltip"; import { ProductListRowToolbar } from "./ProductListRowToolbar"; export const createProductListColumns = ({ @@ -12,7 +13,7 @@ export const createProductListColumns = ({ showGroup?: boolean; } = {}) => [ { - size: 150, + size: 300, header: "Name", accessorKey: "name", cell: ({ row }: { row: Row }) => { @@ -27,7 +28,6 @@ export const createProductListColumns = ({ }, { header: "ID", - size: 150, accessorKey: "id", cell: ({ row }: { row: Row }) => { const product = row.original; @@ -48,18 +48,20 @@ export const createProductListColumns = ({ header: "Group", accessorKey: "group", cell: ({ row }: { row: Row }) => { - return
{row.original.group || "โ€”"}
; + return
{row.original.group || ""}
; }, }, ] : []), { header: "Customers", - size: 50, accessorKey: "active_count", cell: ({ row }: { row: Row }) => { - // This will be populated from counts data - return
{row.original.active_count || 0}
; + return ( +
+ +
+ ); }, }, { diff --git a/vite/src/views/products/products/product-row-toolbar/ProductCountsTooltip.tsx b/vite/src/views/products/products/product-row-toolbar/ProductCountsTooltip.tsx index a62788f9c..d7f4f83c0 100644 --- a/vite/src/views/products/products/product-row-toolbar/ProductCountsTooltip.tsx +++ b/vite/src/views/products/products/product-row-toolbar/ProductCountsTooltip.tsx @@ -1,42 +1,65 @@ -import { keyToTitle } from "@/utils/formatUtils/formatTextUtils"; import { - TooltipProvider, - Tooltip, - TooltipTrigger, - TooltipContent, -} from "@/components/ui/tooltip"; -import { ProductCounts, ProductV2 } from "@autumn/shared"; + numberWithCommas, + type ProductCounts, + type ProductV2, +} from "@autumn/shared"; +import { useNavigate } from "react-router"; +import { InfoTooltip } from "@/components/general/modal-components/InfoTooltip"; +import { Button } from "@/components/v2/buttons/Button"; +import { InfoRow } from "@/components/v2/InfoRow"; import { useProductsQuery } from "@/hooks/queries/useProductsQuery"; +import { keyToTitle } from "@/utils/formatUtils/formatTextUtils"; +import { pushPage } from "@/utils/genUtils"; +import { getVersionCounts } from "@/utils/productUtils"; export const ProductCountsTooltip = ({ product }: { product: ProductV2 }) => { - const { counts: allCounts } = useProductsQuery(); + const navigate = useNavigate(); + const { counts: allCounts, products } = useProductsQuery(); + const activeCount = allCounts?.[product.id]?.active ?? 0; + const versionCounts = getVersionCounts(products); + + const handleClick = (e: React.MouseEvent) => { + e.stopPropagation(); + if (activeCount === 0) return; + + // Build version filter for all versions of this product (comma-separated) + const maxVersion = versionCounts[product.id] || 1; + const versionKeys = Array.from( + { length: maxVersion }, + (_, i) => `${product.id}:${i + 1}`, + ).join(","); + + pushPage({ + path: `/customers`, + navigate, + queryParams: { version: versionKeys }, + preserveParams: false, + }); + }; + return ( - - - -

- {(allCounts && allCounts[product.id]?.active) || 0} -

-
- - {allCounts && - allCounts[product.id] && - Object.keys(allCounts[product.id]).map((key) => { - if (key === "active" || key == "custom" || key == "all") - return null; - return ( -
- {keyToTitle(key)}:{" "} - {allCounts[product.id][key as keyof ProductCounts]} -
- ); - })} -
-
-
+ ); };