fix: edge case for update custom ent, free cont use -> paid cont use, allowance increase

This commit is contained in:
John Yeo
2025-10-27 06:42:50 -07:00
parent e8a2a9f71e
commit ef2fac6fe3
14 changed files with 568 additions and 63 deletions

View File

@@ -24,6 +24,7 @@
"setup:test": "bun scripts/setup-test.ts",
"tests": "bun scripts/test.ts",
"setupci": "node scripts/setupci.js",
"replicate": "bun scripts/replicate.ts",
"db:push": " bun -F @autumn/shared db:push",
"db:generate": "bun -F @autumn/shared db:generate",
"db:migrate": " bun -F @autumn/shared db:migrate",

View File

@@ -5,7 +5,8 @@
"private": true,
"scripts": {
"setup": "tsx setup.js",
"setup-test": "tsx setup-test.ts"
"setup-test": "tsx setup-test.ts",
"replicate": "bun run replicate.ts"
},
"dependencies": {
"@autumn/shared": "workspace:*",

284
scripts/replicate.ts Normal file
View File

@@ -0,0 +1,284 @@
import { exec, spawn } from "node:child_process";
import readline from "node:readline";
import { promisify } from "node:util";
const execAsync = promisify(exec);
/**
* 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";
}
}
/**
* Clean up PostgreSQL URL for pg_dump/psql
* - Removes query parameters after /postgres
* - Changes port 6432 to 5432
*/
function cleanUrl(url: string): string {
// Remove everything after /postgres (including query params)
let cleaned = url.replace(/\/postgres\?.*$/, "/postgres");
// Replace port 6432 with 5432
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",
};
}
}
/**
* Replicate database from source to destination
*/
async function replicateDatabase({
fromUrl,
toUrl,
}: {
fromUrl: string;
toUrl: string;
}) {
console.log("\n🔄 PostgreSQL Database Replication\n");
// Validate URLs
if (!validatePostgresUrl(fromUrl)) {
console.error("❌ Invalid source database URL");
console.error(
" Expected format: postgresql://user:pass@host:port/dbname",
);
process.exit(1);
}
if (!validatePostgresUrl(toUrl)) {
console.error("❌ Invalid destination database URL");
console.error(
" Expected format: postgresql://user:pass@host:port/dbname",
);
process.exit(1);
}
const fromDbName = extractDbName(fromUrl);
const toDbName = extractDbName(toUrl);
console.log(`📤 Source: ${fromDbName}`);
console.log(`📥 Destination: ${toDbName}`);
// Check destination database for production data
console.log("\n🔍 Checking destination database...");
const customerResult = await getCustomerCount(toUrl);
if (customerResult.count === null) {
// Table doesn't exist or can't query - likely a new/empty database
console.log("✅ Destination appears to be empty (no customers table)");
} else {
console.log(`📊 Destination has ${customerResult.count} customers`);
// Protection: Don't allow overwriting databases with > 1000 customers
if (customerResult.count > 1000) {
console.error(
"\n❌ PROTECTION: Destination database has too many customers!",
);
console.error(
` Customer count (${customerResult.count}) exceeds 1000 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 the destination database. Continue? (y/n): ",
);
if (!confirmed) {
console.log("\n❌ Replication cancelled\n");
process.exit(0);
}
const tempFile = `/tmp/db_dump_${Date.now()}.sql`;
// Clean URLs
const cleanedFromUrl = cleanUrl(fromUrl);
const cleanedToUrl = cleanUrl(toUrl);
console.log(`\n📤 Using source URL: ${cleanedFromUrl}`);
console.log(`📥 Using destination URL: ${cleanedToUrl}\n`);
try {
// Step 1: Dump the source database
console.log("📦 Dumping source database...");
await execWithOutput(
`pg_dump "${cleanedFromUrl}" --no-owner --no-privileges -f "${tempFile}" 2>&1`,
);
console.log("✅ Source database dumped successfully");
// Step 2: Restore to destination database
console.log("\n📥 Restoring to destination database...\n");
await execWithOutput(
`psql "${cleanedToUrl}" -f "${tempFile}" --set ON_ERROR_STOP=off 2>&1 | grep -v "invalid command"`,
);
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 replication completed successfully!\n");
} catch (error) {
console.error("\n❌ Replication 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 and reconstruct URLs
* Handles cases where URLs aren't quoted and get split by shell
*/
function parseUrls(): { fromUrl: string; toUrl: string } | null {
const args = process.argv.slice(2);
if (args.length === 0) {
return null;
}
// Join all arguments and try to extract two PostgreSQL URLs
const fullString = args.join(" ");
// Match two postgresql:// URLs (greedy match for first, non-greedy for separation)
const urlPattern =
/(postgres(?:ql)?:\/\/[^\s]+?)[\s]+(postgres(?:ql)?:\/\/[^\s]+)/;
const match = fullString.match(urlPattern);
if (match?.[1] && match[2]) {
return {
fromUrl: match[1],
toUrl: match[2],
};
}
// Fallback: if we have exactly 2 args that look like URLs
if (args.length === 2) {
const [fromUrl, toUrl] = args;
if (
validatePostgresUrl(fromUrl || "") &&
validatePostgresUrl(toUrl || "")
) {
return { fromUrl, toUrl };
}
}
return null;
}
// Parse command line arguments
const urls = parseUrls();
if (!urls) {
console.log("\n🔄 PostgreSQL Database Replication\n");
console.log("Usage:");
console.log(' bun replicate "<from-url>" "<to-url>"\n');
console.log("Example:");
console.log(
' bun replicate "postgresql://user:pass@eu-host:5432/db" "postgresql://user:pass@us-host:5432/db"\n',
);
console.log("⚠️ Important: URLs must be quoted to prevent shell expansion\n");
console.log("Tip: Store URLs in .env and use environment variables:\n");
console.log(" bun replicate $EU_DATABASE_URL $US_DATABASE_URL\n");
process.exit(1);
}
const { fromUrl, toUrl } = urls;
replicateDatabase({ fromUrl, toUrl }).catch((error) => {
console.error("Unexpected error:", error);
process.exit(1);
});

View File

@@ -1,7 +1,13 @@
import { spawn } from "node:child_process";
import { exec, spawn } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { detectAndSetPorts } from "./detect-ports.js";
import readline from "node:readline";
import { promisify } from "node:util";
const execAsync = promisify(exec);
const VITE_PORT = 3000;
const SERVER_PORT = 8080;
/**
* Read environment variable from .env file
@@ -24,6 +30,101 @@ function getEnvVariable(filePath, key) {
return null;
}
/**
* Get the process info using a specific port
*/
async function getProcessOnPort(port) {
try {
const { stdout } = await execAsync(`lsof -ti:${port}`);
const pid = stdout.trim();
if (pid) {
const { stdout: psOut } = await execAsync(`ps -p ${pid} -o comm=`);
const processName = psOut.trim();
return { pid: parseInt(pid), processName };
}
return null;
} catch {
return null;
}
}
/**
* Kill a process by PID
*/
async function killProcess(pid) {
try {
await execAsync(`kill -9 ${pid}`);
return true;
} catch {
return false;
}
}
/**
* Prompt user for confirmation
*/
function promptUser(question) {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise((resolve) => {
rl.question(question, (answer) => {
rl.close();
resolve(answer.toLowerCase() === "y" || answer.toLowerCase() === "yes");
});
});
}
/**
* Check and kill processes on ports 3000 and 8080
*/
async function handlePorts() {
const viteProcess = await getProcessOnPort(VITE_PORT);
const serverProcess = await getProcessOnPort(SERVER_PORT);
const processesToKill = [];
if (viteProcess) processesToKill.push({ port: VITE_PORT, ...viteProcess });
if (serverProcess)
processesToKill.push({ port: SERVER_PORT, ...serverProcess });
if (processesToKill.length === 0) {
console.log("✅ Ports 3000 and 8080 are available\n");
return;
}
console.log("\n⚠ Found processes on required ports:");
for (const proc of processesToKill) {
console.log(` Port ${proc.port}: ${proc.processName} (PID: ${proc.pid})`);
}
const shouldKill = await promptUser(
"\nKill these processes and continue? (y/n): ",
);
if (!shouldKill) {
console.log("❌ Aborted by user");
process.exit(0);
}
console.log("\n🔨 Killing processes...");
for (const proc of processesToKill) {
const killed = await killProcess(proc.pid);
if (killed) {
console.log(` ✅ Killed process ${proc.pid} on port ${proc.port}`);
} else {
console.log(
` ❌ Failed to kill process ${proc.pid} on port ${proc.port}`,
);
}
}
// Wait for ports to be released
await new Promise((r) => setTimeout(r, 500));
console.log("");
}
async function startDev() {
try {
// Check if using remote backend (api.useautumn.com)
@@ -31,20 +132,16 @@ async function startDev() {
const projectRoot = path.join(rootDir, "..");
const viteEnvPath = path.join(projectRoot, "vite", ".env");
const backendUrl =
process.env.VITE_BACKEND_URL || getEnvVariable(viteEnvPath, "VITE_BACKEND_URL");
const isUsingRemoteBackend = backendUrl && backendUrl.includes("api.useautumn.com");
let vitePort = 3000;
let serverPort = 8080;
process.env.VITE_BACKEND_URL ||
getEnvVariable(viteEnvPath, "VITE_BACKEND_URL");
const isUsingRemoteBackend = backendUrl?.includes("api.useautumn.com");
if (isUsingRemoteBackend) {
console.log("\n🌐 Using remote backend (api.useautumn.com)");
console.log("⏭️ Skipping port detection...\n");
console.log("⏭️ Skipping port cleanup...\n");
} else {
// Detect and set ports
const ports = await detectAndSetPorts();
vitePort = ports.vitePort;
serverPort = ports.serverPort;
// Check and kill processes on ports 3000 and 8080 if needed
await handlePorts();
}
// Step 1: Build shared package first (initial build)
@@ -78,9 +175,9 @@ async function startDev() {
"server,workers,vite,shared",
"-c",
"green,yellow,blue,cyan",
`"cd server && SERVER_PORT=${serverPort} bun dev"`,
`"cd server && SERVER_PORT=${SERVER_PORT} bun dev"`,
`"cd server && bun workers:dev"`,
`"cd vite && VITE_PORT=${vitePort} bun dev"`,
`"cd vite && VITE_PORT=${VITE_PORT} bun dev"`,
`"cd shared && bun run dev:watch"`,
],
{
@@ -88,8 +185,8 @@ async function startDev() {
shell: true,
env: {
...process.env,
VITE_PORT: vitePort.toString(),
SERVER_PORT: serverPort.toString(),
VITE_PORT: VITE_PORT.toString(),
SERVER_PORT: SERVER_PORT.toString(),
},
},
);

View File

@@ -15,15 +15,15 @@ fi
# Run tests using TypeScript runner with compact mode
# Adjust --max to control concurrency (default: 6)
BUN_PARALLEL_COMPACT \
'server/tests/check/basic' \
'server/tests/attach/basic' \
'server/tests/attach/upgrade' \
'server/tests/attach/downgrade' \
'server/tests/attach/free' \
'server/tests/attach/addOn' \
'server/tests/attach/entities' \
'server/tests/attach/checkout' \
--max=6 \
# 'server/tests/check/basic' \
# 'server/tests/attach/basic' \
# 'server/tests/attach/upgrade' \
# 'server/tests/attach/downgrade' \
# 'server/tests/attach/free' \
# 'server/tests/attach/addOn' \
# 'server/tests/attach/entities' \

View File

@@ -36,16 +36,16 @@ import { checkCusSubCorrect } from "./utils/checkUtils/checkCustomerCorrect.js";
const { db } = initDrizzle({ maxConnections: 5 });
const orgSlugs = process.env.ORG_SLUGS!.split(",");
let orgSlugs = process.env.ORG_SLUGS!.split(",");
const skipEmails = process.env.SKIP_EMAILS!.split(",");
const skipIds = [
"cus_2tXCCwC6iyiftgA6ndSo1Ubb2dx",
"DxG668K7uDd0Vahk54YWjvCGVgf2",
];
// orgSlugs = ["lumenary"];
const customerId = null;
// customerId = "EBbxiRv9QJKXFy5WiAKeHi";
orgSlugs = ["welcome-back-1747670264"];
let customerId = null;
customerId = "56";
const getSingleCustomer = async ({
stripeCli,

View File

@@ -110,14 +110,6 @@ export const updateStripeSub2 = async ({
logger,
});
// // // 3. Create prorations for continuous use items
// let { replaceables, newItems } = await getContUseInvoiceItems({
// attachParams,
// cusProduct: curMainProduct!,
// sub: curSub,
// logger,
// });
const { replaceables } = await createAndFilterContUseItems({
attachParams,
curMainProduct: curMainProduct!,
@@ -151,24 +143,3 @@ export const updateStripeSub2 = async ({
replaceables,
};
};
// await SubService.addUsageFeatures({
// db,
// stripeId: curSub.id,
// usageFeatures: itemSet.usageFeatures,
// orgId: org.id,
// env: customer.env,
// });
// if (invoiceOnly && attachParams.finalizeInvoice) {
// logger.info(`FINALIZING INVOICE ${latestInvoice?.id}`);
// try {
// latestInvoice = await stripeCli.invoices.finalizeInvoice(
// latestInvoice?.id as string
// );
// } catch (error) {
// logger.error(`Failed to finalize invoice ${latestInvoice?.id}`, {
// error,
// });
// }
// }

View File

@@ -137,7 +137,7 @@ export const createAndFilterContUseItems = async ({
}
logger.info(
`Adding invoice item: ${item.description}, ${item.description}, interval: ${interval}`,
`Adding invoice item: ${item.description}, amount: ${item.amount}, interval: ${interval}`,
);
const { start, end } = subToPeriodStartEnd({ sub });

View File

@@ -70,7 +70,19 @@ export const getContUseNewItems = async ({
feature_id: ent.feature_id,
} as PreviewLineItem;
} else {
let overage = new Decimal(usage).sub(ent.allowance!).toNumber();
/*
For example, free plan comes with 3 users, and usage is 3
- Pro plan is 0 free, $10 per user
- We need to calculate usage for the pro plan, and then charge for that
---
- When allowance increases though, how do we handle it? Don't allow negative overage.
*/
// let overage = new Decimal(usage).sub(ent.allowance!).toNumber();
let overage = Math.max(
new Decimal(usage).sub(ent.allowance!).toNumber(),
0,
);
if (
intervalsSame &&
@@ -153,6 +165,8 @@ export const getContUseInvoiceItems = async ({
(item) => item.price_id === prevCusPrice?.price.id,
);
// TO DELETE
if (!prevCusEnt || !sub || !curItem) {
const newItem = await getContUseNewItems({
price,

View File

@@ -84,7 +84,7 @@ export const auth = betterAuth({
google: {
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
redirectURI: `${process.env.SERVER_URL}/api/auth/callback/google`,
redirectURI: `${process.env.BETTER_AUTH_URL}/api/auth/callback/google`,
},
},
plugins: [

View File

@@ -473,8 +473,6 @@ export const checkCusSubCorrect = async ({
internalEntityId: cp.internal_entity_id,
});
console.log("Cur scheduled product:", curScheduledProduct?.id);
if (curScheduledProduct) {
const scheduledProduct = cusProductToProduct({
cusProduct: curScheduledProduct,

View File

@@ -0,0 +1,138 @@
import {
type AppEnv,
LegacyVersion,
OnDecrease,
OnIncrease,
type Organization,
} from "@autumn/shared";
import { expect } from "chai";
import chalk from "chalk";
import type Stripe from "stripe";
import { addPrefixToProducts, replaceItems } from "tests/attach/utils.js";
import { setupBefore } from "tests/before.js";
import { TestFeature } from "tests/setup/v2Features.js";
import { createProducts } from "tests/utils/productUtils.js";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import {
constructArrearProratedItem,
constructFeatureItem,
} from "@/utils/scriptUtils/constructItem.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { initCustomer } from "@/utils/scriptUtils/initCustomer.js";
import { timeout } from "../../utils/genUtils.js";
const freeItem = constructFeatureItem({
featureId: TestFeature.Users,
includedUsage: 3,
});
const paidUserItem = constructArrearProratedItem({
featureId: TestFeature.Users,
pricePerUnit: 50,
includedUsage: 10,
config: {
on_increase: OnIncrease.ProrateImmediately,
on_decrease: OnDecrease.ProrateImmediately,
},
});
const pro = constructProduct({
items: [freeItem],
type: "pro",
});
const testCase = "updateContUse6";
describe(`${chalk.yellowBright(`contUse/${testCase}: free product, continuous use, then upgrade to pro which has MORE included usage`)}`, () => {
const customerId = testCase;
const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 });
let testClockId: string;
let db: DrizzleCli, org: Organization, env: AppEnv;
let stripeCli: Stripe;
const curUnix = Date.now();
before(async function () {
await setupBefore(this);
const { autumnJs } = this;
db = this.db;
org = this.org;
env = this.env;
stripeCli = this.stripeCli;
addPrefixToProducts({
products: [pro],
prefix: testCase,
});
await createProducts({
autumn,
products: [pro],
customerId,
db,
orgId: org.id,
env,
});
const { testClockId: testClockId1 } = await initCustomer({
autumn: autumnJs,
customerId,
db,
org,
env,
attachPm: "success",
});
testClockId = testClockId1!;
});
const usage = 3;
it("should attach free and track usage", async () => {
await autumn.attach({
customer_id: customerId,
product_id: pro.id,
});
await autumn.track({
customer_id: customerId,
feature_id: TestFeature.Users,
value: usage,
});
await timeout(2000);
});
const customItems = replaceItems({
items: pro.items,
featureId: TestFeature.Users,
newItem: paidUserItem,
});
it("should replace user item with paid and increased allowance", async () => {
const customProduct = {
...pro,
items: customItems,
};
await autumn.attach({
customer_id: customerId,
product_id: pro.id,
is_custom: true,
items: customItems,
});
const customer = await autumn.customers.get(customerId);
expect(customer.invoices?.[0].total).to.equal(0);
});
// const extraUsage = 2;
// const newItem = constructArrearProratedItem({
// featureId: TestFeature.Users,
// pricePerUnit: 50,
// includedUsage: (userItem.included_usage as number) + extraUsage,
// config: {
// on_increase: OnIncrease.ProrateImmediately,
// on_decrease: OnDecrease.ProrateImmediately,
// },
// });
});

View File

@@ -148,7 +148,7 @@ export const completeInvoiceCheckout = async ({
);
if (postalInput) {
await postalInput.click();
await postalInput.type("SW79SJ");
await postalInput.type("123123");
}
} catch (error) {
console.log("Could not find postal code input:", error);

View File

@@ -63,6 +63,7 @@ export const SignIn = () => {
setGoogleLoading(true);
try {
const frontendUrl = import.meta.env.VITE_FRONTEND_URL;
const { error } = await signIn.social({
provider: "google",
callbackURL: `${frontendUrl}${callbackPath}`,