chore: removed priceToInvoiceAmount from server package. Defined line item utility funcitons
This commit is contained in:
@@ -30,6 +30,7 @@
|
||||
"validate-schema": "infisical run --env=prod -- bun scripts/migrations/validate-schema.ts",
|
||||
"setupci": "node scripts/setup/setupci.js",
|
||||
"replicate": "bun scripts/db/replicate.ts",
|
||||
"db:pull": "bun scripts/db/pull.ts",
|
||||
"db:push": " bun -F @autumn/shared db:push",
|
||||
"db:generate": "bun -F @autumn/shared db:generate",
|
||||
"db:migrate": " bun -F @autumn/shared db:migrate"
|
||||
|
||||
381
scripts/db/pull.ts
Normal file
381
scripts/db/pull.ts
Normal file
@@ -0,0 +1,381 @@
|
||||
import { exec, spawn } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import readline from "node:readline";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
const MAX_CUSTOMER_COUNT_LIMIT = 1000;
|
||||
|
||||
/**
|
||||
* Execute command and show live output
|
||||
*/
|
||||
function execWithOutput(command: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, {
|
||||
shell: true,
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
child.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`Command failed with exit code ${code}`));
|
||||
}
|
||||
});
|
||||
|
||||
child.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt user for confirmation
|
||||
*/
|
||||
function promptUser(question: string): Promise<boolean> {
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
rl.question(question, (answer) => {
|
||||
rl.close();
|
||||
resolve(answer.toLowerCase() === "y" || answer.toLowerCase() === "yes");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate PostgreSQL URL format
|
||||
*/
|
||||
function validatePostgresUrl(url: string): boolean {
|
||||
return url.startsWith("postgresql://") || url.startsWith("postgres://");
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract database name from PostgreSQL URL
|
||||
*/
|
||||
function extractDbName(url: string): string {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
return urlObj.pathname.slice(1) || "database";
|
||||
} catch {
|
||||
return "database";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build connection URL to default postgres database
|
||||
* Used for creating databases and checking if they exist
|
||||
*/
|
||||
function buildDefaultDbUrl(url: string): string {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
// Replace the database name with 'postgres' (default database)
|
||||
urlObj.pathname = "/postgres";
|
||||
return urlObj.toString();
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape single quotes in database name for SQL queries
|
||||
*/
|
||||
function escapeDbName(dbName: string): string {
|
||||
return dbName.replace(/'/g, "''");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a database exists
|
||||
*/
|
||||
async function databaseExists(dbUrl: string, dbName: string): Promise<boolean> {
|
||||
try {
|
||||
const defaultDbUrl = buildDefaultDbUrl(dbUrl);
|
||||
const cleanedUrl = cleanUrl(defaultDbUrl);
|
||||
const escapedDbName = escapeDbName(dbName);
|
||||
const { stdout } = await execAsync(
|
||||
`psql "${cleanedUrl}" -t -c "SELECT 1 FROM pg_database WHERE datname = '${escapedDbName}';"`,
|
||||
);
|
||||
return stdout.trim() === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a database if it doesn't exist
|
||||
*/
|
||||
async function ensureDatabaseExists(
|
||||
dbUrl: string,
|
||||
dbName: string,
|
||||
): Promise<void> {
|
||||
const exists = await databaseExists(dbUrl, dbName);
|
||||
if (exists) {
|
||||
console.log(`✅ Database "${dbName}" already exists`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`📝 Creating database "${dbName}"...`);
|
||||
try {
|
||||
const defaultDbUrl = buildDefaultDbUrl(dbUrl);
|
||||
const cleanedUrl = cleanUrl(defaultDbUrl);
|
||||
await execWithOutput(
|
||||
`psql "${cleanedUrl}" -c "CREATE DATABASE \\"${dbName}\\";"`,
|
||||
);
|
||||
console.log(`✅ Database "${dbName}" created successfully`);
|
||||
} catch (error) {
|
||||
console.error(`❌ Failed to create database "${dbName}"`);
|
||||
if (error instanceof Error) {
|
||||
console.error(` ${error.message}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up PostgreSQL URL for pg_dump/psql
|
||||
* - Removes query parameters
|
||||
* - Changes port 6432 to 5432
|
||||
*/
|
||||
function cleanUrl(url: string): string {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
// Remove query parameters
|
||||
urlObj.search = "";
|
||||
// Replace port 6432 with 5432
|
||||
if (urlObj.port === "6432") {
|
||||
urlObj.port = "5432";
|
||||
}
|
||||
return urlObj.toString();
|
||||
} catch {
|
||||
// Fallback to regex-based cleaning if URL parsing fails
|
||||
let cleaned = url.replace(/\?.*$/, "");
|
||||
cleaned = cleaned.replace(/:6432\//, ":5432/");
|
||||
return cleaned;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get customer table row count
|
||||
*/
|
||||
async function getCustomerCount(
|
||||
url: string,
|
||||
): Promise<{ count: number | null; error?: string }> {
|
||||
try {
|
||||
const cleanedUrl = cleanUrl(url);
|
||||
const { stdout } = await execAsync(
|
||||
`psql "${cleanedUrl}" -t -c "SELECT COUNT(*) FROM customers;"`,
|
||||
);
|
||||
const count = parseInt(stdout.trim());
|
||||
if (!Number.isNaN(count)) {
|
||||
return { count };
|
||||
}
|
||||
return { count: null, error: "Could not parse customer count" };
|
||||
} catch (error) {
|
||||
// Table might not exist or connection failed
|
||||
return {
|
||||
count: null,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read DATABASE_URL from server/.env file
|
||||
*/
|
||||
function readLocalDatabaseUrl(): string | null {
|
||||
try {
|
||||
const envPath = join(process.cwd(), "server", ".env");
|
||||
const envContent = readFileSync(envPath, "utf-8");
|
||||
const lines = envContent.split("\n");
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith("DATABASE_URL=")) {
|
||||
const url = trimmed.replace(/^DATABASE_URL=/, "").trim();
|
||||
// Remove quotes if present
|
||||
return url.replace(/^["']|["']$/g, "");
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull database from remote to local
|
||||
*/
|
||||
async function pullDatabase({
|
||||
remoteUrl,
|
||||
localUrl: providedLocalUrl,
|
||||
}: {
|
||||
remoteUrl: string;
|
||||
localUrl?: string;
|
||||
}): Promise<void> {
|
||||
console.log("\n📥 Pulling Remote Database to Local\n");
|
||||
|
||||
// Validate remote URL
|
||||
if (!validatePostgresUrl(remoteUrl)) {
|
||||
console.error("❌ Invalid remote database URL");
|
||||
console.error(
|
||||
" Expected format: postgresql://user:pass@host:port/dbname",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Get local database URL (from argument, env var, or .env file)
|
||||
const localUrl =
|
||||
providedLocalUrl ||
|
||||
process.env.LOCAL_DATABASE_URL ||
|
||||
readLocalDatabaseUrl();
|
||||
|
||||
if (!localUrl) {
|
||||
console.error("❌ Could not find local database URL");
|
||||
console.error(" Options:");
|
||||
console.error(" 1. Ensure server/.env exists and contains DATABASE_URL");
|
||||
console.error(" 2. Pass local URL as second argument");
|
||||
console.error(" 3. Set LOCAL_DATABASE_URL environment variable");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!validatePostgresUrl(localUrl)) {
|
||||
console.error("❌ Invalid local database URL in server/.env");
|
||||
console.error(
|
||||
" Expected format: postgresql://user:pass@host:port/dbname",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const remoteDbName = extractDbName(remoteUrl);
|
||||
const localDbName = extractDbName(localUrl);
|
||||
|
||||
console.log(`📤 Remote: ${remoteDbName}`);
|
||||
console.log(`📥 Local: ${localDbName}`);
|
||||
|
||||
// Ensure local database exists
|
||||
console.log("\n🔍 Checking if local database exists...");
|
||||
await ensureDatabaseExists(localUrl, localDbName);
|
||||
|
||||
// Check local database for production data
|
||||
console.log("\n🔍 Checking local database contents...");
|
||||
const customerResult = await getCustomerCount(localUrl);
|
||||
|
||||
if (customerResult.count === null) {
|
||||
// Table doesn't exist or can't query - likely a new/empty database
|
||||
console.log("✅ Local database appears to be empty (no customers table)");
|
||||
} else {
|
||||
console.log(`📊 Local database has ${customerResult.count} customers`);
|
||||
|
||||
// Protection: Don't allow overwriting databases with > MAX_CUSTOMER_COUNT_LIMIT customers
|
||||
if (customerResult.count > MAX_CUSTOMER_COUNT_LIMIT) {
|
||||
console.error("\n❌ PROTECTION: Local database has too many customers!");
|
||||
console.error(
|
||||
` Customer count (${customerResult.count}) exceeds ${MAX_CUSTOMER_COUNT_LIMIT} limit.`,
|
||||
);
|
||||
console.error(
|
||||
" This safety check prevents accidental overwrites of production databases.\n",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Ask for confirmation
|
||||
const confirmed = await promptUser(
|
||||
"\n⚠️ This will OVERWRITE your local database. Continue? (y/n): ",
|
||||
);
|
||||
|
||||
if (!confirmed) {
|
||||
console.log("\n❌ Pull cancelled\n");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const tempFile = `/tmp/db_dump_${Date.now()}.sql`;
|
||||
|
||||
// Clean URLs
|
||||
const cleanedRemoteUrl = cleanUrl(remoteUrl);
|
||||
const cleanedLocalUrl = cleanUrl(localUrl);
|
||||
|
||||
console.log(`\n📤 Using remote URL: ${cleanedRemoteUrl}`);
|
||||
console.log(`📥 Using local URL: ${cleanedLocalUrl}\n`);
|
||||
|
||||
try {
|
||||
// Step 1: Dump the remote database
|
||||
console.log("📦 Dumping remote database...");
|
||||
await execWithOutput(
|
||||
`pg_dump "${cleanedRemoteUrl}" --no-owner --no-privileges -f "${tempFile}" 2>&1`,
|
||||
);
|
||||
console.log("✅ Remote database dumped successfully");
|
||||
|
||||
// Step 2: Restore to local database
|
||||
console.log("\n📥 Restoring to local database...\n");
|
||||
await execWithOutput(
|
||||
`psql "${cleanedLocalUrl}" -f "${tempFile}" --set ON_ERROR_STOP=off 2>&1`,
|
||||
);
|
||||
console.log("\n✅ Database restored successfully");
|
||||
|
||||
// Step 3: Clean up temp file
|
||||
console.log("\n🧹 Cleaning up temporary files...");
|
||||
await execAsync(`rm "${tempFile}"`);
|
||||
console.log("✅ Cleanup complete");
|
||||
|
||||
console.log("\n✨ Database pull completed successfully!\n");
|
||||
} catch (error) {
|
||||
console.error("\n❌ Pull failed:");
|
||||
if (error instanceof Error) {
|
||||
console.error(` ${error.message}\n`);
|
||||
}
|
||||
|
||||
// Try to clean up temp file
|
||||
try {
|
||||
await execAsync(`rm "${tempFile}"`);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse command line arguments or environment variable
|
||||
const args = process.argv.slice(2);
|
||||
const remoteUrl =
|
||||
args[0] || process.env.REMOTE_DATABASE_URL || process.env.DATABASE_URL_REMOTE;
|
||||
const localUrl = args[1] || process.env.LOCAL_DATABASE_URL;
|
||||
|
||||
if (!remoteUrl) {
|
||||
console.log("\n📥 Pull Remote Database to Local\n");
|
||||
console.log("Usage:");
|
||||
console.log(' bun db:pull "<remote-database-url>" [local-database-url]\n');
|
||||
console.log("Arguments:");
|
||||
console.log(
|
||||
" remote-database-url (required) Remote database URL to pull from",
|
||||
);
|
||||
console.log(
|
||||
" local-database-url (optional) Local database URL to pull into",
|
||||
);
|
||||
console.log(
|
||||
" If not provided, reads from server/.env DATABASE_URL\n",
|
||||
);
|
||||
console.log("Environment variables:");
|
||||
console.log(" REMOTE_DATABASE_URL Remote database URL");
|
||||
console.log(" LOCAL_DATABASE_URL Local database URL (overrides .env)\n");
|
||||
console.log("Examples:");
|
||||
console.log(' bun db:pull "postgresql://user:pass@remote-host:5432/db"');
|
||||
console.log(' bun db:pull "postgresql://remote..." "postgresql://local..."');
|
||||
console.log(' REMOTE_DATABASE_URL="postgresql://..." bun db:pull\n');
|
||||
console.log("⚠️ Important: URLs must be quoted to prevent shell expansion\n");
|
||||
console.log(
|
||||
"By default, the script reads DATABASE_URL from server/.env for the local database.\n",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
pullDatabase({ remoteUrl, localUrl }).catch((error) => {
|
||||
console.error("Unexpected error:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -2,14 +2,14 @@ loadLocalEnv();
|
||||
|
||||
import { loadLocalEnv } from "@server/utils/envUtils";
|
||||
import inquirer from "inquirer";
|
||||
|
||||
export const migrateFunctions = async () => {
|
||||
// Dynamic import to ensure env is loaded first
|
||||
const { initializeDatabaseFunctions } = await import(
|
||||
"@server/db/initializeDatabaseFunctions"
|
||||
);
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
console.log("databaseUrl", databaseUrl);
|
||||
|
||||
if (databaseUrl?.includes("us-west-3")) {
|
||||
const { confirm } = await inquirer.prompt([
|
||||
{
|
||||
|
||||
366
scripts/seed/seedEvents.ts
Normal file
366
scripts/seed/seedEvents.ts
Normal file
@@ -0,0 +1,366 @@
|
||||
import { AppEnv, ErrCode, type EventInsert, RecaseError } from "@autumn/shared";
|
||||
import { initDrizzle } from "@server/db/initDrizzle.js";
|
||||
import { EventService } from "@server/internal/api/events/EventService.js";
|
||||
import { loadLocalEnv } from "@server/utils/envUtils.js";
|
||||
import { generateId } from "@server/utils/genUtils.js";
|
||||
import chalk from "chalk";
|
||||
|
||||
// Load environment variables from server/.env
|
||||
loadLocalEnv();
|
||||
|
||||
// ============================================================================
|
||||
// CONFIGURATION - Edit these values to customize seed behavior
|
||||
// ============================================================================
|
||||
|
||||
const CONFIG = {
|
||||
// Default number of events to generate
|
||||
defaultEventCount: 100,
|
||||
|
||||
// Time range configuration (in days)
|
||||
// Events will be randomly distributed within this range from now
|
||||
timeRangeDays: 30,
|
||||
|
||||
// Default environment
|
||||
defaultEnv: AppEnv.Sandbox,
|
||||
|
||||
// Optional default properties to include in events
|
||||
defaultProperties: {
|
||||
source: "seed_script",
|
||||
version: "1.0",
|
||||
},
|
||||
} as const;
|
||||
|
||||
// ============================================================================
|
||||
// TYPES
|
||||
// ============================================================================
|
||||
|
||||
interface CliArgs {
|
||||
customer_id: string;
|
||||
count?: number;
|
||||
env?: AppEnv;
|
||||
org_id?: string;
|
||||
feature_ids?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CLI ARGUMENT PARSING
|
||||
// ============================================================================
|
||||
|
||||
function parseArgs(): CliArgs {
|
||||
const args = process.argv.slice(2);
|
||||
const parsed: Partial<CliArgs> = {};
|
||||
|
||||
for (const arg of args) {
|
||||
if (arg.startsWith("--")) {
|
||||
const [key, value] = arg.slice(2).split("=");
|
||||
if (key === "customer_id") {
|
||||
parsed.customer_id = value;
|
||||
} else if (key === "count") {
|
||||
parsed.count = Number.parseInt(value, 10);
|
||||
} else if (key === "env") {
|
||||
parsed.env = value as AppEnv;
|
||||
} else if (key === "org_id") {
|
||||
parsed.org_id = value;
|
||||
} else if (key === "feature_ids") {
|
||||
parsed.feature_ids = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!parsed.customer_id) {
|
||||
console.error(chalk.red("❌ Error: --customer_id is required"));
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
"\nUsage: bun run scripts/seed/seedEvents.ts --customer_id=<id> --feature_ids=<id1,id2,id3> [--count=<number>] [--env=<sandbox|live>] [--org_id=<id>]",
|
||||
),
|
||||
);
|
||||
console.log(chalk.gray("\nExample:"));
|
||||
console.log(
|
||||
chalk.gray(
|
||||
" bun run scripts/seed/seedEvents.ts --customer_id=cus_123 --feature_ids=api_call,page_view --count=50 --env=sandbox",
|
||||
),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!parsed.feature_ids) {
|
||||
console.error(chalk.red("❌ Error: --feature_ids is required"));
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
"\nUsage: bun run scripts/seed/seedEvents.ts --customer_id=<id> --feature_ids=<id1,id2,id3> [--count=<number>] [--env=<sandbox|live>] [--org_id=<id>]",
|
||||
),
|
||||
);
|
||||
console.log(chalk.gray("\nExample:"));
|
||||
console.log(
|
||||
chalk.gray(
|
||||
" bun run scripts/seed/seedEvents.ts --customer_id=cus_123 --feature_ids=api_call,page_view --count=50 --env=sandbox",
|
||||
),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return parsed as CliArgs;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// VALIDATION
|
||||
// ============================================================================
|
||||
|
||||
async function validateCustomer({
|
||||
db,
|
||||
customerId,
|
||||
orgId,
|
||||
env,
|
||||
}: {
|
||||
db: ReturnType<typeof initDrizzle>["db"];
|
||||
customerId: string;
|
||||
orgId?: string;
|
||||
env: AppEnv;
|
||||
}) {
|
||||
// Build the where clause based on whether org_id is provided
|
||||
const customer = await db.query.customers.findFirst({
|
||||
where: (customers, { eq, or, and }) => {
|
||||
const customerMatch = or(
|
||||
eq(customers.id, customerId),
|
||||
eq(customers.internal_id, customerId),
|
||||
);
|
||||
|
||||
if (orgId) {
|
||||
return and(
|
||||
customerMatch,
|
||||
eq(customers.org_id, orgId),
|
||||
eq(customers.env, env),
|
||||
);
|
||||
}
|
||||
|
||||
return customerMatch;
|
||||
},
|
||||
with: {
|
||||
org: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!customer) {
|
||||
const errorMsg = orgId
|
||||
? `Customer '${customerId}' not found in org '${orgId}' with env '${env}'`
|
||||
: `Customer '${customerId}' not found`;
|
||||
|
||||
throw new RecaseError({
|
||||
message: errorMsg,
|
||||
code: ErrCode.CustomerNotFound,
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
return customer;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// EVENT GENERATION
|
||||
// ============================================================================
|
||||
|
||||
function generateRandomTimestamp({ daysBack }: { daysBack: number }): Date {
|
||||
const now = Date.now();
|
||||
const startTime = now - daysBack * 24 * 60 * 60 * 1000;
|
||||
const randomTime = startTime + Math.random() * (now - startTime);
|
||||
return new Date(randomTime);
|
||||
}
|
||||
|
||||
function generateEvents({
|
||||
count,
|
||||
customer,
|
||||
env,
|
||||
featureIds,
|
||||
}: {
|
||||
count: number;
|
||||
customer: Awaited<ReturnType<typeof validateCustomer>>;
|
||||
env: AppEnv;
|
||||
featureIds: string[];
|
||||
}): EventInsert[] {
|
||||
const events: EventInsert[] = [];
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const eventName = featureIds[Math.floor(Math.random() * featureIds.length)];
|
||||
const timestamp = generateRandomTimestamp({
|
||||
daysBack: CONFIG.timeRangeDays,
|
||||
});
|
||||
|
||||
const event: EventInsert = {
|
||||
id: generateId("evt"),
|
||||
org_id: customer.org_id,
|
||||
org_slug: customer.org?.slug || "unknown",
|
||||
internal_customer_id: customer.internal_id,
|
||||
customer_id: customer.id || "",
|
||||
env,
|
||||
event_name: eventName,
|
||||
timestamp,
|
||||
created_at: Date.now(),
|
||||
value: null,
|
||||
set_usage: false,
|
||||
entity_id: null,
|
||||
internal_entity_id: null,
|
||||
idempotency_key: null,
|
||||
properties: {
|
||||
...CONFIG.defaultProperties,
|
||||
event_index: i + 1,
|
||||
random_value: Math.floor(Math.random() * 1000),
|
||||
user: {
|
||||
id: Math.floor(Math.random() * 10) + 1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
// Sort events by timestamp for more realistic insertion
|
||||
events.sort(
|
||||
(a, b) => (a.timestamp?.getTime() || 0) - (b.timestamp?.getTime() || 0),
|
||||
);
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MAIN EXECUTION
|
||||
// ============================================================================
|
||||
|
||||
async function main() {
|
||||
console.log(
|
||||
chalk.magentaBright(
|
||||
"\n================ Event Seeding Script ================\n",
|
||||
),
|
||||
);
|
||||
|
||||
// Parse CLI arguments
|
||||
const args = parseArgs();
|
||||
const eventCount = args.count || CONFIG.defaultEventCount;
|
||||
const env = args.env || CONFIG.defaultEnv;
|
||||
const featureIds = args.feature_ids!.split(",").map((id) => id.trim());
|
||||
|
||||
console.log(chalk.cyan("Configuration:"));
|
||||
console.log(chalk.gray(` Customer ID: ${args.customer_id}`));
|
||||
console.log(chalk.gray(` Event Count: ${eventCount}`));
|
||||
console.log(chalk.gray(` Environment: ${env}`));
|
||||
console.log(chalk.gray(` Feature IDs: ${featureIds.join(", ")}`));
|
||||
if (args.org_id) {
|
||||
console.log(chalk.gray(` Organization ID: ${args.org_id}`));
|
||||
}
|
||||
console.log();
|
||||
|
||||
// Initialize database connection
|
||||
const { db, client } = initDrizzle();
|
||||
|
||||
try {
|
||||
// Validate customer exists
|
||||
console.log(chalk.cyan("Validating customer..."));
|
||||
const customer = await validateCustomer({
|
||||
db,
|
||||
customerId: args.customer_id,
|
||||
orgId: args.org_id,
|
||||
env,
|
||||
});
|
||||
|
||||
console.log(
|
||||
chalk.green(
|
||||
`✅ Customer found: ${customer.id} (${customer.name || "No name"})`,
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
chalk.gray(` Organization: ${customer.org?.name || customer.org_id}`),
|
||||
);
|
||||
console.log();
|
||||
|
||||
// Generate events
|
||||
console.log(chalk.cyan(`Generating ${eventCount} events...`));
|
||||
const events = generateEvents({
|
||||
count: eventCount,
|
||||
customer,
|
||||
env,
|
||||
featureIds,
|
||||
});
|
||||
|
||||
console.log(chalk.green(`✅ Generated ${events.length} events`));
|
||||
console.log(
|
||||
chalk.gray(
|
||||
` Time range: ${events[0].timestamp?.toISOString()} to ${events[events.length - 1].timestamp?.toISOString()}`,
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
chalk.gray(
|
||||
` Event types: ${Array.from(new Set(events.map((e) => e.event_name))).join(", ")}`,
|
||||
),
|
||||
);
|
||||
console.log();
|
||||
|
||||
// Insert events
|
||||
console.log(chalk.cyan("Inserting events into database..."));
|
||||
let successCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
// Insert in batches to avoid overwhelming the database
|
||||
const batchSize = 50;
|
||||
for (let i = 0; i < events.length; i += batchSize) {
|
||||
const batch = events.slice(i, i + batchSize);
|
||||
const batchNum = Math.floor(i / batchSize) + 1;
|
||||
const totalBatches = Math.ceil(events.length / batchSize);
|
||||
|
||||
process.stdout.write(
|
||||
chalk.gray(` Batch ${batchNum}/${totalBatches}... `),
|
||||
);
|
||||
|
||||
for (const event of batch) {
|
||||
try {
|
||||
await EventService.insert({ db, event });
|
||||
successCount++;
|
||||
} catch (error) {
|
||||
errorCount++;
|
||||
if (errorCount <= 5) {
|
||||
// Only show first 5 errors to avoid spam
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
`\n ⚠️ Error inserting event ${event.id}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(chalk.green("✓"));
|
||||
}
|
||||
|
||||
console.log();
|
||||
console.log(chalk.green(`✅ Successfully inserted ${successCount} events`));
|
||||
|
||||
if (errorCount > 0) {
|
||||
console.log(chalk.yellow(`⚠️ ${errorCount} events failed to insert`));
|
||||
if (errorCount > 5) {
|
||||
console.log(
|
||||
chalk.gray(` (Only first 5 errors shown to reduce spam)`),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
chalk.magentaBright(
|
||||
"\n================ Seeding Complete ================\n",
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(chalk.red("\n❌ Error during seeding:"));
|
||||
if (error instanceof RecaseError) {
|
||||
console.error(chalk.red(` ${error.message}`));
|
||||
} else if (error instanceof Error) {
|
||||
console.error(chalk.red(` ${error.message}`));
|
||||
console.error(chalk.gray(error.stack));
|
||||
} else {
|
||||
console.error(chalk.red(` ${String(error)}`));
|
||||
}
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
}
|
||||
|
||||
// Run the script
|
||||
main();
|
||||
81
scripts/setup/setup-clickhouse-insights-user.ts
Normal file
81
scripts/setup/setup-clickhouse-insights-user.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Setup readonly user for ClickHouse insights queries
|
||||
* Usage: bun run scripts/setup-clickhouse-insights-user.ts
|
||||
* Or: infisical run --env=dev -- bun scripts/setup-clickhouse-insights-user.ts
|
||||
* Or: infisical run --env=prod -- bun scripts/setup-clickhouse-insights-user.ts
|
||||
*/
|
||||
|
||||
import crypto from "node:crypto";
|
||||
import { createClient } from "@clickhouse/client";
|
||||
|
||||
const ALLOWED_VIRTUAL_INSIGHTS_TABLES = ["org_events_view"] as const;
|
||||
|
||||
async function main() {
|
||||
const required = [
|
||||
"CLICKHOUSE_URL",
|
||||
"CLICKHOUSE_USERNAME",
|
||||
"CLICKHOUSE_PASSWORD",
|
||||
"CLICKHOUSE_INSIGHTS_USERNAME",
|
||||
"CLICKHOUSE_INSIGHTS_PASSWORD",
|
||||
];
|
||||
|
||||
const missing = required.filter((key) => !process.env[key]);
|
||||
if (missing.length > 0) {
|
||||
console.error("Missing env vars:", missing.join(", "));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const insightsUser = process.env.CLICKHOUSE_INSIGHTS_USERNAME!;
|
||||
const insightsPassword = process.env.CLICKHOUSE_INSIGHTS_PASSWORD!;
|
||||
|
||||
const client = createClient({
|
||||
url: process.env.CLICKHOUSE_URL,
|
||||
username: process.env.CLICKHOUSE_USERNAME,
|
||||
password: process.env.CLICKHOUSE_PASSWORD,
|
||||
});
|
||||
|
||||
try {
|
||||
// Check if user exists
|
||||
const result = await client.query({
|
||||
query: "SELECT name FROM system.users WHERE name = {username:String}",
|
||||
query_params: { username: insightsUser },
|
||||
format: "JSONEachRow",
|
||||
});
|
||||
|
||||
const users = await result.json();
|
||||
|
||||
if (users.length === 0) {
|
||||
// Create user
|
||||
const passwordHash = crypto
|
||||
.createHash("sha256")
|
||||
.update(insightsPassword)
|
||||
.digest("hex");
|
||||
|
||||
await client.command({
|
||||
query: `CREATE USER ${insightsUser} IDENTIFIED WITH sha256_hash BY '${passwordHash}' SETTINGS readonly = 1`,
|
||||
});
|
||||
console.log(`✓ Created user: ${insightsUser}`);
|
||||
} else {
|
||||
console.log(`✓ User exists: ${insightsUser}`);
|
||||
}
|
||||
|
||||
// Grant SELECT on virtual tables only
|
||||
// Note: Views with SQL SECURITY DEFINER execute with creator's privileges,
|
||||
// so insights_query_user does NOT need access to underlying tables
|
||||
for (const table of ALLOWED_VIRTUAL_INSIGHTS_TABLES) {
|
||||
await client.command({
|
||||
query: `GRANT SELECT ON ${table} TO ${insightsUser}`,
|
||||
});
|
||||
console.log(`✓ Granted SELECT on ${table}`);
|
||||
}
|
||||
|
||||
console.log("\n✅ Setup complete");
|
||||
} catch (error) {
|
||||
console.error("❌ Error:", error);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -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' \
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -54,7 +54,6 @@ autumnWebhookRouter.post(
|
||||
const { type, data } = evt;
|
||||
|
||||
// console.log("Event:", evt);
|
||||
// console.log("Data:", data);
|
||||
|
||||
switch (type) {
|
||||
case WebhookEventType.CustomerProductsUpdated:
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { ClickHouseClient, QueryParams } from "@clickhouse/client";
|
||||
import {
|
||||
type ClickHouseClient,
|
||||
createClient,
|
||||
type QueryParams,
|
||||
} from "@clickhouse/client";
|
||||
import { clickhouseClient } from "../../db/initClickHouse.js";
|
||||
|
||||
export enum ClickHouseQuery {
|
||||
@@ -17,6 +21,7 @@ export enum ClickHouseQuery {
|
||||
export class ClickHouseManager {
|
||||
private static instance: ClickHouseManager | null = null;
|
||||
private client: ClickHouseClient | null = clickhouseClient;
|
||||
private readonlyClient: ClickHouseClient | null = null;
|
||||
private initialized = false;
|
||||
private initPromise: Promise<void> | null = null;
|
||||
static clickhouseAvailable =
|
||||
@@ -75,6 +80,27 @@ export class ClickHouseManager {
|
||||
return manager.client;
|
||||
}
|
||||
|
||||
public static async getReadonlyClient(): Promise<ClickHouseClient> {
|
||||
const manager = await ClickHouseManager.getInstance();
|
||||
if (!manager.readonlyClient) {
|
||||
if (
|
||||
!process.env.CLICKHOUSE_INSIGHTS_USERNAME ||
|
||||
!process.env.CLICKHOUSE_INSIGHTS_PASSWORD
|
||||
) {
|
||||
throw new Error(
|
||||
"CLICKHOUSE_INSIGHTS_USERNAME and CLICKHOUSE_INSIGHTS_PASSWORD must be set",
|
||||
);
|
||||
}
|
||||
|
||||
manager.readonlyClient = createClient({
|
||||
url: process.env.CLICKHOUSE_URL,
|
||||
username: process.env.CLICKHOUSE_INSIGHTS_USERNAME,
|
||||
password: process.env.CLICKHOUSE_INSIGHTS_PASSWORD,
|
||||
});
|
||||
}
|
||||
return manager.readonlyClient;
|
||||
}
|
||||
|
||||
static async createDateRangeView() {}
|
||||
static async createDateRangeBcView() {}
|
||||
static async createOrgEventsView() {}
|
||||
|
||||
@@ -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,
|
||||
|
||||
192
server/src/external/redis/redisUtils.ts
vendored
192
server/src/external/redis/redisUtils.ts
vendored
@@ -1,155 +1,71 @@
|
||||
import { ErrCode, RecaseError } from "@autumn/shared";
|
||||
import { tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js";
|
||||
import { redis } from "./initRedis.js";
|
||||
|
||||
export const handleAttachRaceCondition = async ({
|
||||
req,
|
||||
res,
|
||||
}: {
|
||||
req: any;
|
||||
res: any;
|
||||
}) => {
|
||||
const customerId = req.body.customer_id;
|
||||
const orgId = req.orgId;
|
||||
const env = req.env;
|
||||
const lockKey = `attach_${customerId}_${orgId}_${env}`;
|
||||
|
||||
// Check if Redis is ready before attempting lock
|
||||
if (redis.status !== "ready") {
|
||||
req.logger.warn("❗️❗️ Redis not ready, proceeding without lock", {
|
||||
status: redis.status,
|
||||
customerId,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const existingLock = await redis.get(lockKey);
|
||||
|
||||
if (existingLock) {
|
||||
throw new RecaseError({
|
||||
message: `Attach already runnning for customer ${customerId}, try again in a few seconds`,
|
||||
code: ErrCode.InvalidRequest,
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
// Create lock with 5 second timeout
|
||||
await redis.set(lockKey, "1", "PX", 5000, "NX");
|
||||
|
||||
const originalJson = res.json;
|
||||
res.json = async function (body: any) {
|
||||
try {
|
||||
await clearLock({ lockKey, logger: req.logger });
|
||||
} catch (error) {
|
||||
req.logger.warn("❗️❗️ Error clearing lock", {
|
||||
error,
|
||||
});
|
||||
}
|
||||
originalJson.call(this, body);
|
||||
};
|
||||
|
||||
return lockKey;
|
||||
} catch (error) {
|
||||
// Only throw if it's a lock conflict error
|
||||
if (error instanceof RecaseError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Redis is down - log warning but allow operation to proceed
|
||||
req.logger.warn("❗️❗️ Redis unavailable, proceeding without lock", {
|
||||
error,
|
||||
customerId,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
export const clearLock = async ({ lockKey }: { lockKey: string }) => {
|
||||
await tryRedisWrite(() => redis.del(lockKey));
|
||||
};
|
||||
|
||||
export const handleCustomerRaceCondition = async ({
|
||||
action,
|
||||
customerId,
|
||||
orgId,
|
||||
env,
|
||||
res,
|
||||
logger,
|
||||
}: {
|
||||
action: any;
|
||||
customerId: string;
|
||||
orgId: string;
|
||||
env: string;
|
||||
res: any;
|
||||
logger: any;
|
||||
}) => {
|
||||
const lockKey = `${action}_${customerId}_${orgId}_${env}`;
|
||||
interface LockData {
|
||||
errorMessage: string;
|
||||
}
|
||||
|
||||
// Check if Redis is ready before attempting lock
|
||||
if (redis.status !== "ready") {
|
||||
logger.warn("❗️❗️ Redis not ready, proceeding without lock", {
|
||||
status: redis.status,
|
||||
action,
|
||||
customerId,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
const DEFAULT_ERROR_MESSAGE =
|
||||
"Operation already in progress, try again in a few seconds";
|
||||
|
||||
try {
|
||||
const existingLock = await redis.get(lockKey);
|
||||
if (existingLock) {
|
||||
throw new RecaseError({
|
||||
message: `Action ${action} already running for customer ${customerId}, try again in a few seconds`,
|
||||
code: ErrCode.InvalidRequest,
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
// Create lock with 5 second timeout
|
||||
await redis.set(lockKey, "1", "PX", 5000, "NX");
|
||||
|
||||
const originalJson = res.json;
|
||||
res.json = async function (body: any) {
|
||||
try {
|
||||
await clearLock({ lockKey, logger });
|
||||
} catch (error) {
|
||||
logger.warn("❗️❗️ Error clearing lock", {
|
||||
error,
|
||||
});
|
||||
}
|
||||
originalJson.call(this, body);
|
||||
};
|
||||
|
||||
return lockKey;
|
||||
} catch (error) {
|
||||
// Only throw if it's a lock conflict error
|
||||
if (error instanceof RecaseError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Redis is down - log warning but allow operation to proceed
|
||||
logger.warn("❗️❗️ Redis unavailable, proceeding without lock", {
|
||||
error,
|
||||
action,
|
||||
customerId,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const clearLock = async ({
|
||||
/**
|
||||
* Acquire a distributed lock using Redis.
|
||||
* If Redis is not ready or errors, returns true to allow the operation to proceed.
|
||||
* @returns true if lock was acquired (or Redis unavailable), throws if lock already exists
|
||||
*/
|
||||
export const acquireLock = async ({
|
||||
lockKey,
|
||||
logger,
|
||||
ttlMs = 10000,
|
||||
errorMessage = DEFAULT_ERROR_MESSAGE,
|
||||
}: {
|
||||
lockKey: string;
|
||||
logger: any;
|
||||
}) => {
|
||||
ttlMs?: number;
|
||||
errorMessage?: string;
|
||||
}): Promise<boolean> => {
|
||||
// If Redis not ready, allow operation to proceed
|
||||
if (redis.status !== "ready") {
|
||||
logger.warn("❗️❗️ Redis not ready, skipping lock clear", {
|
||||
status: redis.status,
|
||||
lockKey,
|
||||
});
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
await redis.del(lockKey);
|
||||
// NX = only set if key doesn't exist, PX = set expiry in milliseconds
|
||||
// Store as JSON for future extensibility
|
||||
const lockData: LockData = { errorMessage };
|
||||
const result = await redis.set(
|
||||
lockKey,
|
||||
JSON.stringify(lockData),
|
||||
"PX",
|
||||
ttlMs,
|
||||
"NX",
|
||||
);
|
||||
|
||||
// If result is null, lock already exists (NX failed)
|
||||
if (result === null) {
|
||||
const existingData = await redis.get(lockKey);
|
||||
const parsed = existingData
|
||||
? (JSON.parse(existingData) as LockData)
|
||||
: null;
|
||||
|
||||
throw new RecaseError({
|
||||
message: parsed?.errorMessage || DEFAULT_ERROR_MESSAGE,
|
||||
code: ErrCode.InvalidRequest,
|
||||
statusCode: 429,
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.warn("❗️❗️ Error clearing lock");
|
||||
logger.warn(error);
|
||||
// Re-throw lock conflict errors
|
||||
if (error instanceof RecaseError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Redis error - allow operation to proceed
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { AffectedResource, ApiVersion } from "@autumn/shared";
|
||||
import type { Context, Env, Handler, MiddlewareHandler } from "hono";
|
||||
import type { ZodType, z } from "zod/v4";
|
||||
import type { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import { acquireLock, clearLock } from "@/external/redis/redisUtils.js";
|
||||
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
|
||||
import { expandMiddleware } from "./expandMiddleware.js";
|
||||
import { validator } from "./validatorMiddleware.js";
|
||||
@@ -95,6 +96,17 @@ export function createRoute<
|
||||
c: ValidatedContext<HonoEnv, Body, Query, Params>,
|
||||
) => Response | Promise<Response>;
|
||||
assertIdempotence?: string | undefined;
|
||||
/** Lock configuration to prevent concurrent requests */
|
||||
lock?: {
|
||||
/** Generate a lock key. Returns null to skip locking. */
|
||||
getKey: (
|
||||
c: ValidatedContext<HonoEnv, Body, Query, Params>,
|
||||
) => string | null;
|
||||
/** Lock TTL in milliseconds (default: 10000) */
|
||||
ttlMs?: number;
|
||||
/** Error message to show when lock is already held */
|
||||
errorMessage?: string;
|
||||
};
|
||||
}) {
|
||||
const middlewares: MiddlewareHandler[] = [];
|
||||
|
||||
@@ -142,18 +154,38 @@ export function createRoute<
|
||||
) => {
|
||||
c.set("validated", true);
|
||||
|
||||
if (opts.withTx) {
|
||||
const db = c.get("ctx").db;
|
||||
|
||||
return await db.transaction(async (tx) => {
|
||||
c.set("ctx", {
|
||||
...c.get("ctx"),
|
||||
db: tx as unknown as DrizzleCli,
|
||||
// Acquire lock if lock config provided
|
||||
let lockKey: string | null = null;
|
||||
if (opts.lock) {
|
||||
lockKey = opts.lock.getKey(c);
|
||||
if (lockKey) {
|
||||
await acquireLock({
|
||||
lockKey,
|
||||
ttlMs: opts.lock.ttlMs,
|
||||
errorMessage: opts.lock.errorMessage,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (opts.withTx) {
|
||||
const db = c.get("ctx").db;
|
||||
|
||||
return await db.transaction(async (tx) => {
|
||||
c.set("ctx", {
|
||||
...c.get("ctx"),
|
||||
db: tx as unknown as DrizzleCli,
|
||||
});
|
||||
return await opts.handler(c);
|
||||
});
|
||||
} else {
|
||||
return await opts.handler(c);
|
||||
});
|
||||
} else {
|
||||
return await opts.handler(c);
|
||||
}
|
||||
} finally {
|
||||
// Always release lock
|
||||
if (lockKey) {
|
||||
await clearLock({ lockKey });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
/** biome-ignore-all lint/complexity/noStaticOnlyClass: wrap it up buddy */
|
||||
|
||||
import { ErrCode, type FullCustomer } from "@autumn/shared";
|
||||
import { ErrCode, type FullCustomer, type RangeEnum } from "@autumn/shared";
|
||||
import type { ClickHouseClient } from "@clickhouse/client";
|
||||
import { Decimal } from "decimal.js";
|
||||
import { StatusCodes } from "http-status-codes";
|
||||
@@ -194,7 +192,7 @@ WHERE org_id = {org_id:String}
|
||||
req: ExtendedRequest;
|
||||
params: {
|
||||
event_names: string[];
|
||||
interval: "24h" | "7d" | "30d" | "90d" | "1bc" | "3bc";
|
||||
interval: RangeEnum;
|
||||
customer_id?: string;
|
||||
no_count?: boolean;
|
||||
};
|
||||
@@ -203,8 +201,7 @@ WHERE org_id = {org_id:String}
|
||||
}) {
|
||||
const { clickhouseClient, org, env, db } = req;
|
||||
|
||||
const intervalType: "24h" | "7d" | "30d" | "90d" | "1bc" | "3bc" =
|
||||
params.interval || "24h";
|
||||
const intervalType: RangeEnum = params.interval || "24h";
|
||||
|
||||
const isBillingCycle = intervalType === "1bc" || intervalType === "3bc";
|
||||
AnalyticsService.handleEarlyExit();
|
||||
@@ -394,22 +391,4 @@ order by dr.period;
|
||||
|
||||
return resultJson;
|
||||
}
|
||||
|
||||
// private static async getSubscriptionsIfNeeded(
|
||||
// customer: FullCustomer,
|
||||
// customerHasSubscriptions: boolean,
|
||||
// db: DrizzleCli
|
||||
// ): Promise<Subscription[]> {
|
||||
// if (customerHasSubscriptions) {
|
||||
// return [];
|
||||
// }
|
||||
|
||||
// return await SubService.getInStripeIds({
|
||||
// db,
|
||||
// ids:
|
||||
// customer.customer_products?.flatMap(
|
||||
// (product: FullCusProduct) => product.subscription_ids ?? []
|
||||
// ) ?? [],
|
||||
// });
|
||||
// }
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
AffectedResource,
|
||||
applyResponseVersionChanges,
|
||||
InsightsQueryBodySchema,
|
||||
type InsightsQueryResponse,
|
||||
InsightsQueryResponseSchema,
|
||||
} from "@autumn/shared";
|
||||
import { ClickHouseManager } from "../../../external/clickhouse/ClickHouseManager";
|
||||
import { createRoute } from "../../../honoMiddlewares/routeHandler";
|
||||
|
||||
const VIRTUAL_TABLES = ["org_events_view"] as const;
|
||||
|
||||
export const handleInsightsQuery = createRoute({
|
||||
body: InsightsQueryBodySchema,
|
||||
handler: async (c) => {
|
||||
const ctx = c.get("ctx");
|
||||
const { query } = c.req.valid("json");
|
||||
|
||||
const containsVirtualTable = VIRTUAL_TABLES.some((table) =>
|
||||
query.includes(`from ${table}`),
|
||||
);
|
||||
|
||||
if (containsVirtualTable) {
|
||||
return c.json(
|
||||
{
|
||||
data: null,
|
||||
error: "Virtual table not allowed in query",
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const cleanedQuery = query.replace(
|
||||
/from\s+events/gi,
|
||||
`from org_events_view(org_id={org_id:String}, org_slug='', env={env:String})`,
|
||||
);
|
||||
|
||||
const readonlyClient = await ClickHouseManager.getReadonlyClient();
|
||||
|
||||
const result = await readonlyClient.query({
|
||||
query: cleanedQuery,
|
||||
query_params: {
|
||||
org_id: ctx.org.id,
|
||||
org_slug: "",
|
||||
env: ctx.env,
|
||||
limit: 1000,
|
||||
},
|
||||
format: "JSON",
|
||||
});
|
||||
|
||||
const resultJson = await result.json();
|
||||
|
||||
const parsedResult = InsightsQueryResponseSchema.parse({
|
||||
data: resultJson,
|
||||
});
|
||||
|
||||
return c.json(
|
||||
applyResponseVersionChanges<InsightsQueryResponse>({
|
||||
input: parsedResult.data,
|
||||
targetVersion: ctx.apiVersion,
|
||||
resource: AffectedResource.Attach,
|
||||
ctx,
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -1,8 +1,10 @@
|
||||
import {
|
||||
AffectedResource,
|
||||
type ApiCustomer,
|
||||
type ApiEntityV1,
|
||||
type ApiPlan,
|
||||
ApiVersion,
|
||||
ApiVersionClass,
|
||||
type AppEnv,
|
||||
type AuthType,
|
||||
addToExpand,
|
||||
@@ -10,6 +12,7 @@ import {
|
||||
CusExpand,
|
||||
type CustomerLegacyData,
|
||||
cusProductToProduct,
|
||||
type EntityLegacyData,
|
||||
type FullCusProduct,
|
||||
type FullProduct,
|
||||
type Organization,
|
||||
@@ -23,6 +26,7 @@ import { FeatureService } from "@/internal/features/FeatureService.js";
|
||||
import { JobName } from "@/queue/JobName.js";
|
||||
import { addTaskToQueue } from "@/queue/queueUtils.js";
|
||||
import { getApiCustomerBase } from "../../customers/cusUtils/apiCusUtils/getApiCustomerBase";
|
||||
import { getApiEntityBase } from "../../entities/entityUtils/apiEntityUtils/getApiEntityBase";
|
||||
import { getPlanResponse } from "../../products/productUtils/productResponseUtils/getPlanResponse";
|
||||
|
||||
interface ActionDetails {
|
||||
@@ -124,8 +128,10 @@ export const handleProductsUpdated = async ({
|
||||
env,
|
||||
});
|
||||
|
||||
ctx.apiVersion = new ApiVersionClass(ApiVersion.V1_2);
|
||||
|
||||
if (ctx.apiVersion.lte(ApiVersion.V1_2)) {
|
||||
addToExpand({
|
||||
ctx = addToExpand({
|
||||
ctx,
|
||||
add: [
|
||||
CusExpand.BalancesFeature,
|
||||
@@ -166,21 +172,27 @@ export const handleProductsUpdated = async ({
|
||||
ctx,
|
||||
});
|
||||
|
||||
// console.log(`API version: ${ctx.apiVersion.value}`);
|
||||
// console.log(`Versioned customer:`, versionedCustomer);
|
||||
// console.log(`Versioned plan:`, versionedPlan);
|
||||
let entity: unknown | undefined;
|
||||
if (fullCus.entity) {
|
||||
const { apiEntity, legacyData } = await getApiEntityBase({
|
||||
ctx,
|
||||
entity: fullCus.entity,
|
||||
fullCus,
|
||||
});
|
||||
|
||||
// let entityRes = null;
|
||||
// if (notNullish(customer?.entity)) {
|
||||
// entityRes = await getSingleEntityResponse({
|
||||
// ctx,
|
||||
// entityId: customer.entity!.id,
|
||||
// fullCus: customer,
|
||||
// entity: customer.entity!,
|
||||
// });
|
||||
// }
|
||||
entity = applyResponseVersionChanges<ApiEntityV1, EntityLegacyData>({
|
||||
input: apiEntity,
|
||||
targetVersion: ctx.apiVersion,
|
||||
resource: AffectedResource.Entity,
|
||||
legacyData,
|
||||
ctx,
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Send Svix event
|
||||
ctx.logger.info(
|
||||
`sending customer.products.updated webhook, customer ID: ${data.customerId}, entity ID: ${fullCus.entity?.id || "none"}`,
|
||||
);
|
||||
await sendSvixEvent({
|
||||
org,
|
||||
env,
|
||||
@@ -188,6 +200,7 @@ export const handleProductsUpdated = async ({
|
||||
data: {
|
||||
scenario,
|
||||
customer: versionedCustomer,
|
||||
entity,
|
||||
updated_product: versionedPlan,
|
||||
},
|
||||
});
|
||||
|
||||
7
server/src/internal/analytics/insightsRouter.ts
Normal file
7
server/src/internal/analytics/insightsRouter.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Hono } from "hono";
|
||||
import type { HonoEnv } from "../../honoUtils/HonoEnv.js";
|
||||
import { handleInsightsQuery } from "./handlers/handleInsightsQuery";
|
||||
|
||||
export const insightsRouter = new Hono<HonoEnv>();
|
||||
|
||||
insightsRouter.post("/query", ...handleInsightsQuery);
|
||||
7
server/src/internal/analytics/legacyAnalyticsRouter.ts
Normal file
7
server/src/internal/analytics/legacyAnalyticsRouter.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Hono } from "hono";
|
||||
import type { HonoEnv } from "../../honoUtils/HonoEnv.js";
|
||||
import { handleEventsAggregation } from "../events/handlers/handleEventsAggregation.js";
|
||||
|
||||
export const legacyAnalyticsRouter = new Hono<HonoEnv>();
|
||||
|
||||
legacyAnalyticsRouter.post("", ...handleEventsAggregation);
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
));
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -74,6 +74,8 @@ export const getAttachParams = async ({
|
||||
|
||||
// Others
|
||||
apiVersion: ctx.apiVersion.value,
|
||||
|
||||
newBillingSubscription: attachBody.new_billing_subscription || false,
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
// });
|
||||
// },
|
||||
// });
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 || "",
|
||||
|
||||
351
server/src/internal/events/EventsAggregationService.ts
Normal file
351
server/src/internal/events/EventsAggregationService.ts
Normal file
@@ -0,0 +1,351 @@
|
||||
import {
|
||||
type BillingCycleResult,
|
||||
type CalculateDateRangeParams,
|
||||
type ClickHouseResult,
|
||||
type DateRangeResult,
|
||||
ErrCode,
|
||||
RecaseError,
|
||||
type TimeseriesEventsParams,
|
||||
type TotalEventsParams,
|
||||
} from "@autumn/shared";
|
||||
import type { ClickHouseClient } from "@clickhouse/client";
|
||||
import { UTCDate } from "@date-fns/utc";
|
||||
import {
|
||||
differenceInDays,
|
||||
format,
|
||||
startOfDay,
|
||||
startOfHour,
|
||||
sub,
|
||||
} from "date-fns";
|
||||
import { Decimal } from "decimal.js";
|
||||
import { StatusCodes } from "http-status-codes";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||
import {
|
||||
generateEventCountExpressions,
|
||||
getBillingCycleStartDate,
|
||||
} from "../analytics/analyticsUtils.js";
|
||||
|
||||
export class EventsAggregationService {
|
||||
private static async calculateDateRange({
|
||||
ctx,
|
||||
params,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
params: CalculateDateRangeParams;
|
||||
}): Promise<DateRangeResult> {
|
||||
const { db } = ctx;
|
||||
const intervalType = params.interval;
|
||||
const binSize =
|
||||
params.bin_size ?? (intervalType === "24h" ? "hour" : "day");
|
||||
|
||||
if (params.custom_range) {
|
||||
return {
|
||||
startDate: format(
|
||||
new UTCDate(params.custom_range.start),
|
||||
"yyyy-MM-dd'T'HH:mm:ss",
|
||||
),
|
||||
endDate: format(
|
||||
new UTCDate(params.custom_range.end),
|
||||
"yyyy-MM-dd'T'HH:mm:ss",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const isBillingCycle = intervalType === "1bc" || intervalType === "3bc";
|
||||
const getBCResults =
|
||||
isBillingCycle && !params.aggregateAll && params.customer
|
||||
? ((await getBillingCycleStartDate(
|
||||
params.customer,
|
||||
db,
|
||||
intervalType as "1bc" | "3bc",
|
||||
)) as BillingCycleResult | null)
|
||||
: null;
|
||||
|
||||
if (getBCResults?.startDate && getBCResults?.endDate) {
|
||||
return {
|
||||
startDate: getBCResults.startDate,
|
||||
endDate: getBCResults.endDate,
|
||||
};
|
||||
}
|
||||
|
||||
const intervalTypeToDaysMap =
|
||||
EventsAggregationService.intervalTypeToDaysMap({
|
||||
gap: 0,
|
||||
});
|
||||
const days =
|
||||
intervalTypeToDaysMap[intervalType as keyof typeof intervalTypeToDaysMap];
|
||||
|
||||
const now = new UTCDate();
|
||||
const endDate = format(now, "yyyy-MM-dd'T'HH:mm:ss");
|
||||
|
||||
const startTime = sub(now, { days });
|
||||
const truncatedStartTime =
|
||||
binSize === "day" ? startOfDay(startTime) : startOfHour(startTime);
|
||||
const startDate = format(truncatedStartTime, "yyyy-MM-dd'T'HH:mm:ss");
|
||||
|
||||
return { startDate, endDate };
|
||||
}
|
||||
|
||||
static intervalTypeToDaysMap({
|
||||
gap,
|
||||
}: {
|
||||
gap?: number;
|
||||
} = {}): Record<string, number> {
|
||||
return {
|
||||
"24h": 1,
|
||||
"7d": 7,
|
||||
"30d": 30,
|
||||
"90d": 90,
|
||||
"1bc": (gap ?? 0) + 1,
|
||||
"3bc": (gap ?? 0) + 1,
|
||||
};
|
||||
}
|
||||
static async getTimeseriesEvents({
|
||||
ctx,
|
||||
params,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
params: TimeseriesEventsParams;
|
||||
}) {
|
||||
const { clickhouseClient, org, env, db } = ctx;
|
||||
|
||||
const intervalType = params.interval;
|
||||
|
||||
const useCustomDateQuery =
|
||||
intervalType === "1bc" || intervalType === "3bc" || !!params.custom_range;
|
||||
|
||||
// Skip billing cycle calculation if aggregating all customers or using custom_range
|
||||
const getBCResults =
|
||||
useCustomDateQuery &&
|
||||
!params.aggregateAll &&
|
||||
params.customer &&
|
||||
!params.custom_range
|
||||
? ((await getBillingCycleStartDate(
|
||||
params.customer,
|
||||
db,
|
||||
intervalType as "1bc" | "3bc",
|
||||
)) as BillingCycleResult | null)
|
||||
: null;
|
||||
|
||||
const countExpressions = generateEventCountExpressions(
|
||||
params.event_names,
|
||||
params.no_count,
|
||||
);
|
||||
|
||||
const getGroupByClause = () => {
|
||||
if (!params.group_by)
|
||||
return { select: "", groupBy: "", orderBy: "", fieldName: null };
|
||||
|
||||
let field: string | null = null;
|
||||
// Extract property path after 'properties.' and escape single quotes for SQL safety
|
||||
const propertyPath = params.group_by.replace("properties.", "");
|
||||
const pathSegments = propertyPath.split(".").map((segment) => {
|
||||
// Validate each segment contains only safe characters (alphanumeric, underscores)
|
||||
if (!/^[a-zA-Z0-9_]+$/.test(segment)) {
|
||||
throw new RecaseError({
|
||||
message:
|
||||
"Invalid property path. Should only contain alphanumeric and underscore characters.",
|
||||
code: ErrCode.InvalidInputs,
|
||||
statusCode: StatusCodes.BAD_REQUEST,
|
||||
});
|
||||
}
|
||||
// Escape single quotes for SQL safety
|
||||
return segment.replace(/'/g, "''");
|
||||
});
|
||||
|
||||
const validSegments = pathSegments.filter(
|
||||
(segment): segment is string => segment !== null,
|
||||
);
|
||||
|
||||
if (validSegments.length === 0) {
|
||||
return { select: "", groupBy: "", orderBy: "", fieldName: null };
|
||||
}
|
||||
|
||||
// Join segments as separate arguments: 'key1', 'key2', 'key3'
|
||||
// ClickHouse JSONExtractString requires each key as a separate argument
|
||||
const escapedPathArgs = validSegments.map((seg) => `'${seg}'`).join(", ");
|
||||
field = `JSONExtractString(e.properties, ${escapedPathArgs})`;
|
||||
|
||||
if (!field)
|
||||
return { select: "", groupBy: "", orderBy: "", fieldName: null };
|
||||
|
||||
const escapedFieldName = params.group_by.replace(/`/g, "``");
|
||||
const columnAlias = `\`${escapedFieldName}\``;
|
||||
|
||||
return {
|
||||
select: `, ${field} as ${columnAlias}`,
|
||||
groupBy: `, ${field}`,
|
||||
orderBy: `, ${field}`,
|
||||
fieldName: params.group_by,
|
||||
};
|
||||
};
|
||||
|
||||
const groupBy = getGroupByClause();
|
||||
const groupByFieldName = groupBy.fieldName;
|
||||
|
||||
const query = `
|
||||
with customer_events as (
|
||||
select *
|
||||
from org_events_view(org_id={org_id:String}, org_slug='', env={env:String})
|
||||
${params.aggregateAll ? "" : "where customer_id = {customer_id:String}"}
|
||||
)
|
||||
select
|
||||
dr.period${groupBy.select},
|
||||
${countExpressions}
|
||||
from date_range_view(bin_size={bin_size:String}, days={days:UInt32}) dr
|
||||
left join customer_events e
|
||||
on date_trunc({bin_size:String}, e.timestamp) = dr.period
|
||||
group by dr.period${groupBy.groupBy}
|
||||
order by dr.period${groupBy.orderBy};
|
||||
`;
|
||||
|
||||
const queryBillingCycle = `
|
||||
with customer_events as (
|
||||
select *
|
||||
from org_events_view(org_id={org_id:String}, org_slug='', env={env:String})
|
||||
${params.aggregateAll ? "" : "where customer_id = {customer_id:String}"}
|
||||
)
|
||||
select
|
||||
dr.period${groupBy.select},
|
||||
${countExpressions}
|
||||
from date_range_bc_view(bin_size={bin_size:String}, start_date={end_date:DateTime}, days={days:UInt32}) dr
|
||||
left join customer_events e
|
||||
on date_trunc({bin_size:String}, e.timestamp) = dr.period
|
||||
group by dr.period${groupBy.groupBy}
|
||||
order by dr.period${groupBy.orderBy};
|
||||
`;
|
||||
|
||||
const customRangeDays = params.custom_range
|
||||
? differenceInDays(
|
||||
new UTCDate(params.custom_range.end),
|
||||
new UTCDate(params.custom_range.start),
|
||||
) + 1
|
||||
: undefined;
|
||||
|
||||
const customRangeEndDate = params.custom_range
|
||||
? format(new UTCDate(params.custom_range.end), "yyyy-MM-dd'T'HH:mm:ss")
|
||||
: undefined;
|
||||
|
||||
const intervalTypeToDaysMap =
|
||||
EventsAggregationService.intervalTypeToDaysMap({
|
||||
gap: getBCResults?.gap,
|
||||
});
|
||||
|
||||
const queryParams = {
|
||||
org_id: org?.id,
|
||||
env: env,
|
||||
customer_id: params.customer_id,
|
||||
days:
|
||||
customRangeDays ??
|
||||
intervalTypeToDaysMap[
|
||||
intervalType as keyof typeof intervalTypeToDaysMap
|
||||
],
|
||||
bin_size: params.bin_size ?? (intervalType === "24h" ? "hour" : "day"),
|
||||
end_date: customRangeEndDate ?? getBCResults?.endDate,
|
||||
};
|
||||
|
||||
// Use date_range_bc_view query for billing cycles or custom ranges
|
||||
const queryToUse =
|
||||
useCustomDateQuery &&
|
||||
!params.aggregateAll &&
|
||||
(getBCResults?.startDate || params.custom_range)
|
||||
? queryBillingCycle
|
||||
: query;
|
||||
|
||||
const result = await (clickhouseClient as ClickHouseClient).query({
|
||||
query: queryToUse,
|
||||
query_params: queryParams,
|
||||
format: "JSON",
|
||||
clickhouse_settings: {
|
||||
output_format_json_quote_decimals: 0,
|
||||
output_format_json_quote_64bit_integers: 1,
|
||||
output_format_json_quote_64bit_floats: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const resultJson = (await result.json()) as ClickHouseResult;
|
||||
|
||||
resultJson.data.forEach((row) => {
|
||||
Object.keys(row).forEach((key: string) => {
|
||||
// Don't convert period or the group_by field to decimal
|
||||
if (key !== "period" && key !== groupByFieldName) {
|
||||
row[key] = new Decimal(row[key] as string | number)
|
||||
.toDecimalPlaces(10)
|
||||
.toNumber();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return resultJson;
|
||||
}
|
||||
|
||||
static async getTotalEvents({
|
||||
ctx,
|
||||
params,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
params: TotalEventsParams;
|
||||
}) {
|
||||
const { clickhouseClient, org, env } = ctx;
|
||||
|
||||
const { startDate, endDate } =
|
||||
await EventsAggregationService.calculateDateRange({
|
||||
ctx,
|
||||
params: {
|
||||
interval: params.interval,
|
||||
bin_size: params.bin_size,
|
||||
custom_range: params.custom_range,
|
||||
customer: params.customer,
|
||||
aggregateAll: params.aggregateAll,
|
||||
},
|
||||
});
|
||||
|
||||
const query = `
|
||||
with customer_events as (
|
||||
select *
|
||||
from org_events_view(org_id={org_id:String}, org_slug='', env={env:String})
|
||||
${params.aggregateAll ? "" : "where customer_id = {customer_id:String}"}
|
||||
)
|
||||
select
|
||||
e.event_name,
|
||||
COUNT(*) as count,
|
||||
SUM(e.value) as sum
|
||||
from customer_events e
|
||||
where e.timestamp >= {start_date:DateTime}
|
||||
and e.timestamp <= {end_date:DateTime}
|
||||
and e.event_name IN {event_names:Array(String)}
|
||||
group by e.event_name;
|
||||
`;
|
||||
|
||||
const result = await (clickhouseClient as ClickHouseClient).query({
|
||||
query,
|
||||
query_params: {
|
||||
org_id: org?.id,
|
||||
env: env,
|
||||
customer_id: params.customer_id,
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
event_names: params.event_names,
|
||||
},
|
||||
format: "JSON",
|
||||
});
|
||||
|
||||
const resultJson = (await result.json()) as ClickHouseResult;
|
||||
const rows = resultJson.data as Array<{
|
||||
event_name: string;
|
||||
count: string;
|
||||
sum: string;
|
||||
}>;
|
||||
|
||||
return rows.reduce(
|
||||
(acc, row) => {
|
||||
acc[row.event_name] = {
|
||||
count: Number(row.count),
|
||||
sum: Number(row.sum ?? 0),
|
||||
};
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, { count: number; sum: number }>,
|
||||
);
|
||||
}
|
||||
}
|
||||
119
server/src/internal/events/eventUtils.ts
Normal file
119
server/src/internal/events/eventUtils.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { UTCDate } from "@date-fns/utc";
|
||||
|
||||
/**
|
||||
* Convert event periods from ISO strings to epoch timestamps.
|
||||
* @param events - The events to convert.
|
||||
* @returns The current time as an epoch timestamp for filtering.
|
||||
*/
|
||||
export function convertPeriodsToEpoch(
|
||||
events: Array<Record<string, string | number>>,
|
||||
): number {
|
||||
const currentTime = new UTCDate().getTime();
|
||||
for (const event of events) {
|
||||
event.period = new UTCDate(event.period as string).getTime();
|
||||
}
|
||||
return currentTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a group value to a string.
|
||||
* @param value - The value to normalize.
|
||||
* @returns The normalized value as a string or null if the value is null or empty.
|
||||
*/
|
||||
function normalizeGroupValue(value: unknown): string | null {
|
||||
if (value == null || value === "") return null;
|
||||
return String(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect grouping metadata from a list of rows.
|
||||
* @param rows - The rows to collect metadata from.
|
||||
* @param groupByField - The field to group by.
|
||||
* @returns The group values and feature names.
|
||||
*/
|
||||
export function collectGroupingMetadata(
|
||||
rows: Array<Record<string, string | number>>,
|
||||
groupByField: string,
|
||||
): { groupValues: Set<string>; featureNames: Set<string> } {
|
||||
const groupValues = new Set<string>();
|
||||
const featureNames = new Set<string>();
|
||||
|
||||
for (const row of rows) {
|
||||
// biome-ignore lint/correctness/noUnusedVariables: period is required here but appears unused
|
||||
const { [groupByField]: groupValue, period, ...metrics } = row;
|
||||
const normalized = normalizeGroupValue(groupValue);
|
||||
if (normalized) {
|
||||
groupValues.add(normalized);
|
||||
}
|
||||
for (const featureName of Object.keys(metrics)) {
|
||||
featureNames.add(featureName);
|
||||
}
|
||||
}
|
||||
|
||||
return { groupValues, featureNames };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a grouped timeseries from a list of rows.
|
||||
* @param rows - The rows to build the grouped timeseries from.
|
||||
* @param groupByField - The field to group by.
|
||||
* @returns The grouped timeseries.
|
||||
*/
|
||||
export function buildGroupedTimeseries(
|
||||
rows: Array<Record<string, string | number>>,
|
||||
groupByField: string,
|
||||
): Map<number, Record<string, number | Record<string, number>>> {
|
||||
const grouped = new Map<
|
||||
number,
|
||||
Record<string, number | Record<string, number>>
|
||||
>();
|
||||
|
||||
for (const row of rows) {
|
||||
const { period, [groupByField]: groupValue, ...metrics } = row;
|
||||
const periodNum = Number(period);
|
||||
|
||||
if (!grouped.has(periodNum)) {
|
||||
grouped.set(periodNum, { period: periodNum });
|
||||
}
|
||||
|
||||
const normalized = normalizeGroupValue(groupValue);
|
||||
if (!normalized) continue;
|
||||
|
||||
const periodData = grouped.get(periodNum)!;
|
||||
for (const [featureName, value] of Object.entries(metrics)) {
|
||||
if (!periodData[featureName]) {
|
||||
periodData[featureName] = {};
|
||||
}
|
||||
(periodData[featureName] as Record<string, number>)[normalized] =
|
||||
Number(value);
|
||||
}
|
||||
}
|
||||
|
||||
return grouped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Backfill missing group values in a grouped timeseries.
|
||||
* @param grouped - The grouped timeseries to backfill.
|
||||
* @param groupValues - The group values to backfill.
|
||||
* @param featureNames - The feature names to backfill.
|
||||
*/
|
||||
export function backfillMissingGroupValues(
|
||||
grouped: Map<number, Record<string, number | Record<string, number>>>,
|
||||
groupValues: Set<string>,
|
||||
featureNames: Set<string>,
|
||||
): void {
|
||||
for (const periodData of grouped.values()) {
|
||||
for (const featureName of featureNames) {
|
||||
if (!periodData[featureName]) {
|
||||
periodData[featureName] = {};
|
||||
}
|
||||
const featureData = periodData[featureName] as Record<string, number>;
|
||||
for (const groupValue of groupValues) {
|
||||
if (featureData[groupValue] === undefined) {
|
||||
featureData[groupValue] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
7
server/src/internal/events/eventsRouter.ts
Normal file
7
server/src/internal/events/eventsRouter.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Hono } from "hono";
|
||||
import type { HonoEnv } from "../../honoUtils/HonoEnv.js";
|
||||
import { handleEventsAggregation } from "./handlers/handleEventsAggregation.js";
|
||||
|
||||
export const eventsRouter = new Hono<HonoEnv>();
|
||||
|
||||
eventsRouter.post("aggregate", ...handleEventsAggregation);
|
||||
102
server/src/internal/events/handlers/handleEventsAggregation.ts
Normal file
102
server/src/internal/events/handlers/handleEventsAggregation.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import type { AggregatedEventRow, ProcessedEventRow } from "@autumn/shared";
|
||||
import {
|
||||
CustomerNotFoundError,
|
||||
ErrCode,
|
||||
EventAggregationBodySchema,
|
||||
RecaseError,
|
||||
} from "@autumn/shared";
|
||||
import { StatusCodes } from "http-status-codes";
|
||||
import { CusService } from "@/internal/customers/CusService";
|
||||
import { createRoute } from "../../../honoMiddlewares/routeHandler";
|
||||
import { EventsAggregationService } from "../EventsAggregationService";
|
||||
import {
|
||||
backfillMissingGroupValues,
|
||||
buildGroupedTimeseries,
|
||||
collectGroupingMetadata,
|
||||
convertPeriodsToEpoch,
|
||||
} from "../eventUtils.js";
|
||||
|
||||
export const handleEventsAggregation = createRoute({
|
||||
body: EventAggregationBodySchema,
|
||||
handler: async (c) => {
|
||||
const ctx = c.get("ctx");
|
||||
const { db, org, env } = ctx;
|
||||
const { customer_id, feature_id, group_by, range, bin_size, custom_range } =
|
||||
c.req.valid("json");
|
||||
|
||||
const customer = await CusService.getFull({
|
||||
db,
|
||||
orgId: org.id,
|
||||
idOrInternalId: customer_id,
|
||||
env,
|
||||
withSubs: true,
|
||||
});
|
||||
|
||||
if (!customer) {
|
||||
throw new CustomerNotFoundError({ customerId: customer_id });
|
||||
}
|
||||
|
||||
const featureIds = Array.isArray(feature_id) ? feature_id : [feature_id];
|
||||
|
||||
const [events, total] = await Promise.all([
|
||||
EventsAggregationService.getTimeseriesEvents({
|
||||
ctx,
|
||||
params: {
|
||||
aggregateAll: false,
|
||||
interval: range,
|
||||
event_names: featureIds,
|
||||
customer_id: customer_id,
|
||||
no_count: true,
|
||||
customer,
|
||||
group_by,
|
||||
bin_size,
|
||||
custom_range,
|
||||
},
|
||||
}),
|
||||
EventsAggregationService.getTotalEvents({
|
||||
ctx,
|
||||
params: {
|
||||
aggregateAll: false,
|
||||
interval: range,
|
||||
event_names: featureIds,
|
||||
customer_id: customer_id,
|
||||
customer,
|
||||
custom_range,
|
||||
bin_size,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!events) {
|
||||
throw new RecaseError({
|
||||
message: "No events found",
|
||||
code: ErrCode.InternalError,
|
||||
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
|
||||
});
|
||||
}
|
||||
|
||||
const currentTime = convertPeriodsToEpoch(events.data);
|
||||
|
||||
let usageList = (events.data as ProcessedEventRow[]).filter(
|
||||
(event) => event.period <= currentTime,
|
||||
) as AggregatedEventRow[];
|
||||
|
||||
if (group_by) {
|
||||
const ungroupedData = usageList as ProcessedEventRow[];
|
||||
|
||||
const { groupValues, featureNames } = collectGroupingMetadata(
|
||||
ungroupedData,
|
||||
group_by,
|
||||
);
|
||||
const grouped = buildGroupedTimeseries(ungroupedData, group_by);
|
||||
backfillMissingGroupValues(grouped, groupValues, featureNames);
|
||||
|
||||
usageList = Array.from(grouped.values()) as AggregatedEventRow[];
|
||||
}
|
||||
|
||||
return c.json({
|
||||
list: usageList,
|
||||
total,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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" });
|
||||
}
|
||||
};
|
||||
@@ -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 });
|
||||
// },
|
||||
// });
|
||||
@@ -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,
|
||||
// );
|
||||
// },
|
||||
// });
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,13 +1,3 @@
|
||||
// import {
|
||||
// ApiFeatureType,
|
||||
// ApiFeatureV0Schema,
|
||||
// ErrCode,
|
||||
// type Feature,
|
||||
// FeatureType,
|
||||
// type FeatureUsageType,
|
||||
// UpdateFeatureParamsSchema,
|
||||
// } from "@autumn/shared";
|
||||
|
||||
import { Hono } from "hono";
|
||||
import type { HonoEnv } from "../../honoUtils/HonoEnv";
|
||||
import { handleCreateFeature } from "./handlers/handleCreateFeature";
|
||||
@@ -17,174 +7,6 @@ import { handleListFeatures } from "./handlers/handleListFeatures";
|
||||
import { handleUpdateFeature } from "./handlers/handleUpdateFeature";
|
||||
import { handleGetFeatureDeletionInfo } from "./internalHandlers/handleGetFeatureDeletionInfo";
|
||||
|
||||
// import express, { type Router } from "express";
|
||||
// import { JobName } from "@/queue/JobName.js";
|
||||
// import { addTaskToQueue } from "@/queue/queueUtils.js";
|
||||
// import RecaseError from "@/utils/errorUtils.js";
|
||||
// import { keyToTitle } from "@/utils/genUtils.js";
|
||||
// import { routeHandler } from "@/utils/routerUtils.js";
|
||||
// import { FeatureService } from "./FeatureService.js";
|
||||
// import { validateFeatureId } from "./featureUtils.js";
|
||||
// import { handleDeleteFeature } from "./handlers/handleDeleteFeature.js";
|
||||
// import { handleGetFeatureDeletionInfo } from "./handlers/handleGetFeatureDeletionInfo.js";
|
||||
// import { handleUpdateFeature } from "./handlers/handleUpdateFeature.js";
|
||||
// import { fromApiFeature, toApiFeature } from "./utils/mapFeatureUtils.js";
|
||||
|
||||
// export const featureRouter: Router = express.Router();
|
||||
|
||||
// // 1. Get features...
|
||||
// featureRouter.get("", async (req: any, res: any) =>
|
||||
// routeHandler({
|
||||
// req,
|
||||
// res,
|
||||
// action: "list features",
|
||||
// handler: async () => {
|
||||
// const includeArchived = req.query.include_archived === "true";
|
||||
// const features = await FeatureService.list({
|
||||
// db: req.db,
|
||||
// orgId: req.orgId,
|
||||
// env: req.env,
|
||||
// archived: includeArchived ? undefined : false,
|
||||
// // showOnlyArchived: includeArchived ? undefined : false,
|
||||
// });
|
||||
|
||||
// res
|
||||
// .status(200)
|
||||
// .json({ list: features.map((feature) => toApiFeature({ feature })) });
|
||||
// },
|
||||
// }),
|
||||
// );
|
||||
|
||||
// featureRouter.get("/:featureId", async (req: any, res: any) =>
|
||||
// routeHandler({
|
||||
// req,
|
||||
// res,
|
||||
// action: "Get feature",
|
||||
// handler: async () => {
|
||||
// const feature = req.features.find(
|
||||
// (f: Feature) => f.id === req.params.featureId,
|
||||
// );
|
||||
|
||||
// if (!feature) {
|
||||
// throw new RecaseError({
|
||||
// message: `Feature with id ${req.params.featureId} not found`,
|
||||
// code: ErrCode.FeatureNotFound,
|
||||
// statusCode: 404,
|
||||
// });
|
||||
// }
|
||||
|
||||
// res.status(200).json(toApiFeature({ feature }));
|
||||
// },
|
||||
// }),
|
||||
// );
|
||||
|
||||
// featureRouter.post("", async (req: any, res: any) =>
|
||||
// routeHandler({
|
||||
// req,
|
||||
// res,
|
||||
// action: "Create feature",
|
||||
// handler: async () => {
|
||||
// const apiFeature = ApiFeatureV0Schema.parse(req.body);
|
||||
// if (!apiFeature.name) {
|
||||
// apiFeature.name = keyToTitle(apiFeature.id);
|
||||
// }
|
||||
|
||||
// validateFeatureId(apiFeature.id);
|
||||
|
||||
// const feature = fromApiFeature({
|
||||
// apiFeature,
|
||||
// orgId: req.orgId,
|
||||
// env: req.env,
|
||||
// });
|
||||
|
||||
// const { db, logger, features: curFeatures } = req;
|
||||
|
||||
// const curFeature = curFeatures.find((f: Feature) => f.id === feature.id);
|
||||
|
||||
// if (curFeature) {
|
||||
// throw new RecaseError({
|
||||
// message: `Feature with id ${feature.id} already exists`,
|
||||
// code: ErrCode.DuplicateFeatureId,
|
||||
// statusCode: 400,
|
||||
// });
|
||||
// }
|
||||
|
||||
// await FeatureService.insert({ db, data: [feature], logger });
|
||||
|
||||
// await addTaskToQueue({
|
||||
// jobName: JobName.GenerateFeatureDisplay,
|
||||
// payload: { feature },
|
||||
// });
|
||||
|
||||
// res.status(200).json(apiFeature);
|
||||
// },
|
||||
// }),
|
||||
// );
|
||||
|
||||
// featureRouter.post("/:feature_id", async (req: any, res: any) =>
|
||||
// routeHandler({
|
||||
// req,
|
||||
// res,
|
||||
// action: "Update feature",
|
||||
// handler: async (req: any, res: any) => {
|
||||
// const { feature_id: featureId } = req.params;
|
||||
// const { features: curFeatures } = req;
|
||||
// const apiFeature = UpdateFeatureParamsSchema.parse(req.body);
|
||||
|
||||
// const originalFeature = curFeatures.find(
|
||||
// (f: Feature) => f.id === featureId,
|
||||
// );
|
||||
|
||||
// if (!originalFeature) {
|
||||
// throw new RecaseError({
|
||||
// message: `Feature with id ${featureId} not found`,
|
||||
// code: ErrCode.FeatureNotFound,
|
||||
// statusCode: 404,
|
||||
// });
|
||||
// }
|
||||
|
||||
// // Replace body...
|
||||
// let featureType = apiFeature.type as unknown as FeatureType;
|
||||
// let usageType: FeatureUsageType | undefined;
|
||||
// if (
|
||||
// apiFeature.type === ApiFeatureType.SingleUsage ||
|
||||
// apiFeature.type === ApiFeatureType.ContinuousUse
|
||||
// ) {
|
||||
// featureType = FeatureType.Metered;
|
||||
// usageType = apiFeature.type as unknown as FeatureUsageType;
|
||||
// }
|
||||
|
||||
// const newConfig = originalFeature.config;
|
||||
// if (usageType) {
|
||||
// newConfig.usage_type = usageType;
|
||||
// }
|
||||
|
||||
// if (apiFeature.credit_schema) {
|
||||
// newConfig.schema = apiFeature.credit_schema.map((credit) => ({
|
||||
// metered_feature_id: credit.metered_feature_id,
|
||||
// credit_amount: credit.credit_cost,
|
||||
// }));
|
||||
// }
|
||||
|
||||
// const newBody = {
|
||||
// id: req.body.id || undefined,
|
||||
// name: req.body.name || undefined,
|
||||
// type: featureType,
|
||||
// config: newConfig,
|
||||
// archived: req.body.archived ?? undefined,
|
||||
// };
|
||||
|
||||
// req.body = newBody;
|
||||
|
||||
// await handleUpdateFeature(req, res, true);
|
||||
// },
|
||||
// }),
|
||||
// );
|
||||
|
||||
// featureRouter.delete("/:featureId", handleDeleteFeature);
|
||||
|
||||
// featureRouter.get("/:feature_id/deletion_info", handleGetFeatureDeletionInfo);
|
||||
|
||||
export const featureRouter = new Hono<HonoEnv>();
|
||||
featureRouter.get("", ...handleListFeatures);
|
||||
featureRouter.post("", ...handleCreateFeature);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -28,7 +28,7 @@ export const addIdsToProductItems = ({
|
||||
const priceIds = new Set<string>();
|
||||
|
||||
const basePriceItem = items.find((item) => item.price_id === null);
|
||||
const baseCurPrice = curPrices.find((price) => isFixedPrice({ price }));
|
||||
const baseCurPrice = curPrices.find((price) => isFixedPrice(price));
|
||||
|
||||
if (basePriceItem && baseCurPrice) {
|
||||
basePriceItem.price_id = baseCurPrice.id;
|
||||
|
||||
@@ -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 })) {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { SQSClient } from "@aws-sdk/client-sqs";
|
||||
|
||||
const DEFAULT_AWS_REGION = "us-west-2";
|
||||
|
||||
/**
|
||||
* Extracts the AWS region from a given SQS queue URL.
|
||||
* Returns undefined if the URL is empty or invalid.
|
||||
@@ -7,7 +9,7 @@ import { SQSClient } from "@aws-sdk/client-sqs";
|
||||
export function extractRegionFromQueueUrl({
|
||||
queueUrl,
|
||||
}: {
|
||||
queueUrl: string;
|
||||
queueUrl: string | undefined;
|
||||
}): string | undefined {
|
||||
if (!queueUrl) return undefined;
|
||||
// SQS URL format: https://sqs.<region>.amazonaws.com/<account>/<queueName>
|
||||
@@ -19,11 +21,9 @@ export function extractRegionFromQueueUrl({
|
||||
|
||||
export const sqs = new SQSClient({
|
||||
region:
|
||||
process.env.NODE_ENV === "production"
|
||||
? "us-west-2"
|
||||
: extractRegionFromQueueUrl({
|
||||
queueUrl: process.env.SQS_QUEUE_URL || "",
|
||||
}),
|
||||
extractRegionFromQueueUrl({
|
||||
queueUrl: process.env.SQS_QUEUE_URL,
|
||||
}) || DEFAULT_AWS_REGION,
|
||||
credentials: {
|
||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID || "",
|
||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || "",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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,
|
||||
// });
|
||||
// });
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -1,19 +1,13 @@
|
||||
import { beforeAll, describe, expect, test } from "bun:test";
|
||||
import {
|
||||
type ApiCusFeatureV3,
|
||||
ApiVersion,
|
||||
type LimitedItem,
|
||||
} from "@autumn/shared";
|
||||
import { type ApiCustomer, ApiVersion, type LimitedItem } from "@autumn/shared";
|
||||
import { TestFeature } from "@tests/setup/v2Features.js";
|
||||
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
|
||||
import chalk from "chalk";
|
||||
import { Decimal } from "decimal.js";
|
||||
import { AutumnInt } from "@/external/autumn/autumnCli.js";
|
||||
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
|
||||
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
|
||||
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
|
||||
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
|
||||
import { timeout } from "../../utils/genUtils.js";
|
||||
|
||||
const messagesFeature = constructFeatureItem({
|
||||
featureId: TestFeature.Messages,
|
||||
@@ -30,11 +24,8 @@ const testCase = "balances-update1";
|
||||
|
||||
describe(`${chalk.yellowBright("balances-update1: testing update balance after track (metered feature)")}`, () => {
|
||||
const customerId = testCase;
|
||||
const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
|
||||
const autumnV2: AutumnInt = new AutumnInt({ version: ApiVersion.V2_0 });
|
||||
|
||||
const usage = 20.132;
|
||||
|
||||
beforeAll(async () => {
|
||||
await initCustomerV3({
|
||||
ctx,
|
||||
@@ -48,75 +39,27 @@ describe(`${chalk.yellowBright("balances-update1: testing update balance after t
|
||||
prefix: testCase,
|
||||
});
|
||||
|
||||
await autumnV1.attach({
|
||||
await autumnV2.attach({
|
||||
customer_id: customerId,
|
||||
product_id: freeProd.id,
|
||||
});
|
||||
});
|
||||
|
||||
const usageAmount = 20.132;
|
||||
test("should track usage and have correct v1 / v2 api cus feature", async () => {
|
||||
await autumnV2.track({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Messages,
|
||||
value: usageAmount,
|
||||
});
|
||||
|
||||
await timeout(2000);
|
||||
const customer = await autumnV2.customers.get(customerId);
|
||||
const feature = customer.features[TestFeature.Messages] as any;
|
||||
|
||||
const currentBalance = new Decimal(messagesFeature.included_usage)
|
||||
.minus(usage)
|
||||
.toNumber();
|
||||
|
||||
expect(feature).toMatchObject({
|
||||
granted_balance: messagesFeature.included_usage,
|
||||
current_balance: currentBalance,
|
||||
usage,
|
||||
purchased_balance: 0,
|
||||
});
|
||||
|
||||
const customerV1 = await autumnV1.customers.get(customerId);
|
||||
const featureV1 = customerV1.features[
|
||||
TestFeature.Messages
|
||||
] as unknown as ApiCusFeatureV3;
|
||||
|
||||
expect(featureV1).toMatchObject({
|
||||
included_usage: messagesFeature.included_usage,
|
||||
balance: currentBalance,
|
||||
usage,
|
||||
});
|
||||
expect(featureV1.next_reset_at).toBeDefined();
|
||||
});
|
||||
|
||||
test("should update balance and have correct v1/v2 api cus feature", async () => {
|
||||
// Restore back to original granted balance (new granted is 20.132)
|
||||
await autumnV2.balances.update({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Messages,
|
||||
current_balance: messagesFeature.included_usage,
|
||||
current_balance: messagesFeature.included_usage + 140,
|
||||
});
|
||||
|
||||
await timeout(2000);
|
||||
const customerV2 = await autumnV2.customers.get<ApiCustomer>(customerId);
|
||||
const balance = customerV2.balances[TestFeature.Messages];
|
||||
|
||||
const customer = await autumnV2.customers.get(customerId);
|
||||
const feature = customer.features[TestFeature.Messages];
|
||||
|
||||
expect(feature).toMatchObject({
|
||||
granted_balance: messagesFeature.included_usage + usageAmount,
|
||||
current_balance: messagesFeature.included_usage,
|
||||
usage: usageAmount,
|
||||
expect(balance).toMatchObject({
|
||||
granted_balance: messagesFeature.included_usage + 140,
|
||||
current_balance: messagesFeature.included_usage + 140,
|
||||
usage: 0,
|
||||
purchased_balance: 0,
|
||||
});
|
||||
|
||||
const customerV1 = await autumnV1.customers.get(customerId);
|
||||
const featureV1 = customerV1.features[TestFeature.Messages];
|
||||
|
||||
expect(featureV1).toMatchObject({
|
||||
included_usage: messagesFeature.included_usage + usageAmount,
|
||||
balance: messagesFeature.included_usage,
|
||||
usage: usageAmount,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,39 +1,40 @@
|
||||
import { beforeAll, describe, expect, test } from "bun:test";
|
||||
import {
|
||||
type ApiCusFeatureV3,
|
||||
type ApiCustomer,
|
||||
ApiVersion,
|
||||
type LimitedItem,
|
||||
ResetInterval,
|
||||
} from "@autumn/shared";
|
||||
import chalk from "chalk";
|
||||
import { Decimal } from "decimal.js";
|
||||
import { TestFeature } from "@tests/setup/v2Features.js";
|
||||
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
|
||||
import chalk from "chalk";
|
||||
import { AutumnInt } from "@/external/autumn/autumnCli.js";
|
||||
import { constructArrearItem } from "@/utils/scriptUtils/constructItem.js";
|
||||
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
|
||||
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
|
||||
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
|
||||
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
|
||||
import { timeout } from "../../utils/genUtils.js";
|
||||
|
||||
const messagesFeature = constructArrearItem({
|
||||
const monthlyMsges = constructFeatureItem({
|
||||
featureId: TestFeature.Messages,
|
||||
price: 0.5,
|
||||
billingUnits: 1,
|
||||
includedUsage: 1000,
|
||||
includedUsage: 100,
|
||||
}) as LimitedItem;
|
||||
|
||||
const lifetimeMsges = constructFeatureItem({
|
||||
featureId: TestFeature.Messages,
|
||||
includedUsage: 50,
|
||||
interval: null,
|
||||
}) as LimitedItem;
|
||||
|
||||
const freeProd = constructProduct({
|
||||
type: "free",
|
||||
id: "usage-based",
|
||||
isDefault: false,
|
||||
items: [messagesFeature],
|
||||
items: [lifetimeMsges, monthlyMsges],
|
||||
});
|
||||
|
||||
const testCase = "balances-update2";
|
||||
|
||||
describe(`${chalk.yellowBright("balances-update2: testing update balance after tracking into overage")}`, () => {
|
||||
describe(`${chalk.yellowBright("balances-update2: testing update balance after track (metered feature)")}`, () => {
|
||||
const customerId = testCase;
|
||||
const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
|
||||
const autumnV2: AutumnInt = new AutumnInt({ version: ApiVersion.V2_0 });
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -41,7 +42,6 @@ describe(`${chalk.yellowBright("balances-update2: testing update balance after t
|
||||
ctx,
|
||||
customerId,
|
||||
withTestClock: false,
|
||||
attachPm: "success",
|
||||
});
|
||||
|
||||
await initProductsV0({
|
||||
@@ -50,102 +50,91 @@ describe(`${chalk.yellowBright("balances-update2: testing update balance after t
|
||||
prefix: testCase,
|
||||
});
|
||||
|
||||
await autumnV1.attach({
|
||||
await autumnV2.attach({
|
||||
customer_id: customerId,
|
||||
product_id: freeProd.id,
|
||||
});
|
||||
});
|
||||
|
||||
const usageAmount = 20.132;
|
||||
test("should track usage and have correct v1 / v2 api cus feature", async () => {
|
||||
await autumnV2.track({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Messages,
|
||||
value: usageAmount,
|
||||
});
|
||||
|
||||
await timeout(2000);
|
||||
|
||||
const customer = await autumnV2.customers.get(customerId);
|
||||
const feature = customer.features[TestFeature.Messages];
|
||||
|
||||
expect(feature).toMatchObject({
|
||||
granted_balance: messagesFeature.included_usage,
|
||||
purchased_balance: 0,
|
||||
current_balance: messagesFeature.included_usage - usageAmount,
|
||||
usage: usageAmount,
|
||||
});
|
||||
|
||||
const customerV1 = await autumnV1.customers.get(customerId);
|
||||
const featureV1 = customerV1.features[TestFeature.Messages];
|
||||
|
||||
expect(featureV1).toMatchObject({
|
||||
included_usage: messagesFeature.included_usage,
|
||||
balance: messagesFeature.included_usage - usageAmount,
|
||||
usage: usageAmount,
|
||||
});
|
||||
});
|
||||
|
||||
const usageAmount2 = 1231.131;
|
||||
const totalUsage = new Decimal(usageAmount).add(usageAmount2).toNumber();
|
||||
test("should track into overage and have correct v1 / v2 api cus feature", async () => {
|
||||
await autumnV2.track({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Messages,
|
||||
value: usageAmount2,
|
||||
});
|
||||
|
||||
await timeout(2000);
|
||||
|
||||
const customer = await autumnV2.customers.get(customerId);
|
||||
const feature = customer.features[TestFeature.Messages];
|
||||
|
||||
expect(feature).toMatchObject({
|
||||
granted_balance: messagesFeature.included_usage,
|
||||
purchased_balance: new Decimal(totalUsage)
|
||||
.sub(messagesFeature.included_usage)
|
||||
.toNumber(),
|
||||
current_balance: 0,
|
||||
usage: totalUsage,
|
||||
});
|
||||
|
||||
const customerV1 = await autumnV1.customers.get(customerId);
|
||||
const featureV1 = customerV1.features[
|
||||
TestFeature.Messages
|
||||
] as unknown as ApiCusFeatureV3;
|
||||
|
||||
expect(featureV1).toMatchObject({
|
||||
included_usage: messagesFeature.included_usage,
|
||||
balance: new Decimal(messagesFeature.included_usage)
|
||||
.sub(new Decimal(totalUsage))
|
||||
.toNumber(),
|
||||
usage: totalUsage,
|
||||
});
|
||||
});
|
||||
return;
|
||||
|
||||
const newBalance = 300;
|
||||
test("should update balance and have correct v1 / v2 api cus feature", async () => {
|
||||
test("should update balance and have correct v2 api balance for one off interval", async () => {
|
||||
await autumnV2.balances.update({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Messages,
|
||||
current_balance: newBalance,
|
||||
current_balance: lifetimeMsges.included_usage + 140,
|
||||
interval: ResetInterval.OneOff,
|
||||
});
|
||||
|
||||
await timeout(2000);
|
||||
const customerV2 = await autumnV2.customers.get<ApiCustomer>(customerId);
|
||||
const balance = customerV2.balances[TestFeature.Messages];
|
||||
|
||||
const customer = await autumnV2.customers.get(customerId);
|
||||
const feature = customer.features[TestFeature.Messages];
|
||||
expect(balance).toMatchObject({
|
||||
granted_balance:
|
||||
monthlyMsges.included_usage + lifetimeMsges.included_usage + 140,
|
||||
current_balance:
|
||||
monthlyMsges.included_usage + lifetimeMsges.included_usage + 140,
|
||||
usage: 0,
|
||||
purchased_balance: 0,
|
||||
});
|
||||
|
||||
expect(feature).toMatchObject({
|
||||
granted_balance: messagesFeature.included_usage,
|
||||
const lifetimeBreakdown = balance.breakdown?.find(
|
||||
(b) => b.reset?.interval === ResetInterval.OneOff,
|
||||
);
|
||||
expect(lifetimeBreakdown).toMatchObject({
|
||||
granted_balance: lifetimeMsges.included_usage + 140,
|
||||
current_balance: lifetimeMsges.included_usage + 140,
|
||||
purchased_balance: 0,
|
||||
usage: 0,
|
||||
});
|
||||
|
||||
purchased_balance: new Decimal(totalUsage)
|
||||
.sub(messagesFeature.included_usage)
|
||||
.toNumber(),
|
||||
const monthlyBreakdown = balance.breakdown?.find(
|
||||
(b) => b.reset?.interval === ResetInterval.Month,
|
||||
);
|
||||
expect(monthlyBreakdown).toMatchObject({
|
||||
granted_balance: monthlyMsges.included_usage,
|
||||
current_balance: monthlyMsges.included_usage,
|
||||
purchased_balance: 0,
|
||||
usage: 0,
|
||||
});
|
||||
});
|
||||
|
||||
current_balance: newBalance,
|
||||
usage: totalUsage,
|
||||
test("should update balance and have correct v2 api balance for one off interval", async () => {
|
||||
await autumnV2.balances.update({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Messages,
|
||||
current_balance: monthlyMsges.included_usage + 120,
|
||||
interval: ResetInterval.Month,
|
||||
});
|
||||
|
||||
const customerV2 = await autumnV2.customers.get<ApiCustomer>(customerId);
|
||||
const balance = customerV2.balances[TestFeature.Messages];
|
||||
|
||||
expect(balance).toMatchObject({
|
||||
granted_balance:
|
||||
monthlyMsges.included_usage + lifetimeMsges.included_usage + 140 + 120,
|
||||
current_balance:
|
||||
monthlyMsges.included_usage + lifetimeMsges.included_usage + 140 + 120,
|
||||
usage: 0,
|
||||
purchased_balance: 0,
|
||||
});
|
||||
|
||||
const lifetimeBreakdown = balance.breakdown?.find(
|
||||
(b) => b.reset?.interval === ResetInterval.OneOff,
|
||||
);
|
||||
expect(lifetimeBreakdown).toMatchObject({
|
||||
granted_balance: lifetimeMsges.included_usage + 140,
|
||||
current_balance: lifetimeMsges.included_usage + 140,
|
||||
purchased_balance: 0,
|
||||
usage: 0,
|
||||
});
|
||||
|
||||
const monthlyBreakdown = balance.breakdown?.find(
|
||||
(b) => b.reset?.interval === ResetInterval.Month,
|
||||
);
|
||||
expect(monthlyBreakdown).toMatchObject({
|
||||
granted_balance: monthlyMsges.included_usage + 120,
|
||||
current_balance: monthlyMsges.included_usage + 120,
|
||||
purchased_balance: 0,
|
||||
usage: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,280 +1,280 @@
|
||||
import { beforeAll, describe, expect, test } from "bun:test";
|
||||
import { type ApiCustomer, ApiVersion, type LimitedItem } from "@autumn/shared";
|
||||
import { TestFeature } from "@tests/setup/v2Features.js";
|
||||
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
|
||||
import chalk from "chalk";
|
||||
import { AutumnInt } from "@/external/autumn/autumnCli.js";
|
||||
import { CusService } from "@/internal/customers/CusService.js";
|
||||
import { constructArrearItem } from "@/utils/scriptUtils/constructItem.js";
|
||||
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
|
||||
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
|
||||
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
|
||||
import { timeout } from "../../utils/genUtils.js";
|
||||
// import { beforeAll, describe, expect, test } from "bun:test";
|
||||
// import { type ApiCustomer, ApiVersion, type LimitedItem } from "@autumn/shared";
|
||||
// import { TestFeature } from "@tests/setup/v2Features.js";
|
||||
// import ctx from "@tests/utils/testInitUtils/createTestContext.js";
|
||||
// import chalk from "chalk";
|
||||
// import { AutumnInt } from "@/external/autumn/autumnCli.js";
|
||||
// import { CusService } from "@/internal/customers/CusService.js";
|
||||
// import { constructArrearItem } from "@/utils/scriptUtils/constructItem.js";
|
||||
// import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
|
||||
// import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
|
||||
// import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
|
||||
// import { timeout } from "../../utils/genUtils.js";
|
||||
|
||||
const usersFeature = constructArrearItem({
|
||||
featureId: TestFeature.Users,
|
||||
price: 10,
|
||||
billingUnits: 1,
|
||||
includedUsage: 0,
|
||||
}) as LimitedItem;
|
||||
// const usersFeature = constructArrearItem({
|
||||
// featureId: TestFeature.Users,
|
||||
// price: 10,
|
||||
// billingUnits: 1,
|
||||
// includedUsage: 0,
|
||||
// }) as LimitedItem;
|
||||
|
||||
const freeProd = constructProduct({
|
||||
type: "free",
|
||||
id: "pay-per-use-users-balance-update3",
|
||||
isDefault: false,
|
||||
items: [usersFeature],
|
||||
});
|
||||
// const freeProd = constructProduct({
|
||||
// type: "free",
|
||||
// id: "pay-per-use-users-balance-update3",
|
||||
// isDefault: false,
|
||||
// items: [usersFeature],
|
||||
// });
|
||||
|
||||
const testCase = "balances-update3";
|
||||
// const testCase = "balances-update3";
|
||||
|
||||
describe(`${chalk.yellowBright("balances-update3: Balance decoupling tests")}`, () => {
|
||||
const customerId = testCase;
|
||||
const autumnV2: AutumnInt = new AutumnInt({ version: ApiVersion.V2_0 });
|
||||
// describe(`${chalk.yellowBright("balances-update3: Balance decoupling tests")}`, () => {
|
||||
// const customerId = testCase;
|
||||
// const autumnV2: AutumnInt = new AutumnInt({ version: ApiVersion.V2_0 });
|
||||
|
||||
const getRawCusEnt = async () => {
|
||||
const fullCus = await CusService.getFull({
|
||||
db: ctx.db,
|
||||
idOrInternalId: customerId,
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
});
|
||||
// const getRawCusEnt = async () => {
|
||||
// const fullCus = await CusService.getFull({
|
||||
// db: ctx.db,
|
||||
// idOrInternalId: customerId,
|
||||
// orgId: ctx.org.id,
|
||||
// env: ctx.env,
|
||||
// });
|
||||
|
||||
return fullCus.customer_products
|
||||
.flatMap((cp) => cp.customer_entitlements)
|
||||
.find(
|
||||
(ce) =>
|
||||
ce.internal_feature_id ===
|
||||
ctx.features.find((f) => f.id === TestFeature.Users)?.internal_id,
|
||||
);
|
||||
};
|
||||
// return fullCus.customer_products
|
||||
// .flatMap((cp) => cp.customer_entitlements)
|
||||
// .find(
|
||||
// (ce) =>
|
||||
// ce.internal_feature_id ===
|
||||
// ctx.features.find((f) => f.id === TestFeature.Users)?.internal_id,
|
||||
// );
|
||||
// };
|
||||
|
||||
const logState = async (label: string) => {
|
||||
const cusEnt = await getRawCusEnt();
|
||||
const customer = (await autumnV2.customers.get(
|
||||
customerId,
|
||||
)) as unknown as ApiCustomer;
|
||||
const balance = customer.balances[TestFeature.Users];
|
||||
// const logState = async (label: string) => {
|
||||
// const cusEnt = await getRawCusEnt();
|
||||
// const customer = (await autumnV2.customers.get(
|
||||
// customerId,
|
||||
// )) as unknown as ApiCustomer;
|
||||
// const balance = customer.balances[TestFeature.Users];
|
||||
|
||||
console.log(`\n=== ${label} ===`);
|
||||
console.log("DB:", {
|
||||
bal: cusEnt?.balance,
|
||||
add_bal: cusEnt?.additional_balance,
|
||||
add_grant: cusEnt?.adjustment,
|
||||
});
|
||||
console.log("API:", {
|
||||
granted: balance.granted_balance,
|
||||
purchased: balance.purchased_balance,
|
||||
current: balance.current_balance,
|
||||
usage: balance.usage,
|
||||
});
|
||||
};
|
||||
// console.log(`\n=== ${label} ===`);
|
||||
// console.log("DB:", {
|
||||
// bal: cusEnt?.balance,
|
||||
// add_bal: cusEnt?.additional_balance,
|
||||
// add_grant: cusEnt?.adjustment,
|
||||
// });
|
||||
// console.log("API:", {
|
||||
// granted: balance.granted_balance,
|
||||
// purchased: balance.purchased_balance,
|
||||
// current: balance.current_balance,
|
||||
// usage: balance.usage,
|
||||
// });
|
||||
// };
|
||||
|
||||
beforeAll(async () => {
|
||||
await initCustomerV3({
|
||||
ctx,
|
||||
customerId,
|
||||
withTestClock: false,
|
||||
attachPm: "success",
|
||||
});
|
||||
// beforeAll(async () => {
|
||||
// await initCustomerV3({
|
||||
// ctx,
|
||||
// customerId,
|
||||
// withTestClock: false,
|
||||
// attachPm: "success",
|
||||
// });
|
||||
|
||||
await initProductsV0({
|
||||
ctx,
|
||||
products: [freeProd],
|
||||
prefix: testCase,
|
||||
});
|
||||
// await initProductsV0({
|
||||
// ctx,
|
||||
// products: [freeProd],
|
||||
// prefix: testCase,
|
||||
// });
|
||||
|
||||
await autumnV2.attach({
|
||||
customer_id: customerId,
|
||||
product_id: freeProd.id,
|
||||
});
|
||||
// await autumnV2.attach({
|
||||
// customer_id: customerId,
|
||||
// product_id: freeProd.id,
|
||||
// });
|
||||
|
||||
await timeout(1000);
|
||||
// await timeout(1000);
|
||||
|
||||
await logState("INITIAL STATE");
|
||||
});
|
||||
// await logState("INITIAL STATE");
|
||||
// });
|
||||
|
||||
test("CASE A: balances.update ADD balance", async () => {
|
||||
// Setup: balance=0, add_bal=0, add_grant=0
|
||||
// Update to 10: diff=+10
|
||||
// Expected: balance=0, add_bal=10, add_grant=10
|
||||
// test("CASE A: balances.update ADD balance", async () => {
|
||||
// // Setup: balance=0, add_bal=0, add_grant=0
|
||||
// // Update to 10: diff=+10
|
||||
// // Expected: balance=0, add_bal=10, add_grant=10
|
||||
|
||||
await autumnV2.balances.update({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Users,
|
||||
current_balance: 10,
|
||||
});
|
||||
// await autumnV2.balances.update({
|
||||
// customer_id: customerId,
|
||||
// feature_id: TestFeature.Users,
|
||||
// current_balance: 10,
|
||||
// });
|
||||
|
||||
await timeout(2000);
|
||||
// await timeout(2000);
|
||||
|
||||
await logState("After CASE A");
|
||||
// await logState("After CASE A");
|
||||
|
||||
const cusEnt = await getRawCusEnt();
|
||||
expect(cusEnt?.balance).toBe(0); // Unchanged
|
||||
expect(cusEnt?.additional_balance).toBe(10); // Added
|
||||
expect(cusEnt?.adjustment).toBe(10); // Added
|
||||
// const cusEnt = await getRawCusEnt();
|
||||
// expect(cusEnt?.balance).toBe(0); // Unchanged
|
||||
// expect(cusEnt?.additional_balance).toBe(10); // Added
|
||||
// expect(cusEnt?.adjustment).toBe(10); // Added
|
||||
|
||||
const customer = await autumnV2.customers.get<ApiCustomer>(customerId);
|
||||
const balance = customer.balances[TestFeature.Users];
|
||||
// current = 0 + 0 + 10 = 10
|
||||
expect(balance.current_balance).toBe(10);
|
||||
expect(balance.granted_balance).toBe(10);
|
||||
});
|
||||
// const customer = await autumnV2.customers.get<ApiCustomer>(customerId);
|
||||
// const balance = customer.balances[TestFeature.Users];
|
||||
// // current = 0 + 0 + 10 = 10
|
||||
// expect(balance.current_balance).toBe(10);
|
||||
// expect(balance.granted_balance).toBe(10);
|
||||
// });
|
||||
|
||||
test("CASE B: balances.update REMOVE with sufficient additional_balance", async () => {
|
||||
// Setup: balance=0, add_bal=10, add_grant=10, current=10
|
||||
// Update to 5: diff=-5
|
||||
// Deduct 5 from add_bal → add_bal=5, balance=0
|
||||
// Expected: balance=0, add_bal=5, add_grant=5
|
||||
// test("CASE B: balances.update REMOVE with sufficient additional_balance", async () => {
|
||||
// // Setup: balance=0, add_bal=10, add_grant=10, current=10
|
||||
// // Update to 5: diff=-5
|
||||
// // Deduct 5 from add_bal → add_bal=5, balance=0
|
||||
// // Expected: balance=0, add_bal=5, add_grant=5
|
||||
|
||||
await autumnV2.balances.update({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Users,
|
||||
current_balance: 5,
|
||||
});
|
||||
// await autumnV2.balances.update({
|
||||
// customer_id: customerId,
|
||||
// feature_id: TestFeature.Users,
|
||||
// current_balance: 5,
|
||||
// });
|
||||
|
||||
await timeout(2000);
|
||||
// await timeout(2000);
|
||||
|
||||
await logState("After CASE B");
|
||||
// await logState("After CASE B");
|
||||
|
||||
const cusEnt = await getRawCusEnt();
|
||||
expect(cusEnt?.balance).toBe(0); // Unchanged (all came from add_bal)
|
||||
expect(cusEnt?.additional_balance).toBe(5); // 10 - 5
|
||||
expect(cusEnt?.adjustment).toBe(5); // 10 - 5
|
||||
// const cusEnt = await getRawCusEnt();
|
||||
// expect(cusEnt?.balance).toBe(0); // Unchanged (all came from add_bal)
|
||||
// expect(cusEnt?.additional_balance).toBe(5); // 10 - 5
|
||||
// expect(cusEnt?.adjustment).toBe(5); // 10 - 5
|
||||
|
||||
const customer = await autumnV2.customers.get<ApiCustomer>(customerId);
|
||||
const balance = customer.balances[TestFeature.Users];
|
||||
// const customer = await autumnV2.customers.get<ApiCustomer>(customerId);
|
||||
// const balance = customer.balances[TestFeature.Users];
|
||||
|
||||
expect(balance.current_balance).toBe(5);
|
||||
});
|
||||
// expect(balance.current_balance).toBe(5);
|
||||
// });
|
||||
|
||||
test("CASE C: balances.update REMOVE with insufficient additional_balance", async () => {
|
||||
// Setup: balance=0, add_bal=5, add_grant=5, current=5
|
||||
// Update to 0: diff=-5
|
||||
// Deduct 5 from add_bal → add_bal=0, remaining=0, balance=0
|
||||
// Expected: balance=0, add_bal=0, add_grant=0
|
||||
// test("CASE C: balances.update REMOVE with insufficient additional_balance", async () => {
|
||||
// // Setup: balance=0, add_bal=5, add_grant=5, current=5
|
||||
// // Update to 0: diff=-5
|
||||
// // Deduct 5 from add_bal → add_bal=0, remaining=0, balance=0
|
||||
// // Expected: balance=0, add_bal=0, add_grant=0
|
||||
|
||||
await autumnV2.balances.update({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Users,
|
||||
current_balance: 0,
|
||||
});
|
||||
// await autumnV2.balances.update({
|
||||
// customer_id: customerId,
|
||||
// feature_id: TestFeature.Users,
|
||||
// current_balance: 0,
|
||||
// });
|
||||
|
||||
await timeout(2000);
|
||||
// await timeout(2000);
|
||||
|
||||
await logState("After CASE C");
|
||||
// await logState("After CASE C");
|
||||
|
||||
const cusEnt = await getRawCusEnt();
|
||||
expect(cusEnt?.balance).toBe(0);
|
||||
expect(cusEnt?.additional_balance).toBe(0); // Floored
|
||||
expect(cusEnt?.adjustment).toBe(0); // 5 - 5
|
||||
// const cusEnt = await getRawCusEnt();
|
||||
// expect(cusEnt?.balance).toBe(0);
|
||||
// expect(cusEnt?.additional_balance).toBe(0); // Floored
|
||||
// expect(cusEnt?.adjustment).toBe(0); // 5 - 5
|
||||
|
||||
const customer = await autumnV2.customers.get<ApiCustomer>(customerId);
|
||||
const balance = customer.balances[TestFeature.Users];
|
||||
expect(balance.current_balance).toBe(0);
|
||||
});
|
||||
// const customer = await autumnV2.customers.get<ApiCustomer>(customerId);
|
||||
// const balance = customer.balances[TestFeature.Users];
|
||||
// expect(balance.current_balance).toBe(0);
|
||||
// });
|
||||
|
||||
test("Track +5 then update REMOVE to trigger main balance deduction", async () => {
|
||||
// Track +5 to create main balance
|
||||
await autumnV2.track({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Users,
|
||||
value: 5,
|
||||
});
|
||||
// test("Track +5 then update REMOVE to trigger main balance deduction", async () => {
|
||||
// // Track +5 to create main balance
|
||||
// await autumnV2.track({
|
||||
// customer_id: customerId,
|
||||
// feature_id: TestFeature.Users,
|
||||
// value: 5,
|
||||
// });
|
||||
|
||||
await timeout(2000);
|
||||
// await timeout(2000);
|
||||
|
||||
let cusEnt = await getRawCusEnt();
|
||||
expect(cusEnt?.balance).toBe(-5); // Overage
|
||||
// let cusEnt = await getRawCusEnt();
|
||||
// expect(cusEnt?.balance).toBe(-5); // Overage
|
||||
|
||||
// Now update to add balance
|
||||
await autumnV2.balances.update({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Users,
|
||||
current_balance: 10,
|
||||
});
|
||||
// // Now update to add balance
|
||||
// await autumnV2.balances.update({
|
||||
// customer_id: customerId,
|
||||
// feature_id: TestFeature.Users,
|
||||
// current_balance: 10,
|
||||
// });
|
||||
|
||||
await timeout(2000);
|
||||
// await timeout(2000);
|
||||
|
||||
await logState("After track +5 then update to 10");
|
||||
// await logState("After track +5 then update to 10");
|
||||
|
||||
cusEnt = await getRawCusEnt();
|
||||
// computed_current = Math.max(0, -5) + 0 = 0
|
||||
// diff = 10 - 0 = +10
|
||||
// add_bal = 0 + 10 = 10, add_grant = 0 + 10 = 10, balance = -5
|
||||
expect(cusEnt?.balance).toBe(-5); // Unchanged
|
||||
expect(cusEnt?.additional_balance).toBe(10);
|
||||
expect(cusEnt?.adjustment).toBe(10);
|
||||
// cusEnt = await getRawCusEnt();
|
||||
// // computed_current = Math.max(0, -5) + 0 = 0
|
||||
// // diff = 10 - 0 = +10
|
||||
// // add_bal = 0 + 10 = 10, add_grant = 0 + 10 = 10, balance = -5
|
||||
// expect(cusEnt?.balance).toBe(-5); // Unchanged
|
||||
// expect(cusEnt?.additional_balance).toBe(10);
|
||||
// expect(cusEnt?.adjustment).toBe(10);
|
||||
|
||||
// Now remove: update to 3
|
||||
// current = Math.max(0, -5) + 10 = 10
|
||||
// diff = 3 - 10 = -7
|
||||
// Deduct 7: 7 from add_bal → add_bal=3, balance=-5
|
||||
await autumnV2.balances.update({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Users,
|
||||
current_balance: 3,
|
||||
});
|
||||
// // Now remove: update to 3
|
||||
// // current = Math.max(0, -5) + 10 = 10
|
||||
// // diff = 3 - 10 = -7
|
||||
// // Deduct 7: 7 from add_bal → add_bal=3, balance=-5
|
||||
// await autumnV2.balances.update({
|
||||
// customer_id: customerId,
|
||||
// feature_id: TestFeature.Users,
|
||||
// current_balance: 3,
|
||||
// });
|
||||
|
||||
await timeout(2000);
|
||||
// await timeout(2000);
|
||||
|
||||
await logState("After update to 3");
|
||||
// await logState("After update to 3");
|
||||
|
||||
cusEnt = await getRawCusEnt();
|
||||
expect(cusEnt?.balance).toBe(-5); // Unchanged
|
||||
expect(cusEnt?.additional_balance).toBe(3); // 10 - 7
|
||||
expect(cusEnt?.adjustment).toBe(3); // 10 - 7
|
||||
});
|
||||
// cusEnt = await getRawCusEnt();
|
||||
// expect(cusEnt?.balance).toBe(-5); // Unchanged
|
||||
// expect(cusEnt?.additional_balance).toBe(3); // 10 - 7
|
||||
// expect(cusEnt?.adjustment).toBe(3); // 10 - 7
|
||||
// });
|
||||
|
||||
test("CASE D: balances.update from negative to positive preserves paid credits", async () => {
|
||||
// Setup: balance=-5, add_bal=3, add_grant=3
|
||||
// Update to 0:
|
||||
// current = Math.max(0,-5) + 3 = 3
|
||||
// diff = 0 - 3 = -3
|
||||
// Deduct 3 from add_bal → add_bal=0, balance=-5
|
||||
// Result: current = Math.max(0,-5) + 0 = 0 ✅
|
||||
// test("CASE D: balances.update from negative to positive preserves paid credits", async () => {
|
||||
// // Setup: balance=-5, add_bal=3, add_grant=3
|
||||
// // Update to 0:
|
||||
// // current = Math.max(0,-5) + 3 = 3
|
||||
// // diff = 0 - 3 = -3
|
||||
// // Deduct 3 from add_bal → add_bal=0, balance=-5
|
||||
// // Result: current = Math.max(0,-5) + 0 = 0 ✅
|
||||
|
||||
await autumnV2.balances.update({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Users,
|
||||
current_balance: 0,
|
||||
});
|
||||
// await autumnV2.balances.update({
|
||||
// customer_id: customerId,
|
||||
// feature_id: TestFeature.Users,
|
||||
// current_balance: 0,
|
||||
// });
|
||||
|
||||
await timeout(2000);
|
||||
// await timeout(2000);
|
||||
|
||||
await logState("After CASE D");
|
||||
// await logState("After CASE D");
|
||||
|
||||
const cusEnt = await getRawCusEnt();
|
||||
expect(cusEnt?.balance).toBe(-5); // Unchanged (still in debt)
|
||||
expect(cusEnt?.additional_balance).toBe(0); // 3 - 3
|
||||
expect(cusEnt?.adjustment).toBe(0); // 3 - 3
|
||||
});
|
||||
// const cusEnt = await getRawCusEnt();
|
||||
// expect(cusEnt?.balance).toBe(-5); // Unchanged (still in debt)
|
||||
// expect(cusEnt?.additional_balance).toBe(0); // 3 - 3
|
||||
// expect(cusEnt?.adjustment).toBe(0); // 3 - 3
|
||||
// });
|
||||
|
||||
test("CASE E: Track negative to fully return debt", async () => {
|
||||
// Setup: balance=-5, add_bal=0, add_grant=0 (from CASE D)
|
||||
// Track -5: adds 5 to main balance
|
||||
// Expected: balance=0, add_bal=0, add_grant=0
|
||||
// test("CASE E: Track negative to fully return debt", async () => {
|
||||
// // Setup: balance=-5, add_bal=0, add_grant=0 (from CASE D)
|
||||
// // Track -5: adds 5 to main balance
|
||||
// // Expected: balance=0, add_bal=0, add_grant=0
|
||||
|
||||
await autumnV2.track({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Users,
|
||||
value: -5,
|
||||
});
|
||||
// await autumnV2.track({
|
||||
// customer_id: customerId,
|
||||
// feature_id: TestFeature.Users,
|
||||
// value: -5,
|
||||
// });
|
||||
|
||||
await timeout(2000);
|
||||
// await timeout(2000);
|
||||
|
||||
await logState("After CASE E");
|
||||
// await logState("After CASE E");
|
||||
|
||||
const cusEnt = await getRawCusEnt();
|
||||
expect(cusEnt?.balance).toBe(0); // -5 + 5 = 0
|
||||
expect(cusEnt?.additional_balance).toBe(0); // Unchanged
|
||||
expect(cusEnt?.adjustment).toBe(0); // Unchanged
|
||||
// const cusEnt = await getRawCusEnt();
|
||||
// expect(cusEnt?.balance).toBe(0); // -5 + 5 = 0
|
||||
// expect(cusEnt?.additional_balance).toBe(0); // Unchanged
|
||||
// expect(cusEnt?.adjustment).toBe(0); // Unchanged
|
||||
|
||||
const customer = await autumnV2.customers.get<ApiCustomer>(customerId);
|
||||
const balance = customer.balances[TestFeature.Users];
|
||||
// const customer = await autumnV2.customers.get<ApiCustomer>(customerId);
|
||||
// const balance = customer.balances[TestFeature.Users];
|
||||
|
||||
expect(balance.current_balance).toBe(0);
|
||||
expect(balance.purchased_balance).toBe(0);
|
||||
expect(balance.granted_balance).toBe(0);
|
||||
expect(balance.usage).toBe(0);
|
||||
});
|
||||
});
|
||||
// expect(balance.current_balance).toBe(0);
|
||||
// expect(balance.purchased_balance).toBe(0);
|
||||
// expect(balance.granted_balance).toBe(0);
|
||||
// expect(balance.usage).toBe(0);
|
||||
// });
|
||||
// });
|
||||
|
||||
@@ -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,
|
||||
// // });
|
||||
// });
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { beforeAll, describe, expect, test } from "bun:test";
|
||||
import { type ApiCustomer, ApiVersion, type LimitedItem } from "@autumn/shared";
|
||||
import { TestFeature } from "@tests/setup/v2Features.js";
|
||||
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
|
||||
import chalk from "chalk";
|
||||
import { AutumnInt } from "@/external/autumn/autumnCli.js";
|
||||
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
|
||||
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
|
||||
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
|
||||
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
|
||||
|
||||
const messagesFeature = constructFeatureItem({
|
||||
featureId: TestFeature.Messages,
|
||||
includedUsage: 100,
|
||||
}) as LimitedItem;
|
||||
|
||||
const freeProd = constructProduct({
|
||||
type: "free",
|
||||
isDefault: false,
|
||||
items: [messagesFeature],
|
||||
});
|
||||
|
||||
const testCase = "update-granted-balance1";
|
||||
|
||||
describe(`${chalk.yellowBright("update-granted-balance1: testing update granted balance")}`, () => {
|
||||
const customerId = testCase;
|
||||
const autumnV2: AutumnInt = new AutumnInt({ version: ApiVersion.V2_0 });
|
||||
|
||||
beforeAll(async () => {
|
||||
await initCustomerV3({
|
||||
ctx,
|
||||
customerId,
|
||||
withTestClock: false,
|
||||
});
|
||||
|
||||
await initProductsV0({
|
||||
ctx,
|
||||
products: [freeProd],
|
||||
prefix: testCase,
|
||||
});
|
||||
|
||||
await autumnV2.attach({
|
||||
customer_id: customerId,
|
||||
product_id: freeProd.id,
|
||||
});
|
||||
});
|
||||
|
||||
test("should update granted balance to 150", async () => {
|
||||
await autumnV2.balances.update({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Messages,
|
||||
current_balance: 100,
|
||||
granted_balance: 150,
|
||||
});
|
||||
|
||||
const customerV2 = await autumnV2.customers.get<ApiCustomer>(customerId);
|
||||
const balance = customerV2.balances[TestFeature.Messages];
|
||||
|
||||
expect(balance).toMatchObject({
|
||||
granted_balance: 150,
|
||||
current_balance: 100,
|
||||
usage: 50,
|
||||
purchased_balance: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { beforeAll, describe, expect, test } from "bun:test";
|
||||
import {
|
||||
type ApiCustomer,
|
||||
ApiVersion,
|
||||
type LimitedItem,
|
||||
ResetInterval,
|
||||
} from "@autumn/shared";
|
||||
import { TestFeature } from "@tests/setup/v2Features.js";
|
||||
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
|
||||
import chalk from "chalk";
|
||||
import { AutumnInt } from "@/external/autumn/autumnCli.js";
|
||||
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
|
||||
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
|
||||
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
|
||||
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
|
||||
|
||||
const monthlyMsges = constructFeatureItem({
|
||||
featureId: TestFeature.Messages,
|
||||
includedUsage: 100,
|
||||
}) as LimitedItem;
|
||||
|
||||
const lifetimeMsges = constructFeatureItem({
|
||||
featureId: TestFeature.Messages,
|
||||
includedUsage: 50,
|
||||
interval: null,
|
||||
}) as LimitedItem;
|
||||
|
||||
const freeProd = constructProduct({
|
||||
type: "free",
|
||||
isDefault: false,
|
||||
items: [monthlyMsges, lifetimeMsges],
|
||||
});
|
||||
|
||||
const testCase = "update-granted-balance2";
|
||||
|
||||
describe(`${chalk.yellowBright("update-granted-balance2: testing update granted balance when there's a breakdown")}`, () => {
|
||||
const customerId = testCase;
|
||||
const autumnV2: AutumnInt = new AutumnInt({ version: ApiVersion.V2_0 });
|
||||
|
||||
beforeAll(async () => {
|
||||
await initCustomerV3({
|
||||
ctx,
|
||||
customerId,
|
||||
withTestClock: false,
|
||||
});
|
||||
|
||||
await initProductsV0({
|
||||
ctx,
|
||||
products: [freeProd],
|
||||
prefix: testCase,
|
||||
});
|
||||
|
||||
await autumnV2.attach({
|
||||
customer_id: customerId,
|
||||
product_id: freeProd.id,
|
||||
});
|
||||
});
|
||||
|
||||
test("should update granted balance to 150 for monthly feature", async () => {
|
||||
await autumnV2.balances.update({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Messages,
|
||||
current_balance: 50,
|
||||
granted_balance: 75,
|
||||
interval: ResetInterval.Month,
|
||||
});
|
||||
|
||||
const customerV2 = await autumnV2.customers.get<ApiCustomer>(customerId);
|
||||
const balance = customerV2.balances[TestFeature.Messages];
|
||||
|
||||
const monthlyBreakdown = balance.breakdown?.find(
|
||||
(b) => b.reset?.interval === ResetInterval.Month,
|
||||
);
|
||||
|
||||
const lifetimeBreakdown = balance.breakdown?.find(
|
||||
(b) => b.reset?.interval === ResetInterval.OneOff,
|
||||
);
|
||||
|
||||
expect(monthlyBreakdown).toMatchObject({
|
||||
granted_balance: 75,
|
||||
current_balance: 50,
|
||||
usage: 25,
|
||||
purchased_balance: 0,
|
||||
});
|
||||
|
||||
expect(lifetimeBreakdown).toMatchObject({
|
||||
granted_balance: 50,
|
||||
current_balance: 50,
|
||||
usage: 0,
|
||||
purchased_balance: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { beforeAll, describe, expect, test } from "bun:test";
|
||||
import { type ApiCustomer, ApiVersion, type LimitedItem } from "@autumn/shared";
|
||||
import { TestFeature } from "@tests/setup/v2Features.js";
|
||||
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
|
||||
import chalk from "chalk";
|
||||
import { AutumnInt } from "@/external/autumn/autumnCli.js";
|
||||
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
|
||||
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
|
||||
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
|
||||
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
|
||||
|
||||
const monthlyMsges = constructFeatureItem({
|
||||
featureId: TestFeature.Messages,
|
||||
includedUsage: 100,
|
||||
}) as LimitedItem;
|
||||
|
||||
const freeProd = constructProduct({
|
||||
type: "free",
|
||||
isDefault: false,
|
||||
items: [monthlyMsges],
|
||||
});
|
||||
|
||||
const testCase = "update-granted-balance4";
|
||||
|
||||
describe(`${chalk.yellowBright("update-granted-balance4: testing update current balance, then update granted balance")}`, () => {
|
||||
const customerId = testCase;
|
||||
const autumnV2: AutumnInt = new AutumnInt({ version: ApiVersion.V2_0 });
|
||||
|
||||
beforeAll(async () => {
|
||||
await initCustomerV3({
|
||||
ctx,
|
||||
customerId,
|
||||
withTestClock: false,
|
||||
});
|
||||
|
||||
await initProductsV0({
|
||||
ctx,
|
||||
products: [freeProd],
|
||||
prefix: testCase,
|
||||
});
|
||||
|
||||
await autumnV2.attach({
|
||||
customer_id: customerId,
|
||||
product_id: freeProd.id,
|
||||
});
|
||||
});
|
||||
|
||||
test("should update current balance to 50", async () => {
|
||||
await autumnV2.balances.update({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Messages,
|
||||
current_balance: 50,
|
||||
});
|
||||
|
||||
const customer = await autumnV2.customers.get<ApiCustomer>(customerId);
|
||||
const balance = customer.balances[TestFeature.Messages];
|
||||
|
||||
expect(balance).toMatchObject({
|
||||
granted_balance: 50,
|
||||
current_balance: 50,
|
||||
usage: 0,
|
||||
purchased_balance: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test("should update granted balance to 100", async () => {
|
||||
await autumnV2.balances.update({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Messages,
|
||||
granted_balance: 100,
|
||||
current_balance: 50,
|
||||
});
|
||||
|
||||
const customer = await autumnV2.customers.get<ApiCustomer>(customerId);
|
||||
const balance = customer.balances[TestFeature.Messages];
|
||||
|
||||
expect(balance).toMatchObject({
|
||||
granted_balance: 100,
|
||||
current_balance: 50,
|
||||
usage: 50,
|
||||
purchased_balance: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import { beforeAll, describe, expect, test } from "bun:test";
|
||||
import {
|
||||
type ApiCustomerV3,
|
||||
ApiVersion,
|
||||
BillingInterval,
|
||||
getCusStripeSubCount,
|
||||
} from "@autumn/shared";
|
||||
import { TestFeature } from "@tests/setup/v2Features.js";
|
||||
import { advanceTestClock } from "@tests/utils/stripeUtils.js";
|
||||
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
|
||||
import chalk from "chalk";
|
||||
import { addWeeks } from "date-fns";
|
||||
import { AutumnInt } from "@/external/autumn/autumnCli.js";
|
||||
import { CusService } from "@/internal/customers/CusService";
|
||||
import { constructPriceItem } from "@/internal/products/product-items/productItemUtils.js";
|
||||
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
|
||||
import {
|
||||
constructProduct,
|
||||
constructRawProduct,
|
||||
} from "@/utils/scriptUtils/createTestProducts.js";
|
||||
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
|
||||
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
|
||||
import { expectProductAttached } from "../../utils/expectUtils/expectProductAttached";
|
||||
|
||||
const paidAddOn = constructRawProduct({
|
||||
id: "addOn",
|
||||
isAddOn: true,
|
||||
items: [
|
||||
constructPriceItem({
|
||||
price: 10,
|
||||
interval: BillingInterval.Month,
|
||||
}),
|
||||
constructFeatureItem({
|
||||
featureId: TestFeature.Messages,
|
||||
includedUsage: 500,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const pro = constructProduct({
|
||||
type: "pro",
|
||||
|
||||
items: [
|
||||
constructFeatureItem({
|
||||
featureId: TestFeature.Messages,
|
||||
includedUsage: 300,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const testCase = "new-billing-subscription1";
|
||||
|
||||
describe(`${chalk.yellowBright("new-billing-subscription: paid product with add on mid cycle. add on should create new sub")}`, () => {
|
||||
const customerId = testCase;
|
||||
const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
|
||||
|
||||
beforeAll(async () => {
|
||||
const result = await initCustomerV3({
|
||||
ctx,
|
||||
customerId,
|
||||
withTestClock: true,
|
||||
attachPm: "success",
|
||||
});
|
||||
|
||||
await initProductsV0({
|
||||
ctx,
|
||||
products: [pro, paidAddOn],
|
||||
prefix: testCase,
|
||||
});
|
||||
|
||||
await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
});
|
||||
|
||||
await advanceTestClock({
|
||||
stripeCli: ctx.stripeCli,
|
||||
testClockId: result.testClockId,
|
||||
advanceTo: addWeeks(new Date(), 2).getTime(),
|
||||
waitForSeconds: 20,
|
||||
});
|
||||
});
|
||||
|
||||
test("should attach add on and have correct sub", async () => {
|
||||
await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
product_id: paidAddOn.id,
|
||||
new_billing_subscription: true,
|
||||
});
|
||||
|
||||
const fullCus = await CusService.getFull({
|
||||
idOrInternalId: customerId,
|
||||
db: ctx.db,
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
});
|
||||
|
||||
const subCount = getCusStripeSubCount({
|
||||
fullCus,
|
||||
});
|
||||
|
||||
expect(subCount).toBe(2);
|
||||
|
||||
const customer = await autumnV1.customers.get(customerId);
|
||||
|
||||
expectProductAttached({
|
||||
customer: customer,
|
||||
product: paidAddOn,
|
||||
});
|
||||
|
||||
const invoices = customer.invoices;
|
||||
expect(invoices.length).toBe(2);
|
||||
expect(invoices[0].total).toBe(10);
|
||||
});
|
||||
|
||||
test("should attach add on again and have 3 subscriptions", async () => {
|
||||
await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
product_id: paidAddOn.id,
|
||||
new_billing_subscription: true,
|
||||
});
|
||||
|
||||
const fullCus = await CusService.getFull({
|
||||
idOrInternalId: customerId,
|
||||
db: ctx.db,
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
});
|
||||
|
||||
const subCount = getCusStripeSubCount({
|
||||
fullCus,
|
||||
});
|
||||
|
||||
expect(subCount).toBe(3);
|
||||
|
||||
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
const addOnProduct = customer.products.find((p) => p.id === paidAddOn.id);
|
||||
expect(addOnProduct?.quantity).toBe(2);
|
||||
|
||||
const invoices = customer.invoices;
|
||||
expect(invoices?.length).toBe(3);
|
||||
expect(invoices?.[0].total).toBe(10);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
}),
|
||||
),
|
||||
}),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
ApiProductItemSchema,
|
||||
} from "../../../models.js";
|
||||
import { ApiEntityWithMeta, entitiesOpenApi } from "../entitiesOpenApi.js";
|
||||
import { analyticsOpenApi } from "./analyticsOpenApi.js";
|
||||
import { coreOpenApi } from "./coreOpenApi.js";
|
||||
import { ApiCustomerWithMeta, customersOpenApi } from "./customersOpenApi.js";
|
||||
import { ApiFeatureWithMeta, featuresOpenApi } from "./featuresOpenApi.js";
|
||||
@@ -70,6 +71,7 @@ const OPENAPI_1_2_0 = createDocument(
|
||||
...coreOpenApi,
|
||||
...customersOpenApi,
|
||||
...entitiesOpenApi,
|
||||
...analyticsOpenApi,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user