Merge branch 'staging' into feat/refactor-check

This commit is contained in:
John Yeo
2025-10-27 06:43:15 -07:00
19 changed files with 825 additions and 83 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

@@ -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 },
});
};

View File

@@ -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!,

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

@@ -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,

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}`,

View File

@@ -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",
)}

View File

@@ -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)}