Merge branch 'staging' into feat/refactor-check
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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
284
scripts/replicate.ts
Normal 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);
|
||||
});
|
||||
@@ -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(),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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' \
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
167
server/src/external/stripe/createStripePrice/createStripePrepaidPriceV2.ts
vendored
Normal file
167
server/src/external/stripe/createStripePrice/createStripePrepaidPriceV2.ts
vendored
Normal file
@@ -0,0 +1,167 @@
|
||||
import {
|
||||
atmnToStripeAmountDecimal,
|
||||
BillingInterval,
|
||||
type EntitlementWithFeature,
|
||||
InternalError,
|
||||
type Organization,
|
||||
type Price,
|
||||
type Product,
|
||||
type UsagePriceConfig,
|
||||
} from "@autumn/shared";
|
||||
import { Decimal } from "decimal.js";
|
||||
import type Stripe from "stripe";
|
||||
import type { DrizzleCli } from "../../../db/initDrizzle.js";
|
||||
import { orgToCurrency } from "../../../internal/orgs/orgUtils.js";
|
||||
import { PriceService } from "../../../internal/products/prices/PriceService.js";
|
||||
import { billingIntervalToStripe } from "../stripePriceUtils.js";
|
||||
|
||||
// 1. Product name
|
||||
const prepaidToStripeTiers = ({
|
||||
ent,
|
||||
price,
|
||||
org,
|
||||
}: {
|
||||
ent: EntitlementWithFeature;
|
||||
price: Price;
|
||||
org: Organization;
|
||||
}) => {
|
||||
const usageTiers = price.config.usage_tiers;
|
||||
if (!usageTiers) {
|
||||
throw new InternalError({
|
||||
message:
|
||||
"[Internal Error] Converting prepaid price to tiers, but `usage_tiers` field is missing",
|
||||
});
|
||||
}
|
||||
// Create paid tiers first
|
||||
const paidTiers: Stripe.PriceCreateParams.Tier[] = usageTiers.map(
|
||||
(tier, index) => {
|
||||
const atmnUnitAmount = new Decimal(tier.amount).div(
|
||||
price.config.billing_units ?? 1,
|
||||
);
|
||||
|
||||
const stripeUnitAmountDecimal = atmnToStripeAmountDecimal({
|
||||
amount: atmnUnitAmount,
|
||||
currency: orgToCurrency({ org }),
|
||||
});
|
||||
|
||||
return {
|
||||
unit_amount_decimal: stripeUnitAmountDecimal,
|
||||
up_to: index === usageTiers.length - 1 ? "inf" : (tier.to ?? 0),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
// 1. Get included usage
|
||||
const includedUsage = ent.allowance;
|
||||
|
||||
if (includedUsage && includedUsage > 0) {
|
||||
paidTiers.forEach((tier) => {
|
||||
if (tier.up_to === "inf") {
|
||||
return;
|
||||
}
|
||||
tier.up_to = new Decimal(tier.up_to as number)
|
||||
.plus(includedUsage)
|
||||
.toNumber();
|
||||
});
|
||||
|
||||
paidTiers.unshift({
|
||||
unit_amount_decimal: "0",
|
||||
up_to: includedUsage,
|
||||
});
|
||||
}
|
||||
|
||||
return paidTiers;
|
||||
};
|
||||
|
||||
export const createStripePrepaidPriceV2 = async ({
|
||||
org,
|
||||
stripeCli,
|
||||
db,
|
||||
price,
|
||||
ent,
|
||||
product,
|
||||
curStripeProd,
|
||||
}: {
|
||||
org: Organization;
|
||||
stripeCli: Stripe;
|
||||
db: DrizzleCli;
|
||||
price: Price;
|
||||
ent: EntitlementWithFeature;
|
||||
product: Product;
|
||||
curStripeProd: Stripe.Product | null;
|
||||
}) => {
|
||||
let recurringData;
|
||||
if (price.config!.interval !== BillingInterval.OneOff) {
|
||||
recurringData = billingIntervalToStripe({
|
||||
interval: price.config!.interval,
|
||||
intervalCount: price.config!.interval_count,
|
||||
});
|
||||
}
|
||||
|
||||
const config = price.config as UsagePriceConfig;
|
||||
const productName = `${product.name} - ${ent.feature.name}`;
|
||||
|
||||
const productData = curStripeProd
|
||||
? { product: curStripeProd.id }
|
||||
: {
|
||||
product_data: {
|
||||
name: productName,
|
||||
},
|
||||
};
|
||||
|
||||
// 2. If billing interval is one off
|
||||
let stripePrice = null;
|
||||
if (price.config!.interval === BillingInterval.OneOff) {
|
||||
const amount = config.usage_tiers[0].amount;
|
||||
|
||||
const unitAmountDecimalStr = atmnToStripeAmountDecimal({
|
||||
amount,
|
||||
currency: orgToCurrency({ org }),
|
||||
});
|
||||
|
||||
stripePrice = await stripeCli.prices.create({
|
||||
...productData,
|
||||
unit_amount_decimal: unitAmountDecimalStr,
|
||||
currency: orgToCurrency({ org }),
|
||||
});
|
||||
|
||||
config.stripe_product_id = stripePrice.product as string;
|
||||
config.stripe_price_id = stripePrice.id;
|
||||
} else {
|
||||
const priceConfigTiers = price.config.usage_tiers;
|
||||
let priceAmountData: Partial<Stripe.PriceCreateParams>;
|
||||
if (priceConfigTiers?.length === 1) {
|
||||
priceAmountData = {
|
||||
unit_amount_decimal: atmnToStripeAmountDecimal({
|
||||
amount: priceConfigTiers[0].amount,
|
||||
currency: orgToCurrency({ org }),
|
||||
}),
|
||||
};
|
||||
} else {
|
||||
priceAmountData = {
|
||||
billing_scheme: "tiered",
|
||||
tiers_mode: "graduated",
|
||||
tiers: prepaidToStripeTiers({ ent, price, org }),
|
||||
};
|
||||
}
|
||||
stripePrice = await stripeCli.prices.create({
|
||||
...productData,
|
||||
currency: orgToCurrency({ org }),
|
||||
...priceAmountData,
|
||||
recurring: {
|
||||
...(recurringData as any),
|
||||
},
|
||||
nickname: `Autumn Price (${ent.feature.name})`,
|
||||
});
|
||||
config.stripe_price_id = stripePrice.id;
|
||||
config.stripe_product_id = stripePrice.product as string;
|
||||
}
|
||||
|
||||
// New config
|
||||
price.config = config;
|
||||
await PriceService.update({
|
||||
db,
|
||||
id: price.id!,
|
||||
update: { config },
|
||||
});
|
||||
};
|
||||
@@ -451,7 +451,7 @@ export const createFullCusProduct = async ({
|
||||
});
|
||||
|
||||
// Expire previous product if not one off and add on...?
|
||||
if (!isOneOff(prices) && product.is_add_on) {
|
||||
if (isFreeProduct(prices) && product.is_add_on) {
|
||||
const { curSameProduct } = getExistingCusProducts({
|
||||
product,
|
||||
cusProducts: attachParams.cusProducts!,
|
||||
|
||||
@@ -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,
|
||||
// });
|
||||
// }
|
||||
// }
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -88,7 +88,7 @@ export const constructPrepaidItem = ({
|
||||
feature_id: featureId,
|
||||
usage_model: UsageModel.Prepaid,
|
||||
|
||||
price: price,
|
||||
price: tiers ? undefined : price,
|
||||
tiers: tiers,
|
||||
billing_units: billingUnits || 100,
|
||||
interval: isOneOff ? null : ProductItemInterval.Month,
|
||||
|
||||
138
server/tests/contUse/update/updateContUse6.ts
Normal file
138
server/tests/contUse/update/updateContUse6.ts
Normal 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,
|
||||
// },
|
||||
// });
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -42,7 +42,7 @@ export function BillingUnits() {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex max-w-28 min-w-fit shrink-0">
|
||||
<div className="flex max-w-24 min-w-fit shrink-0">
|
||||
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
@@ -51,8 +51,8 @@ export function BillingUnits() {
|
||||
variant="muted"
|
||||
className={cn(
|
||||
item.tiers?.length && item.tiers.length > 1
|
||||
? "max-w-28"
|
||||
: "max-w-40",
|
||||
? "max-w-20"
|
||||
: "max-w-20",
|
||||
// "w-fit max-w-32 text-body-secondary overflow-hidden hover:bg-transparent justify-start p-1 h-auto [&:focus]:outline-none [&:focus-visible]:outline-none [&:focus]:ring-0 [&:focus-visible]:ring-0",
|
||||
// "underline hover:text-t3",
|
||||
)}
|
||||
|
||||
@@ -15,6 +15,70 @@ import { useProductItemContext } from "@/views/products/product/product-item/Pro
|
||||
import { addTier, removeTier, updateTier } from "../../utils/tierUtils";
|
||||
import { BillingUnits } from "./BillingUnits";
|
||||
|
||||
const getTierToDisplay = ({
|
||||
tiers,
|
||||
index,
|
||||
includedUsage,
|
||||
}: {
|
||||
tiers: PriceTier[];
|
||||
index: number;
|
||||
includedUsage: number | string | null;
|
||||
}) => {
|
||||
const tier = tiers[index];
|
||||
if (!tier) return "0";
|
||||
|
||||
// 1. If infinite, return "∞"
|
||||
if (tier.to === Infinite) return "∞";
|
||||
|
||||
// 2. Return tier.to + includedUsage
|
||||
if (typeof includedUsage === "number" && includedUsage > 0) {
|
||||
return ((tier.to || 0) + includedUsage).toString();
|
||||
}
|
||||
|
||||
// 3. Return tier.to + 0
|
||||
return (tier.to || 0).toString();
|
||||
};
|
||||
|
||||
const TierToInput = ({ index }: { index: number }) => {
|
||||
const { item, setItem } = useProductItemContext();
|
||||
const tiers = item?.tiers || [];
|
||||
const includedUsage =
|
||||
typeof item?.included_usage === "number" ? item?.included_usage : 0;
|
||||
|
||||
const isInfinite = index === tiers.length - 1;
|
||||
|
||||
const handleInputBlur = (value: string) => {
|
||||
if (isInfinite) return;
|
||||
// Set tier value in tiers array...
|
||||
const valueWithIncludedUsage =
|
||||
parseFloat(value) -
|
||||
(typeof includedUsage === "number" ? includedUsage : 0);
|
||||
const newTiers = [...tiers];
|
||||
newTiers[index] = { ...newTiers[index], to: valueWithIncludedUsage };
|
||||
setItem({ ...item, tiers: newTiers });
|
||||
};
|
||||
|
||||
const [tierVal, setTierVal] = useState<string>(
|
||||
getTierToDisplay({ tiers, index, includedUsage }),
|
||||
);
|
||||
|
||||
return (
|
||||
<Input
|
||||
// value={isInfinite ? "∞" : getDisplayValue(toKey, tier.to)}
|
||||
value={tierVal}
|
||||
// onFocus={() =>
|
||||
// !isInfinite && handleInputFocus(toKey, tier.to)
|
||||
// }
|
||||
onBlur={() => handleInputBlur(tierVal)}
|
||||
onChange={(e) => setTierVal(e.target.value)}
|
||||
className="w-full"
|
||||
placeholder={isInfinite ? "∞" : "100"}
|
||||
inputMode="decimal"
|
||||
disabled={isInfinite || (tiers.length === 2 && index === 1)} // Disable infinity or 2nd tier in 2-tier setup
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export function PriceTiers() {
|
||||
const { item, setItem } = useProductItemContext();
|
||||
const { org } = useOrg();
|
||||
@@ -148,7 +212,11 @@ export function PriceTiers() {
|
||||
value={
|
||||
index === 0
|
||||
? (includedUsage || 0).toString()
|
||||
: (tiers[index - 1]?.to || 0).toString()
|
||||
: getTierToDisplay({
|
||||
tiers,
|
||||
index: index - 1,
|
||||
includedUsage,
|
||||
})
|
||||
}
|
||||
onChange={() => null} // Read-only for "from" value
|
||||
className="w-full"
|
||||
@@ -160,28 +228,30 @@ export function PriceTiers() {
|
||||
</span>
|
||||
<div className="flex flex-1 text-sm items-center min-w-0">
|
||||
{/* To value - disable if infinite (last tier) or if 2nd tier in 2-tier setup */}
|
||||
<Input
|
||||
value={isInfinite ? "∞" : getDisplayValue(toKey, tier.to)}
|
||||
onFocus={() =>
|
||||
!isInfinite && handleInputFocus(toKey, tier.to)
|
||||
}
|
||||
onBlur={() =>
|
||||
!isInfinite && handleInputBlur(toKey, "to", index)
|
||||
}
|
||||
onChange={(e) =>
|
||||
!isInfinite &&
|
||||
handleInputChange(toKey, e.target.value, "to", index)
|
||||
}
|
||||
<TierToInput index={index} />
|
||||
{/* <Input
|
||||
// value={isInfinite ? "∞" : getDisplayValue(toKey, tier.to)}
|
||||
value={getTierToDisplay(index)}
|
||||
// onFocus={() =>
|
||||
// !isInfinite && handleInputFocus(toKey, tier.to)
|
||||
// }
|
||||
// onBlur={() =>
|
||||
// !isInfinite && handleInputBlur(toKey, "to", index)
|
||||
// }
|
||||
// onChange={(e) =>
|
||||
// !isInfinite &&
|
||||
// handleInputChange(toKey, e.target.value, "to", index)
|
||||
// }
|
||||
className="w-full"
|
||||
placeholder={isInfinite ? "∞" : "100"}
|
||||
inputMode="decimal"
|
||||
disabled={isInfinite || (tiers.length === 2 && index === 1)} // Disable infinity or 2nd tier in 2-tier setup
|
||||
/>
|
||||
/> */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Price input with currency */}
|
||||
<div className="w-24 shrink-0">
|
||||
<div className="w-22 shrink-0">
|
||||
<InputGroup className="min-w-0">
|
||||
<InputGroupInput
|
||||
value={getDisplayValue(amountKey, tier.amount)}
|
||||
|
||||
Reference in New Issue
Block a user