diff --git a/.claude/rules/write-tests.mdc b/.claude/rules/write-tests.mdc index 6caf6017e..73b3a7c6e 100644 --- a/.claude/rules/write-tests.mdc +++ b/.claude/rules/write-tests.mdc @@ -43,6 +43,12 @@ When attaching a prepaid feature and passing `options` with a quantity: 1. **Legacy attach** (`autumnV1.attach`): `quantity` should NOT be divided by billing units and should be **exclusive** of included usage (i.e. only the prepaid amount, not counting what's already included free). 2. **New attach** (`autumnV1.billing.attach`): `quantity` should NOT be divided by billing units and should be **inclusive** of included usage (i.e. total desired amount including the free included portion). +## Subscription Verification + +- **New tests**: Use `expectStripeSubscriptionCorrect` from `@tests/integration/billing/utils/expectStripeSubCorrect` — it uses production code (`buildStripePhasesUpdate`) to compute expected state and handles inline entity-scoped prices, schedule phases, and post-cycle schedule release. +- **Existing tests**: Keep using `expectSubToBeCorrect` unless you're updating the test. +- Always call `expectStripeSubscriptionCorrect({ ctx, customerId })` after any `billing.attach()` or `subscriptions.update()` call in new tests. + ## Type Checking - After writing or editing test files, ALWAYS run `bun ts` in the `server/` directory to check for type errors before considering the task done. diff --git a/.claude/skills/write-test/SKILL.md b/.claude/skills/write-test/SKILL.md index f5ebc966d..87495a8b9 100644 --- a/.claude/skills/write-test/SKILL.md +++ b/.claude/skills/write-test/SKILL.md @@ -34,6 +34,7 @@ Write integration tests for the Autumn billing system using the `initScenario` p - Use generic types with `AutumnInt`: `autumnV1.customers.get()`, `autumnV1.check()` - **USE UTILITY FUNCTIONS WHENEVER POSSIBLE** - the shorter the code, the better. Check `server/tests/integration/billing/utils/` for existing utilities like `expectCustomerProducts`, `expectProductScheduled`, `expectCustomerInvoiceCorrect`, etc. - **Set up all prerequisite state in `initScenario` actions** - the test body should only call the single action being tested +- **ALWAYS call `expectStripeSubscriptionCorrect({ ctx, customerId })` after billing actions** — this uses production code to verify Stripe subscription state matches expectations **DON'T:** - Use plain `test()` - **ALWAYS use `test.concurrent()`** diff --git a/.claude/skills/write-test/references/EXPECTATIONS.md b/.claude/skills/write-test/references/EXPECTATIONS.md index 88657fe01..55f28bf9e 100644 --- a/.claude/skills/write-test/references/EXPECTATIONS.md +++ b/.claude/skills/write-test/references/EXPECTATIONS.md @@ -8,6 +8,7 @@ import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/e import { expectCustomerProducts, expectProductActive, expectProductCanceling, expectProductScheduled, expectProductNotPresent } from "@tests/integration/billing/utils/expectCustomerProductCorrect"; import { expectProductTrialing, expectProductNotTrialing } from "@tests/integration/billing/utils/expectCustomerProductTrialing"; import { expectPreviewNextCycleCorrect } from "@tests/integration/billing/utils/expectPreviewNextCycleCorrect"; +import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect"; import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect"; import { expectProductAttached, expectScheduledApiSub } from "@tests/utils/expectUtils/expectProductAttached"; ``` @@ -274,7 +275,56 @@ expectPreviewNextCycleCorrect({ This ensures the Stripe subscription state matches Autumn's internal state. -### `expectSubToBeCorrect` +### `expectStripeSubscriptionCorrect` (PREFERRED for new tests) + +Verifies Stripe subscriptions match expected state derived from customer products. +Handles inline entity-scoped prices, subscription schedules, and cancellation. +Uses `buildStripePhasesUpdate` (production code) to compute expected state. + +```typescript +import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect"; + +await expectStripeSubscriptionCorrect({ + ctx, // TestContext from initScenario + customerId, + options?: { + subCount?: number, // Expected total subscription count + subId?: string, // Verify a specific subscription only + status?: "active" | "trialing", + shouldBeCanceling?: boolean, // Override: expect canceling state + rewards?: string[], // Expected coupon/discount IDs + debug?: boolean, // Log detailed comparison info + }, +}); +``` + +**Key features:** +- Matches inline items by `autumn_customer_price_id` metadata +- Validates `unit_amount_decimal` on inline prices — catches stale/wrong price amounts on Stripe subscription items +- Validates schedule phases (multi_phase scenarios) including item-level comparison +- Handles post-cycle schedule release (Stripe keeps schedule ID but status is "released") +- Works with entity-scoped prepaid products + +```typescript +// Basic usage — verify all subscriptions for a customer +await expectStripeSubscriptionCorrect({ ctx, customerId }); + +// With subscription count check +await expectStripeSubscriptionCorrect({ + ctx, + customerId, + options: { subCount: 1 }, +}); + +// Debug mode for troubleshooting +await expectStripeSubscriptionCorrect({ + ctx, + customerId, + options: { debug: true }, +}); +``` + +### `expectSubToBeCorrect` (Legacy — use for existing tests only) Deep verification of subscription state in database. **Use for paid products.** @@ -315,11 +365,11 @@ await expectNoStripeSubscription({ | Scenario | Utility | |----------|---------| -| Attached paid product | `expectSubToBeCorrect` | -| Attached free product | `expectNoStripeSubscription` | -| Upgraded free → paid | `expectSubToBeCorrect` | -| Downgraded paid → free (after cycle) | `expectNoStripeSubscription` | -| Scheduled downgrade (before cycle) | `expectSubToBeCorrect` (sub still exists until cycle end) | +| New test with paid product | `expectStripeSubscriptionCorrect` | +| New test with entity-scoped inline prices | `expectStripeSubscriptionCorrect` | +| Existing test (don't change unless updating) | `expectSubToBeCorrect` | +| Free product / downgrade to free | `expectNoStripeSubscription` | +| Scheduled downgrade (before cycle) | `expectStripeSubscriptionCorrect` (validates schedule phases) | ## Complete Example @@ -399,14 +449,8 @@ test.concurrent(`${chalk.yellowBright("trial: full lifecycle")}`, async () => { latestTotal: 20, }); - // Verify subscription state in DB - await expectSubToBeCorrect({ - db: ctx.db, - customerId, - org: ctx.org, - env: ctx.env, - flags: { checkNotTrialing: true }, - }); + // Verify Stripe subscription matches expected state + await expectStripeSubscriptionCorrect({ ctx, customerId }); }); ``` diff --git a/others/sequin/tinybird_mega_transform.ex b/others/sequin/tinybird_mega_transform.ex new file mode 100644 index 000000000..4048ed9b9 --- /dev/null +++ b/others/sequin/tinybird_mega_transform.ex @@ -0,0 +1,255 @@ +def transform(action, record, changes, metadata) do + ts = metadata.commit_timestamp + lsn = metadata.commit_lsn + ikey = metadata.idempotency_key + cdc = %{__action: action, __commit_timestamp: ts, __commit_lsn: lsn, __idempotency_key: ikey} + + di = fn v -> if v == nil, do: nil, else: Decimal.to_integer(v) end + df = fn v -> if v == nil, do: nil, else: Decimal.to_float(v) end + bi = fn v -> if v, do: 1, else: 0 end + r = record + + Map.merge( + cdc, + case metadata.table_name do + "customers" -> + %{ + internal_id: r["internal_id"], + org_id: r["org_id"], + created_at: di.(r["created_at"]), + name: r["name"], + id: r["id"], + email: r["email"], + fingerprint: r["fingerprint"], + metadata: r["metadata"] |> JSON.encode!(), + env: r["env"], + processor: r["processor"] |> JSON.encode!(), + processors: (r["processors"] || %{}) |> JSON.encode!(), + send_email_receipts: bi.(r["send_email_receipts"]) + } + + "invoices" -> + %{ + id: r["id"], + created_at: di.(r["created_at"]), + product_ids: r["product_ids"] || [], + internal_product_ids: r["internal_product_ids"] || [], + internal_customer_id: r["internal_customer_id"], + internal_entity_id: r["internal_entity_id"], + stripe_id: r["stripe_id"], + status: r["status"], + hosted_invoice_url: r["hosted_invoice_url"], + total: df.(r["total"]), + currency: r["currency"], + discounts: r["discounts"] |> JSON.encode!(), + items: r["items"] |> JSON.encode!() + } + + "organizations" -> + %{ + id: r["id"], + slug: r["slug"], + name: r["name"], + logo: r["logo"], + created_at_ts: if(r["createdAt"] == nil, do: nil, else: to_string(r["createdAt"])), + metadata: r["metadata"], + default_currency: r["default_currency"] || "usd", + stripe_connected: bi.(r["stripe_connected"]), + created_at: di.(r["created_at"]), + onboarded: bi.(r["onboarded"]), + deployed: bi.(r["deployed"]) + } + + "customer_products" -> + %{ + id: r["id"], + internal_customer_id: r["internal_customer_id"], + internal_product_id: r["internal_product_id"], + internal_entity_id: r["internal_entity_id"], + created_at: di.(r["created_at"]), + status: r["status"], + processor: r["processor"] |> JSON.encode!(), + canceled: bi.(r["canceled"]), + canceled_at: di.(r["canceled_at"]), + ended_at: di.(r["ended_at"]), + starts_at: di.(r["starts_at"]), + options: (r["options"] || []) |> JSON.encode!(), + product_id: r["product_id"], + free_trial_id: r["free_trial_id"], + trial_ends_at: di.(r["trial_ends_at"]), + collection_method: r["collection_method"] || "charge_automatically", + subscription_ids: r["subscription_ids"] || [], + scheduled_ids: r["scheduled_ids"] || [], + quantity: if(r["quantity"] == nil, do: 1.0, else: Decimal.to_float(r["quantity"])), + is_custom: bi.(r["is_custom"]), + customer_id: r["customer_id"], + entity_id: r["entity_id"], + billing_version: r["billing_version"], + api_version: df.(r["api_version"]), + api_semver: r["api_semver"] + } + + "customer_entitlements" -> + %{ + id: r["id"], + customer_product_id: r["customer_product_id"], + entitlement_id: r["entitlement_id"], + internal_customer_id: r["internal_customer_id"], + internal_entity_id: r["internal_entity_id"], + internal_feature_id: r["internal_feature_id"], + unlimited: bi.(r["unlimited"]), + balance: if(r["balance"] == nil, do: 0.0, else: Decimal.to_float(r["balance"])), + created_at: di.(r["created_at"]), + next_reset_at: di.(r["next_reset_at"]), + usage_allowed: bi.(r["usage_allowed"]), + adjustment: df.(r["adjustment"]), + additional_balance: + if(r["additional_balance"] == nil, + do: 0.0, + else: Decimal.to_float(r["additional_balance"]) + ), + entities: (r["entities"] || %{}) |> JSON.encode!(), + expires_at: di.(r["expires_at"]), + cache_version: r["cache_version"] || 0, + customer_id: r["customer_id"], + feature_id: r["feature_id"] + } + + "customer_prices" -> + %{ + id: r["id"], + created_at: di.(r["created_at"]), + price_id: r["price_id"], + options: r["options"] |> JSON.encode!(), + internal_customer_id: r["internal_customer_id"], + customer_product_id: r["customer_product_id"] + } + + "replaceables" -> + %{ + id: r["id"], + cus_ent_id: r["cus_ent_id"], + created_at: r["created_at"], + from_entity_id: r["from_entity_id"], + delete_next_cycle: bi.(r["delete_next_cycle"]) + } + + "rollovers" -> + %{ + id: r["id"], + cus_ent_id: r["cus_ent_id"], + balance: if(r["balance"] == nil, do: 0.0, else: Decimal.to_float(r["balance"])), + expires_at: di.(r["expires_at"]), + usage: if(r["usage"] == nil, do: 0.0, else: Decimal.to_float(r["usage"])), + entities: (r["entities"] || %{}) |> JSON.encode!() + } + + "entitlements" -> + %{ + id: r["id"], + created_at: di.(r["created_at"]), + internal_feature_id: r["internal_feature_id"], + internal_product_id: r["internal_product_id"], + is_custom: bi.(r["is_custom"]), + allowance_type: r["allowance_type"], + allowance: df.(r["allowance"]), + interval: r["interval"], + interval_count: + if(r["interval_count"] == nil, do: 1.0, else: Decimal.to_float(r["interval_count"])), + carry_from_previous: bi.(r["carry_from_previous"]), + entity_feature_id: r["entity_feature_id"], + org_id: r["org_id"], + feature_id: r["feature_id"], + usage_limit: df.(r["usage_limit"]), + rollover: r["rollover"] |> JSON.encode!() + } + + "free_trials" -> + %{ + id: r["id"], + created_at: di.(r["created_at"]), + internal_product_id: r["internal_product_id"], + duration: r["duration"] || "day", + length: df.(r["length"]), + unique_fingerprint: bi.(r["unique_fingerprint"]), + is_custom: bi.(r["is_custom"]), + card_required: bi.(r["card_required"]) + } + + "entities" -> + %{ + id: r["id"], + org_id: r["org_id"], + created_at: di.(r["created_at"]), + internal_id: r["internal_id"], + internal_customer_id: r["internal_customer_id"], + env: r["env"], + name: r["name"], + deleted: bi.(r["deleted"]), + internal_feature_id: r["internal_feature_id"], + feature_id: r["feature_id"] + } + + "features" -> + %{ + internal_id: r["internal_id"], + org_id: r["org_id"], + created_at: di.(r["created_at"]), + env: r["env"], + id: r["id"], + name: r["name"], + type: r["type"], + config: r["config"] |> JSON.encode!(), + display: r["display"] |> JSON.encode!(), + archived: bi.(r["archived"]), + event_names: r["event_names"] || [] + } + + "prices" -> + %{ + id: r["id"], + org_id: r["org_id"], + internal_product_id: r["internal_product_id"], + config: r["config"] |> JSON.encode!(), + created_at: di.(r["created_at"]), + billing_type: r["billing_type"], + tier_behavior: r["tier_behavior"], + is_custom: bi.(r["is_custom"]), + entitlement_id: r["entitlement_id"], + proration_config: r["proration_config"] |> JSON.encode!() + } + + "products" -> + %{ + internal_id: r["internal_id"], + id: r["id"], + name: r["name"], + description: r["description"], + org_id: r["org_id"], + created_at: di.(r["created_at"]), + env: r["env"], + is_add_on: bi.(r["is_add_on"]), + is_default: bi.(r["is_default"]), + group: r["group"], + version: if(r["version"] == nil, do: 1.0, else: Decimal.to_float(r["version"])), + processor: r["processor"] |> JSON.encode!(), + base_variant_id: r["base_variant_id"], + archived: bi.(r["archived"]) + } + + "subscriptions" -> + %{ + id: r["id"], + org_id: r["org_id"], + stripe_id: r["stripe_id"], + stripe_schedule_id: r["stripe_schedule_id"], + created_at: di.(r["created_at"]), + metadata: (r["metadata"] || %{}) |> JSON.encode!(), + usage_features: r["usage_features"] || [], + env: r["env"], + current_period_start: di.(r["current_period_start"]), + current_period_end: di.(r["current_period_end"]) + } + end + ) +end diff --git a/others/sequin/tinybird_routing.ex b/others/sequin/tinybird_routing.ex new file mode 100644 index 000000000..0167ca238 --- /dev/null +++ b/others/sequin/tinybird_routing.ex @@ -0,0 +1,24 @@ +def route(action, record, changes, metadata) do + datasource = case metadata.table_name do + "customers" -> "customers" + "invoices" -> "invoices" + "organizations" -> "organizations" + "customer_products" -> "customer_products" + "customer_entitlements" -> "customer_entitlements" + "customer_prices" -> "customer_prices" + "replaceables" -> "replaceables" + "rollovers" -> "rollovers" + "entitlements" -> "entitlements" + "free_trials" -> "free_trials" + "entities" -> "entities" + "subscriptions" -> "subscriptions" + "features" -> "features" + "prices" -> "prices" + "products" -> "products" + end + + %{ + method: "POST", + endpoint_path: "?name=#{datasource}&format=json" + } +end diff --git a/package.json b/package.json index 411f6a02e..802347a47 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "vite:build": "bun -F @autumn/vite build:bun", "t": "infisical run --env=dev -- bun scripts/testScripts/testDispatcher.ts", "d": "lsof -ti:8080 -ti:3000 | xargs kill -9 2>/dev/null || true; ENV_FILE=.env infisical run --env=dev -- bun scripts/dev.ts", + "dx": "bun scripts/dx.ts", "d:test": "lsof -ti:8080 -ti:3000 | xargs kill -9 2>/dev/null || true; ENV_FILE=.env infisical run --env=test -- bun scripts/dev.ts", "p": "ENV_FILE=.env.prod infisical run --env=prod -- bun scripts/dev.ts", "setup": "node scripts/setup/setup.js", diff --git a/scripts/dev.ts b/scripts/dev.ts index 2cb9a1cd2..153cdd541 100644 --- a/scripts/dev.ts +++ b/scripts/dev.ts @@ -2,9 +2,17 @@ import { existsSync, readFileSync, rmSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; -const VITE_PORT = 3000; -const SERVER_PORT = 8080; -const CHECKOUT_PORT = 3001; +const worktreeIdx = process.argv.indexOf("--worktree"); +const worktreeNum = + worktreeIdx !== -1 && process.argv[worktreeIdx + 1] + ? Number.parseInt(process.argv[worktreeIdx + 1], 10) + : 1; +const portOffset = (worktreeNum - 1) * 100; + +const VITE_PORT = 3000 + portOffset; +const SERVER_PORT = 8080 + portOffset; +const CHECKOUT_PORT = 3001 + portOffset; +const skipWorkers = worktreeNum > 1; /** * Read environment variable from .env file @@ -71,7 +79,15 @@ async function startDev() { } } - console.log("Starting development servers...\n"); + if (worktreeNum > 1) { + console.log(`Starting worktree ${worktreeNum} (no workers)...\n`); + } else { + console.log("Starting development servers...\n"); + } + + console.log(` vite: http://localhost:${VITE_PORT}`); + console.log(` server: http://localhost:${SERVER_PORT}`); + console.log(` checkout: http://localhost:${CHECKOUT_PORT}\n`); // Use cmd on Windows, sh on Unix const isWindows = process.platform === "win32"; @@ -94,21 +110,40 @@ async function startDev() { `bunx concurrently -n server,workers -c green,yellow "cd server && SERVER_PORT=${SERVER_PORT} bun start" "cd server && bun workers"`, ]; } - } else if (isWindows) { - const serverCmd = `cd server && set SERVER_PORT=${SERVER_PORT} && bun dev`; - const workersCmd = `cd server && bun workers:dev`; - const viteCmd = `cd vite && set VITE_PORT=${VITE_PORT} && bun dev`; - const checkoutCmd = `cd apps/checkout && set VITE_PORT=${CHECKOUT_PORT} && bun dev`; - shellArgs = [ - "cmd", - "/c", - `bunx concurrently -n server,workers,vite,checkout -c green,yellow,blue,magenta "${serverCmd}" "${workersCmd}" "${viteCmd}" "${checkoutCmd}"`, - ]; } else { + const names = ["server"]; + const colors = ["green"]; + const cmds = [ + isWindows + ? `"cd server && set SERVER_PORT=${SERVER_PORT} && bun dev"` + : `"cd server && SERVER_PORT=${SERVER_PORT} bun dev"`, + ]; + + if (!skipWorkers) { + names.push("workers"); + colors.push("yellow"); + cmds.push( + isWindows + ? `"cd server && bun workers:dev"` + : `"cd server && bun workers:dev"`, + ); + } + + names.push("vite", "checkout"); + colors.push("blue", "magenta"); + cmds.push( + isWindows + ? `"cd vite && set VITE_PORT=${VITE_PORT} && bun dev"` + : `"cd vite && VITE_PORT=${VITE_PORT} bun dev"`, + isWindows + ? `"cd apps/checkout && set VITE_PORT=${CHECKOUT_PORT} && bun dev"` + : `"cd apps/checkout && VITE_PORT=${CHECKOUT_PORT} bun dev"`, + ); + shellArgs = [ - "sh", - "-c", - `bunx concurrently -n server,workers,vite,checkout -c green,yellow,blue,magenta "cd server && SERVER_PORT=${SERVER_PORT} bun dev" "cd server && bun workers:dev" "cd vite && VITE_PORT=${VITE_PORT} bun dev" "cd apps/checkout && VITE_PORT=${CHECKOUT_PORT} bun dev"`, + isWindows ? "cmd" : "sh", + isWindows ? "/c" : "-c", + `bunx concurrently -n ${names.join(",")} -c ${colors.join(",")} ${cmds.join(" ")}`, ]; } @@ -119,6 +154,12 @@ async function startDev() { VITE_PORT: VITE_PORT.toString(), SERVER_PORT: SERVER_PORT.toString(), CHECKOUT_PORT: CHECKOUT_PORT.toString(), + ...(worktreeNum > 1 && { + CLIENT_URL: `http://localhost:${VITE_PORT}`, + BETTER_AUTH_URL: `http://localhost:${SERVER_PORT}`, + VITE_BACKEND_URL: `http://localhost:${SERVER_PORT}`, + VITE_FRONTEND_URL: `http://localhost:${VITE_PORT}`, + }), }, stdout: "inherit", stderr: "inherit", diff --git a/scripts/dx.ts b/scripts/dx.ts new file mode 100644 index 000000000..90585f221 --- /dev/null +++ b/scripts/dx.ts @@ -0,0 +1,66 @@ +import { createConnection } from "node:net"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +function isPortInUse({ port }: { port: number }): Promise { + return new Promise((resolve) => { + const socket = createConnection({ port, host: "127.0.0.1" }); + socket.on("connect", () => { + socket.destroy(); + resolve(true); + }); + socket.on("error", () => { + socket.destroy(); + resolve(false); + }); + }); +} + +async function findFreeWorktree(): Promise { + for (let n = 2; n <= 10; n++) { + const serverPort = 8080 + (n - 1) * 100; + if (!(await isPortInUse({ port: serverPort }))) return n; + } + console.error("No free worktree slots (2-10). All server ports in use."); + process.exit(1); +} + +// Allow explicit override: `bun dx 3`, otherwise auto-detect +const explicitArg = Number.parseInt(process.argv[2] || "", 10); +const worktreeNum = + !Number.isNaN(explicitArg) && explicitArg >= 2 + ? explicitArg + : await findFreeWorktree(); + +const offset = (worktreeNum - 1) * 100; +const vitePort = 3000 + offset; +const serverPort = 8080 + offset; +const checkoutPort = 3001 + offset; + +console.log( + `Worktree ${worktreeNum} -> vite:${vitePort}, server:${serverPort}, checkout:${checkoutPort}\n`, +); + +const portArgs = [ + `-ti:${vitePort}`, + `-ti:${serverPort}`, + `-ti:${checkoutPort}`, +].join(" "); +const killCmd = `lsof ${portArgs} | xargs kill -9 2>/dev/null || true`; +const devCmd = `ENV_FILE=.env infisical run --env=dev -- bun scripts/dev.ts --worktree ${worktreeNum}`; + +const rootDir = dirname(fileURLToPath(import.meta.url)); +const projectRoot = join(rootDir, ".."); + +const proc = Bun.spawn(["sh", "-c", `${killCmd}; ${devCmd}`], { + cwd: projectRoot, + env: process.env, + stdout: "inherit", + stderr: "inherit", +}); + +process.on("SIGINT", () => proc.kill("SIGINT")); +process.on("SIGTERM", () => proc.kill("SIGTERM")); + +await proc.exited; +process.exit(proc.exitCode ?? 0); diff --git a/server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/tasks/handleCheckoutSessionMetadataV2/updateOptionsFromStripeCheckoutSession.ts b/server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/tasks/handleCheckoutSessionMetadataV2/updateOptionsFromStripeCheckoutSession.ts index 49fe1ddbf..c569ed023 100644 --- a/server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/tasks/handleCheckoutSessionMetadataV2/updateOptionsFromStripeCheckoutSession.ts +++ b/server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/tasks/handleCheckoutSessionMetadataV2/updateOptionsFromStripeCheckoutSession.ts @@ -3,6 +3,7 @@ import { type DeferredAutumnBillingPlanData, featureOptionUtils, getStartingBalance, + isCustomerProductEntityScoped, priceUtils, } from "@autumn/shared"; import { stripeCheckoutSessionUtils } from "@/external/stripe/checkoutSessions/utils"; @@ -36,6 +37,12 @@ export const updateOptionsFromStripeCheckoutSession = async ({ if (!price || priceUtils.isTieredOneOff({ price, product: fullProduct })) continue; + // Entity-scoped products use inline prices with pre-calculated amounts; + // the checkout line item quantity is not meaningful, so keep original options. + if (isCustomerProductEntityScoped(newCustomerProduct)) { + continue; + } + const featureOptionsQuantity = stripeCheckoutSessionUtils.convert.toFeatureOptionsQuantity({ stripeCheckoutSession, diff --git a/server/src/initHono.ts b/server/src/initHono.ts index f7b111416..dfe4871e6 100644 --- a/server/src/initHono.ts +++ b/server/src/initHono.ts @@ -22,24 +22,7 @@ import { apiRouter } from "./routers/apiRouter.js"; import { internalRouter } from "./routers/internalRouter.js"; import { publicRouter } from "./routers/publicRouter.js"; import { auth } from "./utils/auth.js"; - -const ALLOWED_ORIGINS = [ - "http://localhost:3000", - "http://localhost:3001", - "http://localhost:3002", - "http://localhost:3003", - "http://localhost:3004", - "http://localhost:3005", - "http://localhost:3006", - "http://localhost:3007", - "http://localhost:5173", - "http://localhost:5174", - "https://app.useautumn.com", - "https://staging.useautumn.com", - "https://dev.useautumn.com", - "https://api.staging.useautumn.com", - "https://localhost:8080", -]; +import { isAllowedOrigin } from "./utils/corsOrigins.js"; const ALLOWED_HEADERS = [ "app_env", @@ -72,7 +55,7 @@ export const createHonoApp = () => { app.use( "*", cors({ - origin: ALLOWED_ORIGINS, + origin: isAllowedOrigin, allowHeaders: ALLOWED_HEADERS, allowMethods: ["POST", "GET", "PUT", "DELETE", "PATCH", "OPTIONS"], exposeHeaders: ["Content-Length"], diff --git a/server/src/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionScheduleAction.ts b/server/src/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionScheduleAction.ts index a7a0618f3..d59bd4d78 100644 --- a/server/src/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionScheduleAction.ts +++ b/server/src/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionScheduleAction.ts @@ -18,6 +18,7 @@ const toCreatePhase = ( items: phase.items?.map((item) => ({ price: item.price, quantity: item.quantity, + ...(item.metadata && { metadata: item.metadata }), })), end_date: typeof phase.end_date === "number" ? phase.end_date : undefined, discounts: phase.discounts as @@ -29,13 +30,18 @@ const toCreatePhase = ( * Builds phases for updating a schedule that was created from a subscription. * The first phase must use the schedule's actual current phase start_date AND items. * Stripe doesn't allow modifying items in an active phase, so we preserve them exactly. + * + * Stripe's `from_subscription` doesn't copy item-level metadata onto schedule phase items, + * so we re-apply metadata from the subscription items (which DO have it) by matching price ID. */ const buildAnchoredPhases = ({ params, existingSchedule, + stripeSubscription, }: { params: { phases?: Stripe.SubscriptionScheduleUpdateParams.Phase[] }; existingSchedule: Stripe.SubscriptionSchedule; + stripeSubscription?: Stripe.Subscription; }): Stripe.SubscriptionScheduleUpdateParams.Phase[] => { const inputPhases = params.phases ?? []; if (inputPhases.length === 0) return []; @@ -45,12 +51,39 @@ const buildAnchoredPhases = ({ throw new Error("Cannot update schedule: missing current phase start_date"); } + // Build a lookup of price ID → metadata from the subscription items + const subItemMetadataByPriceId = new Map>(); + if (stripeSubscription) { + for (const subItem of stripeSubscription.items.data) { + if (subItem.metadata && Object.keys(subItem.metadata).length > 0) { + subItemMetadataByPriceId.set(subItem.price.id, subItem.metadata); + } + } + } + // Map existing items to update format (response type -> request type) + // Re-apply metadata from subscription items since Stripe's from_subscription strips it const existingFirstPhaseItems: Stripe.SubscriptionScheduleUpdateParams.Phase["items"] = - existingSchedule.phases[0]?.items.map((item) => ({ - price: typeof item.price === "string" ? item.price : item.price?.id, - quantity: item.quantity ?? undefined, - })); + existingSchedule.phases[0]?.items.map((item) => { + const priceId = + typeof item.price === "string" ? item.price : item.price?.id; + + // Prefer metadata from the subscription item (reliable source) + const subMetadata = priceId + ? subItemMetadataByPriceId.get(priceId) + : undefined; + const metadata = + subMetadata ?? + (item.metadata && Object.keys(item.metadata).length > 0 + ? item.metadata + : undefined); + + return { + price: priceId, + quantity: item.quantity ?? undefined, + ...(metadata && { metadata }), + }; + }); // First phase: preserve start_date AND items from existing schedule // Stripe doesn't allow modifying items in an active/in-progress phase @@ -74,16 +107,22 @@ const createScheduleFromSubscription = async ({ stripeCli, subscriptionId, params, + stripeSubscription, }: { stripeCli: Stripe; subscriptionId: string; params: Stripe.SubscriptionScheduleUpdateParams; + stripeSubscription?: Stripe.Subscription; }): Promise => { const schedule = await stripeCli.subscriptionSchedules.create({ from_subscription: subscriptionId, }); - const phases = buildAnchoredPhases({ params, existingSchedule: schedule }); + const phases = buildAnchoredPhases({ + params, + existingSchedule: schedule, + stripeSubscription, + }); return await stripeCli.subscriptionSchedules.update(schedule.id, { phases, @@ -125,6 +164,7 @@ export const executeStripeSubscriptionScheduleAction = async ({ stripeCli, subscriptionId: stripeSubscription.id, params, + stripeSubscription, }); } @@ -162,6 +202,7 @@ export const executeStripeSubscriptionScheduleAction = async ({ ? subscriptionId : subscriptionId.id, params, + stripeSubscription, }); // Update existing customer products with the new schedule ID diff --git a/server/src/internal/billing/v2/providers/stripe/utils/checkoutSessions/buildStripeCheckoutSessionItems.ts b/server/src/internal/billing/v2/providers/stripe/utils/checkoutSessions/buildStripeCheckoutSessionItems.ts index 73ad2c85d..2ef89963c 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/checkoutSessions/buildStripeCheckoutSessionItems.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/checkoutSessions/buildStripeCheckoutSessionItems.ts @@ -10,6 +10,7 @@ import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { customerProductsToOneOffStripeItemSpecs } from "@/internal/billing/v2/providers/stripe/utils/stripeItemSpec/customerProductsToOneOffStripeItemSpecs"; import { customerProductsToRecurringStripeItemSpecs } from "@/internal/billing/v2/providers/stripe/utils/stripeItemSpec/customerProductsToRecurringStripeItemSpecs"; import { filterStripeItemSpecsByLargestInterval } from "@/internal/billing/v2/providers/stripe/utils/stripeItemSpec/filterStripeItemSpecsByLargestInterval"; +import { stripeItemSpecToCheckoutLineItem } from "@/internal/billing/v2/providers/stripe/utils/stripeItemSpec/stripeItemSpecToStripeParam"; import { updateOneOffTieredItems } from "./updateOneOffTieredItems"; export const buildStripeCheckoutSessionItems = ({ @@ -50,7 +51,8 @@ export const buildStripeCheckoutSessionItems = ({ // 5. Convert recurring item specs to line items const recurringLineItems = recurringStripeItemSpecs.map((item) => { - const { autumnPrice, quantity, stripePriceId, autumnEntitlement } = item; + const { autumnPrice, autumnEntitlement } = item; + const lineItem = stripeItemSpecToCheckoutLineItem({ spec: item }); // If it's a prepaid price, allow adjustable quantity if (autumnPrice && autumnEntitlement && isPrepaidPrice(autumnPrice)) { @@ -60,8 +62,7 @@ export const buildStripeCheckoutSessionItems = ({ ); return { - price: stripePriceId, - quantity: quantity ?? 0, + ...lineItem, adjustable_quantity: isAdjustable ? { enabled: true, @@ -75,11 +76,7 @@ export const buildStripeCheckoutSessionItems = ({ } as Stripe.Checkout.SessionCreateParams.LineItem; } - // Fixed price - return { - price: stripePriceId, - quantity: quantity ?? 0, - }; + return lineItem; }); // 6. Convert one-off item specs to line items (handles tiered one-off prices) diff --git a/server/src/internal/billing/v2/providers/stripe/utils/stripeItemSpec/cusPriceToStripeItemSpec/allocatedToStripeItemSpec.ts b/server/src/internal/billing/v2/providers/stripe/utils/stripeItemSpec/cusPriceToStripeItemSpec/allocatedToStripeItemSpec.ts new file mode 100644 index 000000000..37b617160 --- /dev/null +++ b/server/src/internal/billing/v2/providers/stripe/utils/stripeItemSpec/cusPriceToStripeItemSpec/allocatedToStripeItemSpec.ts @@ -0,0 +1,40 @@ +import { + cusEntToBillingObjects, + type FullCusEntWithFullCusProduct, + InternalError, + type StripeItemSpec, + type UsagePriceConfig, +} from "@autumn/shared"; +import { cusEntToInvoiceUsage } from "@shared/utils/cusEntUtils/overageUtils/cusEntToInvoiceUsage"; + +/** + * Converts an in-arrear prorated (allocated) price to a StripeItemSpec. + * Computes existing usage from the cusEnt. + */ +export const allocatedToStripeItemSpec = ({ + cusEntWithCusProduct, +}: { + cusEntWithCusProduct: FullCusEntWithFullCusProduct; +}): StripeItemSpec | null => { + const billing = cusEntToBillingObjects({ cusEnt: cusEntWithCusProduct }); + if (!billing) return null; + + const { price, product } = billing; + const config = price.config as UsagePriceConfig; + + if (!config.stripe_price_id) { + throw new InternalError({ + message: `[allocatedToStripeItemSpec] config.stripe_price_id is empty for autumn price: ${price.id}`, + }); + } + + const existingUsage = cusEntToInvoiceUsage({ cusEnt: cusEntWithCusProduct }); + + return { + stripePriceId: config.stripe_price_id, + quantity: existingUsage, + autumnPrice: price, + autumnProduct: product, + autumnCusEnt: cusEntWithCusProduct, + }; +}; diff --git a/server/src/internal/billing/v2/providers/stripe/utils/stripeItemSpec/cusPriceToStripeItemSpec/consumableToStripeItemSpec.ts b/server/src/internal/billing/v2/providers/stripe/utils/stripeItemSpec/cusPriceToStripeItemSpec/consumableToStripeItemSpec.ts new file mode 100644 index 000000000..c864a5f73 --- /dev/null +++ b/server/src/internal/billing/v2/providers/stripe/utils/stripeItemSpec/cusPriceToStripeItemSpec/consumableToStripeItemSpec.ts @@ -0,0 +1,44 @@ +import { + cusEntToBillingObjects, + type FullCusEntWithFullCusProduct, + InternalError, + isConsumablePrice, + type StripeItemSpec, + type UsagePriceConfig, +} from "@autumn/shared"; + +/** + * Converts a usage-in-arrear (consumable) price to a StripeItemSpec. + * For entity-scoped / beta API / Vercel, uses the empty price with quantity 0. + */ +export const consumableToStripeItemSpec = ({ + cusEntWithCusProduct, +}: { + cusEntWithCusProduct: FullCusEntWithFullCusProduct; +}): StripeItemSpec | null => { + const billing = cusEntToBillingObjects({ cusEnt: cusEntWithCusProduct }); + if (!billing) return null; + + const { price, product } = billing; + + if (!isConsumablePrice(price)) { + throw new InternalError({ + message: `[consumableToStripeItemSpec] Price ${price.id} is not a consumable price`, + }); + } + + const config = price.config as UsagePriceConfig; + + const priceId = config.stripe_price_id ?? config.stripe_empty_price_id; + if (!priceId) { + throw new InternalError({ + message: `[consumableToStripeItemSpec] config.stripe_price_id is empty for autumn price: ${price.id}`, + }); + } + + return { + stripePriceId: priceId, + autumnPrice: price, + autumnProduct: product, + }; +}; diff --git a/server/src/internal/billing/v2/providers/stripe/utils/stripeItemSpec/cusPriceToStripeItemSpec/cusEntToInlineStripePrice.ts b/server/src/internal/billing/v2/providers/stripe/utils/stripeItemSpec/cusPriceToStripeItemSpec/cusEntToInlineStripePrice.ts new file mode 100644 index 000000000..4b894a550 --- /dev/null +++ b/server/src/internal/billing/v2/providers/stripe/utils/stripeItemSpec/cusPriceToStripeItemSpec/cusEntToInlineStripePrice.ts @@ -0,0 +1,71 @@ +import { + cusEntsToAllowance, + cusEntToPrepaidInvoiceOverage, + type FullCusEntWithFullCusProduct, + InternalError, + type Organization, + orgToCurrency, + priceToLineAmount, + type StripeInlinePrice, +} from "@autumn/shared"; +import { cusEntToCusPrice } from "@shared/utils/cusEntUtils/convertCusEntUtils/cusEntToCusPrice"; +import { atmnToStripeAmountDecimal } from "@shared/utils/productUtils/priceUtils/convertAmountUtils"; +import { priceToStripeRecurringParams } from "@shared/utils/productUtils/priceUtils/convertPrice/priceToStripeRecurringParams"; + +/** + * Builds a flat inline Stripe price for an entity-scoped prepaid item. + * Calculates the total amount using tier logic, + * since Stripe doesn't support tiered price_data on inline prices. + */ +export const cusEntToInlineStripePrice = ({ + cusEnt, + org, +}: { + cusEnt: FullCusEntWithFullCusProduct; + org: Organization; +}): StripeInlinePrice => { + const cusPrice = cusEntToCusPrice({ cusEnt }); + if (!cusPrice) { + throw new InternalError({ + message: `[cusEntToInlineStripePrice] No cus price found for cus ent (feature: ${cusEnt.entitlement.feature_id})`, + }); + } + + const price = cusPrice.price; + const recurring = priceToStripeRecurringParams({ price }); + const currency = orgToCurrency({ org }); + + const productId = price.config.stripe_product_id; + if (!productId) { + throw new InternalError({ + message: `[cusEntToInlineStripePrice] Price ${price.id} has no stripe_product_id for inline price`, + }); + } + + // 1. Get overage (purchased quantity in feature units + const overage = cusEntToPrepaidInvoiceOverage({ + cusEnt, + useUpcomingQuantity: true, + }); + + const allowance = cusEntsToAllowance({ cusEnts: [cusEnt] }); + + // 4. Calculate total dollar amount using tier logic + const totalAmount = priceToLineAmount({ + price, + overage, + allowance, + }); + + const totalStripeAmount = atmnToStripeAmountDecimal({ + amount: totalAmount, + currency, + }); + + return { + product: productId, + currency, + ...(recurring && { recurring }), + unit_amount_decimal: totalStripeAmount, + }; +}; diff --git a/server/src/internal/billing/v2/providers/stripe/utils/stripeItemSpec/cusPriceToStripeItemSpec/cusPriceToStripeItemSpec.ts b/server/src/internal/billing/v2/providers/stripe/utils/stripeItemSpec/cusPriceToStripeItemSpec/cusPriceToStripeItemSpec.ts new file mode 100644 index 000000000..61a88964d --- /dev/null +++ b/server/src/internal/billing/v2/providers/stripe/utils/stripeItemSpec/cusPriceToStripeItemSpec/cusPriceToStripeItemSpec.ts @@ -0,0 +1,84 @@ +import { + type BillingContext, + cusPriceToCusEntWithCusProduct, + type FullCusProduct, + type FullCustomerPrice, + isAllocatedPrice, + isConsumablePrice, + isFixedPrice, + isPrepaidPrice, + type StripeItemSpec, +} from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { allocatedToStripeItemSpec } from "./allocatedToStripeItemSpec"; +import { consumableToStripeItemSpec } from "./consumableToStripeItemSpec"; +import { fixedPriceToStripeItemSpec } from "./fixedPriceToStripeItemSpec"; +import { prepaidToStripeItemSpec } from "./prepaidToStripeItemSpec"; + +/** + * Converts a single customer price to a StripeItemSpec. + * Resolves the associated cusEnt, then dispatches to the appropriate handler. + */ +export const cusPriceToStripeItemSpec = ({ + ctx, + cusPrice, + cusProduct, + billingContext, +}: { + ctx: AutumnContext; + cusPrice: FullCustomerPrice; + cusProduct: FullCusProduct; + billingContext?: BillingContext; +}): StripeItemSpec | null => { + const price = cusPrice.price; + + let spec: StripeItemSpec | null = null; + + // 1. Fixed / one-off price (no entitlement needed) + if (isFixedPrice(price)) { + spec = fixedPriceToStripeItemSpec({ cusPrice, cusProduct }); + } else { + // Resolve cusEntWithCusProduct for usage-based prices + const cusEntWithCusProduct = cusPriceToCusEntWithCusProduct({ + cusProduct, + cusPrice, + cusEnts: cusProduct.customer_entitlements, + }); + + if (!cusEntWithCusProduct) { + return null; + } + + // 2. Prepaid (usage-in-advance) + if (isPrepaidPrice(price)) { + spec = prepaidToStripeItemSpec({ + ctx, + cusEntWithCusProduct, + }); + } + + // 3. Consumable (usage-in-arrear) + if (isConsumablePrice(price)) { + spec = consumableToStripeItemSpec({ + cusEntWithCusProduct, + }); + } + + // 4. Allocated (in-arrear prorated) + if (isAllocatedPrice(price)) { + spec = allocatedToStripeItemSpec({ cusEntWithCusProduct }); + } + } + + if (!spec) { + return null; + } + + // Attach metadata for correlating Stripe items back to Autumn prices + spec.metadata = { + autumn_price_id: price.id, + autumn_customer_price_id: cusPrice.id, + }; + + return spec; +}; diff --git a/server/src/internal/billing/v2/providers/stripe/utils/stripeItemSpec/cusPriceToStripeItemSpec/fixedPriceToStripeItemSpec.ts b/server/src/internal/billing/v2/providers/stripe/utils/stripeItemSpec/cusPriceToStripeItemSpec/fixedPriceToStripeItemSpec.ts new file mode 100644 index 000000000..0a2b3c045 --- /dev/null +++ b/server/src/internal/billing/v2/providers/stripe/utils/stripeItemSpec/cusPriceToStripeItemSpec/fixedPriceToStripeItemSpec.ts @@ -0,0 +1,34 @@ +import { + cusProductToProduct, + type FixedPriceConfig, + type FullCusProduct, + type FullCustomerPrice, + InternalError, + type StripeItemSpec, +} from "@autumn/shared"; + +/** Converts a fixed-cycle or one-off price to a StripeItemSpec. */ +export const fixedPriceToStripeItemSpec = ({ + cusPrice, + cusProduct, +}: { + cusPrice: FullCustomerPrice; + cusProduct: FullCusProduct; +}): StripeItemSpec => { + const price = cusPrice.price; + const product = cusProductToProduct({ cusProduct }); + const config = price.config as FixedPriceConfig; + + if (!config.stripe_price_id) { + throw new InternalError({ + message: `[fixedPriceToStripeItemSpec] Price ${price.id} has no config.stripe_price_id`, + }); + } + + return { + stripePriceId: config.stripe_price_id, + quantity: 1, + autumnPrice: price, + autumnProduct: product, + }; +}; diff --git a/server/src/internal/billing/v2/providers/stripe/utils/stripeItemSpec/cusPriceToStripeItemSpec/prepaidToStripeItemSpec.ts b/server/src/internal/billing/v2/providers/stripe/utils/stripeItemSpec/cusPriceToStripeItemSpec/prepaidToStripeItemSpec.ts new file mode 100644 index 000000000..5a60e4ba8 --- /dev/null +++ b/server/src/internal/billing/v2/providers/stripe/utils/stripeItemSpec/cusPriceToStripeItemSpec/prepaidToStripeItemSpec.ts @@ -0,0 +1,78 @@ +import { + cusEntToBillingObjects, + type FullCusEntWithFullCusProduct, + featureOptionUtils, + InternalError, + isPrepaidPrice, + priceUtils, + type StripeItemSpec, + type UsagePriceConfig, +} from "@autumn/shared"; +import { notNullish } from "@server/utils/genUtils"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { cusEntToInlineStripePrice } from "./cusEntToInlineStripePrice"; + +/** + * Converts a prepaid (usage-in-advance) price to a StripeItemSpec. + * For entity-scoped products, uses an inline price so each entity gets unique tiers. + * For non-entity-scoped, uses the stored stripe_prepaid_price_v2_id. + */ +export const prepaidToStripeItemSpec = ({ + ctx, + cusEntWithCusProduct, +}: { + ctx: AutumnContext; + cusEntWithCusProduct: FullCusEntWithFullCusProduct; +}): StripeItemSpec | null => { + const billing = cusEntToBillingObjects({ cusEnt: cusEntWithCusProduct }); + if (!billing) return null; + + const { cusProduct, price, product, entitlement, options } = billing; + + if (!isPrepaidPrice(price)) { + throw new InternalError({ + message: `[prepaidToStripeItemSpec] Price ${price.id} is not a prepaid price`, + }); + } + + const config = price.config as UsagePriceConfig; + const isEntityScoped = notNullish(cusProduct.internal_entity_id); + const isTieredOneOff = priceUtils.isTieredOneOff({ price, product }); + + if (isEntityScoped || isTieredOneOff) { + const inlinePrice = cusEntToInlineStripePrice({ + cusEnt: cusEntWithCusProduct, + org: ctx.org, + }); + + return { + stripeInlinePrice: inlinePrice, + quantity: 1, + autumnPrice: price, + autumnEntitlement: entitlement, + autumnProduct: product, + autumnCusEnt: cusEntWithCusProduct, + }; + } + + if (!config.stripe_prepaid_price_v2_id) { + throw new InternalError({ + message: `[prepaidToStripeItemSpec] Price ${price.id} has no stripe_prepaid_price_v2_id`, + }); + } + + const quantity = featureOptionUtils.convert.toV2StripeQuantity({ + featureOptions: options ?? undefined, + price, + entitlement, + }); + + return { + stripePriceId: config.stripe_prepaid_price_v2_id, + quantity, + autumnPrice: price, + autumnEntitlement: entitlement, + autumnProduct: product, + autumnCusEnt: cusEntWithCusProduct, + }; +}; diff --git a/server/src/internal/billing/v2/providers/stripe/utils/stripeItemSpec/customerProductsToRecurringStripeItemSpecs.ts b/server/src/internal/billing/v2/providers/stripe/utils/stripeItemSpec/customerProductsToRecurringStripeItemSpecs.ts index 4de1a30b3..935d18b86 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/stripeItemSpec/customerProductsToRecurringStripeItemSpecs.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/stripeItemSpec/customerProductsToRecurringStripeItemSpecs.ts @@ -1,15 +1,15 @@ -import type { BillingContext, StripeItemSpec } from "@autumn/shared"; +import type { + BillingContext, + FullCusProduct, + StripeItemSpec, +} from "@autumn/shared"; import { customerProductToStripeItemSpecs } from "@server/internal/billing/v2/providers/stripe/utils/subscriptionItems/customerProductToStripeItemSpecs"; -import type { FullCusProduct } from "@shared/models/cusProductModels/cusProductModels"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; /** - * Convert customer products to recurring stripe item specs. - * For metered prices (quantity undefined), we preserve undefined as Stripe requires. - * @param ctx - The context - * @param billingContext - The billing context - * @param customerProducts - The customer products - * @returns The recurring stripe item specs + * Converts customer products to recurring stripe item specs. + * Deduplicates stored-price items by stripePriceId (accumulating quantities). + * Entity-scoped inline items are never deduplicated — each entity gets its own item. */ export const customerProductsToRecurringStripeItemSpecs = ({ ctx, @@ -20,7 +20,8 @@ export const customerProductsToRecurringStripeItemSpecs = ({ billingContext: BillingContext; customerProducts: FullCusProduct[]; }): StripeItemSpec[] => { - const stripeItemSpecsByPriceId = new Map(); + const storedPriceSpecs = new Map(); + const inlineSpecs: StripeItemSpec[] = []; for (const customerProduct of customerProducts) { const { recurringItems } = customerProductToStripeItemSpecs({ @@ -29,31 +30,29 @@ export const customerProductsToRecurringStripeItemSpecs = ({ customerProduct, }); - for (const recurringItem of recurringItems) { - const existingItem = stripeItemSpecsByPriceId.get( - recurringItem.stripePriceId, - ); + for (const item of recurringItems) { + // Entity-scoped inline items are never deduplicated + if (item.stripeInlinePrice) { + inlineSpecs.push(item); + continue; + } - if (existingItem) { - // For metered prices, quantity is undefined and should stay undefined - if ( - recurringItem.quantity === undefined && - existingItem.quantity === undefined - ) { - // Both metered - keep undefined + const priceId = item.stripePriceId!; + const existing = storedPriceSpecs.get(priceId); + + if (existing) { + // Metered prices: quantity is undefined, keep undefined + if (item.quantity === undefined && existing.quantity === undefined) { + // Both metered — keep as-is } else { - // Licensed prices - accumulate quantity - existingItem.quantity = - (existingItem.quantity ?? 0) + (recurringItem.quantity ?? 0); + // Licensed prices — accumulate quantity + existing.quantity = (existing.quantity ?? 0) + (item.quantity ?? 0); } } else { - stripeItemSpecsByPriceId.set( - recurringItem.stripePriceId, - recurringItem, - ); + storedPriceSpecs.set(priceId, item); } } } - return Array.from(stripeItemSpecsByPriceId.values()); + return [...Array.from(storedPriceSpecs.values()), ...inlineSpecs]; }; diff --git a/server/src/internal/billing/v2/providers/stripe/utils/stripeItemSpec/stripeItemSpecToStripeParam.ts b/server/src/internal/billing/v2/providers/stripe/utils/stripeItemSpec/stripeItemSpecToStripeParam.ts new file mode 100644 index 000000000..7b34d99e6 --- /dev/null +++ b/server/src/internal/billing/v2/providers/stripe/utils/stripeItemSpec/stripeItemSpecToStripeParam.ts @@ -0,0 +1,89 @@ +import { + InternalError, + type StripeInlinePrice, + type StripeItemSpec, +} from "@autumn/shared"; +import type Stripe from "stripe"; + +type StoredPriceParam = { price: string }; +type RecurringInlinePriceParam = { + price_data: Stripe.SubscriptionCreateParams.Item["price_data"]; +}; + +/** + * Returns the price param for a StripeItemSpec — either a stored price ID or inline price_data. + * For inline prices, asserts that `recurring` is present (one-off items should not reach this path). + */ +const toRecurringPriceParam = ({ + spec, +}: { + spec: StripeItemSpec; +}): StoredPriceParam | RecurringInlinePriceParam => { + if (spec.stripeInlinePrice) { + if (!spec.stripeInlinePrice.recurring) { + throw new InternalError({ + message: + "stripeItemSpecToSubscriptionItem called with non-recurring inline price — one-off items should use the invoice path", + code: "inline_price_missing_recurring", + }); + } + return { + price_data: { + ...spec.stripeInlinePrice, + recurring: spec.stripeInlinePrice.recurring, + }, + }; + } + return { price: spec.stripePriceId! }; +}; + +/** Converts a StripeItemSpec to a Stripe subscription item param (create or update). + * Only call with recurring specs — one-off items use a separate invoice path. */ +export const stripeItemSpecToSubscriptionItem = ({ + spec, +}: { + spec: StripeItemSpec; +}): Stripe.SubscriptionCreateParams.Item => { + return { + ...toRecurringPriceParam({ spec }), + ...(spec.quantity !== undefined && { quantity: spec.quantity }), + ...(spec.metadata && { metadata: spec.metadata }), + }; +}; + +/** Returns a price param without recurring validation — for checkout line items. */ +const toPriceParam = ({ + spec, +}: { + spec: StripeItemSpec; +}): StoredPriceParam | { price_data: StripeInlinePrice } => { + if (spec.stripeInlinePrice) { + return { price_data: spec.stripeInlinePrice }; + } + return { price: spec.stripePriceId! }; +}; + +/** Converts a StripeItemSpec to a Stripe checkout session line item. */ +export const stripeItemSpecToCheckoutLineItem = ({ + spec, +}: { + spec: StripeItemSpec; +}): Stripe.Checkout.SessionCreateParams.LineItem => { + return { + ...toPriceParam({ spec }), + quantity: spec.quantity, + }; +}; + +/** Converts a StripeItemSpec to a Stripe subscription schedule phase item. */ +export const stripeItemSpecToPhaseItem = ({ + spec, +}: { + spec: StripeItemSpec; +}): Stripe.SubscriptionScheduleUpdateParams.Phase.Item => { + return { + ...toRecurringPriceParam({ spec }), + ...(spec.quantity !== undefined && { quantity: spec.quantity }), + ...(spec.metadata && { metadata: spec.metadata }), + } as Stripe.SubscriptionScheduleUpdateParams.Phase.Item; +}; diff --git a/server/src/internal/billing/v2/providers/stripe/utils/subscriptionItems/buildStripeSubscriptionItemsUpdate.ts b/server/src/internal/billing/v2/providers/stripe/utils/subscriptionItems/buildStripeSubscriptionItemsUpdate.ts index 14b544bce..7f3a2b7b1 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/subscriptionItems/buildStripeSubscriptionItemsUpdate.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/subscriptionItems/buildStripeSubscriptionItemsUpdate.ts @@ -9,14 +9,12 @@ import { stripeSubscriptionItemToStripePriceId } from "@/external/stripe/subscri import { findStripeSubscriptionItemByStripePriceId } from "@/external/stripe/subscriptions/subscriptionItems/utils/findStripeSubscriptionItemUtils"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { customerProductsToRecurringStripeItemSpecs } from "@/internal/billing/v2/providers/stripe/utils/stripeItemSpec/customerProductsToRecurringStripeItemSpecs"; +import { stripeItemSpecToSubscriptionItem } from "@/internal/billing/v2/providers/stripe/utils/stripeItemSpec/stripeItemSpecToStripeParam"; import { findStripeItemSpecByStripePriceId } from "./findStripeItemSpec"; /** - * Convert stripe item specs to stripe subscription update params items. - * For metered prices (quantity undefined), we don't include quantity as Stripe requires. - * @param billingContext - The billing context - * @param stripeItemSpecs - The stripe item specs - * @returns The subscription item update params + * Diffs desired stripe item specs against current subscription items. + * Handles both stored-price and entity-scoped inline-price items. */ const stripeItemSpecsToSubItemsUpdate = ({ billingContext, @@ -29,48 +27,51 @@ const stripeItemSpecsToSubItemsUpdate = ({ const currentSubscriptionItems = stripeSubscription?.items.data ?? []; const subItemsUpdate: Stripe.SubscriptionUpdateParams.Item[] = []; - for (const stripeItemSpec of stripeItemSpecs) { + + for (const spec of stripeItemSpecs) { + // Inline prices are always new items (no existing sub item to match) + if (spec.stripeInlinePrice) { + subItemsUpdate.push(stripeItemSpecToSubscriptionItem({ spec })); + continue; + } + + // Stored price — check for existing subscription item + if (!spec.stripePriceId) continue; + const existingItem = findStripeSubscriptionItemByStripePriceId({ - stripePriceId: stripeItemSpec.stripePriceId, + stripePriceId: spec.stripePriceId, stripeSubscriptionItems: currentSubscriptionItems, }); const shouldUpdateItem = - existingItem && existingItem.quantity !== stripeItemSpec.quantity; + existingItem && existingItem.quantity !== spec.quantity; const shouldCreateItem = !existingItem; if (shouldUpdateItem) { - // For metered prices, don't include quantity - if (stripeItemSpec.quantity === undefined) { - subItemsUpdate.push({ id: existingItem.id }); - } else { - subItemsUpdate.push({ - id: existingItem.id, - quantity: stripeItemSpec.quantity, - }); - } + subItemsUpdate.push({ + id: existingItem.id, + ...(spec.quantity !== undefined && { quantity: spec.quantity }), + ...(spec.metadata && { metadata: spec.metadata }), + }); } + if (shouldCreateItem) { - // For metered prices, don't include quantity - if (stripeItemSpec.quantity === undefined) { - subItemsUpdate.push({ price: stripeItemSpec.stripePriceId }); - } else { - subItemsUpdate.push({ - price: stripeItemSpec.stripePriceId, - quantity: stripeItemSpec.quantity, - }); - } + subItemsUpdate.push({ + price: spec.stripePriceId, + ...(spec.quantity !== undefined && { quantity: spec.quantity }), + ...(spec.metadata && { metadata: spec.metadata }), + }); } } + // Remove subscription items that are no longer in the desired specs for (const subItem of currentSubscriptionItems) { - const stripeItemSpec = findStripeItemSpecByStripePriceId({ + const matchingSpec = findStripeItemSpecByStripePriceId({ stripePriceId: stripeSubscriptionItemToStripePriceId(subItem), stripeItemSpecs, }); - const shouldRemoveItem = !stripeItemSpec; - if (shouldRemoveItem) { + if (!matchingSpec) { subItemsUpdate.push({ id: subItem.id, deleted: true }); } } @@ -98,14 +99,14 @@ export const buildStripeSubscriptionItemsUpdate = ({ customerProducts: relatedCustomerProducts, }); - // 3. Get recurring subscription item array (doesn't include one off items) + // 3. Get recurring subscription item array (doesn't include one-off items) const recurringStripeItemSpecs = customerProductsToRecurringStripeItemSpecs({ ctx, billingContext, customerProducts: activeCustomerProducts, }); - // 5. Diff it with the current subscription items + // 4. Diff against current subscription items return stripeItemSpecsToSubItemsUpdate({ billingContext, stripeItemSpecs: recurringStripeItemSpecs, diff --git a/server/src/internal/billing/v2/providers/stripe/utils/subscriptionItems/customerProductToStripeItemSpecs.ts b/server/src/internal/billing/v2/providers/stripe/utils/subscriptionItems/customerProductToStripeItemSpecs.ts index f11439b0d..7518a0d40 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/subscriptionItems/customerProductToStripeItemSpecs.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/subscriptionItems/customerProductToStripeItemSpecs.ts @@ -1,30 +1,15 @@ -import type { BillingContext } from "@autumn/shared"; import { - addCusProductToCusEnt, - BillingVersion, - cusPriceToCusEnt, - cusProductToProduct, - entToOptions, - type FeatureOptions, + type BillingContext, type FullCusProduct, - formatPrice, - InternalError, - isAllocatedCustomerEntitlement, isOneOffPrice, - priceUtils, type StripeItemSpec, } from "@autumn/shared"; -import { cusEntToInvoiceUsage } from "@shared/utils/cusEntUtils/overageUtils/cusEntToInvoiceUsage"; -import { priceToStripeItem } from "@/external/stripe/priceToStripeItem/priceToStripeItem"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { cusPriceToStripeItemSpec } from "@/internal/billing/v2/providers/stripe/utils/stripeItemSpec/cusPriceToStripeItemSpec/cusPriceToStripeItemSpec"; /** - * Convert a customer product to stripe item specs - * A stripe item spec is an internal intermediate type containing the stripe price id and quantity of the item. - * @param ctx - The context - * @param customerProduct - The customer product - * @param billingContext - The billing context - * @returns The stripe item specs + * Converts a customer product to stripe item specs (recurring + one-off). + * Delegates each cusPrice to cusPriceToStripeItemSpec. */ export const customerProductToStripeItemSpecs = ({ ctx, @@ -38,86 +23,23 @@ export const customerProductToStripeItemSpecs = ({ recurringItems: StripeItemSpec[]; oneOffItems: StripeItemSpec[]; } => { - const { org } = ctx; - const product = cusProductToProduct({ cusProduct: customerProduct }); - - const cusPrices = customerProduct.customer_prices; - const cusEnts = customerProduct.customer_entitlements; - - const fromVercel = billingContext?.paymentMethod?.type === "custom"; - const recurringItems: StripeItemSpec[] = []; const oneOffItems: StripeItemSpec[] = []; - for (const cusPrice of cusPrices) { - const price = cusPrice.price; - const cusEnt = cusPriceToCusEnt({ cusPrice, cusEnts }); - const ent = cusEnt?.entitlement; - - let options: FeatureOptions | undefined; - let existingUsage: number | undefined; - const cusEntWithCusProduct = cusEnt - ? addCusProductToCusEnt({ - cusEnt, - cusProduct: customerProduct, - }) - : undefined; - - if (cusEnt) { - const ent = cusEnt.entitlement; - options = entToOptions({ ent, options: customerProduct.options ?? [] }); - - if ( - cusEntWithCusProduct && - isAllocatedCustomerEntitlement(cusEntWithCusProduct) - ) { - existingUsage = cusEntToInvoiceUsage({ cusEnt: cusEntWithCusProduct }); - } - } - - const stripeItem = priceToStripeItem({ - price, - product, - org, - options, - isCheckout: false, // TODO: Add this back in? - relatedEnt: ent, - existingUsage, - // withEntity: notNullish(customerProduct.internal_entity_id), - withEntity: false, - apiVersion: ctx.apiVersion.value, - fromVercel, - isPrepaidPriceV2: billingContext?.billingVersion === BillingVersion.V2, + for (const cusPrice of customerProduct.customer_prices) { + const spec = cusPriceToStripeItemSpec({ + ctx, + cusPrice, + cusProduct: customerProduct, + billingContext, }); - if (!stripeItem) continue; + if (!spec) continue; - const { lineItem } = stripeItem; - - if (!lineItem.price && !priceUtils.isTieredOneOff({ price, product })) { - throw new InternalError({ - message: `Autumn price ${formatPrice({ price })} has no stripe price id`, - }); - } - - if (isOneOffPrice(price)) { - oneOffItems.push({ - stripePriceId: lineItem.price ?? "", - quantity: lineItem?.quantity, - autumnPrice: price, - autumnEntitlement: ent, - autumnProduct: product, - autumnCusEnt: cusEntWithCusProduct, - }); + if (isOneOffPrice(cusPrice.price)) { + oneOffItems.push(spec); } else { - recurringItems.push({ - stripePriceId: lineItem.price ?? "", - quantity: lineItem?.quantity, - autumnPrice: price, - autumnEntitlement: ent, - autumnProduct: product, - autumnCusEnt: cusEntWithCusProduct, - }); + recurringItems.push(spec); } } diff --git a/server/src/internal/billing/v2/providers/stripe/utils/subscriptionSchedules/buildStripePhasesUpdate.ts b/server/src/internal/billing/v2/providers/stripe/utils/subscriptionSchedules/buildStripePhasesUpdate.ts index 6cd2fb262..894978533 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/subscriptionSchedules/buildStripePhasesUpdate.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/subscriptionSchedules/buildStripePhasesUpdate.ts @@ -7,6 +7,7 @@ import { import type Stripe from "stripe"; import { logPhase } from "@/external/stripe/subscriptionSchedules/utils/logStripeSchedulePhaseUtils"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { stripeItemSpecToPhaseItem } from "@/internal/billing/v2/providers/stripe/utils/stripeItemSpec/stripeItemSpecToStripeParam"; import { customerProductToStripeItemSpecs } from "@/internal/billing/v2/providers/stripe/utils/subscriptionItems/customerProductToStripeItemSpecs"; import { isCustomerProductActiveDuringPeriod } from "@/internal/billing/v2/providers/stripe/utils/subscriptionSchedules/isCustomerProductActiveAtEpochMs"; import { buildTransitionPoints } from "./buildTransitionPoints"; @@ -28,8 +29,8 @@ const normalizeCustomerProductTimestamps = ( /** * Converts customer products to Stripe schedule phase items. - * Merges quantities for duplicate price IDs. - * For metered prices (quantity undefined), we don't set quantity as Stripe requires. + * Merges quantities for duplicate stored price IDs. + * Entity-scoped inline items are kept separate (never merged). */ const customerProductsToPhaseItems = ({ ctx, @@ -40,8 +41,8 @@ const customerProductsToPhaseItems = ({ billingContext: BillingContext; customerProducts: FullCusProduct[]; }): Stripe.SubscriptionScheduleUpdateParams.Phase.Item[] => { - // Track stripePriceId -> quantity (undefined means metered/no quantity) - const itemMap = new Map(); + const storedPriceMap = new Map(); + const inlineItems: Stripe.SubscriptionScheduleUpdateParams.Phase.Item[] = []; for (const customerProduct of customerProducts) { const { recurringItems } = customerProductToStripeItemSpecs({ @@ -51,26 +52,34 @@ const customerProductsToPhaseItems = ({ }); for (const item of recurringItems) { - // For metered prices, quantity is undefined and should stay undefined + // Entity-scoped inline prices — never merge + if (item.stripeInlinePrice) { + inlineItems.push(stripeItemSpecToPhaseItem({ spec: item })); + continue; + } + + const priceId = item.stripePriceId!; if (item.quantity === undefined) { - // Metered price - don't set quantity - if (!itemMap.has(item.stripePriceId)) { - itemMap.set(item.stripePriceId, undefined); + if (!storedPriceMap.has(priceId)) { + storedPriceMap.set(priceId, undefined); } } else { - // Licensed price - accumulate quantity - const currentQuantity = itemMap.get(item.stripePriceId) ?? 0; - itemMap.set(item.stripePriceId, currentQuantity + item.quantity); + const current = storedPriceMap.get(priceId) ?? 0; + storedPriceMap.set(priceId, current + item.quantity); } } } - return Array.from(itemMap.entries()).map(([price, quantity]) => { - if (quantity === undefined) { - return { price }; - } - return { price, quantity }; - }); + const storedItems = Array.from(storedPriceMap.entries()).map( + ([price, quantity]) => { + if (quantity === undefined) { + return { price }; + } + return { price, quantity }; + }, + ); + + return [...storedItems, ...inlineItems]; }; /** diff --git a/server/src/internal/billing/v2/providers/stripe/utils/subscriptions/buildStripeSubscriptionCreateAction.ts b/server/src/internal/billing/v2/providers/stripe/utils/subscriptions/buildStripeSubscriptionCreateAction.ts index 7aa07f23f..690190deb 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/subscriptions/buildStripeSubscriptionCreateAction.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/subscriptions/buildStripeSubscriptionCreateAction.ts @@ -28,8 +28,11 @@ export const buildStripeSubscriptionCreateAction = ({ const stripeSubscriptionCreateParams: Stripe.SubscriptionCreateParams = { customer: stripeCustomer.id, items: subItemsUpdate.map((item) => ({ - price: item.price, + ...(item.price_data + ? { price_data: item.price_data } + : { price: item.price }), quantity: item.quantity, + ...(item.metadata && { metadata: item.metadata }), })), billing_mode: { type: "flexible" }, diff --git a/server/src/internal/customers/attach/mergeUtils/subToNewSchedule.ts b/server/src/internal/customers/attach/mergeUtils/subToNewSchedule.ts index a2ec3244f..bb8ceab72 100644 --- a/server/src/internal/customers/attach/mergeUtils/subToNewSchedule.ts +++ b/server/src/internal/customers/attach/mergeUtils/subToNewSchedule.ts @@ -78,10 +78,27 @@ export const subToNewSchedule = async ({ await stripeCli.subscriptionSchedules.update(newScheduleId, { phases: [ { - items: newSchedule.phases[0].items.map((item) => ({ - price: item.price as string, - quantity: item.quantity, - })), + items: newSchedule.phases[0].items.map((item) => { + const priceId = item.price as string; + + // Re-apply metadata from subscription items since + // Stripe's from_subscription doesn't copy item metadata + const subItem = sub.items.data.find( + (si) => si.price.id === priceId, + ); + const metadata = + subItem?.metadata && Object.keys(subItem.metadata).length > 0 + ? subItem.metadata + : item.metadata && Object.keys(item.metadata).length > 0 + ? item.metadata + : undefined; + + return { + price: priceId, + quantity: item.quantity, + ...(metadata && { metadata }), + }; + }), start_date: newSchedule.phases[0].start_date, end_date: endOfBillingPeriod, trial_end: sub?.trial_end || undefined, diff --git a/server/src/utils/auth.ts b/server/src/utils/auth.ts index 0b84c5dc2..77998ea20 100644 --- a/server/src/utils/auth.ts +++ b/server/src/utils/auth.ts @@ -75,6 +75,11 @@ export const auth = betterAuth({ for (let i = 0; i <= 10; i++) { origins.push(`http://localhost:${3000 + i}`); } + + // Support multi-worktree dev with offset ports (e.g. localhost:3100) + if (process.env.CLIENT_URL) { + origins.push(process.env.CLIENT_URL); + } } return origins; diff --git a/server/src/utils/corsOrigins.ts b/server/src/utils/corsOrigins.ts new file mode 100644 index 000000000..0413b47b2 --- /dev/null +++ b/server/src/utils/corsOrigins.ts @@ -0,0 +1,29 @@ +export const ALLOWED_ORIGINS = [ + "http://localhost:3000", + "http://localhost:3001", + "http://localhost:3002", + "http://localhost:3003", + "http://localhost:3004", + "http://localhost:3005", + "http://localhost:3006", + "http://localhost:3007", + "http://localhost:5173", + "http://localhost:5174", + "https://app.useautumn.com", + "https://staging.useautumn.com", + "https://dev.useautumn.com", + "https://api.staging.useautumn.com", + "https://localhost:8080", +]; + +/** Allow any localhost origin in dev for multi-worktree support */ +export const isAllowedOrigin = (origin: string): string | undefined => { + if (ALLOWED_ORIGINS.includes(origin)) return origin; + if ( + process.env.NODE_ENV !== "production" && + /^https?:\/\/localhost:\d+$/.test(origin) + ) { + return origin; + } + return undefined; +}; diff --git a/server/tests/_groups/domains/billing/billingV2.ts b/server/tests/_groups/domains/billing/billingV2.ts index e8b4b9c34..aaf6568d8 100644 --- a/server/tests/_groups/domains/billing/billingV2.ts +++ b/server/tests/_groups/domains/billing/billingV2.ts @@ -4,5 +4,11 @@ export const billingV2: TestGroup = { name: "billing-v2", description: "V2 billing tests: migrations, attach, update-subscription", tier: "domain", - paths: ["migrations", "billing/attach", "billing/update-subscription"], + paths: [ + "migrations", + "billing/attach", + "billing/update-subscription", + "billing/multi-attach", + "billing/setup-payment", + ], }; diff --git a/server/tests/_groups/domains/billing/prepaidVolume.ts b/server/tests/_groups/domains/billing/prepaidVolume.ts deleted file mode 100644 index b4d330ae3..000000000 --- a/server/tests/_groups/domains/billing/prepaidVolume.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { TestGroup } from "../../types"; - -export const prepaidVolume: TestGroup = { - name: "prepaid-volume", - description: "Prepaid volume-based tier pricing tests", - tier: "domain", - paths: [ - "unit/billing/invoicing/line-item-utils/volume-tiers-to-line-amount.test.ts", - "unit/billing/invoicing/line-item-utils/tiers-to-line-amount.test.ts", - "integration/billing/attach/new-plan/attach-prepaid-volume.test.ts", - "integration/billing/attach/new-plan/attach-prepaid-volume-entities.test.ts", - "integration/billing/attach/new-plan/new-prepaid.test.ts", - "integration/billing/attach/immediate-switch/immediate-switch-prepaid-volume.test.ts", - "integration/billing/attach/immediate-switch/immediate-switch-entities-prepaid-volume.test.ts", - "integration/billing/attach/scheduled-switch/scheduled-switch-prepaid-volume.test.ts", - "integration/billing/attach/edge-cases/v1-v2-compatibility/prepaid/attach-prepaid-volume-edge-cases.test.ts", - "integration/billing/attach/checkout/stripe-checkout/stripe-checkout-prepaid.test.ts", - "integration/billing/update-subscription/update-quantity/volume-tiers-update-quantity.test.ts", - "integration/billing/update-subscription/custom-plan/update-paid-tier-behavior.test.ts", - "integration/billing/legacy/attach/new/legacy-new-volume.test.ts", - "integration/crud/plans/create-plan-advanced.test.ts", - "integration/crud/plans/get-plan-advanced.test.ts", - "integration/balances/check/check-prepaid.test.ts", - "integration/balances/check/check-balance-price.test.ts", - "integration/billing/attach/v2-params/v2-customize.test.ts", - ], -}; diff --git a/server/tests/_groups/index.ts b/server/tests/_groups/index.ts index 05d0bf488..31686309c 100644 --- a/server/tests/_groups/index.ts +++ b/server/tests/_groups/index.ts @@ -15,11 +15,11 @@ import { updateBalance } from "./domains/balances/updateBalance"; import { billing } from "./domains/billing/billing"; import { billingV1 } from "./domains/billing/billingV1"; import { billingV2 } from "./domains/billing/billingV2"; -import { prepaidVolume } from "./domains/billing/prepaidVolume"; import { crud } from "./domains/crud"; import { misc } from "./domains/misc"; import { webhooks } from "./domains/webhooks"; import { suites } from "./suites"; +import { temp } from "./temp"; import type { TestGroup, TestSuite } from "./types"; export type { TestGroup, TestSuite, TestTier } from "./types"; @@ -39,7 +39,7 @@ const allGroups: TestGroup[] = [ billing, billingV1, billingV2, - prepaidVolume, + temp, crud, webhooks, advanced, diff --git a/server/tests/_groups/temp.ts b/server/tests/_groups/temp.ts new file mode 100644 index 000000000..4c1cce4ef --- /dev/null +++ b/server/tests/_groups/temp.ts @@ -0,0 +1,15 @@ +import type { TestGroup } from "./types"; + +export const temp: TestGroup = { + name: "temp", + description: "Entity prepaid test suite", + tier: "domain", + paths: [ + "tests/integration/billing/attach/scheduled-switch/scheduled-switch-prepaid-entities.test.ts", + "tests/integration/billing/attach/new-plan/prepaid/attach-prepaid-entities.test.ts", + "tests/integration/billing/attach/new-plan/prepaid/attach-prepaid-volume-entities.test.ts", + "tests/integration/billing/attach/immediate-switch/immediate-switch-entities-prepaid-volume.test.ts", + "tests/integration/billing/update-subscription/update-quantity/multi-entity-quantity.test.ts", + "tests/integration/billing/update-subscription/update-quantity/multi-entity-quantity-proration.test.ts", + ], +}; diff --git a/server/tests/_temp/temp.test.ts b/server/tests/_temp/temp.test.ts index 13f267c6d..6a827180b 100644 --- a/server/tests/_temp/temp.test.ts +++ b/server/tests/_temp/temp.test.ts @@ -1,46 +1,49 @@ import { test } from "bun:test"; -import { - FreeTrialDuration, - type UpdateSubscriptionV1Params, -} from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features"; import { items } from "@tests/utils/fixtures/items"; import { products } from "@tests/utils/fixtures/products"; import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; import chalk from "chalk"; -const customerId = "temp-test"; +const BILLING_UNITS = 100; +const PRICE_PER_UNIT = 10; + +const INCLUDED_USAGE = 100; test.concurrent(`${chalk.yellowBright("temp: rest update then rpc inverse update returns product to baseline")}`, async () => { - const proProd = products.pro({ - id: "pro", - items: [items.monthlyCredits({ includedUsage: 100 })], - }); - const customerId = "temp"; + const customerId = "prepaid-ent-two-included"; + const quantity1 = 300; - const { autumnV1, autumnV2_1 } = await initScenario({ + const prepaidItem = items.prepaidMessages({ + includedUsage: INCLUDED_USAGE, + billingUnits: BILLING_UNITS, + price: PRICE_PER_UNIT, + }); + + const pro = products.base({ + id: "base-prepaid-ent-inc", + items: [prepaidItem], + }); + + const { autumnV1, entities } = await initScenario({ customerId, - actions: [], setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [proProd] }), + s.customer({}), + s.products({ list: [pro] }), + s.entities({ count: 2, featureId: TestFeature.Users }), ], + actions: [], }); - const result = await autumnV1.billing.attach({ + // Attach to entity 1 + const attach1 = await autumnV1.billing.attach({ customer_id: customerId, - product_id: proProd.id, - free_trial: { - length: 14, - duration: FreeTrialDuration.Day, - }, + product_id: pro.id, + entity_id: entities[0].id, + options: [{ feature_id: TestFeature.Messages, quantity: quantity1 }], + redirect_mode: "if_required", }); - console.log(result); - - const updateResult = - await autumnV2_1.subscriptions.update({ - customer_id: customerId, - plan_id: proProd.id, - }); - console.log(updateResult); + console.log("attach1", attach1); + return; }); diff --git a/server/tests/integration/billing/attach/checkout/stripe-checkout/prepaid/stripe-checkout-prepaid-entities.test.ts b/server/tests/integration/billing/attach/checkout/stripe-checkout/prepaid/stripe-checkout-prepaid-entities.test.ts new file mode 100644 index 000000000..5315fa35c --- /dev/null +++ b/server/tests/integration/billing/attach/checkout/stripe-checkout/prepaid/stripe-checkout-prepaid-entities.test.ts @@ -0,0 +1,170 @@ +import { expect, test } from "bun:test"; +import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { expectCustomerProductCorrect } from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect"; +import { TestFeature } from "@tests/setup/v2Features"; +import { completeStripeCheckoutFormV2 } from "@tests/utils/browserPool"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; + +const BILLING_UNITS = 100; +const PRICE_PER_UNIT = 10; + +const INCLUDED_USAGE = 100; + +test.concurrent(`${chalk.yellowBright("attach: stripe checkout prepaid entities")}`, async () => { + const customerId = "prepaid-ent-two-included"; + const quantity1 = 300; + + const prepaidItem = items.prepaidMessages({ + includedUsage: INCLUDED_USAGE, + billingUnits: BILLING_UNITS, + price: PRICE_PER_UNIT, + }); + + const pro = products.base({ + id: "base-prepaid-ent-inc", + items: [prepaidItem], + }); + + const { autumnV1, entities, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({}), + s.products({ list: [pro] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [], + }); + + const params = { + customer_id: customerId, + product_id: pro.id, + entity_id: entities[0].id, + options: [{ feature_id: TestFeature.Messages, quantity: quantity1 }], + redirect_mode: "if_required", + }; + + const preview = await autumnV1.billing.previewAttach(params); + expect(preview.total).toBe(20); + + // Attach to entity 1 + const res = await autumnV1.billing.attach(params); + expect(res.payment_url).toBeDefined(); + expect(res.payment_url).toContain("checkout.stripe.com"); + + // Complete checkout + + await completeStripeCheckoutFormV2({ url: res.payment_url }); + + const customerAfter = await autumnV1.customers.get(customerId); + await expectCustomerProductCorrect({ + customerId, + customer: customerAfter, + productId: pro.id, + state: "active", + }); + + expectCustomerFeatureCorrect({ + customerId, + customer: customerAfter, + featureId: TestFeature.Messages, + includedUsage: quantity1, + balance: quantity1, + usage: 0, + }); + + await expectCustomerInvoiceCorrect({ + customerId, + customer: customerAfter, + count: 1, + latestTotal: 20, + latestStatus: "paid", + }); + + await expectStripeSubscriptionCorrect({ + ctx, + customerId, + }); +}); +test.concurrent(`${chalk.yellowBright("attach: stripe checkout prepaid volume entities")}`, async () => { + const customerId = "prepaid-ent-two-volume"; + const quantity1 = 600; + + const prepaidItem = items.volumePrepaidMessages({ + includedUsage: 100, + billingUnits: 1, + tiers: [ + { to: 500, amount: 0, flat_amount: 30 }, + { to: "inf" as const, amount: 0, flat_amount: 50 }, + ], + }); + + const pro = products.base({ + id: "base-prepaid-ent-vol", + items: [prepaidItem], + }); + + const { autumnV1, entities, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({}), + s.products({ list: [pro] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [], + }); + + const params = { + customer_id: customerId, + product_id: pro.id, + entity_id: entities[0].id, + options: [{ feature_id: TestFeature.Messages, quantity: quantity1 }], + redirect_mode: "if_required", + }; + + const preview = await autumnV1.billing.previewAttach(params); + expect(preview.total).toBe(30); // flat amount + + // Attach to entity 1 + const res = await autumnV1.billing.attach(params); + expect(res.payment_url).toBeDefined(); + expect(res.payment_url).toContain("checkout.stripe.com"); + + // Complete checkout + + await completeStripeCheckoutFormV2({ url: res.payment_url }); + + const customerAfter = await autumnV1.customers.get(customerId); + await expectCustomerProductCorrect({ + customerId, + customer: customerAfter, + productId: pro.id, + state: "active", + }); + + expectCustomerFeatureCorrect({ + customerId, + customer: customerAfter, + featureId: TestFeature.Messages, + includedUsage: quantity1, + balance: quantity1, + usage: 0, + }); + + await expectCustomerInvoiceCorrect({ + customerId, + customer: customerAfter, + count: 1, + latestTotal: 30, // flat amount + latestStatus: "paid", + }); + + await expectStripeSubscriptionCorrect({ + ctx, + customerId, + }); +}); diff --git a/server/tests/integration/billing/attach/immediate-switch/entities/immediate-switch-entities-prepaid-volume.test.ts b/server/tests/integration/billing/attach/immediate-switch/entities/immediate-switch-entities-prepaid-volume.test.ts index bb1874d65..95b3a46e3 100644 --- a/server/tests/integration/billing/attach/immediate-switch/entities/immediate-switch-entities-prepaid-volume.test.ts +++ b/server/tests/integration/billing/attach/immediate-switch/entities/immediate-switch-entities-prepaid-volume.test.ts @@ -27,7 +27,7 @@ import { expectCustomerProducts, expectProductActive, } from "@tests/integration/billing/utils/expectCustomerProductCorrect"; -import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect"; +import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect"; import { TestFeature } from "@tests/setup/v2Features"; import { items } from "@tests/utils/fixtures/items"; import { products } from "@tests/utils/fixtures/products"; @@ -240,12 +240,7 @@ test.concurrent(`${chalk.yellowBright("attach-prepaid-volume-entities: graduated count: 4, }); - await expectSubToBeCorrect({ - db: ctx.db, - customerId, - org: ctx.org, - env: ctx.env, - }); + await expectStripeSubscriptionCorrect({ ctx, customerId }); }); // ═══════════════════════════════════════════════════════════════════════════════ @@ -426,10 +421,5 @@ test.concurrent(`${chalk.yellowBright("attach-prepaid-volume-entities: graduated count: 4, }); - await expectSubToBeCorrect({ - db: ctx.db, - customerId, - org: ctx.org, - env: ctx.env, - }); + await expectStripeSubscriptionCorrect({ ctx, customerId }); }); diff --git a/server/tests/integration/billing/attach/new-plan/prepaid/attach-oneoff-prepaid-tiers.test.ts b/server/tests/integration/billing/attach/new-plan/prepaid/attach-oneoff-prepaid-tiers.test.ts new file mode 100644 index 000000000..d715c5daf --- /dev/null +++ b/server/tests/integration/billing/attach/new-plan/prepaid/attach-oneoff-prepaid-tiers.test.ts @@ -0,0 +1,219 @@ +/** + * Attach One-Off Prepaid with Tiered Pricing (Attach V2) + * + * Tests for attaching one-off products with included usage and tiered pricing + * via the direct attach flow (customer already has payment method). + * + * Test 1: Customer-level attach with tiered one-off + * - Included usage: 100 units (1 free pack) + * - Tiered pricing: 0-500 @ $10/pack, 501+ @ $5/pack + * - Request 800 units → 1 free + 5×$10 + 2×$5 = $60 prepaid + * - Total: $10 base + $60 prepaid = $70 + * + * Test 2: Entity-level attach with tiered one-off + * - Same tiered pricing, attached to two entities with different quantities + * - Entity 1: 300 units → 1 free + 2×$10 = $20 prepaid + * - Entity 2: 800 units → 1 free + 5×$10 + 2×$5 = $60 prepaid + */ + +import { expect, test } from "bun:test"; +import type { ApiCustomerV3, ApiEntityV0 } from "@autumn/shared"; +import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { expectProductActive } from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; + +const BILLING_UNITS = 100; +const INCLUDED_USAGE = 100; +const BASE_PRICE = 10; +const TIERS = [ + { to: 500 as const, amount: 10 }, + { to: "inf" as const, amount: 5 }, +]; + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 1: Customer-level one-off with tiered pricing +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("oneoff-prepaid-tiers: customer-level tiered one-off")}`, async () => { + const customerId = "oneoff-tiers-customer"; + const quantity = 800; + + // 800 total = 8 packs: 1 free (includedUsage) + 7 paid + // Tier 1 (0-500): 5 paid packs × $10 = $50 + // Tier 2 (501+): 2 paid packs × $5 = $10 + // Prepaid total: $60 + const expectedPrepaidCost = 5 * 10 + 2 * 5; + const expectedTotal = BASE_PRICE + expectedPrepaidCost; + + const tieredOneOffItem = items.tieredOneOffMessages({ + includedUsage: INCLUDED_USAGE, + billingUnits: BILLING_UNITS, + tiers: TIERS, + }); + + const oneOff = products.oneOff({ + id: "one-off-tiered-cus", + items: [tieredOneOffItem], + }); + + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [oneOff] }), + ], + actions: [], + }); + + const preview = await autumnV1.billing.previewAttach({ + customer_id: customerId, + product_id: oneOff.id, + options: [{ feature_id: TestFeature.Messages, quantity }], + }); + expect(preview.total).toBe(expectedTotal); + + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: oneOff.id, + options: [{ feature_id: TestFeature.Messages, quantity }], + redirect_mode: "if_required", + }); + + const customer = await autumnV1.customers.get(customerId); + + await expectProductActive({ customer, productId: oneOff.id }); + + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + balance: quantity, + usage: 0, + }); + + await expectCustomerInvoiceCorrect({ + customer, + count: 1, + latestTotal: expectedTotal, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 2: Entity-level one-off with tiered pricing +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("oneoff-prepaid-tiers: entity-level tiered one-off")}`, async () => { + const customerId = "oneoff-tiers-entity"; + const quantity1 = 300; + const quantity2 = 800; + + // Entity 1: 300 total = 3 packs: 1 free + 2 paid + // All 2 paid packs in tier 1 (0-500): 2 × $10 = $20 + const expectedPrepaidCost1 = 2 * 10; + const expectedTotal1 = BASE_PRICE + expectedPrepaidCost1; + + // Entity 2: 800 total = 8 packs: 1 free + 7 paid + // Tier 1 (0-500): 5 paid packs × $10 = $50 + // Tier 2 (501+): 2 paid packs × $5 = $10 + const expectedPrepaidCost2 = 5 * 10 + 2 * 5; + const expectedTotal2 = BASE_PRICE + expectedPrepaidCost2; + + const tieredOneOffItem = items.tieredOneOffMessages({ + includedUsage: INCLUDED_USAGE, + billingUnits: BILLING_UNITS, + tiers: TIERS, + }); + + const oneOff = products.oneOff({ + id: "one-off-tiered-ent", + items: [tieredOneOffItem], + }); + + const { autumnV1, entities } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [oneOff] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [], + }); + + // Attach to entity 1 + const preview1 = await autumnV1.billing.previewAttach({ + customer_id: customerId, + product_id: oneOff.id, + entity_id: entities[0].id, + options: [{ feature_id: TestFeature.Messages, quantity: quantity1 }], + }); + expect(preview1.total).toBe(expectedTotal1); + + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: oneOff.id, + entity_id: entities[0].id, + options: [{ feature_id: TestFeature.Messages, quantity: quantity1 }], + redirect_mode: "if_required", + }); + + // Attach to entity 2 + const preview2 = await autumnV1.billing.previewAttach({ + customer_id: customerId, + product_id: oneOff.id, + entity_id: entities[1].id, + options: [{ feature_id: TestFeature.Messages, quantity: quantity2 }], + }); + expect(preview2.total).toBe(expectedTotal2); + + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: oneOff.id, + entity_id: entities[1].id, + options: [{ feature_id: TestFeature.Messages, quantity: quantity2 }], + redirect_mode: "if_required", + }); + + // Verify entity 1 balance + const entity1 = await autumnV1.entities.get( + customerId, + entities[0].id, + ); + await expectProductActive({ customer: entity1, productId: oneOff.id }); + expectCustomerFeatureCorrect({ + customer: entity1, + featureId: TestFeature.Messages, + balance: quantity1, + usage: 0, + }); + + // Verify entity 2 balance + const entity2 = await autumnV1.entities.get( + customerId, + entities[1].id, + ); + await expectProductActive({ customer: entity2, productId: oneOff.id }); + expectCustomerFeatureCorrect({ + customer: entity2, + featureId: TestFeature.Messages, + balance: quantity2, + usage: 0, + }); + + // Verify invoices: 2 total (one per entity attach) + const customer = await autumnV1.customers.get(customerId); + await expectCustomerInvoiceCorrect({ + customer, + count: 2, + latestTotal: expectedTotal2, + }); + await expectCustomerInvoiceCorrect({ + customer, + count: 2, + invoiceIndex: 1, + latestTotal: expectedTotal1, + }); +}); diff --git a/server/tests/integration/billing/attach/new-plan/prepaid/attach-prepaid-entities.test.ts b/server/tests/integration/billing/attach/new-plan/prepaid/attach-prepaid-entities.test.ts new file mode 100644 index 000000000..c3d8082bb --- /dev/null +++ b/server/tests/integration/billing/attach/new-plan/prepaid/attach-prepaid-entities.test.ts @@ -0,0 +1,211 @@ +/** + * Attach Prepaid to Multiple Entities + * + * Test 1: Attaches a prepaid product with included usage to two separate entities. + * Each entity gets its own independent balance. + * Product: base (no base price) with prepaid messages (100 included, $10 per 100 extra) + * Entity 1: quantity 300 → 100 included + 200 purchased (2×$10 = $20) + * Entity 2: quantity 500 → 100 included + 400 purchased (4×$10 = $40) + * + * Test 2: Customer has the same prepaid product attached, then entities also attach it. + * Customer balance = customer's own + sum of entity balances. + * Entity balance = entity's own + customer's balance (inheritance). + */ + +import { expect, test } from "bun:test"; +import type { ApiCustomerV3, ApiEntityV0 } from "@autumn/shared"; +import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { expectProductActive } from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; + +const BILLING_UNITS = 100; +const PRICE_PER_UNIT = 10; +const INCLUDED_USAGE = 100; + +test.concurrent(`${chalk.yellowBright("prepaid-entities: attach prepaid messages with included usage to two entities")}`, async () => { + const customerId = "prepaid-ent-two-included"; + const quantity1 = 300; + const quantity2 = 500; + + const purchasedUnits1 = (quantity1 - INCLUDED_USAGE) / BILLING_UNITS; + const purchasedUnits2 = (quantity2 - INCLUDED_USAGE) / BILLING_UNITS; + const prepaidCost1 = purchasedUnits1 * PRICE_PER_UNIT; + const prepaidCost2 = purchasedUnits2 * PRICE_PER_UNIT; + + const prepaidItem = items.prepaidMessages({ + includedUsage: INCLUDED_USAGE, + billingUnits: BILLING_UNITS, + price: PRICE_PER_UNIT, + }); + + const base = products.base({ + id: "base-prepaid-ent-inc", + items: [prepaidItem], + }); + + const { autumnV1, entities, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [base] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [ + s.billing.attach({ + productId: base.id, + entityIndex: 0, + options: [{ feature_id: TestFeature.Messages, quantity: quantity1 }], + }), + s.billing.attach({ + productId: base.id, + entityIndex: 1, + options: [{ feature_id: TestFeature.Messages, quantity: quantity2 }], + }), + ], + }); + + // Verify entity 1: product active, balance = 300 + const entity1 = await autumnV1.entities.get( + customerId, + entities[0].id, + ); + await expectProductActive({ customer: entity1, productId: base.id }); + expectCustomerFeatureCorrect({ + customer: entity1, + featureId: TestFeature.Messages, + balance: quantity1, + usage: 0, + }); + + // Verify entity 2: product active, balance = 500 + const entity2 = await autumnV1.entities.get( + customerId, + entities[1].id, + ); + await expectProductActive({ customer: entity2, productId: base.id }); + expectCustomerFeatureCorrect({ + customer: entity2, + featureId: TestFeature.Messages, + balance: quantity2, + usage: 0, + }); + + // Verify invoices: no base price, so totals are just prepaid costs + const customer = await autumnV1.customers.get(customerId); + await expectCustomerInvoiceCorrect({ + customer, + count: 2, + latestTotal: prepaidCost2, + }); + await expectCustomerInvoiceCorrect({ + customer, + count: 2, + invoiceIndex: 1, + latestTotal: prepaidCost1, + }); + + // Verify Stripe subscription matches expected state + await expectStripeSubscriptionCorrect({ ctx, customerId }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 2: Customer + Entity both have same prepaid product (balance inheritance) +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Customer attaches prepaid product at customer level (qty 200 → balance 200). + * Then entity 1 attaches same product at entity level (qty 300 → balance 300). + * + * Expected: + * - Customer total balance = 200 (own) + 300 (entity) = 500 + * - Entity 1 balance = 300 (own) + 200 (inherited from customer) = 500 + * - Invoices: 2 total (customer attach + entity attach) + */ +test.concurrent(`${chalk.yellowBright("prepaid-entities: customer + entity both have same prepaid product")}`, async () => { + const customerId = "prepaid-ent-with-customer"; + const customerQuantity = 200; + const entityQuantity = 300; + + const customerPurchasedUnits = + (customerQuantity - INCLUDED_USAGE) / BILLING_UNITS; + const entityPurchasedUnits = + (entityQuantity - INCLUDED_USAGE) / BILLING_UNITS; + const customerPrepaidCost = customerPurchasedUnits * PRICE_PER_UNIT; + const entityPrepaidCost = entityPurchasedUnits * PRICE_PER_UNIT; + + const prepaidItem = items.prepaidMessages({ + includedUsage: INCLUDED_USAGE, + billingUnits: BILLING_UNITS, + price: PRICE_PER_UNIT, + }); + + const base = products.base({ + id: "base-prepaid-cus-ent", + items: [prepaidItem], + }); + + const { autumnV1, entities, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [base] }), + s.entities({ count: 1, featureId: TestFeature.Users }), + ], + actions: [ + // Customer-level attach first + s.billing.attach({ + productId: base.id, + options: [ + { feature_id: TestFeature.Messages, quantity: customerQuantity }, + ], + }), + // Then entity-level attach + s.billing.attach({ + productId: base.id, + entityIndex: 0, + options: [ + { feature_id: TestFeature.Messages, quantity: entityQuantity }, + ], + }), + ], + }); + + // Verify entity 1: own balance (300) + inherited from customer (200) = 500 + const entity1 = await autumnV1.entities.get( + customerId, + entities[0].id, + ); + await expectProductActive({ customer: entity1, productId: base.id }); + expectCustomerFeatureCorrect({ + customer: entity1, + featureId: TestFeature.Messages, + balance: entityQuantity + customerQuantity, + }); + + // Verify customer total: own (200) + entity (300) = 500 + const customer = await autumnV1.customers.get(customerId); + expect(customer.features?.[TestFeature.Messages]?.balance).toBe( + customerQuantity + entityQuantity, + ); + + // Invoices: 2 total — customer attach + entity attach + await expectCustomerInvoiceCorrect({ + customer, + count: 2, + latestTotal: entityPrepaidCost, + }); + await expectCustomerInvoiceCorrect({ + customer, + count: 2, + invoiceIndex: 1, + latestTotal: customerPrepaidCost, + }); + + await expectStripeSubscriptionCorrect({ ctx, customerId }); +}); diff --git a/server/tests/integration/billing/attach/new-plan/prepaid/attach-prepaid-volume-entities.test.ts b/server/tests/integration/billing/attach/new-plan/prepaid/attach-prepaid-volume-entities.test.ts index 358d3c335..2b9039593 100644 --- a/server/tests/integration/billing/attach/new-plan/prepaid/attach-prepaid-volume-entities.test.ts +++ b/server/tests/integration/billing/attach/new-plan/prepaid/attach-prepaid-volume-entities.test.ts @@ -1,17 +1,18 @@ /** * Attach Prepaid Volume vs Graduated — Entity-Level Initial Attach Test * - * Two entities share one customer. One is on graduated pricing, the other on - * volume pricing, for the same tier structure. Confirms that the initial - * invoice totals correctly reflect each pricing model independently. + * Test 1: Two entities, one graduated, one volume, same tier structure. + * 800 units, tier 2: + * Graduated: 5×$10 + 3×$5 = $65 + * Volume: 8×$5 = $40 + * + * Test 2: Volume prepaid with includedUsage — verifies that the included + * usage acts as a free tier and the remaining purchased units are ALL + * charged at the volume rate (the tier that the purchased quantity falls into). * * Tiers (billingUnits = 100): * Tier 1: 0–500 units @ $10/pack * Tier 2: 501+ units @ $5/pack - * - * 800 units, tier 2: - * Graduated: 5×$10 + 3×$5 = $65 - * Volume: 8×$5 = $40 ← cheaper, all at tier-2 rate */ import { expect, test } from "bun:test"; @@ -19,6 +20,7 @@ import type { ApiCustomerV3, ApiEntityV0 } from "@autumn/shared"; import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect"; import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; import { expectProductActive } from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect"; import { TestFeature } from "@tests/setup/v2Features"; import { items } from "@tests/utils/fixtures/items"; import { products } from "@tests/utils/fixtures/products"; @@ -67,7 +69,7 @@ test.concurrent(`${chalk.yellowBright("attach-prepaid-volume-entities: graduated const gradPro = products.pro({ id: "grad-pro-ent-800", items: [gradItem] }); const volPro = products.pro({ id: "vol-pro-ent-800", items: [volItem] }); - const { autumnV1, entities } = await initScenario({ + const { autumnV1, entities, ctx } = await initScenario({ customerId, setup: [ s.customer({ paymentMethod: "success" }), @@ -152,4 +154,137 @@ test.concurrent(`${chalk.yellowBright("attach-prepaid-volume-entities: graduated invoiceIndex: 1, latestTotal: BASE_PRICE + gradExpectedPrepaid, }); + + await expectStripeSubscriptionCorrect({ ctx, customerId }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 2: Volume prepaid with includedUsage — all purchased units at volume rate +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Volume prepaid with includedUsage = 200 (must be multiple of billingUnits=100). + * + * Entity 1: quantity 800 → 200 included free, 600 purchased + * Purchased packs = 600/100 = 6 packs → falls in tier 2 (>5 packs) + * Volume: ALL 6 packs at tier-2 rate = 6×$5 = $30 + * Invoice = $20 base + $30 = $50 + * + * Entity 2: quantity 400 → 200 included free, 200 purchased + * Purchased packs = 200/100 = 2 packs → falls in tier 1 (≤5 packs) + * Volume: ALL 2 packs at tier-1 rate = 2×$10 = $20 + * Invoice = $20 base + $20 = $40 + * + * Confirms includedUsage is subtracted before volume pricing is applied, + * and that volume pricing charges ALL purchased units at the single tier rate. + */ +test.concurrent(`${chalk.yellowBright("attach-prepaid-volume-entities: volume with includedUsage (200 free, tier pricing on rest)")}`, async () => { + const customerId = "vol-ent-included-200"; + const includedUsage = 200; + const quantity1 = 800; + const quantity2 = 400; + + // Entity 1: 800 (including included usage) purchased + const purchasedPacks1 = quantity1 / BILLING_UNITS; + const volExpected1 = purchasedPacks1 * 5; // tier 2 rate (>5 packs) + + // Entity 2: 400 (including included usage) purchased + const purchasedPacks2 = quantity2 / BILLING_UNITS; + const volExpected2 = purchasedPacks2 * 10; // tier 1 rate (≤5 packs) + + const volItem = items.volumePrepaidMessages({ + includedUsage, + billingUnits: BILLING_UNITS, + tiers: TIERS, + }); + + const volPro = products.pro({ id: "vol-pro-inc-200", items: [volItem] }); + + const { autumnV1, entities, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [volPro] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [], + }); + + // ── Preview entity 1: $20 base + $30 volume = $50 ── + const preview1 = await autumnV1.billing.previewAttach({ + customer_id: customerId, + product_id: volPro.id, + entity_id: entities[0].id, + options: [{ feature_id: TestFeature.Messages, quantity: quantity1 }], + }); + expect(preview1.total).toBe(BASE_PRICE + volExpected1); + + // ── Preview entity 2: $20 base + $20 volume = $40 ── + const preview2 = await autumnV1.billing.previewAttach({ + customer_id: customerId, + product_id: volPro.id, + entity_id: entities[1].id, + options: [{ feature_id: TestFeature.Messages, quantity: quantity2 }], + }); + expect(preview2.total).toBe(BASE_PRICE + volExpected2); + + // ── Attach both ── + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: volPro.id, + entity_id: entities[0].id, + options: [{ feature_id: TestFeature.Messages, quantity: quantity1 }], + redirect_mode: "if_required", + }); + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: volPro.id, + entity_id: entities[1].id, + options: [{ feature_id: TestFeature.Messages, quantity: quantity2 }], + redirect_mode: "if_required", + }); + + // ── Assert entity 1: balance = 800 ── + const entity1 = await autumnV1.entities.get( + customerId, + entities[0].id, + ); + await expectProductActive({ customer: entity1, productId: volPro.id }); + expectCustomerFeatureCorrect({ + customer: entity1, + featureId: TestFeature.Messages, + balance: quantity1, + usage: 0, + }); + + // ── Assert entity 2: balance = 400 ── + const entity2 = await autumnV1.entities.get( + customerId, + entities[1].id, + ); + await expectProductActive({ customer: entity2, productId: volPro.id }); + expectCustomerFeatureCorrect({ + customer: entity2, + featureId: TestFeature.Messages, + balance: quantity2, + usage: 0, + }); + + // ── Customer invoices: 2 total ── + // Invoice 0 (latest): entity 2 — $40 + // Invoice 1: entity 1 — $50 + const customer = await autumnV1.customers.get(customerId); + await expectCustomerInvoiceCorrect({ + customer, + count: 2, + latestTotal: BASE_PRICE + volExpected2, + }); + await expectCustomerInvoiceCorrect({ + customer, + count: 2, + invoiceIndex: 1, + latestTotal: BASE_PRICE + volExpected1, + }); + + await expectStripeSubscriptionCorrect({ ctx, customerId }); }); diff --git a/server/tests/integration/billing/attach/scheduled-switch/scheduled-switch-prepaid-no-options.test.ts b/server/tests/integration/billing/attach/scheduled-switch/prepaid/scheduled-switch-prepaid-no-options.test.ts similarity index 100% rename from server/tests/integration/billing/attach/scheduled-switch/scheduled-switch-prepaid-no-options.test.ts rename to server/tests/integration/billing/attach/scheduled-switch/prepaid/scheduled-switch-prepaid-no-options.test.ts diff --git a/server/tests/integration/billing/attach/scheduled-switch/scheduled-switch-prepaid-volume.test.ts b/server/tests/integration/billing/attach/scheduled-switch/prepaid/scheduled-switch-prepaid-volume.test.ts similarity index 100% rename from server/tests/integration/billing/attach/scheduled-switch/scheduled-switch-prepaid-volume.test.ts rename to server/tests/integration/billing/attach/scheduled-switch/prepaid/scheduled-switch-prepaid-volume.test.ts diff --git a/server/tests/integration/billing/attach/scheduled-switch/scheduled-switch-prepaid.test.ts b/server/tests/integration/billing/attach/scheduled-switch/prepaid/scheduled-switch-prepaid.test.ts similarity index 100% rename from server/tests/integration/billing/attach/scheduled-switch/scheduled-switch-prepaid.test.ts rename to server/tests/integration/billing/attach/scheduled-switch/prepaid/scheduled-switch-prepaid.test.ts diff --git a/server/tests/integration/billing/attach/scheduled-switch/scheduled-switch-prepaid-entities.test.ts b/server/tests/integration/billing/attach/scheduled-switch/scheduled-switch-prepaid-entities.test.ts new file mode 100644 index 000000000..6979ea0af --- /dev/null +++ b/server/tests/integration/billing/attach/scheduled-switch/scheduled-switch-prepaid-entities.test.ts @@ -0,0 +1,555 @@ +/** + * Scheduled Switch Entities — Prepaid Messages + * + * Tests that scheduled downgrades work correctly with entity-scoped prepaid + * products. Each entity gets independent inline prices in Stripe, and the + * subscription schedule must preserve per-entity pricing through the transition. + * + * Products use prepaid messages (100 included, $10/100 units). + * Quantity in billing.attach is INCLUSIVE of included usage. + * + * Price math (BILLING_UNITS=100, PRICE_PER_UNIT=$10, INCLUDED_USAGE=100): + * quantity 500 → (500-100)/100 * $10 = $40 prepaid + base price + * quantity 300 → (300-100)/100 * $10 = $20 prepaid + base price + */ + +import { test } from "bun:test"; +import type { ApiCustomerV3, ApiEntityV0 } from "@autumn/shared"; +import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { + expectCustomerProducts, + expectProductCanceling, + expectProductScheduled, +} from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { advanceToNextInvoice } from "@tests/utils/testAttachUtils/testAttachUtils"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; + +const BILLING_UNITS = 100; +const PRICE_PER_UNIT = 10; +const INCLUDED_USAGE = 100; + +const PRO_BASE = 20; +const PREMIUM_BASE = 50; + +/** Prepaid cost for a given quantity: (qty - included) / billingUnits * price */ +const prepaidCost = (quantity: number) => + ((quantity - INCLUDED_USAGE) / BILLING_UNITS) * PRICE_PER_UNIT; + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 1: Single entity premium → pro downgrade + advance cycle +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Both entities start on premium ($50/mo) with 500 prepaid messages. + * Downgrade entity 1 to pro ($20/mo) with 300 messages. + * + * Expected: + * Entity 1: premium canceling + pro scheduled, balance still 500 (until cycle ends) + * Entity 2: premium active, balance 500 + * Stripe schedule reflects the scheduled downgrade with inline prepaid prices + */ +test.concurrent(`${chalk.yellowBright("scheduled-switch-entities-prepaid 1: single entity premium→pro downgrade + advance cycle")}`, async () => { + const customerId = "sched-prepaid-ent-pre-cycle"; + const premiumQuantity = 500; + const proQuantity = 300; + + const prepaidItem = items.prepaidMessages({ + includedUsage: INCLUDED_USAGE, + billingUnits: BILLING_UNITS, + price: PRICE_PER_UNIT, + }); + + const premium = products.premium({ + id: "premium-prepaid", + items: [prepaidItem], + }); + const pro = products.pro({ + id: "pro-prepaid", + items: [prepaidItem], + }); + + const { autumnV1, entities, ctx, testClockId } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [premium, pro] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [ + s.billing.attach({ + productId: premium.id, + entityIndex: 0, + options: [ + { feature_id: TestFeature.Messages, quantity: premiumQuantity }, + ], + }), + ], + }); + + // Action under test: downgrade entity 1 to pro (scheduled) + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: pro.id, + entity_id: entities[0].id, + options: [{ feature_id: TestFeature.Messages, quantity: proQuantity }], + redirect_mode: "if_required", + }); + + // Verify entity 1: premium canceling, pro scheduled + const entity1 = await autumnV1.entities.get( + customerId, + entities[0].id, + ); + await expectProductCanceling({ customer: entity1, productId: premium.id }); + await expectProductScheduled({ customer: entity1, productId: pro.id }); + + // Balances unchanged before cycle ends + expectCustomerFeatureCorrect({ + customer: entity1, + featureId: TestFeature.Messages, + balance: premiumQuantity, + usage: 0, + }); + + // Stripe schedule should reflect the downgrade + await expectStripeSubscriptionCorrect({ ctx, customerId }); + + await advanceToNextInvoice({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + }); + + const customerAfter = await autumnV1.customers.get(customerId); + + await expectCustomerProducts({ + customer: customerAfter, + active: [pro.id], + notPresent: [premium.id], + }); + + expectCustomerFeatureCorrect({ + customer: customerAfter, + featureId: TestFeature.Messages, + balance: proQuantity, + usage: 0, + }); + await expectStripeSubscriptionCorrect({ ctx, customerId }); + + // Invoices: + // 0 (latest): renewal — pro base ($20) + prepaid 300 ($20) = $40 + // 1: initial — premium base ($50) + prepaid 500 ($40) = $90 + await expectCustomerInvoiceCorrect({ + customer: customerAfter, + count: 2, + latestTotal: PRO_BASE + prepaidCost(proQuantity), + }); + await expectCustomerInvoiceCorrect({ + customer: customerAfter, + count: 2, + invoiceIndex: 1, + latestTotal: PREMIUM_BASE + prepaidCost(premiumQuantity), + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 2: Entity 1 premium → pro, entity 2 stays premium → advance cycle +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Both entities on premium with 500 messages. Downgrade entity 1 to pro with 300. + * After cycle: entity 1 on pro with 300 balance, entity 2 renewed on premium with 500 balance. + */ +test.concurrent(`${chalk.yellowBright("scheduled-switch-entities-prepaid 2: entity 1 premium→pro, entity 2 stays premium, advance cycle")}`, async () => { + const customerId = "sched-prepaid-ent-post-cycle"; + const premiumQuantity = 500; + const proQuantity = 300; + + const prepaidItem = items.prepaidMessages({ + includedUsage: INCLUDED_USAGE, + billingUnits: BILLING_UNITS, + price: PRICE_PER_UNIT, + }); + + const premium = products.premium({ + id: "premium-prepaid", + items: [prepaidItem], + }); + const pro = products.pro({ + id: "pro-prepaid", + items: [prepaidItem], + }); + + const { autumnV1, entities, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [premium, pro] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [ + s.billing.attach({ + productId: premium.id, + entityIndex: 0, + options: [ + { feature_id: TestFeature.Messages, quantity: premiumQuantity }, + ], + }), + s.billing.attach({ + productId: premium.id, + entityIndex: 1, + options: [ + { feature_id: TestFeature.Messages, quantity: premiumQuantity }, + ], + }), + s.billing.attach({ + productId: pro.id, + entityIndex: 0, + options: [{ feature_id: TestFeature.Messages, quantity: proQuantity }], + }), + s.advanceToNextInvoice(), + ], + }); + + // After cycle: entity 1 on pro, entity 2 on premium + const entity1 = await autumnV1.entities.get( + customerId, + entities[0].id, + ); + const entity2 = await autumnV1.entities.get( + customerId, + entities[1].id, + ); + + await expectCustomerProducts({ + customer: entity1, + active: [pro.id], + notPresent: [premium.id], + }); + await expectCustomerProducts({ + customer: entity2, + active: [premium.id], + notPresent: [pro.id], + }); + + // Entity 1 gets pro quantity, entity 2 keeps premium quantity + expectCustomerFeatureCorrect({ + customer: entity1, + featureId: TestFeature.Messages, + balance: proQuantity, + usage: 0, + }); + expectCustomerFeatureCorrect({ + customer: entity2, + featureId: TestFeature.Messages, + balance: premiumQuantity, + usage: 0, + }); + + await expectStripeSubscriptionCorrect({ ctx, customerId }); + + // Invoices: + // 0 (latest): renewal — entity 1 pro ($20+$20) + entity 2 premium ($50+$40) = $130 + // 1: initial — entity 2 premium ($50+$40) = $90 + // 2: initial — entity 1 premium ($50+$40) = $90 + const customer = await autumnV1.customers.get(customerId); + await expectCustomerInvoiceCorrect({ + customer, + count: 3, + latestTotal: + PRO_BASE + + prepaidCost(proQuantity) + + PREMIUM_BASE + + prepaidCost(premiumQuantity), + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 3: Both entities premium → pro → advance cycle +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Both entities on premium with 500 messages. Downgrade both to pro with 300. + * After cycle: both on pro with 300 balance. + */ +test.concurrent(`${chalk.yellowBright("scheduled-switch-entities-prepaid 3: both entities premium→pro, advance cycle")}`, async () => { + const customerId = "sched-prepaid-ent-both-down"; + const premiumQuantity = 500; + const proQuantity = 300; + + const prepaidItem = items.prepaidMessages({ + includedUsage: INCLUDED_USAGE, + billingUnits: BILLING_UNITS, + price: PRICE_PER_UNIT, + }); + + const premium = products.premium({ + id: "premium-prepaid", + items: [prepaidItem], + }); + const pro = products.pro({ + id: "pro-prepaid", + items: [prepaidItem], + }); + + const { autumnV1, entities, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [premium, pro] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [ + s.billing.attach({ + productId: premium.id, + entityIndex: 0, + options: [ + { feature_id: TestFeature.Messages, quantity: premiumQuantity }, + ], + }), + s.billing.attach({ + productId: premium.id, + entityIndex: 1, + options: [ + { feature_id: TestFeature.Messages, quantity: premiumQuantity }, + ], + }), + s.billing.attach({ + productId: pro.id, + entityIndex: 0, + options: [{ feature_id: TestFeature.Messages, quantity: proQuantity }], + }), + s.billing.attach({ + productId: pro.id, + entityIndex: 1, + options: [{ feature_id: TestFeature.Messages, quantity: proQuantity }], + }), + s.advanceToNextInvoice(), + ], + }); + + // After cycle: both on pro + const entity1 = await autumnV1.entities.get( + customerId, + entities[0].id, + ); + const entity2 = await autumnV1.entities.get( + customerId, + entities[1].id, + ); + + await expectCustomerProducts({ + customer: entity1, + active: [pro.id], + notPresent: [premium.id], + }); + await expectCustomerProducts({ + customer: entity2, + active: [pro.id], + notPresent: [premium.id], + }); + + expectCustomerFeatureCorrect({ + customer: entity1, + featureId: TestFeature.Messages, + balance: proQuantity, + usage: 0, + }); + expectCustomerFeatureCorrect({ + customer: entity2, + featureId: TestFeature.Messages, + balance: proQuantity, + usage: 0, + }); + + await expectStripeSubscriptionCorrect({ ctx, customerId }); + + // Invoices: + // 0 (latest): renewal — entity 1 pro ($20+$20) + entity 2 pro ($20+$20) = $80 + // 1: initial — entity 2 premium ($50+$40) = $90 + // 2: initial — entity 1 premium ($50+$40) = $90 + const customer = await autumnV1.customers.get(customerId); + await expectCustomerInvoiceCorrect({ + customer, + count: 3, + latestTotal: 2 * (PRO_BASE + prepaidCost(proQuantity)), + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 4: Entity 1 pro → free (scheduled), entity 2 pro → premium (immediate) +// → advance cycle +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Both entities on pro ($20/mo) with 300 prepaid messages. + * Entity 1 downgrades to free (scheduled). + * Entity 2 upgrades to premium (immediate). + * + * Pre-cycle: + * Entity 1: pro canceling + free scheduled + * Entity 2: premium active with 500 balance + * + * Post-cycle: + * Entity 1: free active with 200 balance + * Entity 2: premium active with 500 balance (renewed) + */ +test.concurrent(`${chalk.yellowBright("scheduled-switch-entities-prepaid 4: entity 1 pro→free, entity 2 pro→premium, advance cycle")}`, async () => { + const customerId = "sched-prepaid-ent-cross"; + const proQuantity = 300; + const freeQuantity = 200; + const premiumQuantity = 500; + + const prepaidItem = items.prepaidMessages({ + includedUsage: INCLUDED_USAGE, + billingUnits: BILLING_UNITS, + price: PRICE_PER_UNIT, + }); + + const free = products.base({ + id: "free-prepaid", + items: [prepaidItem], + }); + const pro = products.pro({ + id: "pro-prepaid", + items: [prepaidItem], + }); + const premium = products.premium({ + id: "premium-prepaid", + items: [prepaidItem], + }); + + const { autumnV1, entities, ctx, testClockId } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [free, pro, premium] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [ + s.billing.attach({ + productId: pro.id, + entityIndex: 0, + options: [{ feature_id: TestFeature.Messages, quantity: proQuantity }], + }), + s.billing.attach({ + productId: pro.id, + entityIndex: 1, + options: [{ feature_id: TestFeature.Messages, quantity: proQuantity }], + }), + ], + }); + + // Action under test: downgrade entity 1, upgrade entity 2 + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: free.id, + entity_id: entities[0].id, + options: [{ feature_id: TestFeature.Messages, quantity: freeQuantity }], + redirect_mode: "if_required", + }); + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: premium.id, + entity_id: entities[1].id, + options: [{ feature_id: TestFeature.Messages, quantity: premiumQuantity }], + redirect_mode: "if_required", + }); + + // ── Pre-cycle checks ── + + // Entity 1: pro canceling, free scheduled + const preCycleEntity1 = await autumnV1.entities.get( + customerId, + entities[0].id, + ); + await expectProductCanceling({ + customer: preCycleEntity1, + productId: pro.id, + }); + await expectProductScheduled({ + customer: preCycleEntity1, + productId: free.id, + }); + + // Entity 2: premium active (immediate upgrade) + const preCycleEntity2 = await autumnV1.entities.get( + customerId, + entities[1].id, + ); + await expectCustomerProducts({ + customer: preCycleEntity2, + active: [premium.id], + notPresent: [pro.id], + }); + expectCustomerFeatureCorrect({ + customer: preCycleEntity2, + featureId: TestFeature.Messages, + balance: premiumQuantity, + usage: 0, + }); + + await expectStripeSubscriptionCorrect({ ctx, customerId }); + + // ── Advance cycle ── + + await advanceToNextInvoice({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + }); + + // ── Post-cycle checks ── + + const postCycleEntity1 = await autumnV1.entities.get( + customerId, + entities[0].id, + ); + const postCycleEntity2 = await autumnV1.entities.get( + customerId, + entities[1].id, + ); + + await expectCustomerProducts({ + customer: postCycleEntity1, + active: [free.id], + notPresent: [pro.id, premium.id], + }); + await expectCustomerProducts({ + customer: postCycleEntity2, + active: [premium.id], + notPresent: [pro.id, free.id], + }); + + expectCustomerFeatureCorrect({ + customer: postCycleEntity1, + featureId: TestFeature.Messages, + balance: freeQuantity, + usage: 0, + }); + expectCustomerFeatureCorrect({ + customer: postCycleEntity2, + featureId: TestFeature.Messages, + balance: premiumQuantity, + usage: 0, + }); + + await expectStripeSubscriptionCorrect({ ctx, customerId }); + + // Invoices (post-cycle): + // 0 (latest): renewal — entity 1 free ($0 + prepaid $10) + entity 2 premium ($50+$40) = $100 + // + proration invoice from entity 2's immediate pro→premium upgrade + // + 2 initial pro attaches + // Just check the latest renewal total + const customerAfter = await autumnV1.customers.get(customerId); + await expectCustomerInvoiceCorrect({ + customer: customerAfter, + count: 4, + latestTotal: + prepaidCost(freeQuantity) + PREMIUM_BASE + prepaidCost(premiumQuantity), + }); +}); diff --git a/server/tests/integration/billing/update-subscription/update-quantity/multi-entity-quantity-proration.test.ts b/server/tests/integration/billing/update-subscription/update-quantity/multi-entity-quantity-proration.test.ts new file mode 100644 index 000000000..9e712d0fb --- /dev/null +++ b/server/tests/integration/billing/update-subscription/update-quantity/multi-entity-quantity-proration.test.ts @@ -0,0 +1,717 @@ +/** + * Multi-Entity Quantity Proration Config Tests + * + * Tests 1-3: ProrateNextCycle behavior with entity-scoped prepaid products. + * Balance updates immediately but charge/credit is deferred to next cycle invoice. + * All use file-level constants: BILLING_UNITS=10, PRICE_PER_UNIT=5, INCLUDED_USAGE=20. + * + * Tests 4-5: OnDecrease.None behavior with entity-scoped prepaid products. + * Decrease is scheduled for next cycle (no credit), balance stays until renewal. + * Use local constants: billingUnits=100, pricePerUnit=10, includedUsage=100. + * + * Test 1: ProrateNextCycle increase — entity gets balance immediately, billing deferred + * Test 2: ProrateNextCycle decrease — balance changes immediately, credit deferred + * Test 3: Mixed — one entity increases (ProrateNextCycle), other decreases (ProrateImmediately) + * Test 4: OnDecrease.None — no credit invoice on decrease + * Test 5: OnDecrease.None — decrease then increase back (net zero) + */ + +import { expect, test } from "bun:test"; +import { type ApiCustomerV3, OnDecrease, OnIncrease } from "@autumn/shared"; +import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect.js"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect.js"; +import { expectProductItemCorrect } from "@tests/integration/billing/utils/expectProductItemCorrect.js"; +import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect"; +import { calculateProratedDiff } from "@tests/integration/billing/utils/proration"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { advanceToNextInvoice } from "@tests/utils/testAttachUtils/testAttachUtils"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; + +// ── File-level constants for Tests 1-3 ── +const BILLING_UNITS = 10; +const PRICE_PER_UNIT = 5; +const INCLUDED_USAGE = 20; + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 1: ProrateNextCycle increase — balance now, billing deferred +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Entity 1 starts with 100 units. Cost = (100-20)/10 * $5 = $40. + * Entity 2 starts with 50 units. Cost = (50-20)/10 * $5 = $15. + * + * Entity 1 increases to 200 units. New cost = (200-20)/10 * $5 = $90. + * Preview shows $0 (deferred). Balance updates immediately to 200. + * Entity 2 is unchanged. + * + * After advancing to next cycle: + * Renewal = $90 (entity1) + $15 (entity2) = $105 + * Plus prorated increase deferred from mid-cycle. + */ +test.concurrent(`${chalk.yellowBright("multi-entity-proration: ProrateNextCycle increase — balance now, billing deferred")}`, async () => { + const customerId = "multi-ent-proration-increase"; + + const prepaidItem = items.prepaid({ + featureId: TestFeature.Messages, + billingUnits: BILLING_UNITS, + price: PRICE_PER_UNIT, + includedUsage: INCLUDED_USAGE, + config: { + on_increase: OnIncrease.ProrateNextCycle, + on_decrease: OnDecrease.ProrateNextCycle, + }, + }); + + const product = products.base({ + id: "prepaid", + items: [prepaidItem], + }); + + const initialQuantity1 = 10 * BILLING_UNITS; // 100 + const initialQuantity2 = 5 * BILLING_UNITS; // 50 + const newQuantity1 = 20 * BILLING_UNITS; // 200 + + // Costs: (qty - includedUsage) / billingUnits * pricePerUnit + const entity1OldCost = + ((initialQuantity1 - INCLUDED_USAGE) / BILLING_UNITS) * PRICE_PER_UNIT; // $40 + const entity1NewCost = + ((newQuantity1 - INCLUDED_USAGE) / BILLING_UNITS) * PRICE_PER_UNIT; // $90 + const entity2Cost = + ((initialQuantity2 - INCLUDED_USAGE) / BILLING_UNITS) * PRICE_PER_UNIT; // $15 + const renewalAmount = entity1NewCost + entity2Cost; // $105 + + const { autumnV1, ctx, entities, testClockId, advancedTo } = + await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [product] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [ + s.billing.attach({ + productId: product.id, + entityIndex: 0, + options: [ + { + feature_id: TestFeature.Messages, + quantity: initialQuantity1, + }, + ], + }), + s.billing.attach({ + productId: product.id, + entityIndex: 1, + options: [ + { + feature_id: TestFeature.Messages, + quantity: initialQuantity2, + }, + ], + }), + ], + }); + + const beforeCustomer = + await autumnV1.customers.get(customerId); + const invoiceCountBefore = beforeCustomer.invoices?.length ?? 0; + + // Preview the upgrade — should be $0 (deferred to next cycle) + const preview = await autumnV1.subscriptions.previewUpdate({ + customer_id: customerId, + entity_id: entities[0].id, + product_id: product.id, + options: [{ feature_id: TestFeature.Messages, quantity: newQuantity1 }], + }); + expect(preview.total).toBe(0); + + // Calculate prorated diff BEFORE advancing (billing period changes after) + const proratedIncrease = await calculateProratedDiff({ + customerId, + advancedTo, + oldAmount: entity1OldCost, + newAmount: entity1NewCost, + }); + + // Execute the upgrade + await autumnV1.subscriptions.update({ + customer_id: customerId, + entity_id: entities[0].id, + product_id: product.id, + options: [{ feature_id: TestFeature.Messages, quantity: newQuantity1 }], + }); + + // Balance updates immediately to 200 + const entity1 = await autumnV1.entities.get(customerId, entities[0].id); + expectCustomerFeatureCorrect({ + customer: entity1, + featureId: TestFeature.Messages, + balance: newQuantity1, + }); + + // Entity 2 unchanged + const entity2 = await autumnV1.entities.get(customerId, entities[1].id); + expectCustomerFeatureCorrect({ + customer: entity2, + featureId: TestFeature.Messages, + balance: initialQuantity2, + }); + + // No new finalized invoice created yet + const afterUpdate = await autumnV1.customers.get(customerId); + const finalizedAfter = afterUpdate.invoices?.filter( + (inv) => inv.status === "paid" || inv.status === "open", + ); + expect(finalizedAfter?.length).toBe(invoiceCountBefore); + + // Advance to next cycle — deferred proration + renewal should appear + await advanceToNextInvoice({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + }); + + const afterCycle = await autumnV1.customers.get(customerId); + expectCustomerInvoiceCorrect({ + customer: afterCycle, + count: invoiceCountBefore + 1, + latestStatus: "paid", + latestTotal: renewalAmount + proratedIncrease, + }); + + await expectStripeSubscriptionCorrect({ ctx, customerId }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 2: ProrateNextCycle decrease — balance changes immediately, credit deferred +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Entity 1 starts with 200 units. Cost = (200-20)/10 * $5 = $90. + * Entity 2 starts with 100 units. Cost = (100-20)/10 * $5 = $40. + * + * Entity 1 decreases to 50 units. New cost = (50-20)/10 * $5 = $15. + * Preview shows $0 (deferred). Balance changes immediately to 50. + * + * After advancing to next cycle: + * Renewal = $15 (entity1) + $40 (entity2) = $55 + * Plus prorated credit deferred from mid-cycle (negative). + */ +test.concurrent(`${chalk.yellowBright("multi-entity-proration: ProrateNextCycle decrease — immediate balance, deferred credit")}`, async () => { + const customerId = "multi-ent-proration-decrease"; + + const prepaidItem = items.prepaid({ + featureId: TestFeature.Messages, + billingUnits: BILLING_UNITS, + price: PRICE_PER_UNIT, + includedUsage: INCLUDED_USAGE, + config: { + on_increase: OnIncrease.ProrateNextCycle, + on_decrease: OnDecrease.ProrateNextCycle, + }, + }); + + const product = products.base({ + id: "prepaid", + items: [prepaidItem], + }); + + const initialQuantity1 = 20 * BILLING_UNITS; // 200 + const initialQuantity2 = 10 * BILLING_UNITS; // 100 + const newQuantity1 = 5 * BILLING_UNITS; // 50 + + // Costs + const entity1OldCost = + ((initialQuantity1 - INCLUDED_USAGE) / BILLING_UNITS) * PRICE_PER_UNIT; // $90 + const entity1NewCost = + ((newQuantity1 - INCLUDED_USAGE) / BILLING_UNITS) * PRICE_PER_UNIT; // $15 + const entity2Cost = + ((initialQuantity2 - INCLUDED_USAGE) / BILLING_UNITS) * PRICE_PER_UNIT; // $40 + const renewalAmount = entity1NewCost + entity2Cost; // $55 + + const { autumnV1, ctx, entities, testClockId, advancedTo } = + await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [product] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [ + s.billing.attach({ + productId: product.id, + entityIndex: 0, + options: [ + { + feature_id: TestFeature.Messages, + quantity: initialQuantity1, + }, + ], + }), + s.billing.attach({ + productId: product.id, + entityIndex: 1, + options: [ + { + feature_id: TestFeature.Messages, + quantity: initialQuantity2, + }, + ], + }), + ], + }); + + const beforeCustomer = + await autumnV1.customers.get(customerId); + const invoiceCountBefore = beforeCustomer.invoices?.length ?? 0; + + // Preview the downgrade — should be $0 (deferred) + const preview = await autumnV1.subscriptions.previewUpdate({ + customer_id: customerId, + entity_id: entities[0].id, + product_id: product.id, + options: [{ feature_id: TestFeature.Messages, quantity: newQuantity1 }], + }); + expect(preview.total).toBe(0); + + // Calculate prorated diff BEFORE advancing + const proratedCredit = await calculateProratedDiff({ + customerId, + advancedTo, + oldAmount: entity1OldCost, + newAmount: entity1NewCost, + }); + + // Execute the downgrade + await autumnV1.subscriptions.update({ + customer_id: customerId, + entity_id: entities[0].id, + product_id: product.id, + options: [{ feature_id: TestFeature.Messages, quantity: newQuantity1 }], + }); + + // Balance changes immediately to 50 (billing is deferred, not balance) + const entity1After = await autumnV1.entities.get(customerId, entities[0].id); + expectCustomerFeatureCorrect({ + customer: entity1After, + featureId: TestFeature.Messages, + balance: newQuantity1, + }); + + // Entity 2 unchanged + const entity2After = await autumnV1.entities.get(customerId, entities[1].id); + expectCustomerFeatureCorrect({ + customer: entity2After, + featureId: TestFeature.Messages, + balance: initialQuantity2, + }); + + // No new invoice created yet + const afterUpdate = await autumnV1.customers.get(customerId); + expectCustomerInvoiceCorrect({ + customer: afterUpdate, + count: invoiceCountBefore, + }); + + // Advance to next cycle — deferred credit applied to renewal invoice + await advanceToNextInvoice({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + }); + + const entity1PostCycle = await autumnV1.entities.get( + customerId, + entities[0].id, + ); + expectCustomerFeatureCorrect({ + customer: entity1PostCycle, + featureId: TestFeature.Messages, + balance: newQuantity1, + }); + + // Entity 2 still at its original allocation (renewed) + const entity2PostCycle = await autumnV1.entities.get( + customerId, + entities[1].id, + ); + expectCustomerFeatureCorrect({ + customer: entity2PostCycle, + featureId: TestFeature.Messages, + balance: initialQuantity2, + }); + + // Renewal invoice = renewal amount + prorated credit (negative) + const afterCycle = await autumnV1.customers.get(customerId); + expectCustomerInvoiceCorrect({ + customer: afterCycle, + count: invoiceCountBefore + 1, + latestStatus: "paid", + latestTotal: renewalAmount + proratedCredit, + }); + + await expectStripeSubscriptionCorrect({ ctx, customerId }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 4: OnDecrease.None — no credit invoice on decrease +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Entity 1 starts with 400 units. Packs = (400-100)/100 = 3, cost = 3 * $10 = $30. + * Entity 2 starts with 300 units. Packs = (300-100)/100 = 2, cost = 2 * $10 = $20. + * + * Entity 1 decreases to 200 units. Packs = (200-100)/100 = 1, cost = 1 * $10 = $10. + * With OnDecrease.None: + * - Preview = $0, no credit invoice + * - Balance stays at 400 until next cycle + * - After cycle: balance becomes 200, renewal = $10 + $20 = $30 (flat, no proration) + */ +test.concurrent(`${chalk.yellowBright("multi-entity-proration: OnDecrease.None — no credit invoice")}`, async () => { + const customerId = "multi-ent-proration-no-decrease"; + const billingUnits = 100; + const pricePerUnit = 10; + const includedUsage = 100; + + const prepaidItem = items.prepaid({ + featureId: TestFeature.Messages, + billingUnits, + price: pricePerUnit, + includedUsage, + config: { + on_increase: OnIncrease.ProrateImmediately, + on_decrease: OnDecrease.None, + }, + }); + + const product = products.base({ + id: "prepaid", + items: [prepaidItem], + }); + + const initialQuantity1 = 4 * billingUnits; // 400 + const initialQuantity2 = 3 * billingUnits; // 300 + const newQuantity1 = 2 * billingUnits; // 200 + + // Costs + const entity1NewCost = + ((newQuantity1 - includedUsage) / billingUnits) * pricePerUnit; // $10 + const entity2Cost = + ((initialQuantity2 - includedUsage) / billingUnits) * pricePerUnit; // $20 + const renewalAmount = entity1NewCost + entity2Cost; // $30 + + const { autumnV1, ctx, entities, testClockId } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [product] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [ + s.billing.attach({ + productId: product.id, + entityIndex: 0, + options: [ + { + feature_id: TestFeature.Messages, + quantity: initialQuantity1, + }, + ], + }), + s.billing.attach({ + productId: product.id, + entityIndex: 1, + options: [ + { + feature_id: TestFeature.Messages, + quantity: initialQuantity2, + }, + ], + }), + ], + }); + + const beforeInvoices = + await autumnV1.customers.get(customerId); + const invoiceCountBefore = beforeInvoices.invoices?.length ?? 0; + + // Preview the downgrade — should be $0 (no immediate credit) + const preview = await autumnV1.subscriptions.previewUpdate({ + customer_id: customerId, + entity_id: entities[0].id, + product_id: product.id, + options: [{ feature_id: TestFeature.Messages, quantity: newQuantity1 }], + }); + expect(preview.total).toBe(0); + + // Execute the downgrade + await autumnV1.subscriptions.update({ + customer_id: customerId, + entity_id: entities[0].id, + product_id: product.id, + options: [{ feature_id: TestFeature.Messages, quantity: newQuantity1 }], + }); + + // Balance stays at 400 (OnDecrease.None keeps old balance until next cycle) + const entity1After = await autumnV1.entities.get(customerId, entities[0].id); + expectCustomerFeatureCorrect({ + customer: entity1After, + featureId: TestFeature.Messages, + balance: initialQuantity1, + }); + + // No new invoice created + const afterUpdate = await autumnV1.customers.get(customerId); + expectCustomerInvoiceCorrect({ + customer: afterUpdate, + count: invoiceCountBefore, + }); + + // Entity 2 unchanged + const entity2 = await autumnV1.entities.get(customerId, entities[1].id); + expectCustomerFeatureCorrect({ + customer: entity2, + featureId: TestFeature.Messages, + balance: initialQuantity2, + }); + + // Advance to next cycle + await advanceToNextInvoice({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + }); + + // After cycle: entity 1 balance = 200 (new quantity takes effect) + const afterAdvance = await autumnV1.entities.get(customerId, entities[0].id); + expectCustomerFeatureCorrect({ + customer: afterAdvance, + featureId: TestFeature.Messages, + balance: newQuantity1, + }); + + // Renewal invoice: flat renewal, no proration adjustments + const customerAfter = await autumnV1.customers.get(customerId); + expectCustomerInvoiceCorrect({ + customer: customerAfter, + count: invoiceCountBefore + 1, + latestTotal: renewalAmount, + }); + + await expectStripeSubscriptionCorrect({ ctx, customerId }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 5: OnDecrease.None — decrease then increase back (net zero) +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Entity 1 starts with 400 units (cost $30). Entity 2 starts with 300 (cost $20). + * + * Entity 1 decreases 400->200 (OnDecrease.None: no invoice, balance stays 400). + * Entity 1 increases back to 400 (no-op: current Stripe sub is still at 400). + * Preview = $0, no new invoice. + * + * After cycle: renewal = $30 + $20 = $50 (original amounts, net change = 0). + */ +test.concurrent(`${chalk.yellowBright("multi-entity-proration: OnDecrease.None — decrease then increase back (net zero)")}`, async () => { + const customerId = "multi-ent-proration-none-netzero"; + const billingUnits = 100; + const pricePerUnit = 10; + const includedUsage = 100; + + const prepaidItem = items.prepaid({ + featureId: TestFeature.Messages, + billingUnits, + price: pricePerUnit, + includedUsage, + config: { + on_increase: OnIncrease.ProrateImmediately, + on_decrease: OnDecrease.None, + }, + }); + + const product = products.base({ + id: "prepaid", + items: [prepaidItem], + }); + + const initialQuantity1 = 4 * billingUnits; // 400 + const initialQuantity2 = 3 * billingUnits; // 300 + + // Costs + const entity1Cost = + ((initialQuantity1 - includedUsage) / billingUnits) * pricePerUnit; // $30 + const entity2Cost = + ((initialQuantity2 - includedUsage) / billingUnits) * pricePerUnit; // $20 + const renewalAmount = entity1Cost + entity2Cost; // $50 + + const { autumnV1, ctx, entities, testClockId } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [product] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [ + s.billing.attach({ + productId: product.id, + entityIndex: 0, + options: [ + { + feature_id: TestFeature.Messages, + quantity: initialQuantity1, + }, + ], + }), + s.billing.attach({ + productId: product.id, + entityIndex: 1, + options: [ + { + feature_id: TestFeature.Messages, + quantity: initialQuantity2, + }, + ], + }), + ], + }); + + const beforeInvoices = + await autumnV1.customers.get(customerId); + const invoiceCountBefore = beforeInvoices.invoices?.length ?? 0; + + // ── Step 1: Decrease entity 1 from 400 -> 200 ── + const decreasePreview = await autumnV1.subscriptions.previewUpdate({ + customer_id: customerId, + entity_id: entities[0].id, + product_id: product.id, + options: [ + { + feature_id: TestFeature.Messages, + quantity: 2 * billingUnits, // 200 + }, + ], + }); + expect(decreasePreview.total).toBe(0); + + await autumnV1.subscriptions.update({ + customer_id: customerId, + entity_id: entities[0].id, + product_id: product.id, + options: [ + { + feature_id: TestFeature.Messages, + quantity: 2 * billingUnits, // 200 + }, + ], + }); + + // Balance stays at 400 (OnDecrease.None) + const entity1AfterDecrease = await autumnV1.entities.get( + customerId, + entities[0].id, + ); + expectCustomerFeatureCorrect({ + customer: entity1AfterDecrease, + featureId: TestFeature.Messages, + balance: initialQuantity1, + }); + + // Verify product item: quantity=400, upcomingQuantity=200 + await expectProductItemCorrect({ + customer: entity1AfterDecrease, + productId: product.id, + featureId: TestFeature.Messages, + quantity: initialQuantity1 - includedUsage, + upcomingQuantity: 2 * billingUnits - includedUsage, + }); + + // No new invoice + const afterDecrease = await autumnV1.customers.get(customerId); + expectCustomerInvoiceCorrect({ + customer: afterDecrease, + count: invoiceCountBefore, + }); + + // ── Step 2: Increase entity 1 back to 400 (no-op) ── + const increasePreview = await autumnV1.subscriptions.previewUpdate({ + customer_id: customerId, + entity_id: entities[0].id, + product_id: product.id, + options: [{ feature_id: TestFeature.Messages, quantity: initialQuantity1 }], + }); + expect(increasePreview.total).toBe(0); + + await autumnV1.subscriptions.update({ + customer_id: customerId, + entity_id: entities[0].id, + product_id: product.id, + options: [{ feature_id: TestFeature.Messages, quantity: initialQuantity1 }], + }); + + // Balance still 400 + const entity1AfterIncrease = await autumnV1.entities.get( + customerId, + entities[0].id, + ); + expectCustomerFeatureCorrect({ + customer: entity1AfterIncrease, + featureId: TestFeature.Messages, + balance: initialQuantity1, + }); + + // Verify product item: quantity=400, upcomingQuantity should be gone (back to original) + await expectProductItemCorrect({ + customer: entity1AfterIncrease, + productId: product.id, + featureId: TestFeature.Messages, + quantity: initialQuantity1 - includedUsage, + upcomingQuantity: 300, + }); + + // Still no new invoices (increase back was a no-op) + const afterIncrease = await autumnV1.customers.get(customerId); + expectCustomerInvoiceCorrect({ + customer: afterIncrease, + count: invoiceCountBefore, + }); + + // ── Step 3: Advance to next cycle ── + await advanceToNextInvoice({ + stripeCli: ctx.stripeCli, + testClockId: testClockId!, + }); + + // Renewal: original amounts since net change = 0 + const customerAfter = await autumnV1.customers.get(customerId); + expectCustomerInvoiceCorrect({ + customer: customerAfter, + count: invoiceCountBefore + 1, + latestTotal: renewalAmount, + }); + + // Entity 1 balance renewed at 400 (original quantity) + const entity1PostCycle = await autumnV1.entities.get( + customerId, + entities[0].id, + ); + expectCustomerFeatureCorrect({ + customer: entity1PostCycle, + featureId: TestFeature.Messages, + balance: initialQuantity1, + }); + + // Entity 2 unchanged + const entity2PostCycle = await autumnV1.entities.get( + customerId, + entities[1].id, + ); + expectCustomerFeatureCorrect({ + customer: entity2PostCycle, + featureId: TestFeature.Messages, + balance: initialQuantity2, + }); + + await expectStripeSubscriptionCorrect({ ctx, customerId }); +}); diff --git a/server/tests/integration/billing/update-subscription/update-quantity/multi-entity-quantity.test.ts b/server/tests/integration/billing/update-subscription/update-quantity/multi-entity-quantity.test.ts index 2719d54dc..251610fcf 100644 --- a/server/tests/integration/billing/update-subscription/update-quantity/multi-entity-quantity.test.ts +++ b/server/tests/integration/billing/update-subscription/update-quantity/multi-entity-quantity.test.ts @@ -1,14 +1,13 @@ import { expect, test } from "bun:test"; -import { type ApiCustomerV3, OnDecrease, OnIncrease } from "@autumn/shared"; +import type { ApiCustomerV3 } from "@autumn/shared"; import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect.js"; import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect.js"; import { expectProductActive } from "@tests/integration/billing/utils/expectCustomerProductCorrect.js"; import { expectLatestInvoiceCorrect } from "@tests/integration/billing/utils/expectLatestInvoiceCorrect.js"; -import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect.js"; +import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect"; import { TestFeature } from "@tests/setup/v2Features.js"; import { items } from "@tests/utils/fixtures/items.js"; import { products } from "@tests/utils/fixtures/products.js"; -import { advanceToNextInvoice } from "@tests/utils/testAttachUtils/testAttachUtils"; import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; import chalk from "chalk"; @@ -22,7 +21,6 @@ import chalk from "chalk"; * - Entity 1 increases quantity while Entity 2 remains unchanged * - Entity 2 decreases quantity while Entity 1 remains unchanged * - Cross-entity mixed changes (increase one, decrease other) - * - OnDecrease.None config (no credit invoice on decrease) * - Different products per entity with quantity updates * - Multiple features per entity */ @@ -48,11 +46,7 @@ test.concurrent(`${chalk.yellowBright("multi-entity-quantity: entity 1 increases const initialQuantity2 = 5 * billingUnits; // 60 const newQuantity1 = 20 * billingUnits; // 240 - const { - autumnV1, - ctx: testContext, - entities, - } = await initScenario({ + const { autumnV1, ctx, entities } = await initScenario({ customerId, setup: [ s.customer({ paymentMethod: "success" }), @@ -120,13 +114,11 @@ test.concurrent(`${chalk.yellowBright("multi-entity-quantity: entity 1 increases amount: 10 * pricePerUnit, }); - // Verify subscription count - await expectSubToBeCorrect({ - db: testContext.db, + // Verify Stripe subscription matches expected state + await expectStripeSubscriptionCorrect({ + ctx, customerId, - org: testContext.org, - env: testContext.env, - subCount: 1, + options: { subCount: 1 }, }); }); @@ -151,7 +143,7 @@ test.concurrent(`${chalk.yellowBright("multi-entity-quantity: entity 2 decreases const initialQuantity2 = 15 * billingUnits; // 180 const newQuantity2 = 5 * billingUnits; // 60 - const { autumnV1, entities } = await initScenario({ + const { autumnV1, entities, ctx } = await initScenario({ customerId, setup: [ s.customer({ paymentMethod: "success" }), @@ -218,6 +210,8 @@ test.concurrent(`${chalk.yellowBright("multi-entity-quantity: entity 2 decreases productId: product.id, amount: -10 * pricePerUnit, }); + + await expectStripeSubscriptionCorrect({ ctx, customerId }); }); // Test 3: Cross-Entity Mixed Changes @@ -242,7 +236,7 @@ test.concurrent(`${chalk.yellowBright("multi-entity-quantity: mixed changes acro const newQuantity1 = 15 * billingUnits; // 150 (increase) const newQuantity2 = 10 * billingUnits; // 100 (decrease) - const { autumnV1, entities } = await initScenario({ + const { autumnV1, entities, ctx } = await initScenario({ customerId, setup: [ s.customer({ paymentMethod: "success" }), @@ -321,119 +315,11 @@ test.concurrent(`${chalk.yellowBright("multi-entity-quantity: mixed changes acro customer, count: 4, }); + + await expectStripeSubscriptionCorrect({ ctx, customerId }); }); -// Test 4: OnDecrease.None Config - No Credit Invoice -// With OnDecrease.None, the balance changes immediately but NO credit invoice is created -// This differs from OnDecrease.ProrateImmediately which creates a credit invoice -test.concurrent(`${chalk.yellowBright("multi-entity-quantity: OnDecrease.None creates no credit invoice")}`, async () => { - const customerId = "multi-ent-qty-no-proration"; - const billingUnits = 100; - const pricePerUnit = 10; - - const prepaidItem = items.prepaid({ - featureId: TestFeature.Messages, - billingUnits, - price: pricePerUnit, - config: { - on_increase: OnIncrease.ProrateImmediately, - on_decrease: OnDecrease.None, - }, - }); - - const product = products.base({ - id: "prepaid", - items: [prepaidItem], - }); - - const initialQuantity1 = 4 * billingUnits; // 400 - const initialQuantity2 = 3 * billingUnits; // 300 - const newQuantity1 = 2 * billingUnits; // 200 (decrease) - - const { autumnV1, entities, ctx, testClockId } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [product] }), - s.entities({ count: 2, featureId: TestFeature.Users }), - ], - actions: [ - s.attach({ - productId: product.id, - entityIndex: 0, - options: [ - { feature_id: TestFeature.Messages, quantity: initialQuantity1 }, - ], - }), - s.attach({ - productId: product.id, - entityIndex: 1, - options: [ - { feature_id: TestFeature.Messages, quantity: initialQuantity2 }, - ], - }), - ], - }); - - const beforeInvoices = - await autumnV1.customers.get(customerId); - const invoiceCountBefore = beforeInvoices.invoices?.length || 0; - - // Preview the downgrade - should be $0 (no immediate credit) - const preview = await autumnV1.subscriptions.previewUpdate({ - customer_id: customerId, - entity_id: entities[0].id, - product_id: product.id, - options: [{ feature_id: TestFeature.Messages, quantity: newQuantity1 }], - }); - - expect(preview.total).toBe(0); - - // Execute the downgrade - await autumnV1.subscriptions.update({ - customer_id: customerId, - entity_id: entities[0].id, - product_id: product.id, - options: [{ feature_id: TestFeature.Messages, quantity: newQuantity1 }], - }); - - // Balance is updated AFTER the next cycle with OnDecrease.None - const entity1After = await autumnV1.entities.get(customerId, entities[0].id); - expectCustomerFeatureCorrect({ - customer: entity1After, - featureId: TestFeature.Messages, - balance: initialQuantity1, - }); - - // No new invoice should be created (the key behavior of OnDecrease.None) - const afterUpdate = await autumnV1.customers.get(customerId); - expectCustomerInvoiceCorrect({ - customer: afterUpdate, - count: invoiceCountBefore, - }); - - // Entity 2 should be unchanged - const entity2 = await autumnV1.entities.get(customerId, entities[1].id); - expectCustomerFeatureCorrect({ - customer: entity2, - featureId: TestFeature.Messages, - balance: initialQuantity2, - }); - - await advanceToNextInvoice({ - stripeCli: ctx.stripeCli, - testClockId: testClockId!, - }); - - const afterAdvance = await autumnV1.entities.get(customerId, entities[0].id); - expectCustomerFeatureCorrect({ - customer: afterAdvance, - featureId: TestFeature.Messages, - balance: newQuantity1, - }); -}); - -// Test 5: Different Products Per Entity +// Test 4: Different Products Per Entity (was Test 5) test.concurrent(`${chalk.yellowBright("multi-entity-quantity: different products per entity")}`, async () => { const customerId = "multi-ent-qty-diff-products"; const billingUnits = 10; @@ -464,7 +350,7 @@ test.concurrent(`${chalk.yellowBright("multi-entity-quantity: different products const initialQuantityPro = 10 * billingUnits; // 100 const newQuantityPro = 15 * billingUnits; // 150 (increase) - const { autumnV1, entities } = await initScenario({ + const { autumnV1, entities, ctx } = await initScenario({ customerId, setup: [ s.customer({ paymentMethod: "success" }), @@ -539,9 +425,11 @@ test.concurrent(`${chalk.yellowBright("multi-entity-quantity: different products productId: proProduct.id, amount: 5 * 8, }); + + await expectStripeSubscriptionCorrect({ ctx, customerId }); }); -// Test 6: Multiple Features Per Entity +// Test 5: Multiple Features Per Entity (was Test 6) test.concurrent(`${chalk.yellowBright("multi-entity-quantity: multiple features per entity")}`, async () => { const customerId = "multi-ent-qty-multi-feat"; const messagesBillingUnits = 10; @@ -566,7 +454,7 @@ test.concurrent(`${chalk.yellowBright("multi-entity-quantity: multiple features items: [messagesItem, wordsItem], }); - const { autumnV1, entities } = await initScenario({ + const { autumnV1, entities, ctx } = await initScenario({ customerId, setup: [ s.customer({ paymentMethod: "success" }), @@ -662,4 +550,6 @@ test.concurrent(`${chalk.yellowBright("multi-entity-quantity: multiple features productId: product.id, amount: 5 * messagesPrice - 1 * wordsPrice, }); + + await expectStripeSubscriptionCorrect({ ctx, customerId }); }); diff --git a/server/tests/integration/billing/utils/expectStripeSubCorrect/classifyPhaseScenario.ts b/server/tests/integration/billing/utils/expectStripeSubCorrect/classifyPhaseScenario.ts new file mode 100644 index 000000000..28f93e259 --- /dev/null +++ b/server/tests/integration/billing/utils/expectStripeSubCorrect/classifyPhaseScenario.ts @@ -0,0 +1,69 @@ +import type Stripe from "stripe"; + +export type PhaseScenario = + | "no_phases" + | "single_indefinite" + | "simple_cancel" + | "multi_phase"; + +const phaseHasItems = ( + phase: Stripe.SubscriptionScheduleUpdateParams.Phase, +): boolean => { + return phase.items !== undefined && phase.items.length > 0; +}; + +/** Strips empty phases from both ends (mirrors production filterEmptyPhases). */ +const filterEmptyPhases = ( + phases: Stripe.SubscriptionScheduleUpdateParams.Phase[], +): Stripe.SubscriptionScheduleUpdateParams.Phase[] => { + const firstNonEmptyIndex = phases.findIndex(phaseHasItems); + if (firstNonEmptyIndex === -1) return []; + + let lastNonEmptyIndex = phases.length - 1; + while (lastNonEmptyIndex >= 0 && !phaseHasItems(phases[lastNonEmptyIndex])) { + lastNonEmptyIndex--; + } + + return phases.slice(firstNonEmptyIndex, lastNonEmptyIndex + 1); +}; + +/** + * Classifies raw phases into one of 4 scenarios. + * Returns the scenario, non-empty phases, and the expected cancel_at (in seconds) if applicable. + */ +export const classifyPhaseScenario = ({ + rawPhases, +}: { + rawPhases: Stripe.SubscriptionScheduleUpdateParams.Phase[]; +}): { + scenario: PhaseScenario; + scheduledPhases: Stripe.SubscriptionScheduleUpdateParams.Phase[]; + cancelAtSeconds?: number; +} => { + const scheduledPhases = filterEmptyPhases(rawPhases); + + const lastPhase = rawPhases[rawPhases.length - 1]; + const endsWithEmptyPhase = !!lastPhase && !phaseHasItems(lastPhase); + const cancelAtSeconds = + endsWithEmptyPhase && typeof lastPhase.start_date === "number" + ? lastPhase.start_date + : undefined; + + let scenario: PhaseScenario; + + if (scheduledPhases.length === 0) { + scenario = "no_phases"; + } else if (scheduledPhases.length === 1) { + if (endsWithEmptyPhase) { + scenario = "simple_cancel"; + } else if (!scheduledPhases[0].end_date) { + scenario = "single_indefinite"; + } else { + scenario = "multi_phase"; + } + } else { + scenario = "multi_phase"; + } + + return { scenario, scheduledPhases, cancelAtSeconds }; +}; diff --git a/server/tests/integration/billing/utils/expectStripeSubCorrect/expectStripeSubscriptionCorrect.ts b/server/tests/integration/billing/utils/expectStripeSubCorrect/expectStripeSubscriptionCorrect.ts new file mode 100644 index 000000000..ce7517f4c --- /dev/null +++ b/server/tests/integration/billing/utils/expectStripeSubCorrect/expectStripeSubscriptionCorrect.ts @@ -0,0 +1,81 @@ +import { expect } from "bun:test"; +import { + customerProductsToStripeSubscriptionIds, + notNullish, +} from "@autumn/shared"; +import type { TestContext } from "@tests/utils/testInitUtils/createTestContext"; +import { CusService } from "@/internal/customers/CusService"; +import type { ExpectStripeSubOptions } from "./types"; +import { verifySubscription } from "./verifySubscription"; + +/** + * Verifies that all Stripe subscriptions for a customer match the expected state + * derived from their customer products. Handles multiple subscriptions (new_billing_subscription), + * inline entity-scoped prices, schedules, and cancellation. + */ +export const expectStripeSubscriptionCorrect = async ({ + ctx, + customerId, + options, +}: { + ctx: TestContext; + customerId: string; + options?: ExpectStripeSubOptions; +}) => { + // 1. Fetch full customer + const fullCustomer = await CusService.getFull({ + ctx, + idOrInternalId: customerId, + withEntities: true, + }); + + const cusProducts = fullCustomer.customer_products; + + // 2. Validate total subscription count if requested + if (options?.subCount !== undefined) { + const stripeCustomerId = fullCustomer.processor?.id; + expect( + stripeCustomerId, + `Customer ${customerId} has no Stripe processor ID`, + ).toBeDefined(); + + const subs = await ctx.stripeCli.subscriptions.list({ + customer: stripeCustomerId, + }); + expect(subs.data.length).toBe(options.subCount); + } + + // 3. Determine which subscriptions to verify + if (options?.subId) { + await verifySubscription({ + ctx, + subId: options.subId, + cusProducts, + options, + }); + return; + } + + // Verify ALL subscriptions referenced by cusProducts + const subIds = customerProductsToStripeSubscriptionIds({ + customerProducts: cusProducts, + }).filter(notNullish); + + if (options?.debug) { + console.log(`\nFound ${subIds.length} subscription(s) to verify:`, subIds); + } + + expect( + subIds.length, + "Expected at least one subscription ID on customer products", + ).toBeGreaterThan(0); + + for (const subId of subIds) { + await verifySubscription({ + ctx, + subId, + cusProducts, + options, + }); + } +}; diff --git a/server/tests/integration/billing/utils/expectStripeSubCorrect/helpers/compareItems.ts b/server/tests/integration/billing/utils/expectStripeSubCorrect/helpers/compareItems.ts new file mode 100644 index 000000000..70467bd79 --- /dev/null +++ b/server/tests/integration/billing/utils/expectStripeSubCorrect/helpers/compareItems.ts @@ -0,0 +1,173 @@ +import { expect } from "bun:test"; +import type { StripeInlinePrice } from "@autumn/shared"; +import type Stripe from "stripe"; +import type { NormalizedItem } from "../types"; + +/** Normalizes an actual Stripe subscription item into a comparable format. */ +export const normalizeActualSubItem = ({ + item, +}: { + item: Stripe.SubscriptionItem; +}): NormalizedItem => { + const autumnCusPriceId = item.metadata?.autumn_customer_price_id; + return { + priceId: item.price.id, + autumnCustomerPriceId: autumnCusPriceId || undefined, + quantity: item.quantity ?? 0, + isInline: !!autumnCusPriceId, + unitAmountDecimal: autumnCusPriceId + ? (item.price.unit_amount_decimal ?? undefined) + : undefined, + }; +}; + +/** Normalizes an actual Stripe schedule phase item into a comparable format. */ +export const normalizeActualPhaseItem = ({ + item, +}: { + item: Stripe.SubscriptionSchedule.Phase.Item; +}): NormalizedItem => { + const priceId = typeof item.price === "string" ? item.price : item.price.id; + const autumnCusPriceId = item.metadata?.autumn_customer_price_id; + const priceObj = + typeof item.price !== "string" && "unit_amount_decimal" in item.price + ? item.price + : undefined; + const unitAmountDecimal = + autumnCusPriceId && priceObj + ? (priceObj.unit_amount_decimal ?? undefined) + : undefined; + return { + priceId, + autumnCustomerPriceId: autumnCusPriceId || undefined, + quantity: item.quantity ?? 0, + isInline: !!autumnCusPriceId, + unitAmountDecimal, + }; +}; + +/** Normalizes an expected phase item (from buildStripePhasesUpdate) into a comparable format. */ +export const normalizeExpectedPhaseItem = ({ + item, +}: { + item: Stripe.SubscriptionScheduleUpdateParams.Phase.Item; +}): NormalizedItem => { + const hasInlinePrice = "price_data" in item; + const metadata = item.metadata as Record | undefined; + + let unitAmountDecimal: string | undefined; + if (hasInlinePrice) { + const priceData = (item as { price_data: StripeInlinePrice }).price_data; + unitAmountDecimal = priceData.unit_amount_decimal; + } + + return { + priceId: hasInlinePrice ? undefined : (item.price as string), + autumnCustomerPriceId: metadata?.autumn_customer_price_id, + quantity: (item.quantity as number) ?? 0, + isInline: hasInlinePrice, + unitAmountDecimal, + }; +}; + +/** + * Compares expected items against actual items. + * Stored items match by priceId. Inline items match by autumn_customer_price_id. + */ +export const compareItems = ({ + expectedItems, + actualItems, + label, + debug, +}: { + expectedItems: NormalizedItem[]; + actualItems: NormalizedItem[]; + label: string; + debug?: boolean; +}) => { + if (debug) { + console.log(`\n[${label}] Expected items (${expectedItems.length}):`); + for (const item of expectedItems) { + console.log( + ` ${item.isInline ? "inline" : "stored"} | price=${item.priceId ?? "N/A"} | cusPriceId=${item.autumnCustomerPriceId ?? "N/A"} | qty=${item.quantity} | amount=${item.unitAmountDecimal ?? "N/A"}`, + ); + } + console.log(`[${label}] Actual items (${actualItems.length}):`); + for (const item of actualItems) { + console.log( + ` ${item.isInline ? "inline" : "stored"} | price=${item.priceId ?? "N/A"} | cusPriceId=${item.autumnCustomerPriceId ?? "N/A"} | qty=${item.quantity} | amount=${item.unitAmountDecimal ?? "N/A"}`, + ); + } + } + + for (const expected of expectedItems) { + let actual: NormalizedItem | undefined; + + if (expected.isInline) { + actual = actualItems.find( + (a) => a.autumnCustomerPriceId === expected.autumnCustomerPriceId, + ); + + if (!actual) { + console.error( + `[${label}] Missing inline item with autumn_customer_price_id=${expected.autumnCustomerPriceId}`, + ); + console.error(` Expected:`, expected); + console.error(` Actual items:`, actualItems); + } + } else { + actual = actualItems.find((a) => a.priceId === expected.priceId); + + if (!actual) { + console.error( + `[${label}] Missing stored item with priceId=${expected.priceId}`, + ); + console.error(` Expected:`, expected); + console.error(` Actual items:`, actualItems); + } + } + + expect( + actual, + `[${label}] No matching actual item for expected: ${JSON.stringify(expected)}`, + ).toBeDefined(); + + if (actual && actual.quantity !== expected.quantity) { + console.error( + `[${label}] Quantity mismatch for ${expected.isInline ? `inline cusPriceId=${expected.autumnCustomerPriceId}` : `stored priceId=${expected.priceId}`}: expected=${expected.quantity}, actual=${actual.quantity}`, + ); + } + + expect(actual?.quantity).toBe(expected.quantity); + + // Compare unit_amount_decimal for inline prices + if ( + actual && + expected.unitAmountDecimal !== undefined && + actual.unitAmountDecimal !== undefined + ) { + if (actual.unitAmountDecimal !== expected.unitAmountDecimal) { + const itemLabel = expected.isInline + ? `inline cusPriceId=${expected.autumnCustomerPriceId}` + : `stored priceId=${expected.priceId}`; + console.error( + `[${label}] Price amount mismatch for ${itemLabel}: expected=${expected.unitAmountDecimal}, actual=${actual.unitAmountDecimal}`, + ); + } + expect( + actual.unitAmountDecimal, + `[${label}] unit_amount_decimal mismatch for ${expected.isInline ? `inline cusPriceId=${expected.autumnCustomerPriceId}` : `stored priceId=${expected.priceId}`}`, + ).toBe(expected.unitAmountDecimal); + } + } + + if (actualItems.length !== expectedItems.length) { + console.error( + `[${label}] Item count mismatch: expected=${expectedItems.length}, actual=${actualItems.length}`, + ); + console.error(` Expected:`, expectedItems); + console.error(` Actual:`, actualItems); + } + + expect(actualItems.length).toBe(expectedItems.length); +}; diff --git a/server/tests/integration/billing/utils/expectStripeSubCorrect/helpers/validateRewards.ts b/server/tests/integration/billing/utils/expectStripeSubCorrect/helpers/validateRewards.ts new file mode 100644 index 000000000..aea9b39c7 --- /dev/null +++ b/server/tests/integration/billing/utils/expectStripeSubCorrect/helpers/validateRewards.ts @@ -0,0 +1,29 @@ +import { expect } from "bun:test"; +import type Stripe from "stripe"; + +/** Validates that the subscription has exactly the expected reward coupon IDs. */ +export const validateRewards = ({ + sub, + rewards, +}: { + sub: Stripe.Subscription; + rewards: string[]; +}) => { + const subCouponIds = + sub.discounts?.map((discount) => { + if (typeof discount === "string") return discount; + const d = discount as Stripe.Discount; + return d.source?.coupon + ? typeof d.source.coupon === "string" + ? d.source.coupon + : d.source.coupon.id + : undefined; + }) ?? []; + + for (const reward of rewards) { + const found = subCouponIds.find((id) => id === reward); + expect(found, `Expected reward coupon ${reward} on sub`).toBeDefined(); + } + + expect(subCouponIds.length).toBe(rewards.length); +}; diff --git a/server/tests/integration/billing/utils/expectStripeSubCorrect/helpers/validateSchedulePhases.ts b/server/tests/integration/billing/utils/expectStripeSubCorrect/helpers/validateSchedulePhases.ts new file mode 100644 index 000000000..76889200b --- /dev/null +++ b/server/tests/integration/billing/utils/expectStripeSubCorrect/helpers/validateSchedulePhases.ts @@ -0,0 +1,84 @@ +import { expect } from "bun:test"; +import type { TestContext } from "@tests/utils/testInitUtils/createTestContext"; +import type Stripe from "stripe"; +import { similarUnix } from "@/internal/customers/attach/mergeUtils/phaseUtils/phaseUtils"; +import { formatUnixToDateTime } from "@/utils/genUtils"; +import { + compareItems, + normalizeActualPhaseItem, + normalizeExpectedPhaseItem, +} from "./compareItems"; + +/** Validates that actual Stripe schedule phases match the expected phases. */ +export const validateSchedulePhases = async ({ + ctx, + sub, + scheduledPhases, + debug, +}: { + ctx: TestContext; + sub: Stripe.Subscription; + scheduledPhases: Stripe.SubscriptionScheduleUpdateParams.Phase[]; + debug?: boolean; +}) => { + expect( + sub.schedule, + `Expected subscription ${sub.id} to have a schedule for phase validation`, + ).not.toBeNull(); + if (!sub.schedule) return; // type narrowing only — expect above will fail first + + const scheduleId = + typeof sub.schedule === "string" ? sub.schedule : sub.schedule.id; + + const schedule = await ctx.stripeCli.subscriptionSchedules.retrieve( + scheduleId, + { expand: ["phases.items.price"] }, + ); + + for (let i = 0; i < scheduledPhases.length; i++) { + const expectedPhase = scheduledPhases[i]; + const expectedStartSeconds = expectedPhase.start_date as number; + + const actualPhase = schedule.phases.find((phase) => + similarUnix({ + unix1: expectedStartSeconds * 1000, + unix2: phase.start_date * 1000, + }), + ); + + if (!actualPhase) { + console.error( + `No matching schedule phase found for expected phase ${i} starting at ${formatUnixToDateTime(expectedStartSeconds * 1000)}`, + ); + console.error( + `Available phases:`, + schedule.phases.map((p) => ({ + start: formatUnixToDateTime(p.start_date * 1000), + end: formatUnixToDateTime(p.end_date * 1000), + items: p.items.length, + })), + ); + } + + expect( + actualPhase, + `No matching phase at ${formatUnixToDateTime(expectedStartSeconds * 1000)}`, + ).toBeDefined(); + + if (!actualPhase) continue; + + const expectedItems = (expectedPhase.items ?? []).map((item) => + normalizeExpectedPhaseItem({ item }), + ); + const actualItems = actualPhase.items.map((item) => + normalizeActualPhaseItem({ item }), + ); + + compareItems({ + expectedItems, + actualItems, + label: `schedule phase ${i} (${formatUnixToDateTime(expectedStartSeconds * 1000)})`, + debug, + }); + } +}; diff --git a/server/tests/integration/billing/utils/expectStripeSubCorrect/helpers/validateSubState.ts b/server/tests/integration/billing/utils/expectStripeSubCorrect/helpers/validateSubState.ts new file mode 100644 index 000000000..60924abf2 --- /dev/null +++ b/server/tests/integration/billing/utils/expectStripeSubCorrect/helpers/validateSubState.ts @@ -0,0 +1,137 @@ +import { expect } from "bun:test"; +import type { TestContext } from "@tests/utils/testInitUtils/createTestContext"; +import type Stripe from "stripe"; +import { isStripeSubscriptionCanceling } from "@/external/stripe/subscriptions/utils/classifyStripeSubscriptionUtils"; +import type { PhaseScenario } from "../classifyPhaseScenario"; + +/** + * Checks whether the subscription has an active schedule with future phase transitions. + * After a schedule completes/releases, Stripe keeps the ID on the subscription + * but the schedule status is "released" or "completed" — not active. + * Also, a schedule in its final phase with end_behavior "release" is effectively done + * even if Stripe hasn't processed the release yet (test clock timing). + */ +const hasActiveScheduleWithFuturePhases = async ({ + ctx, + sub, +}: { + ctx: TestContext; + sub: Stripe.Subscription; +}): Promise => { + if (!sub.schedule) return false; + + const scheduleId = + typeof sub.schedule === "string" ? sub.schedule : sub.schedule.id; + const schedule = + await ctx.stripeCli.subscriptionSchedules.retrieve(scheduleId); + + // Already released/completed/canceled — not active + if ( + schedule.status === "released" || + schedule.status === "completed" || + schedule.status === "canceled" + ) { + return false; + } + + // Schedule is active but may be in its final phase awaiting release. + // If end_behavior is "release" and we're in the last phase, treat as done. + if (schedule.end_behavior === "release" && schedule.phases.length > 0) { + const lastPhase = schedule.phases[schedule.phases.length - 1]; + const currentPhase = schedule.current_phase; + if (currentPhase && currentPhase.start_date === lastPhase.start_date) { + return false; + } + } + + return true; +}; + +/** Validates cancel/schedule state on a subscription based on the classified scenario. */ +export const validateSubState = async ({ + ctx, + sub, + scenario, + cancelAtSeconds, + shouldBeCanceling, + debug, +}: { + ctx: TestContext; + sub: Stripe.Subscription; + scenario: PhaseScenario; + cancelAtSeconds?: number; + shouldBeCanceling?: boolean; + debug?: boolean; +}) => { + // Explicit override takes priority + if (shouldBeCanceling === true) { + expect( + isStripeSubscriptionCanceling(sub), + "Expected subscription to be canceling", + ).toBe(true); + return; + } + + if (shouldBeCanceling === false) { + expect( + isStripeSubscriptionCanceling(sub), + "Expected subscription to NOT be canceling", + ).toBe(false); + return; + } + + // Infer expectations from scenario + switch (scenario) { + case "no_phases": + break; + + case "single_indefinite": { + if (debug) { + console.log( + `single_indefinite: cancel_at=${sub.cancel_at}, schedule=${sub.schedule}`, + ); + } + expect(sub.cancel_at).toBeNull(); + const active = await hasActiveScheduleWithFuturePhases({ ctx, sub }); + expect( + active, + `Expected no active schedule with future phases on sub ${sub.id}, but schedule ${sub.schedule} is still active`, + ).toBe(false); + break; + } + + case "simple_cancel": { + if (debug) { + console.log( + `simple_cancel: cancel_at=${sub.cancel_at}, expected=${cancelAtSeconds}, schedule=${sub.schedule}`, + ); + } + expect(sub.cancel_at).not.toBeNull(); + + if (cancelAtSeconds !== undefined && sub.cancel_at !== null) { + expect(Math.abs(sub.cancel_at - cancelAtSeconds)).toBeLessThanOrEqual( + 1, + ); + } + + const active = await hasActiveScheduleWithFuturePhases({ ctx, sub }); + expect( + active, + `Expected no active schedule with future phases on sub ${sub.id} for simple_cancel`, + ).toBe(false); + break; + } + + case "multi_phase": + if (debug) { + console.log( + `multi_phase: schedule=${sub.schedule}, cancel_at=${sub.cancel_at}`, + ); + } + expect( + sub.schedule, + `Expected subscription ${sub.id} to have a schedule`, + ).not.toBeNull(); + break; + } +}; diff --git a/server/tests/integration/billing/utils/expectStripeSubCorrect/index.ts b/server/tests/integration/billing/utils/expectStripeSubCorrect/index.ts new file mode 100644 index 000000000..c93e52970 --- /dev/null +++ b/server/tests/integration/billing/utils/expectStripeSubCorrect/index.ts @@ -0,0 +1,6 @@ +export { + classifyPhaseScenario, + type PhaseScenario, +} from "./classifyPhaseScenario"; +export { expectStripeSubscriptionCorrect } from "./expectStripeSubscriptionCorrect"; +export type { ExpectStripeSubOptions, NormalizedItem } from "./types"; diff --git a/server/tests/integration/billing/utils/expectStripeSubCorrect/types.ts b/server/tests/integration/billing/utils/expectStripeSubCorrect/types.ts new file mode 100644 index 000000000..6972cf257 --- /dev/null +++ b/server/tests/integration/billing/utils/expectStripeSubCorrect/types.ts @@ -0,0 +1,20 @@ +import type { BillingVersion } from "@autumn/shared"; + +export type ExpectStripeSubOptions = { + status?: "active" | "trialing"; + shouldBeCanceling?: boolean; + subId?: string; + subCount?: number; + rewards?: string[]; + billingVersion?: BillingVersion; + debug?: boolean; +}; + +export type NormalizedItem = { + priceId?: string; + autumnCustomerPriceId?: string; + quantity: number; + isInline: boolean; + /** Stripe unit_amount_decimal (string, in smallest currency unit). Present for inline prices. */ + unitAmountDecimal?: string; +}; diff --git a/server/tests/integration/billing/utils/expectStripeSubCorrect/verifySubscription.ts b/server/tests/integration/billing/utils/expectStripeSubCorrect/verifySubscription.ts new file mode 100644 index 000000000..adea6d83c --- /dev/null +++ b/server/tests/integration/billing/utils/expectStripeSubCorrect/verifySubscription.ts @@ -0,0 +1,128 @@ +import { expect } from "bun:test"; +import { cp, type FullCusProduct } from "@autumn/shared"; +import { contexts } from "@tests/utils/fixtures/db/contexts"; +import type { TestContext } from "@tests/utils/testInitUtils/createTestContext"; +import { buildStripePhasesUpdate } from "@/internal/billing/v2/providers/stripe/utils/subscriptionSchedules/buildStripePhasesUpdate"; +import { formatUnixToDateTime } from "@/utils/genUtils"; +import { classifyPhaseScenario } from "./classifyPhaseScenario"; +import { + compareItems, + normalizeActualSubItem, + normalizeExpectedPhaseItem, +} from "./helpers/compareItems"; +import { validateRewards } from "./helpers/validateRewards"; +import { validateSchedulePhases } from "./helpers/validateSchedulePhases"; +import { validateSubState } from "./helpers/validateSubState"; +import type { ExpectStripeSubOptions } from "./types"; + +/** Verifies a single Stripe subscription against expected state derived from customer products. */ +export const verifySubscription = async ({ + ctx, + subId, + cusProducts, + options, +}: { + ctx: TestContext; + subId: string; + cusProducts: FullCusProduct[]; + options?: ExpectStripeSubOptions; +}) => { + const debug = options?.debug ?? false; + + // Filter to cusProducts belonging to this subscription that are paid + recurring + relevant status + const relatedCusProducts = cusProducts.filter( + (cusProduct) => + cusProduct.subscription_ids?.includes(subId) && + cp(cusProduct).paid().recurring().hasRelevantStatus().valid, + ); + + if (debug) { + console.log(`\n--- Verifying subscription: ${subId} ---`); + console.log( + `Related cusProducts (${relatedCusProducts.length}):`, + relatedCusProducts.map((cp) => ({ + product: cp.product.name, + status: cp.status, + canceled: cp.canceled, + entity: cp.internal_entity_id, + })), + ); + } + + // 1. Build expected phases using production code + const billingContext = contexts.createBilling({ + customerProducts: relatedCusProducts, + }); + + const rawPhases = buildStripePhasesUpdate({ + ctx, + billingContext, + customerProducts: relatedCusProducts, + }); + + // 2. Classify into scenario + const { scenario, scheduledPhases, cancelAtSeconds } = classifyPhaseScenario({ + rawPhases, + }); + + if (debug) { + console.log(`Scenario: ${scenario}`); + console.log(`Scheduled phases: ${scheduledPhases.length}`); + console.log( + `Cancel at: ${cancelAtSeconds ? formatUnixToDateTime(cancelAtSeconds * 1000) : "none"}`, + ); + } + + // 3. Fetch the actual Stripe subscription + const sub = await ctx.stripeCli.subscriptions.retrieve(subId, { + expand: ["discounts.coupon"], + }); + + // 4. Compare current subscription items against first phase items + const firstPhase = scheduledPhases[0]; + if (firstPhase) { + const expectedItems = (firstPhase.items ?? []).map((item) => + normalizeExpectedPhaseItem({ item }), + ); + const actualItems = sub.items.data.map((item) => + normalizeActualSubItem({ item }), + ); + + compareItems({ + expectedItems, + actualItems, + label: `sub:${subId}`, + debug, + }); + } + + // 5. Validate cancel / schedule state based on scenario + await validateSubState({ + ctx, + sub, + scenario, + cancelAtSeconds, + shouldBeCanceling: options?.shouldBeCanceling, + debug, + }); + + // 6. Validate schedule phases if multi_phase + if (scenario === "multi_phase") { + await validateSchedulePhases({ + ctx, + sub, + scheduledPhases, + debug, + }); + } + + // 7. Validate status override + if (options?.status) { + expect(sub.status).toBe(options.status); + } + + // 8. Validate rewards/discounts + if (options?.rewards) { + validateRewards({ sub, rewards: options.rewards }); + } +}; diff --git a/server/tests/unit/corsOrigins.test.ts b/server/tests/unit/corsOrigins.test.ts new file mode 100644 index 000000000..23b237998 --- /dev/null +++ b/server/tests/unit/corsOrigins.test.ts @@ -0,0 +1,66 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { ALLOWED_ORIGINS, isAllowedOrigin } from "@/utils/corsOrigins.js"; + +describe("isAllowedOrigin", () => { + const originalNodeEnv = process.env.NODE_ENV; + + afterEach(() => { + process.env.NODE_ENV = originalNodeEnv; + }); + + describe("production", () => { + test("allows hardcoded production origins", () => { + process.env.NODE_ENV = "production"; + for (const origin of ALLOWED_ORIGINS) { + expect(isAllowedOrigin(origin)).toBe(origin); + } + }); + + test("rejects arbitrary localhost ports", () => { + process.env.NODE_ENV = "production"; + expect(isAllowedOrigin("http://localhost:3100")).toBeUndefined(); + expect(isAllowedOrigin("http://localhost:8180")).toBeUndefined(); + expect(isAllowedOrigin("http://localhost:9999")).toBeUndefined(); + }); + + test("rejects external origins", () => { + process.env.NODE_ENV = "production"; + expect(isAllowedOrigin("https://evil.com")).toBeUndefined(); + expect(isAllowedOrigin("https://fake.useautumn.com")).toBeUndefined(); + }); + }); + + describe("non-production", () => { + test("allows hardcoded origins", () => { + process.env.NODE_ENV = "development"; + for (const origin of ALLOWED_ORIGINS) { + expect(isAllowedOrigin(origin)).toBe(origin); + } + }); + + test("allows any localhost port (worktree offsets)", () => { + process.env.NODE_ENV = "development"; + expect(isAllowedOrigin("http://localhost:3100")).toBe( + "http://localhost:3100", + ); + expect(isAllowedOrigin("http://localhost:8180")).toBe( + "http://localhost:8180", + ); + expect(isAllowedOrigin("http://localhost:3200")).toBe( + "http://localhost:3200", + ); + }); + + test("rejects external origins", () => { + process.env.NODE_ENV = "development"; + expect(isAllowedOrigin("https://evil.com")).toBeUndefined(); + expect(isAllowedOrigin("http://evil.com:3000")).toBeUndefined(); + }); + + test("rejects localhost with path or query", () => { + process.env.NODE_ENV = "development"; + expect(isAllowedOrigin("http://localhost:3000/evil")).toBeUndefined(); + expect(isAllowedOrigin("http://localhost:3000?x=1")).toBeUndefined(); + }); + }); +}); diff --git a/server/tests/utils/browserPool/browserConfig.ts b/server/tests/utils/browserPool/browserConfig.ts index 0d8614c1b..a338f2142 100644 --- a/server/tests/utils/browserPool/browserConfig.ts +++ b/server/tests/utils/browserPool/browserConfig.ts @@ -9,7 +9,7 @@ export const USE_KERNEL = !!process.env.USE_KERNEL_BROWSER; // export const USE_KERNEL = false; /** Run browsers in headless mode (set false to watch the browser) */ -export const HEADLESS = true; +export const HEADLESS = false; /** Path to local Chromium/Chrome executable (auto-detected if not set in env) */ export const CHROMIUM_PATH = diff --git a/server/tinybird/copies/customer_entitlements_backfill.pipe b/server/tinybird/copies/customer_entitlements_backfill.pipe new file mode 100644 index 000000000..e1a7eea7e --- /dev/null +++ b/server/tinybird/copies/customer_entitlements_backfill.pipe @@ -0,0 +1,68 @@ +DESCRIPTION > + Chunked backfill of customer_entitlements from Postgres into customer_entitlements. + Chunks by created_at (epoch ms). Cast numeric columns to match datasource schema. + +NODE raw +SQL > + % + SELECT + id, + customer_product_id, + entitlement_id, + internal_customer_id, + internal_entity_id, + internal_feature_id, + unlimited, + balance, + created_at, + next_reset_at, + usage_allowed, + adjustment, + additional_balance, + entities, + expires_at, + cache_version, + customer_id, + feature_id + FROM postgresql( + 'us-west-3.pg.psdb.cloud:5432', + 'postgres', + 'customer_entitlements', + {{tb_secret('PG_USERNAME')}}, + {{tb_secret('PG_PASSWORD')}}, + 'public' + ) + WHERE created_at > {{Int64(start_epoch_ms, 0)}} + AND created_at <= {{Int64(end_epoch_ms, 9999999999999)}} + AND balance < 1e15 + AND additional_balance < 1e15 + +NODE migrate +SQL > + SELECT + assumeNotNull(id) AS id, + customer_product_id, + coalesce(entitlement_id, '') AS entitlement_id, + coalesce(internal_customer_id, '') AS internal_customer_id, + internal_entity_id, + coalesce(internal_feature_id, '') AS internal_feature_id, + toUInt8(if(coalesce(unlimited, false), 1, 0)) AS unlimited, + toFloat64OrZero(toString(coalesce(balance, toDecimal128(0, 19)))) AS balance, + created_at::Int64 AS created_at, + if(next_reset_at IS NULL, NULL, next_reset_at::Int64) AS next_reset_at, + toUInt8(if(coalesce(usage_allowed, false), 1, 0)) AS usage_allowed, + toFloat64OrNull(toString(adjustment)) AS adjustment, + toFloat64OrZero(toString(coalesce(additional_balance, toDecimal128(0, 19)))) AS additional_balance, + ifNull(toString(entities), '{}') AS entities, + if(expires_at IS NULL, NULL, expires_at::Int64) AS expires_at, + coalesce(cache_version, 0) AS cache_version, + customer_id, + feature_id, + 'read' AS __action, + toDateTime64(now(), 6) AS __commit_timestamp, + 0 AS __commit_lsn, + concat('backfill-', assumeNotNull(id)) AS __idempotency_key + FROM raw + +TYPE COPY +TARGET_DATASOURCE customer_entitlements diff --git a/server/tinybird/copies/customer_prices_backfill.pipe b/server/tinybird/copies/customer_prices_backfill.pipe new file mode 100644 index 000000000..087376bf1 --- /dev/null +++ b/server/tinybird/copies/customer_prices_backfill.pipe @@ -0,0 +1,31 @@ +DESCRIPTION > + Chunked backfill of customer_prices from Postgres into customer_prices. + Chunks by created_at (epoch ms). Cast numeric columns to match datasource schema. + +NODE migrate +SQL > + % + SELECT + assumeNotNull(id) AS id, + created_at::Int64 AS created_at, + price_id, + ifNull(toString(options), 'null') AS options, + internal_customer_id, + customer_product_id, + 'read' AS __action, + toDateTime64(now(), 6) AS __commit_timestamp, + 0 AS __commit_lsn, + concat('backfill-', assumeNotNull(id)) AS __idempotency_key + FROM postgresql( + 'us-west-3.pg.psdb.cloud:5432', + 'postgres', + 'customer_prices', + {{tb_secret('PG_USERNAME')}}, + {{tb_secret('PG_PASSWORD')}}, + 'public' + ) + WHERE created_at > {{Int64(start_epoch_ms, 0)}} + AND created_at <= {{Int64(end_epoch_ms, 9999999999999)}} + +TYPE COPY +TARGET_DATASOURCE customer_prices diff --git a/server/tinybird/copies/customer_products_backfill.pipe b/server/tinybird/copies/customer_products_backfill.pipe new file mode 100644 index 000000000..df2afd1b9 --- /dev/null +++ b/server/tinybird/copies/customer_products_backfill.pipe @@ -0,0 +1,51 @@ +DESCRIPTION > + Chunked backfill of customer_products from Postgres into customer_products. + Chunks by created_at (epoch ms). Cast numeric columns to match datasource schema. + +NODE migrate +SQL > + % + SELECT + assumeNotNull(id) AS id, + coalesce(internal_customer_id, '') AS internal_customer_id, + coalesce(internal_product_id, '') AS internal_product_id, + internal_entity_id, + created_at::Int64 AS created_at, + status, + ifNull(toString(processor), 'null') AS processor, + toUInt8(coalesce(canceled, false)) AS canceled, + if(canceled_at IS NULL, NULL, canceled_at::Int64) AS canceled_at, + if(ended_at IS NULL, NULL, ended_at::Int64) AS ended_at, + if(starts_at IS NULL, NULL, starts_at::Int64) AS starts_at, + if(options IS NULL, '[]', toJSONString(arrayMap(x -> assumeNotNull(x), options))) AS options, + product_id, + free_trial_id, + if(trial_ends_at IS NULL, NULL, trial_ends_at::Int64) AS trial_ends_at, + coalesce(collection_method, 'charge_automatically') AS collection_method, + arrayMap(x -> assumeNotNull(x), coalesce(subscription_ids, [])) AS subscription_ids, + arrayMap(x -> assumeNotNull(x), coalesce(scheduled_ids, [])) AS scheduled_ids, + coalesce(quantity, 1)::Float64 AS quantity, + toUInt8(if(is_custom, 1, 0)) AS is_custom, + customer_id, + entity_id, + billing_version, + if(api_version IS NULL, NULL, api_version::Int64) AS api_version, + api_semver, + 'read' AS __action, + toDateTime64(now(), 6) AS __commit_timestamp, + 0 AS __commit_lsn, + concat('backfill-', assumeNotNull(id)) AS __idempotency_key + FROM postgresql( + 'us-west-3.pg.psdb.cloud:5432', + 'postgres', + 'customer_products', + {{tb_secret('PG_USERNAME')}}, + {{tb_secret('PG_PASSWORD')}}, + 'public' + ) + WHERE created_at > {{Int64(start_epoch_ms, 0)}} + AND created_at <= {{Int64(end_epoch_ms, 9999999999999)}} + AND (quantity IS NULL OR quantity < 1e15) + +TYPE COPY +TARGET_DATASOURCE customer_products diff --git a/server/tinybird/copies/customers_backfill.pipe b/server/tinybird/copies/customers_backfill.pipe new file mode 100644 index 000000000..a8d351b71 --- /dev/null +++ b/server/tinybird/copies/customers_backfill.pipe @@ -0,0 +1,37 @@ +DESCRIPTION > + Chunked backfill of customers from Postgres into customers. + Chunks by created_at (epoch ms). Cast numeric columns to match datasource schema. + +NODE migrate +SQL > + % + SELECT + coalesce(internal_id, '') AS internal_id, + coalesce(org_id, '') AS org_id, + created_at::Int64 AS created_at, + name, + id, + email, + fingerprint, + coalesce(toString(metadata), '{}') AS metadata, + coalesce(env, '') AS env, + coalesce(toString(processor), 'null') AS processor, + coalesce(toString(processors), '{}') AS processors, + if(coalesce(send_email_receipts, false) = true, 1, 0) AS send_email_receipts, + 'read' AS __action, + toDateTime64(now(), 6) AS __commit_timestamp, + 0 AS __commit_lsn, + concat('backfill-', coalesce(internal_id, '')) AS __idempotency_key + FROM postgresql( + 'us-west-3.pg.psdb.cloud:5432', + 'postgres', + 'customers', + {{tb_secret('PG_USERNAME')}}, + {{tb_secret('PG_PASSWORD')}}, + 'public' + ) + WHERE created_at > {{Int64(start_epoch_ms, 0)}} + AND created_at <= {{Int64(end_epoch_ms, 9999999999999)}} + +TYPE COPY +TARGET_DATASOURCE customers diff --git a/server/tinybird/copies/entities_backfill.pipe b/server/tinybird/copies/entities_backfill.pipe new file mode 100644 index 000000000..e6b6b11ae --- /dev/null +++ b/server/tinybird/copies/entities_backfill.pipe @@ -0,0 +1,35 @@ +DESCRIPTION > + Chunked backfill of entities from Postgres into entities. + Chunks by created_at (epoch ms). Cast numeric columns to match datasource schema. + +NODE migrate +SQL > + % + SELECT + id, + org_id, + created_at::Int64 AS created_at, + coalesce(internal_id, '') AS internal_id, + coalesce(internal_customer_id, '') AS internal_customer_id, + env, + name, + toUInt8(if(deleted, 1, 0)) AS deleted, + internal_feature_id, + feature_id, + 'read' AS __action, + toDateTime64(now(), 6) AS __commit_timestamp, + 0 AS __commit_lsn, + concat('backfill-', coalesce(internal_id, '')) AS __idempotency_key + FROM postgresql( + 'us-west-3.pg.psdb.cloud:5432', + 'postgres', + 'entities', + {{tb_secret('PG_USERNAME')}}, + {{tb_secret('PG_PASSWORD')}}, + 'public' + ) + WHERE created_at > {{Int64(start_epoch_ms, 0)}} + AND created_at <= {{Int64(end_epoch_ms, 9999999999999)}} + +TYPE COPY +TARGET_DATASOURCE entities diff --git a/server/tinybird/copies/entitlements_backfill.pipe b/server/tinybird/copies/entitlements_backfill.pipe new file mode 100644 index 000000000..825bceca4 --- /dev/null +++ b/server/tinybird/copies/entitlements_backfill.pipe @@ -0,0 +1,42 @@ +DESCRIPTION > + Chunked backfill of entitlements from Postgres into entitlements. + Chunks by created_at (epoch ms). Cast numeric columns to match datasource schema. + +NODE migrate +SQL > + % + SELECT + assumeNotNull(id) AS id, + created_at::Int64 AS created_at, + coalesce(internal_feature_id, '') AS internal_feature_id, + internal_product_id, + if(coalesce(is_custom, false) = true, 1, 0) AS is_custom, + allowance_type, + if(allowance IS NULL, NULL, allowance::Float64) AS allowance, + interval, + coalesce(interval_count, 1)::Float64 AS interval_count, + if(coalesce(carry_from_previous, false) = true, 1, 0) AS carry_from_previous, + entity_feature_id, + org_id, + feature_id, + if(usage_limit IS NULL, NULL, usage_limit::Float64) AS usage_limit, + ifNull(toString(rollover), 'null') AS rollover, + 'read' AS __action, + toDateTime64(now(), 6) AS __commit_timestamp, + 0 AS __commit_lsn, + concat('backfill-', assumeNotNull(id)) AS __idempotency_key + FROM postgresql( + 'us-west-3.pg.psdb.cloud:5432', + 'postgres', + 'entitlements', + {{tb_secret('PG_USERNAME')}}, + {{tb_secret('PG_PASSWORD')}}, + 'public' + ) + WHERE created_at > {{Int64(start_epoch_ms, 0)}} + AND created_at <= {{Int64(end_epoch_ms, 9999999999999)}} + AND (allowance IS NULL OR allowance < 1e15) + AND (usage_limit IS NULL OR usage_limit < 1e15) + +TYPE COPY +TARGET_DATASOURCE entitlements diff --git a/server/tinybird/copies/features_backfill.pipe b/server/tinybird/copies/features_backfill.pipe new file mode 100644 index 000000000..7ffb2640e --- /dev/null +++ b/server/tinybird/copies/features_backfill.pipe @@ -0,0 +1,36 @@ +DESCRIPTION > + Chunked backfill of features from Postgres into features. + Chunks by created_at (epoch ms). Cast numeric columns to match datasource schema. + +NODE migrate +SQL > + % + SELECT + coalesce(internal_id, '') AS internal_id, + coalesce(org_id, '') AS org_id, + if(created_at IS NULL, NULL, created_at::Int64) AS created_at, + env, + coalesce(id, '') AS id, + name, + coalesce(type, '') AS type, + ifNull(toString(config), 'null') AS config, + ifNull(toString(display), 'null') AS display, + toUInt8(if(archived = true, 1, 0)) AS archived, + arrayMap(x -> assumeNotNull(x), coalesce(event_names, [])) AS event_names, + 'read' AS __action, + toDateTime64(now(), 6) AS __commit_timestamp, + 0 AS __commit_lsn, + concat('backfill-', coalesce(internal_id, '')) AS __idempotency_key + FROM postgresql( + 'us-west-3.pg.psdb.cloud:5432', + 'postgres', + 'features', + {{tb_secret('PG_USERNAME')}}, + {{tb_secret('PG_PASSWORD')}}, + 'public' + ) + WHERE created_at > {{Int64(start_epoch_ms, 0)}} + AND created_at <= {{Int64(end_epoch_ms, 9999999999999)}} + +TYPE COPY +TARGET_DATASOURCE features diff --git a/server/tinybird/copies/free_trials_backfill.pipe b/server/tinybird/copies/free_trials_backfill.pipe new file mode 100644 index 000000000..28e76086a --- /dev/null +++ b/server/tinybird/copies/free_trials_backfill.pipe @@ -0,0 +1,33 @@ +DESCRIPTION > + Chunked backfill of free_trials from Postgres into free_trials. + Chunks by created_at (epoch ms). Cast numeric columns to match datasource schema. + +NODE migrate +SQL > + % + SELECT + assumeNotNull(id) AS id, + created_at::Int64 AS created_at, + internal_product_id, + coalesce(duration, 'day') AS duration, + if(length IS NULL, NULL, length::Float64) AS length, + toUInt8(if(coalesce(unique_fingerprint, false), 1, 0)) AS unique_fingerprint, + toUInt8(if(coalesce(is_custom, false), 1, 0)) AS is_custom, + toUInt8(if(coalesce(card_required, false), 1, 0)) AS card_required, + 'read' AS __action, + toDateTime64(now(), 6) AS __commit_timestamp, + 0 AS __commit_lsn, + concat('backfill-', assumeNotNull(id)) AS __idempotency_key + FROM postgresql( + 'us-west-3.pg.psdb.cloud:5432', + 'postgres', + 'free_trials', + {{tb_secret('PG_USERNAME')}}, + {{tb_secret('PG_PASSWORD')}}, + 'public' + ) + WHERE created_at > {{Int64(start_epoch_ms, 0)}} + AND created_at <= {{Int64(end_epoch_ms, 9999999999999)}} + +TYPE COPY +TARGET_DATASOURCE free_trials diff --git a/server/tinybird/copies/invoices_backfill.pipe b/server/tinybird/copies/invoices_backfill.pipe new file mode 100644 index 000000000..4153348b5 --- /dev/null +++ b/server/tinybird/copies/invoices_backfill.pipe @@ -0,0 +1,39 @@ +DESCRIPTION > + Chunked backfill of invoices from Postgres into invoices. + Chunks by created_at (epoch ms). Cast numeric columns to match datasource schema. + +NODE migrate +SQL > + % + SELECT + assumeNotNull(id) AS id, + created_at::Int64 AS created_at, + arrayMap(x -> assumeNotNull(x), product_ids) AS product_ids, + arrayMap(x -> assumeNotNull(x), internal_product_ids) AS internal_product_ids, + assumeNotNull(internal_customer_id) AS internal_customer_id, + internal_entity_id, + assumeNotNull(stripe_id) AS stripe_id, + assumeNotNull(status) AS status, + hosted_invoice_url, + total::Float64 AS total, + assumeNotNull(currency) AS currency, + coalesce(discounts::String, '[]') AS discounts, + coalesce(items::String, '[]') AS items, + 'read' AS __action, + now() AS __commit_timestamp, + 0 AS __commit_lsn, + concat('backfill-', id) AS __idempotency_key + FROM postgresql( + 'us-west-3.pg.psdb.cloud:5432', + 'postgres', + 'invoices', + {{tb_secret('PG_USERNAME')}}, + {{tb_secret('PG_PASSWORD')}}, + 'public' + ) + WHERE created_at > {{Int64(start_epoch_ms, 0)}} + AND created_at <= {{Int64(end_epoch_ms, 9999999999999)}} + AND (total IS NULL OR total < 1e15) + +TYPE COPY +TARGET_DATASOURCE invoices diff --git a/server/tinybird/copies/organizations_backfill.pipe b/server/tinybird/copies/organizations_backfill.pipe new file mode 100644 index 000000000..5140be0ad --- /dev/null +++ b/server/tinybird/copies/organizations_backfill.pipe @@ -0,0 +1,36 @@ +DESCRIPTION > + Chunked backfill of organizations from Postgres into organizations. + Chunks by created_at (epoch ms). Cast numeric/timestamp columns to match datasource schema. + +NODE migrate +SQL > + % + SELECT + assumeNotNull(id) AS id, + assumeNotNull(slug) AS slug, + assumeNotNull(name) AS name, + logo, + createdAt::String AS created_at_ts, + metadata, + coalesce(default_currency, 'usd') AS default_currency, + if(stripe_connected = true, 1, 0) AS stripe_connected, + created_at::Int64 AS created_at, + if(onboarded = true, 1, 0) AS onboarded, + if(deployed = true, 1, 0) AS deployed, + 'read' AS __action, + now() AS __commit_timestamp, + 0 AS __commit_lsn, + concat('backfill-', id) AS __idempotency_key + FROM postgresql( + 'us-west-3.pg.psdb.cloud:5432', + 'postgres', + 'organizations', + {{tb_secret('PG_USERNAME')}}, + {{tb_secret('PG_PASSWORD')}}, + 'public' + ) + WHERE created_at > {{Int64(start_epoch_ms, 0)}} + AND created_at <= {{Int64(end_epoch_ms, 9999999999999)}} + +TYPE COPY +TARGET_DATASOURCE organizations diff --git a/server/tinybird/copies/prices_backfill.pipe b/server/tinybird/copies/prices_backfill.pipe new file mode 100644 index 000000000..11db0343c --- /dev/null +++ b/server/tinybird/copies/prices_backfill.pipe @@ -0,0 +1,35 @@ +DESCRIPTION > + Chunked backfill of prices from Postgres into prices. + Chunks by created_at (epoch ms). Cast numeric columns to match datasource schema. + +NODE migrate +SQL > + % + SELECT + assumeNotNull(id) AS id, + coalesce(org_id, '') AS org_id, + coalesce(internal_product_id, '') AS internal_product_id, + ifNull(toString(config), 'null') AS config, + created_at::Int64 AS created_at, + billing_type, + tier_behavior, + toUInt8(if(coalesce(is_custom, false) = true, 1, 0)) AS is_custom, + entitlement_id, + ifNull(toString(proration_config), 'null') AS proration_config, + 'read' AS __action, + toDateTime64(now(), 6) AS __commit_timestamp, + 0 AS __commit_lsn, + concat('backfill-', assumeNotNull(id)) AS __idempotency_key + FROM postgresql( + 'us-west-3.pg.psdb.cloud:5432', + 'postgres', + 'prices', + {{tb_secret('PG_USERNAME')}}, + {{tb_secret('PG_PASSWORD')}}, + 'public' + ) + WHERE created_at > {{Int64(start_epoch_ms, 0)}} + AND created_at <= {{Int64(end_epoch_ms, 9999999999999)}} + +TYPE COPY +TARGET_DATASOURCE prices diff --git a/server/tinybird/copies/products_backfill.pipe b/server/tinybird/copies/products_backfill.pipe new file mode 100644 index 000000000..f95d2a2d3 --- /dev/null +++ b/server/tinybird/copies/products_backfill.pipe @@ -0,0 +1,39 @@ +DESCRIPTION > + Chunked backfill of products from Postgres into products. + Chunks by created_at (epoch ms). Cast numeric columns to match datasource schema. + +NODE migrate +SQL > + % + SELECT + coalesce(internal_id, '') AS internal_id, + coalesce(id, '') AS id, + name, + description, + coalesce(org_id, '') AS org_id, + created_at::Int64 AS created_at, + coalesce(env, '') AS env, + toUInt8(if(is_add_on = true, 1, 0)) AS is_add_on, + toUInt8(if(is_default = true, 1, 0)) AS is_default, + group, + coalesce(version, 1)::Float64 AS version, + ifNull(toString(processor), 'null') AS processor, + base_variant_id, + toUInt8(if(archived = true, 1, 0)) AS archived, + 'read' AS __action, + toDateTime64(now(), 6) AS __commit_timestamp, + 0 AS __commit_lsn, + concat('backfill-', coalesce(internal_id, '')) AS __idempotency_key + FROM postgresql( + 'us-west-3.pg.psdb.cloud:5432', + 'postgres', + 'products', + {{tb_secret('PG_USERNAME')}}, + {{tb_secret('PG_PASSWORD')}}, + 'public' + ) + WHERE created_at > {{Int64(start_epoch_ms, 0)}} + AND created_at <= {{Int64(end_epoch_ms, 9999999999999)}} + +TYPE COPY +TARGET_DATASOURCE products diff --git a/server/tinybird/copies/replaceables_backfill.pipe b/server/tinybird/copies/replaceables_backfill.pipe new file mode 100644 index 000000000..310c0bcaa --- /dev/null +++ b/server/tinybird/copies/replaceables_backfill.pipe @@ -0,0 +1,30 @@ +DESCRIPTION > + Chunked backfill of replaceables from Postgres into replaceables. + Chunks by created_at (epoch ms / bigint). Cast numeric columns to match datasource schema. + +NODE migrate +SQL > + % + SELECT + assumeNotNull(id) AS id, + coalesce(cus_ent_id, '') AS cus_ent_id, + created_at::Int64 AS created_at, + from_entity_id, + if(coalesce(delete_next_cycle, false) = true, 1, 0) AS delete_next_cycle, + 'read' AS __action, + toDateTime64(now(), 6) AS __commit_timestamp, + 0 AS __commit_lsn, + concat('backfill-', assumeNotNull(id)) AS __idempotency_key + FROM postgresql( + 'us-west-3.pg.psdb.cloud:5432', + 'postgres', + 'replaceables', + {{tb_secret('PG_USERNAME')}}, + {{tb_secret('PG_PASSWORD')}}, + 'public' + ) + WHERE created_at > {{Int64(start_epoch_ms, 0)}} + AND created_at <= {{Int64(end_epoch_ms, 9999999999999)}} + +TYPE COPY +TARGET_DATASOURCE replaceables diff --git a/server/tinybird/copies/rollovers_backfill.pipe b/server/tinybird/copies/rollovers_backfill.pipe new file mode 100644 index 000000000..0bcaea069 --- /dev/null +++ b/server/tinybird/copies/rollovers_backfill.pipe @@ -0,0 +1,35 @@ +DESCRIPTION > + Offset-based backfill of rollovers from Postgres into rollovers. + Pages through the full table using LIMIT + OFFSET ordered by id. + Stop when a page returns fewer rows than page_size. + +NODE migrate +SQL > + % + SELECT + assumeNotNull(id) AS id, + coalesce(cus_ent_id, '') AS cus_ent_id, + coalesce(balance, 0)::Float64 AS balance, + if(expires_at IS NULL, NULL, expires_at::Int64) AS expires_at, + coalesce(usage, 0)::Float64 AS usage, + ifNull(toString(entities), '{}') AS entities, + 'read' AS __action, + toDateTime64(now(), 6) AS __commit_timestamp, + 0 AS __commit_lsn, + concat('backfill-', assumeNotNull(id)) AS __idempotency_key + FROM postgresql( + 'us-west-3.pg.psdb.cloud:5432', + 'postgres', + 'rollovers', + {{tb_secret('PG_USERNAME')}}, + {{tb_secret('PG_PASSWORD')}}, + 'public' + ) + WHERE balance < 1e15 + AND usage < 1e15 + ORDER BY id ASC + LIMIT {{Int32(page_size, 5000)}} + OFFSET {{Int32(offset, 0)}} + +TYPE COPY +TARGET_DATASOURCE rollovers diff --git a/server/tinybird/copies/subscriptions_backfill.pipe b/server/tinybird/copies/subscriptions_backfill.pipe new file mode 100644 index 000000000..a45b77829 --- /dev/null +++ b/server/tinybird/copies/subscriptions_backfill.pipe @@ -0,0 +1,35 @@ +DESCRIPTION > + Chunked backfill of subscriptions from Postgres into subscriptions. + Chunks by created_at (epoch ms). Cast numeric columns to match datasource schema. + +NODE migrate +SQL > + % + SELECT + assumeNotNull(id) AS id, + coalesce(org_id, '') AS org_id, + stripe_id, + stripe_schedule_id, + created_at::Int64 AS created_at, + ifNull(toString(metadata), '{}') AS metadata, + arrayMap(x -> assumeNotNull(x), coalesce(usage_features, [])) AS usage_features, + env, + if(current_period_start IS NULL, NULL, current_period_start::Int64) AS current_period_start, + if(current_period_end IS NULL, NULL, current_period_end::Int64) AS current_period_end, + 'read' AS __action, + toDateTime64(now(), 6) AS __commit_timestamp, + 0 AS __commit_lsn, + concat('backfill-', assumeNotNull(id)) AS __idempotency_key + FROM postgresql( + 'us-west-3.pg.psdb.cloud:5432', + 'postgres', + 'subscriptions', + {{tb_secret('PG_USERNAME')}}, + {{tb_secret('PG_PASSWORD')}}, + 'public' + ) + WHERE created_at > {{Int64(start_epoch_ms, 0)}} + AND created_at <= {{Int64(end_epoch_ms, 9999999999999)}} + +TYPE COPY +TARGET_DATASOURCE subscriptions diff --git a/server/tinybird/datasources/customer_entitlements.datasource b/server/tinybird/datasources/customer_entitlements.datasource new file mode 100644 index 000000000..c50097627 --- /dev/null +++ b/server/tinybird/datasources/customer_entitlements.datasource @@ -0,0 +1,34 @@ +TOKEN sequin_cdc APPEND + +DESCRIPTION > + customer_entitlements CDC events from Sequin (Postgres → Tinybird via WAL). + action = insert | update | delete | read (read = initial backfill row). + +SCHEMA > + `id` String `json:$.id`, + `customer_product_id` Nullable(String) `json:$.customer_product_id`, + `entitlement_id` String `json:$.entitlement_id`, + `internal_customer_id` String `json:$.internal_customer_id`, + `internal_entity_id` Nullable(String) `json:$.internal_entity_id`, + `internal_feature_id` String `json:$.internal_feature_id`, + `unlimited` UInt8 `json:$.unlimited`, + `balance` Float64 `json:$.balance`, + `created_at` Int64 `json:$.created_at`, + `next_reset_at` Nullable(Int64) `json:$.next_reset_at`, + `usage_allowed` UInt8 `json:$.usage_allowed`, + `adjustment` Nullable(Float64) `json:$.adjustment`, + `additional_balance` Float64 `json:$.additional_balance`, + `entities` String `json:$.entities`, + `expires_at` Nullable(Int64) `json:$.expires_at`, + `cache_version` Int32 `json:$.cache_version`, + `customer_id` Nullable(String) `json:$.customer_id`, + `feature_id` Nullable(String) `json:$.feature_id`, + `__action` String `json:$.__action`, + `__commit_timestamp` DateTime64(6) `json:$.__commit_timestamp`, + `__commit_lsn` Int64 `json:$.__commit_lsn`, + `__idempotency_key` String `json:$.__idempotency_key` + +ENGINE "ReplacingMergeTree" +ENGINE_VER "__commit_lsn" +ENGINE_SORTING_KEY "id" +ENGINE_PRIMARY_KEY "id" diff --git a/server/tinybird/datasources/customer_prices.datasource b/server/tinybird/datasources/customer_prices.datasource new file mode 100644 index 000000000..d626f2885 --- /dev/null +++ b/server/tinybird/datasources/customer_prices.datasource @@ -0,0 +1,22 @@ +TOKEN sequin_cdc APPEND + +DESCRIPTION > + customer_prices CDC events from Sequin (Postgres → Tinybird via WAL). + action = insert | update | delete | read (read = initial backfill row). + +SCHEMA > + `id` String `json:$.id`, + `created_at` Int64 `json:$.created_at`, + `price_id` Nullable(String) `json:$.price_id`, + `options` String `json:$.options`, + `internal_customer_id` Nullable(String) `json:$.internal_customer_id`, + `customer_product_id` Nullable(String) `json:$.customer_product_id`, + `__action` String `json:$.__action`, + `__commit_timestamp` DateTime64(6) `json:$.__commit_timestamp`, + `__commit_lsn` Int64 `json:$.__commit_lsn`, + `__idempotency_key` String `json:$.__idempotency_key` + +ENGINE "ReplacingMergeTree" +ENGINE_VER "__commit_lsn" +ENGINE_SORTING_KEY "id" +ENGINE_PRIMARY_KEY "id" diff --git a/server/tinybird/datasources/customer_products.datasource b/server/tinybird/datasources/customer_products.datasource new file mode 100644 index 000000000..1f9634b64 --- /dev/null +++ b/server/tinybird/datasources/customer_products.datasource @@ -0,0 +1,41 @@ +TOKEN sequin_cdc APPEND + +DESCRIPTION > + customer_products CDC events from Sequin (Postgres → Tinybird via WAL). + action = insert | update | delete | read (read = initial backfill row). + +SCHEMA > + `id` String `json:$.id`, + `internal_customer_id` String `json:$.internal_customer_id`, + `internal_product_id` String `json:$.internal_product_id`, + `internal_entity_id` Nullable(String) `json:$.internal_entity_id`, + `created_at` Nullable(Int64) `json:$.created_at`, + `status` Nullable(String) `json:$.status`, + `processor` String `json:$.processor`, + `canceled` UInt8 `json:$.canceled`, + `canceled_at` Nullable(Int64) `json:$.canceled_at`, + `ended_at` Nullable(Int64) `json:$.ended_at`, + `starts_at` Nullable(Int64) `json:$.starts_at`, + `options` String `json:$.options`, + `product_id` Nullable(String) `json:$.product_id`, + `free_trial_id` Nullable(String) `json:$.free_trial_id`, + `trial_ends_at` Nullable(Int64) `json:$.trial_ends_at`, + `collection_method` String `json:$.collection_method`, + `subscription_ids` Array(String) `json:$.subscription_ids[:]`, + `scheduled_ids` Array(String) `json:$.scheduled_ids[:]`, + `quantity` Float64 `json:$.quantity`, + `is_custom` UInt8 `json:$.is_custom`, + `customer_id` Nullable(String) `json:$.customer_id`, + `entity_id` Nullable(String) `json:$.entity_id`, + `billing_version` Nullable(String) `json:$.billing_version`, + `api_version` Nullable(Int64) `json:$.api_version`, + `api_semver` Nullable(String) `json:$.api_semver`, + `__action` String `json:$.__action`, + `__commit_timestamp` DateTime64(6) `json:$.__commit_timestamp`, + `__commit_lsn` Int64 `json:$.__commit_lsn`, + `__idempotency_key` String `json:$.__idempotency_key` + +ENGINE "ReplacingMergeTree" +ENGINE_VER "__commit_lsn" +ENGINE_SORTING_KEY "id" +ENGINE_PRIMARY_KEY "id" diff --git a/server/tinybird/datasources/customers.datasource b/server/tinybird/datasources/customers.datasource new file mode 100644 index 000000000..062d97338 --- /dev/null +++ b/server/tinybird/datasources/customers.datasource @@ -0,0 +1,26 @@ +TOKEN sequin_cdc APPEND + +DESCRIPTION > + Customer CDC events from Sequin (PlanetScale Postgres → Tinybird via WAL). + action = insert | update | delete | read (read = initial backfill row). +SCHEMA > + `internal_id` String `json:$.internal_id`, + `org_id` String `json:$.org_id`, + `created_at` Nullable(Int64) `json:$.created_at`, + `name` Nullable(String) `json:$.name`, + `id` Nullable(String) `json:$.id`, + `email` Nullable(String) `json:$.email`, + `fingerprint` Nullable(String) `json:$.fingerprint`, + `metadata` String `json:$.metadata`, + `env` String `json:$.env`, + `processor` String `json:$.processor`, + `processors` String `json:$.processors`, + `send_email_receipts` UInt8 `json:$.send_email_receipts`, + `__action` String `json:$.__action`, + `__commit_timestamp` DateTime64(6) `json:$.__commit_timestamp`, + `__commit_lsn` Int64 `json:$.__commit_lsn`, + `__idempotency_key` String `json:$.__idempotency_key` +ENGINE "ReplacingMergeTree" +ENGINE_VER "__commit_lsn" +ENGINE_SORTING_KEY "internal_id" +ENGINE_PRIMARY_KEY "internal_id" diff --git a/server/tinybird/datasources/entities.datasource b/server/tinybird/datasources/entities.datasource new file mode 100644 index 000000000..0b5386f61 --- /dev/null +++ b/server/tinybird/datasources/entities.datasource @@ -0,0 +1,27 @@ +TOKEN sequin_cdc APPEND + +DESCRIPTION > + entities CDC events from Sequin (Postgres → Tinybird via WAL). + action = insert | update | delete | read (read = initial backfill row). + Primary key is internal_id (the stable surrogate PK), not id (the user-facing id). + +SCHEMA > + `id` Nullable(String) `json:$.id`, + `org_id` Nullable(String) `json:$.org_id`, + `created_at` Int64 `json:$.created_at`, + `internal_id` String `json:$.internal_id`, + `internal_customer_id` String `json:$.internal_customer_id`, + `env` Nullable(String) `json:$.env`, + `name` Nullable(String) `json:$.name`, + `deleted` UInt8 `json:$.deleted`, + `internal_feature_id` Nullable(String) `json:$.internal_feature_id`, + `feature_id` Nullable(String) `json:$.feature_id`, + `__action` String `json:$.__action`, + `__commit_timestamp` DateTime64(6) `json:$.__commit_timestamp`, + `__commit_lsn` Int64 `json:$.__commit_lsn`, + `__idempotency_key` String `json:$.__idempotency_key` + +ENGINE "ReplacingMergeTree" +ENGINE_VER "__commit_lsn" +ENGINE_SORTING_KEY "internal_id" +ENGINE_PRIMARY_KEY "internal_id" diff --git a/server/tinybird/datasources/entitlements.datasource b/server/tinybird/datasources/entitlements.datasource new file mode 100644 index 000000000..d5460a802 --- /dev/null +++ b/server/tinybird/datasources/entitlements.datasource @@ -0,0 +1,31 @@ +TOKEN sequin_cdc APPEND + +DESCRIPTION > + entitlements CDC events from Sequin (Postgres → Tinybird via WAL). + action = insert | update | delete | read (read = initial backfill row). + +SCHEMA > + `id` String `json:$.id`, + `created_at` Int64 `json:$.created_at`, + `internal_feature_id` String `json:$.internal_feature_id`, + `internal_product_id` Nullable(String) `json:$.internal_product_id`, + `is_custom` UInt8 `json:$.is_custom`, + `allowance_type` Nullable(String) `json:$.allowance_type`, + `allowance` Nullable(Float64) `json:$.allowance`, + `interval` Nullable(String) `json:$.interval`, + `interval_count` Float64 `json:$.interval_count`, + `carry_from_previous` UInt8 `json:$.carry_from_previous`, + `entity_feature_id` Nullable(String) `json:$.entity_feature_id`, + `org_id` Nullable(String) `json:$.org_id`, + `feature_id` Nullable(String) `json:$.feature_id`, + `usage_limit` Nullable(Float64) `json:$.usage_limit`, + `rollover` String `json:$.rollover`, + `__action` String `json:$.__action`, + `__commit_timestamp` DateTime64(6) `json:$.__commit_timestamp`, + `__commit_lsn` Int64 `json:$.__commit_lsn`, + `__idempotency_key` String `json:$.__idempotency_key` + +ENGINE "ReplacingMergeTree" +ENGINE_VER "__commit_lsn" +ENGINE_SORTING_KEY "id" +ENGINE_PRIMARY_KEY "id" diff --git a/server/tinybird/datasources/features.datasource b/server/tinybird/datasources/features.datasource new file mode 100644 index 000000000..f7a7d4869 --- /dev/null +++ b/server/tinybird/datasources/features.datasource @@ -0,0 +1,27 @@ +TOKEN sequin_cdc APPEND + +DESCRIPTION > + features CDC events from Sequin (Postgres → Tinybird via WAL). + action = insert | update | delete | read (read = initial backfill row). + +SCHEMA > + `internal_id` String `json:$.internal_id`, + `org_id` String `json:$.org_id`, + `created_at` Nullable(Int64) `json:$.created_at`, + `env` Nullable(String) `json:$.env`, + `id` String `json:$.id`, + `name` Nullable(String) `json:$.name`, + `type` String `json:$.type`, + `config` String `json:$.config`, + `display` String `json:$.display`, + `archived` UInt8 `json:$.archived`, + `event_names` Array(String) `json:$.event_names[:]`, + `__action` String `json:$.__action`, + `__commit_timestamp` DateTime64(6) `json:$.__commit_timestamp`, + `__commit_lsn` Int64 `json:$.__commit_lsn`, + `__idempotency_key` String `json:$.__idempotency_key` + +ENGINE "ReplacingMergeTree" +ENGINE_VER "__commit_lsn" +ENGINE_SORTING_KEY "internal_id" +ENGINE_PRIMARY_KEY "internal_id" diff --git a/server/tinybird/datasources/free_trials.datasource b/server/tinybird/datasources/free_trials.datasource new file mode 100644 index 000000000..a4afd0fcc --- /dev/null +++ b/server/tinybird/datasources/free_trials.datasource @@ -0,0 +1,24 @@ +TOKEN sequin_cdc APPEND + +DESCRIPTION > + free_trials CDC events from Sequin (Postgres → Tinybird via WAL). + action = insert | update | delete | read (read = initial backfill row). + +SCHEMA > + `id` String `json:$.id`, + `created_at` Int64 `json:$.created_at`, + `internal_product_id` Nullable(String) `json:$.internal_product_id`, + `duration` String `json:$.duration`, + `length` Nullable(Float64) `json:$.length`, + `unique_fingerprint` UInt8 `json:$.unique_fingerprint`, + `is_custom` UInt8 `json:$.is_custom`, + `card_required` UInt8 `json:$.card_required`, + `__action` String `json:$.__action`, + `__commit_timestamp` DateTime64(6) `json:$.__commit_timestamp`, + `__commit_lsn` Int64 `json:$.__commit_lsn`, + `__idempotency_key` String `json:$.__idempotency_key` + +ENGINE "ReplacingMergeTree" +ENGINE_VER "__commit_lsn" +ENGINE_SORTING_KEY "id" +ENGINE_PRIMARY_KEY "id" diff --git a/server/tinybird/datasources/invoices.datasource b/server/tinybird/datasources/invoices.datasource new file mode 100644 index 000000000..480e8172b --- /dev/null +++ b/server/tinybird/datasources/invoices.datasource @@ -0,0 +1,27 @@ +TOKEN sequin_cdc APPEND + +DESCRIPTION > + Invoice CDC events from Sequin (PlanetScale Postgres → Tinybird via WAL). + action = insert | update | delete | read (read = initial backfill row). +SCHEMA > + `id` String `json:$.id`, + `created_at` Nullable(Int64) `json:$.created_at`, + `product_ids` Array(String) `json:$.product_ids[:]`, + `internal_product_ids` Array(String) `json:$.internal_product_ids[:]`, + `internal_customer_id` String `json:$.internal_customer_id`, + `internal_entity_id` Nullable(String) `json:$.internal_entity_id`, + `stripe_id` String `json:$.stripe_id`, + `status` String `json:$.status`, + `hosted_invoice_url` Nullable(String) `json:$.hosted_invoice_url`, + `total` Nullable(Float64) `json:$.total`, + `currency` String `json:$.currency`, + `discounts` String `json:$.discounts`, + `items` String `json:$.items`, + `__action` String `json:$.__action`, + `__commit_timestamp` DateTime64(6) `json:$.__commit_timestamp`, + `__commit_lsn` Int64 `json:$.__commit_lsn`, + `__idempotency_key` String `json:$.__idempotency_key` +ENGINE "ReplacingMergeTree" +ENGINE_VER "__commit_lsn" +ENGINE_SORTING_KEY "id" +ENGINE_PRIMARY_KEY "id" \ No newline at end of file diff --git a/server/tinybird/datasources/organizations.datasource b/server/tinybird/datasources/organizations.datasource new file mode 100644 index 000000000..74340fa9a --- /dev/null +++ b/server/tinybird/datasources/organizations.datasource @@ -0,0 +1,25 @@ +TOKEN sequin_cdc APPEND + +DESCRIPTION > + Organization CDC events from Sequin (PlanetScale Postgres → Tinybird via WAL). + action = insert | update | delete | read (read = initial backfill row). +SCHEMA > + `id` String `json:$.id`, + `slug` String `json:$.slug`, + `name` String `json:$.name`, + `logo` Nullable(String) `json:$.logo`, + `created_at_ts` Nullable(String) `json:$.created_at_ts`, + `metadata` Nullable(String) `json:$.metadata`, + `default_currency` String `json:$.default_currency`, + `stripe_connected` UInt8 `json:$.stripe_connected`, + `created_at` Nullable(Int64) `json:$.created_at`, + `onboarded` UInt8 `json:$.onboarded`, + `deployed` UInt8 `json:$.deployed`, + `__action` String `json:$.__action`, + `__commit_timestamp` DateTime64(6) `json:$.__commit_timestamp`, + `__commit_lsn` Int64 `json:$.__commit_lsn`, + `__idempotency_key` String `json:$.__idempotency_key` +ENGINE "ReplacingMergeTree" +ENGINE_VER "__commit_lsn" +ENGINE_SORTING_KEY "id" +ENGINE_PRIMARY_KEY "id" \ No newline at end of file diff --git a/server/tinybird/datasources/prices.datasource b/server/tinybird/datasources/prices.datasource new file mode 100644 index 000000000..2d632382e --- /dev/null +++ b/server/tinybird/datasources/prices.datasource @@ -0,0 +1,26 @@ +TOKEN sequin_cdc APPEND + +DESCRIPTION > + prices CDC events from Sequin (Postgres → Tinybird via WAL). + action = insert | update | delete | read (read = initial backfill row). + +SCHEMA > + `id` String `json:$.id`, + `org_id` String `json:$.org_id`, + `internal_product_id` String `json:$.internal_product_id`, + `config` String `json:$.config`, + `created_at` Int64 `json:$.created_at`, + `billing_type` Nullable(String) `json:$.billing_type`, + `tier_behavior` Nullable(String) `json:$.tier_behavior`, + `is_custom` UInt8 `json:$.is_custom`, + `entitlement_id` Nullable(String) `json:$.entitlement_id`, + `proration_config` String `json:$.proration_config`, + `__action` String `json:$.__action`, + `__commit_timestamp` DateTime64(6) `json:$.__commit_timestamp`, + `__commit_lsn` Int64 `json:$.__commit_lsn`, + `__idempotency_key` String `json:$.__idempotency_key` + +ENGINE "ReplacingMergeTree" +ENGINE_VER "__commit_lsn" +ENGINE_SORTING_KEY "id" +ENGINE_PRIMARY_KEY "id" diff --git a/server/tinybird/datasources/products.datasource b/server/tinybird/datasources/products.datasource new file mode 100644 index 000000000..ad45bf153 --- /dev/null +++ b/server/tinybird/datasources/products.datasource @@ -0,0 +1,30 @@ +TOKEN sequin_cdc APPEND + +DESCRIPTION > + products CDC events from Sequin (Postgres → Tinybird via WAL). + action = insert | update | delete | read (read = initial backfill row). + +SCHEMA > + `internal_id` String `json:$.internal_id`, + `id` String `json:$.id`, + `name` Nullable(String) `json:$.name`, + `description` Nullable(String) `json:$.description`, + `org_id` String `json:$.org_id`, + `created_at` Int64 `json:$.created_at`, + `env` String `json:$.env`, + `is_add_on` UInt8 `json:$.is_add_on`, + `is_default` UInt8 `json:$.is_default`, + `group` Nullable(String) `json:$.group`, + `version` Float64 `json:$.version`, + `processor` String `json:$.processor`, + `base_variant_id` Nullable(String) `json:$.base_variant_id`, + `archived` UInt8 `json:$.archived`, + `__action` String `json:$.__action`, + `__commit_timestamp` DateTime64(6) `json:$.__commit_timestamp`, + `__commit_lsn` Int64 `json:$.__commit_lsn`, + `__idempotency_key` String `json:$.__idempotency_key` + +ENGINE "ReplacingMergeTree" +ENGINE_VER "__commit_lsn" +ENGINE_SORTING_KEY "internal_id" +ENGINE_PRIMARY_KEY "internal_id" diff --git a/server/tinybird/datasources/replaceables.datasource b/server/tinybird/datasources/replaceables.datasource new file mode 100644 index 000000000..c0bff1a0a --- /dev/null +++ b/server/tinybird/datasources/replaceables.datasource @@ -0,0 +1,21 @@ +TOKEN sequin_cdc APPEND + +DESCRIPTION > + replaceables CDC events from Sequin (Postgres → Tinybird via WAL). + action = insert | update | delete | read (read = initial backfill row). + +SCHEMA > + `id` String `json:$.id`, + `cus_ent_id` String `json:$.cus_ent_id`, + `created_at` Int64 `json:$.created_at`, + `from_entity_id` Nullable(String) `json:$.from_entity_id`, + `delete_next_cycle` UInt8 `json:$.delete_next_cycle`, + `__action` String `json:$.__action`, + `__commit_timestamp` DateTime64(6) `json:$.__commit_timestamp`, + `__commit_lsn` Int64 `json:$.__commit_lsn`, + `__idempotency_key` String `json:$.__idempotency_key` + +ENGINE "ReplacingMergeTree" +ENGINE_VER "__commit_lsn" +ENGINE_SORTING_KEY "id" +ENGINE_PRIMARY_KEY "id" diff --git a/server/tinybird/datasources/rollovers.datasource b/server/tinybird/datasources/rollovers.datasource new file mode 100644 index 000000000..cf6099efe --- /dev/null +++ b/server/tinybird/datasources/rollovers.datasource @@ -0,0 +1,23 @@ +TOKEN sequin_cdc APPEND + +DESCRIPTION > + rollovers CDC events from Sequin (Postgres → Tinybird via WAL). + action = insert | update | delete | read (read = initial backfill row). + No created_at — deduplication is by id + __commit_lsn. + +SCHEMA > + `id` String `json:$.id`, + `cus_ent_id` String `json:$.cus_ent_id`, + `balance` Float64 `json:$.balance`, + `expires_at` Nullable(Int64) `json:$.expires_at`, + `usage` Float64 `json:$.usage`, + `entities` String `json:$.entities`, + `__action` String `json:$.__action`, + `__commit_timestamp` DateTime64(6) `json:$.__commit_timestamp`, + `__commit_lsn` Int64 `json:$.__commit_lsn`, + `__idempotency_key` String `json:$.__idempotency_key` + +ENGINE "ReplacingMergeTree" +ENGINE_VER "__commit_lsn" +ENGINE_SORTING_KEY "id" +ENGINE_PRIMARY_KEY "id" diff --git a/server/tinybird/datasources/subscriptions.datasource b/server/tinybird/datasources/subscriptions.datasource new file mode 100644 index 000000000..6e54ce583 --- /dev/null +++ b/server/tinybird/datasources/subscriptions.datasource @@ -0,0 +1,26 @@ +TOKEN sequin_cdc APPEND + +DESCRIPTION > + subscriptions CDC events from Sequin (Postgres → Tinybird via WAL). + action = insert | update | delete | read (read = initial backfill row). + +SCHEMA > + `id` String `json:$.id`, + `org_id` String `json:$.org_id`, + `stripe_id` Nullable(String) `json:$.stripe_id`, + `stripe_schedule_id` Nullable(String) `json:$.stripe_schedule_id`, + `created_at` Int64 `json:$.created_at`, + `metadata` String `json:$.metadata`, + `usage_features` Array(String) `json:$.usage_features[:]`, + `env` Nullable(String) `json:$.env`, + `current_period_start` Nullable(Int64) `json:$.current_period_start`, + `current_period_end` Nullable(Int64) `json:$.current_period_end`, + `__action` String `json:$.__action`, + `__commit_timestamp` DateTime64(6) `json:$.__commit_timestamp`, + `__commit_lsn` Int64 `json:$.__commit_lsn`, + `__idempotency_key` String `json:$.__idempotency_key` + +ENGINE "ReplacingMergeTree" +ENGINE_VER "__commit_lsn" +ENGINE_SORTING_KEY "id" +ENGINE_PRIMARY_KEY "id" diff --git a/server/tinybird/scripts/backfill_base.ts b/server/tinybird/scripts/backfill_base.ts new file mode 100644 index 000000000..b1d582ba2 --- /dev/null +++ b/server/tinybird/scripts/backfill_base.ts @@ -0,0 +1,284 @@ +#!/usr/bin/env bun + +/** + * Shared utilities and config for all epoch-ms-chunked Tinybird backfill scripts. + */ + +import { execSync } from "child_process"; + +// ============================================================================ +// CONSTANTS +// ============================================================================ + +export const MAX_RETRIES = 3; +export const RETRY_DELAY_MS = 5000; +export const DELAY_BETWEEN_CHUNKS_MS = 2000; +export const DEFAULT_CHUNK_DAYS = 30; + +// ============================================================================ +// TYPES +// ============================================================================ + +export interface Chunk { + startEpochMs: number; + endEpochMs: number; +} + +export interface TableConfig { + /** Human-readable label, e.g. "Customers" */ + label: string; + /** Tinybird copy pipe name, e.g. "customers_backfill" */ + copyPipeName: string; + /** Tinybird datasource table name, e.g. "customers" */ + datasource: string; + /** Epoch ms of the earliest known row in Postgres */ + startEpochMs: number; +} + +// ============================================================================ +// UTILITIES +// ============================================================================ + +export function execCapture({ cmd }: { cmd: string }): string { + try { + return execSync(cmd, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim(); + } catch (error: any) { + return error.stdout?.trim() ?? ""; + } +} + +const TB_API_URL = process.env.TINYBIRD_API_URL ?? "https://api.us-west-2.aws.tinybird.co"; +const TB_TOKEN = process.env.TINYBIRD_TOKEN; + +if (!TB_TOKEN) { + console.error("TINYBIRD_TOKEN env var is not set. Export it before running backfill scripts."); + process.exit(1); +} + +/** Query Tinybird SQL API directly — returns parsed rows as an array of objects. */ +export async function tbSql({ query }: { query: string }): Promise[]> { + const queryWithFormat = `${query.trimEnd()} FORMAT JSONEachRow`; + const url = `${TB_API_URL}/v0/sql?q=${encodeURIComponent(queryWithFormat)}`; + const res = await fetch(url, { + headers: { Authorization: `Bearer ${TB_TOKEN}` }, + }); + if (!res.ok) { + const body = await res.text(); + throw new Error(`Tinybird SQL API error ${res.status}: ${body}`); + } + const text = await res.text(); + // JSONEachRow returns one JSON object per line + return text + .split("\n") + .map((l) => l.trim()) + .filter((l) => l.length > 0) + .map((l) => JSON.parse(l) as Record); +} + +export function exec({ cmd }: { cmd: string }): void { + execSync(cmd, { encoding: "utf-8", stdio: "inherit" }); +} + +export async function sleep({ ms }: { ms: number }): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** Extract a single numeric value from the first row/column of a tbSql result. */ +export function extractNumber({ rows, col }: { rows: Record[]; col: string }): number | null { + const val = rows[0]?.[col]; + if (val === null || val === undefined) return null; + const num = Number(val); + return isNaN(num) ? null : num; +} + +export function formatEpochMs({ epochMs }: { epochMs: number }): string { + return new Date(epochMs).toISOString(); +} + +// ============================================================================ +// TINYBIRD HELPERS +// ============================================================================ + +/** Returns the MAX created_at among backfill ('read') rows, or null if none. */ +export async function getMaxCreatedAt({ datasource }: { datasource: string }): Promise { + const rows = await tbSql({ + query: `SELECT max(created_at) AS val FROM ${datasource} WHERE __action = 'read'`, + }); + const num = extractNumber({ rows, col: "val" }); + return num && num > 0 ? num : null; +} + +/** Returns count of non-deleted rows in the datasource. */ +export async function getRowCount({ datasource }: { datasource: string }): Promise { + const rows = await tbSql({ + query: `SELECT count() AS val FROM ${datasource} FINAL WHERE __action != 'delete'`, + }); + return extractNumber({ rows, col: "val" }) ?? 0; +} + +// ============================================================================ +// CHUNK GENERATION (FORWARD — oldest → newest) +// ============================================================================ + +export function generateChunksForward({ + startEpochMs, + endEpochMs, + chunkDays, +}: { + startEpochMs: number; + endEpochMs: number; + chunkDays: number; +}): Chunk[] { + const chunks: Chunk[] = []; + const chunkMs = chunkDays * 24 * 60 * 60 * 1000; + let current = startEpochMs; + + while (current < endEpochMs) { + const next = Math.min(current + chunkMs, endEpochMs); + chunks.push({ startEpochMs: current, endEpochMs: next }); + current = next; + } + + return chunks; +} + +// ============================================================================ +// COPY JOB EXECUTION +// ============================================================================ + +export async function waitForCopyJobs(): Promise { + const maxAttempts = 60; + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const waiting = execCapture({ + cmd: "tb --cloud job ls --status waiting --kind copy 2>/dev/null | grep -c '^id:' || echo 0", + }); + const working = execCapture({ + cmd: "tb --cloud job ls --status working --kind copy 2>/dev/null | grep -c '^id:' || echo 0", + }); + const total = parseInt(waiting, 10) + parseInt(working, 10); + + if (total === 0) return; + if (attempt === 0) console.log(` Waiting for ${total} existing copy job(s) to complete...`); + + await sleep({ ms: 5000 }); + } + + throw new Error("Timed out waiting for existing copy jobs to complete"); +} + +export async function runCopyJob({ + copyPipeName, + startEpochMs, + endEpochMs, + retries = MAX_RETRIES, +}: { + copyPipeName: string; + startEpochMs: number; + endEpochMs: number; + retries?: number; +}): Promise { + for (let attempt = 1; attempt <= retries; attempt++) { + try { + await waitForCopyJobs(); + exec({ + cmd: `tb --cloud copy run ${copyPipeName} --param start_epoch_ms="${startEpochMs}" --param end_epoch_ms="${endEpochMs}" --wait`, + }); + return true; + } catch (error: any) { + const msg = error.message || error.toString(); + if (attempt < retries) { + console.log(` Attempt ${attempt}/${retries} failed. Retrying in ${RETRY_DELAY_MS / 1000}s...`); + console.log(` Error: ${msg.substring(0, 200)}`); + await sleep({ ms: RETRY_DELAY_MS }); + } else { + console.error(` All ${retries} attempts failed for chunk ${startEpochMs} -> ${endEpochMs}`); + console.error(` Error: ${msg}`); + return false; + } + } + } + return false; +} + +// ============================================================================ +// CORE BACKFILL RUNNER +// ============================================================================ + +/** + * Runs a complete forward (oldest → newest) backfill for a single table config. + * Idempotent: resumes from MAX(created_at) of existing 'read' rows. + */ +export async function runTableBackfill({ + config, + chunkDays, + dryRun, +}: { + config: TableConfig; + chunkDays: number; + dryRun: boolean; +}): Promise { + console.log(`\n=== ${config.label} Backfill ===`); + + const tinybirdMax = await getMaxCreatedAt({ datasource: config.datasource }); + if (tinybirdMax) { + console.log(` Resume point: ${tinybirdMax} (${formatEpochMs({ epochMs: tinybirdMax })})`); + } else { + console.log(" No backfilled rows yet — starting from beginning"); + } + + const resumePoint = tinybirdMax ? tinybirdMax + 1 : config.startEpochMs; + const endPoint = Date.now(); + + console.log(` Start: ${formatEpochMs({ epochMs: resumePoint })}`); + console.log(` End: ${formatEpochMs({ epochMs: endPoint })}`); + + if (resumePoint >= endPoint) { + console.log(` Already complete. Rows in Tinybird: ${await getRowCount({ datasource: config.datasource })}`); + return; + } + + const chunks = generateChunksForward({ startEpochMs: resumePoint, endEpochMs: endPoint, chunkDays }); + console.log(` Chunks: ${chunks.length} (${chunkDays} days each)\n`); + + if (dryRun) { + chunks.forEach((chunk, i) => { + console.log( + ` ${i + 1}. ${formatEpochMs({ epochMs: chunk.startEpochMs })} -> ${formatEpochMs({ epochMs: chunk.endEpochMs })}`, + ); + }); + return; + } + + for (let i = 0; i < chunks.length; i++) { + const chunk = chunks[i]; + const chunkNum = i + 1; + + console.log( + `Chunk ${chunkNum}/${chunks.length}: ${formatEpochMs({ epochMs: chunk.startEpochMs })} -> ${formatEpochMs({ epochMs: chunk.endEpochMs })}`, + ); + + const startTime = Date.now(); + const success = await runCopyJob({ + copyPipeName: config.copyPipeName, + startEpochMs: chunk.startEpochMs, + endEpochMs: chunk.endEpochMs, + }); + const duration = Math.round((Date.now() - startTime) / 1000); + + if (success) { + const count = await getRowCount({ datasource: config.datasource }); + console.log(` ✓ Chunk ${chunkNum} done in ${duration}s. Rows: ${count}\n`); + } else { + console.log(` ✗ Chunk ${chunkNum} FAILED after ${duration}s`); + console.log(" Re-run this script to resume.\n"); + process.exit(1); + } + + if (i < chunks.length - 1) { + await sleep({ ms: DELAY_BETWEEN_CHUNKS_MS }); + } + } + + console.log(`=== ${config.label} Backfill Complete. Rows: ${await getRowCount({ datasource: config.datasource })} ===\n`); +} diff --git a/server/tinybird/scripts/backfill_cli.ts b/server/tinybird/scripts/backfill_cli.ts new file mode 100644 index 000000000..b7df3ab7f --- /dev/null +++ b/server/tinybird/scripts/backfill_cli.ts @@ -0,0 +1,303 @@ +#!/usr/bin/env bun + +/** + * Interactive CLI for backfilling Postgres tables into Tinybird. + * + * Multi-select which tables to backfill, then runs them sequentially. + * All jobs are idempotent — safe to re-run, automatically resumes from progress. + * + * Usage: + * bun scripts/backfill_cli.ts + * bun scripts/backfill_cli.ts --chunk-days 14 + * bun scripts/backfill_cli.ts --dry-run + * bun scripts/backfill_cli.ts --all + */ + +import * as readline from "readline"; +import { + DEFAULT_CHUNK_DAYS, + type TableConfig, + runTableBackfill, +} from "./backfill_base.js"; + +// ============================================================================ +// TABLE REGISTRY +// NOTE: rollovers is NOT listed here — it uses cursor-based pagination. +// Run: bun scripts/backfill_rollovers.ts +// ============================================================================ + +const TABLE_CONFIGS: TableConfig[] = [ + { + label: "customers", + copyPipeName: "customers_backfill", + datasource: "customers", + startEpochMs: 1706987055000, + }, + { + label: "invoices", + copyPipeName: "invoices_backfill", + datasource: "invoices", + startEpochMs: 1706987056000, + }, + { + label: "organizations", + copyPipeName: "organizations_backfill", + datasource: "organizations", + startEpochMs: 1737543151220, + }, + { + label: "customer_products", + copyPipeName: "customer_products_backfill", + datasource: "customer_products", + startEpochMs: 1677713742000, + }, + { + label: "customer_entitlements", + copyPipeName: "customer_entitlements_backfill", + datasource: "customer_entitlements", + startEpochMs: 1738268254191, + }, + { + label: "customer_prices", + copyPipeName: "customer_prices_backfill", + datasource: "customer_prices", + startEpochMs: 1738341655109, + }, + { + label: "replaceables", + copyPipeName: "replaceables_backfill", + datasource: "replaceables", + startEpochMs: 1751360953240, + }, + { + label: "entitlements", + copyPipeName: "entitlements_backfill", + datasource: "entitlements", + startEpochMs: 1737570713388, + }, + { + label: "free_trials", + copyPipeName: "free_trials_backfill", + datasource: "free_trials", + startEpochMs: 1738168893927, + }, + { + label: "entities", + copyPipeName: "entities_backfill", + datasource: "entities", + startEpochMs: 1743685142432, + }, + { + label: "subscriptions", + copyPipeName: "subscriptions_backfill", + datasource: "subscriptions", + startEpochMs: 1744118448491, + }, + { + label: "features", + copyPipeName: "features_backfill", + datasource: "features", + startEpochMs: 1737570301197, + }, + { + label: "prices", + copyPipeName: "prices_backfill", + datasource: "prices", + startEpochMs: 1737570713388, + }, + { + label: "products", + copyPipeName: "products_backfill", + datasource: "products", + startEpochMs: 1737570326143, + }, + // rollovers intentionally excluded — use backfill_rollovers.ts instead +]; + +// ============================================================================ +// ARGUMENT PARSING +// ============================================================================ + +interface Args { + chunkDays: number; + dryRun: boolean; + all: boolean; +} + +function parseArgs(): Args { + const args: Args = { + chunkDays: DEFAULT_CHUNK_DAYS, + dryRun: false, + all: false, + }; + + for (let i = 2; i < process.argv.length; i++) { + const arg = process.argv[i]; + if (arg === "--chunk-days" && process.argv[i + 1]) { + args.chunkDays = parseInt(process.argv[++i], 10); + } else if (arg === "--dry-run") { + args.dryRun = true; + } else if (arg === "--all") { + args.all = true; + } else if (arg === "--help" || arg === "-h") { + console.log(` +Interactive Tinybird backfill CLI + +Usage: bun scripts/backfill_cli.ts [options] + +Options: + --chunk-days Days per chunk (default: ${DEFAULT_CHUNK_DAYS}) + --dry-run Show chunks without executing copy jobs + --all Skip prompt, backfill all tables + --help, -h Show this help message + +Available tables: +${TABLE_CONFIGS.map((t, i) => ` ${i + 1}. ${t.label}`).join("\n")} + +Note: rollovers uses cursor-based pagination — run separately: + bun scripts/backfill_rollovers.ts +`); + process.exit(0); + } + } + + return args; +} + +// ============================================================================ +// MULTI-SELECT PROMPT +// ============================================================================ + +async function multiSelect({ + items, + prompt, +}: { + items: string[]; + prompt: string; +}): Promise { + const selected = new Set(); + + // Pre-select all + items.forEach((_, i) => selected.add(i)); + + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + // Keys: 1-9 for indices 0-8, then a-z for indices 9-34 + // (a/A are reserved for select-all, n/N for select-none — skip those) + const KEYS = "123456789bcdefghijklmopqrstuvwxyz"; + + const keyForIndex = (i: number) => KEYS[i] ?? "?"; + + const renderMenu = () => { + console.clear(); + console.log(prompt); + console.log("(key to toggle, A to select all, N to select none, Enter to confirm)\n"); + items.forEach((item, i) => { + const checked = selected.has(i) ? "[x]" : "[ ]"; + console.log(` ${checked} ${keyForIndex(i)}. ${item}`); + }); + console.log(""); + }; + + return new Promise((resolve) => { + renderMenu(); + + // Use raw mode for keypress detection + if (process.stdin.isTTY) { + process.stdin.setRawMode(true); + } + + const onKeypress = (chunk: Buffer) => { + const key = chunk.toString().toLowerCase(); + + if (key === "\r" || key === "\n") { + // Enter — confirm + process.stdin.removeListener("data", onKeypress); + if (process.stdin.isTTY) { + process.stdin.setRawMode(false); + } + rl.close(); + console.clear(); + resolve([...selected].sort((a, b) => a - b)); + return; + } + + if (key === "a") { + items.forEach((_, i) => selected.add(i)); + renderMenu(); + return; + } + + if (key === "n") { + selected.clear(); + renderMenu(); + return; + } + + if (key === "\x03") { + // Ctrl+C + process.exit(0); + } + + // Map key to index + const idx = KEYS.indexOf(key); + if (idx !== -1 && idx < items.length) { + if (selected.has(idx)) { + selected.delete(idx); + } else { + selected.add(idx); + } + renderMenu(); + return; + } + }; + + process.stdin.on("data", onKeypress); + }); +} + +// ============================================================================ +// MAIN +// ============================================================================ + +async function main() { + const args = parseArgs(); + + let selectedConfigs: TableConfig[]; + + if (args.all) { + selectedConfigs = TABLE_CONFIGS; + console.log("--all flag set: backfilling all tables.\n"); + } else { + const selectedIndices = await multiSelect({ + items: TABLE_CONFIGS.map((t) => t.label), + prompt: "Select tables to backfill:", + }); + + if (selectedIndices.length === 0) { + console.log("No tables selected. Exiting."); + process.exit(0); + } + + selectedConfigs = selectedIndices.map((i) => TABLE_CONFIGS[i]); + } + + console.log(`\nBackfilling ${selectedConfigs.length} table(s): ${selectedConfigs.map((t) => t.label).join(", ")}`); + console.log(`Chunk size: ${args.chunkDays} days`); + if (args.dryRun) console.log("DRY RUN — no copy jobs will be executed.\n"); + + for (const config of selectedConfigs) { + await runTableBackfill({ config, chunkDays: args.chunkDays, dryRun: args.dryRun }); + } + + console.log("\n=========================================="); + console.log("All selected backfills complete."); +} + +main().catch((error) => { + console.error("Fatal error:", error); + process.exit(1); +}); diff --git a/server/tinybird/scripts/backfill_events.ts b/server/tinybird/scripts/backfill_events.ts index 40ba07066..fecb9a310 100644 --- a/server/tinybird/scripts/backfill_events.ts +++ b/server/tinybird/scripts/backfill_events.ts @@ -124,7 +124,7 @@ function exec(cmd: string, silent = false): string { function execCapture(cmd: string): string { try { - return execSync(cmd, { encoding: "utf-8", stderr: "pipe" }).trim(); + return execSync(cmd, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim(); } catch (error: any) { return error.stdout?.trim() ?? ""; } diff --git a/server/tinybird/scripts/backfill_rollovers.ts b/server/tinybird/scripts/backfill_rollovers.ts new file mode 100644 index 000000000..37193e77e --- /dev/null +++ b/server/tinybird/scripts/backfill_rollovers.ts @@ -0,0 +1,170 @@ +#!/usr/bin/env bun + +/** + * Offset-based idempotent backfill for rollovers from Postgres to Tinybird. + * + * Rollovers have no created_at so we can't chunk by date. + * Pages through the full table with LIMIT + OFFSET ordered by id. + * Stops when a page returns fewer rows than page_size. + * + * Usage: + * bun scripts/backfill_rollovers.ts + * bun scripts/backfill_rollovers.ts --page-size 1000 + * bun scripts/backfill_rollovers.ts --offset 10000 + * bun scripts/backfill_rollovers.ts --dry-run + */ + +import { + MAX_RETRIES, + RETRY_DELAY_MS, + DELAY_BETWEEN_CHUNKS_MS, + exec, + tbSql, + sleep, + extractNumber, + waitForCopyJobs, +} from "./backfill_base.js"; + +// ============================================================================ +// CONFIGURATION +// ============================================================================ + +const DATASOURCE = "rollovers"; +const COPY_PIPE = "rollovers_backfill"; +const DEFAULT_PAGE_SIZE = 5000; + +// ============================================================================ +// ARGUMENT PARSING +// ============================================================================ + +function parseArgs(): { pageSize: number; offsetOverride?: number; dryRun: boolean } { + const args = { pageSize: DEFAULT_PAGE_SIZE, offsetOverride: undefined as number | undefined, dryRun: false }; + + for (let i = 2; i < process.argv.length; i++) { + const arg = process.argv[i]; + if (arg === "--page-size" && process.argv[i + 1]) { + args.pageSize = parseInt(process.argv[++i], 10); + } else if (arg === "--offset" && process.argv[i + 1]) { + args.offsetOverride = parseInt(process.argv[++i], 10); + } else if (arg === "--dry-run") { + args.dryRun = true; + } else if (arg === "--help" || arg === "-h") { + console.log(` +Offset-based backfill for rollovers + +Usage: bun scripts/backfill_rollovers.ts [options] + +Options: + --page-size Rows per page (default: ${DEFAULT_PAGE_SIZE}) + --offset Start from this offset (default: 0) + --dry-run Print plan without executing + --help, -h Show this help +`); + process.exit(0); + } + } + + return args; +} + +// ============================================================================ +// HELPERS +// ============================================================================ + +async function getRowCount(): Promise { + const rows = await tbSql({ + query: `SELECT count() AS val FROM ${DATASOURCE} FINAL WHERE __action != 'delete'`, + }); + return extractNumber({ rows, col: "val" }) ?? 0; +} + +async function runPage({ + offset, + pageSize, + retries = MAX_RETRIES, +}: { + offset: number; + pageSize: number; + retries?: number; +}): Promise { + for (let attempt = 1; attempt <= retries; attempt++) { + try { + await waitForCopyJobs(); + exec({ + cmd: `TB_VERSION_WARNING=0 tb --cloud copy run ${COPY_PIPE} --param offset="${offset}" --param page_size="${pageSize}" --wait`, + }); + return true; + } catch (error: any) { + const msg = error.message || error.toString(); + if (attempt < retries) { + console.log(` Attempt ${attempt}/${retries} failed. Retrying in ${RETRY_DELAY_MS / 1000}s...`); + console.log(` Error: ${msg.substring(0, 200)}`); + await sleep({ ms: RETRY_DELAY_MS }); + } else { + console.error(` All ${retries} attempts failed at offset ${offset}`); + console.error(` Error: ${msg}`); + return false; + } + } + } + return false; +} + +// ============================================================================ +// MAIN +// ============================================================================ + +async function main() { + console.log("=== Rollovers Backfill (Offset-based) ===\n"); + + const args = parseArgs(); + let offset = args.offsetOverride ?? 0; + + console.log(`Page size: ${args.pageSize}`); + console.log(`Starting offset: ${offset}\n`); + + if (args.dryRun) { + console.log("DRY RUN — would run pages starting at offset", offset); + process.exit(0); + } + + let pageNum = 0; + + while (true) { + pageNum++; + console.log(`=== Page ${pageNum}: offset=${offset} ===`); + const startTime = Date.now(); + + const success = await runPage({ offset, pageSize: args.pageSize }); + const duration = Math.round((Date.now() - startTime) / 1000); + + if (!success) { + console.log(`✗ Page ${pageNum} FAILED after ${duration}s`); + console.log("To resume from this point, re-run with:"); + console.log(` bun scripts/backfill_rollovers.ts --offset ${offset}\n`); + process.exit(1); + } + + const rowCount = await getRowCount(); + console.log(`✓ Page ${pageNum} done in ${duration}s. Rows in Tinybird: ${rowCount}\n`); + + // A page returning fewer rows than page_size means we've hit the end + // We detect this by comparing expected next offset vs actual row count + offset += args.pageSize; + if (rowCount < offset) { + console.log("Last page was partial — backfill complete."); + break; + } + + await sleep({ ms: DELAY_BETWEEN_CHUNKS_MS }); + } + + console.log("=========================================="); + console.log("=== Rollovers Backfill Complete ==="); + console.log(`Total rollovers in Tinybird: ${await getRowCount()}`); +} + +main().catch((error) => { + console.error("Fatal error:", error); + process.exit(1); +}); diff --git a/shared/models/billingModels/stripe/stripeItemSpec.ts b/shared/models/billingModels/stripe/stripeItemSpec.ts index 52fa1d59a..cadb93fd2 100644 --- a/shared/models/billingModels/stripe/stripeItemSpec.ts +++ b/shared/models/billingModels/stripe/stripeItemSpec.ts @@ -1,11 +1,30 @@ import type { FullCusEntWithFullCusProduct } from "@models/cusProductModels/cusEntModels/cusEntWithProduct"; import type { EntitlementWithFeature } from "@models/productModels/entModels/entModels"; +import type Stripe from "stripe"; import type { Price } from "../../productModels/priceModels/priceModels"; import type { FullProduct } from "../../productModels/productModels"; +/** + * Inline Stripe price data for entity-scoped items. + * Pre-calculated flat amount (not tiered) — Stripe doesn't support tiered price_data. + * `recurring` is omitted for one-off prices. + */ +export type StripeInlinePrice = { + product: string; + currency: string; + recurring?: Stripe.PriceCreateParams.Recurring; + unit_amount_decimal: string; +}; + +/** + * Intermediate type bridging Autumn price model to Stripe line items. + * Either `stripePriceId` (stored price) or `stripeInlinePrice` (entity-scoped inline) must be set. + */ export type StripeItemSpec = { - stripePriceId: string; // stripe price ID + stripePriceId?: string; + stripeInlinePrice?: StripeInlinePrice; quantity?: number; + metadata?: Record; autumnPrice?: Price; autumnEntitlement?: EntitlementWithFeature; autumnProduct?: FullProduct; diff --git a/shared/utils/billingUtils/invoicingUtils/lineItemBuilders/usagePriceToLineItem.ts b/shared/utils/billingUtils/invoicingUtils/lineItemBuilders/usagePriceToLineItem.ts index 038ea75c2..648aa8aac 100644 --- a/shared/utils/billingUtils/invoicingUtils/lineItemBuilders/usagePriceToLineItem.ts +++ b/shared/utils/billingUtils/invoicingUtils/lineItemBuilders/usagePriceToLineItem.ts @@ -1,4 +1,5 @@ import { cusEntsToAllowance } from "@utils/cusEntUtils"; +import { cusEntToPrepaidInvoiceOverage } from "@utils/cusEntUtils/balanceUtils/cusEntsToPrepaidInvoiceOverage"; import { Decimal } from "decimal.js"; import { InternalError } from "../../../../api/errors/base/InternalError"; import type { LineItemContext } from "../../../../models/billingModels/lineItem/lineItemContext"; @@ -11,7 +12,6 @@ import { cusEntToInvoiceUsage } from "../../../cusEntUtils/overageUtils/cusEntTo import { isConsumablePrice, isPrepaidPrice, - isVolumePrice, } from "../../../productUtils/priceUtils/classifyPriceUtils"; import { usagePriceToLineDescription } from "../descriptionUtils/usagePriceToLineDescription"; import { priceToLineAmount } from "../lineItemUtils/priceToLineAmount"; @@ -49,12 +49,10 @@ export const usagePriceToLineItem = ({ const price = cusPrice.price; // 1. Get overage + // don't use upcoming quantity for prepaid prices by default. THe price that users have paid currently is quantity. let overage = 0; if (isPrepaidPrice(cusPrice.price)) { - overage = cusEntsToPrepaidQuantity({ - cusEnts: [cusEnt], - sumAcrossEntities: false, - }); + overage = cusEntToPrepaidInvoiceOverage({ cusEnt }); } else { overage = cusEntToInvoiceOverage({ cusEnt }); } @@ -63,9 +61,6 @@ export const usagePriceToLineItem = ({ // which tier applies, and the ENTIRE total is charged at that tier's rate. // So we add allowance back to overage before pricing. const allowance = cusEntsToAllowance({ cusEnts: [cusEnt] }); - if (isVolumePrice(cusPrice.price)) { - overage = new Decimal(overage).add(allowance).toNumber(); - } // 2. Get usage let usage = 0; diff --git a/shared/utils/cusEntUtils/balanceUtils/cusEntsToPrepaidInvoiceOverage.ts b/shared/utils/cusEntUtils/balanceUtils/cusEntsToPrepaidInvoiceOverage.ts new file mode 100644 index 000000000..8da3342bc --- /dev/null +++ b/shared/utils/cusEntUtils/balanceUtils/cusEntsToPrepaidInvoiceOverage.ts @@ -0,0 +1,52 @@ +import { cusEntToCusPrice } from "@utils/cusEntUtils/convertCusEntUtils/cusEntToCusPrice"; +import { Decimal } from "decimal.js"; +import { + type FullCusEntWithFullCusProduct, + isPrepaidPrice, + isVolumeBasedCusEnt, + sumValues, +} from "../../.."; +import { cusEntToPrepaidQuantity } from "./cusEntsToPrepaidQuantity"; +import { cusEntsToAllowance } from "./grantedBalanceUtils/cusEntsToAllowance"; + +export const cusEntToPrepaidInvoiceOverage = ({ + cusEnt, + useUpcomingQuantity = false, +}: { + cusEnt: FullCusEntWithFullCusProduct; + useUpcomingQuantity?: boolean; +}) => { + // 2. If cus ent is not prepaid, skip + const cusPrice = cusEntToCusPrice({ cusEnt }); + + if (!cusPrice || !isPrepaidPrice(cusPrice.price)) return 0; + + if (!cusEnt.customer_product) return 0; + + // 3. Get quantity + const prepaidQuantity = cusEntToPrepaidQuantity({ + cusEnt, + useUpcomingQuantity, + }); + const allowance = cusEntsToAllowance({ cusEnts: [cusEnt] }); + + const isVolume = isVolumeBasedCusEnt(cusEnt); + + return isVolume + ? new Decimal(prepaidQuantity).add(allowance).toNumber() + : prepaidQuantity; +}; + +export const cusEntsToPrepaidInvoiceOverage = ({ + cusEnts, +}: { + cusEnts: FullCusEntWithFullCusProduct[]; +}) => { + return sumValues( + cusEnts.map((cusEnt) => + cusEntToPrepaidInvoiceOverage({ + cusEnt, + }), + ), + ); +}; diff --git a/shared/utils/cusEntUtils/balanceUtils/cusEntsToPrepaidQuantity.ts b/shared/utils/cusEntUtils/balanceUtils/cusEntsToPrepaidQuantity.ts index 660271472..66a8756f2 100644 --- a/shared/utils/cusEntUtils/balanceUtils/cusEntsToPrepaidQuantity.ts +++ b/shared/utils/cusEntUtils/balanceUtils/cusEntsToPrepaidQuantity.ts @@ -11,9 +11,11 @@ import { cusProductToFeatureOptions } from "../../cusProductUtils/convertCusProd export const cusEntToPrepaidQuantity = ({ cusEnt, sumAcrossEntities = false, + useUpcomingQuantity = false, }: { cusEnt: FullCusEntWithFullCusProduct; sumAcrossEntities?: boolean; + useUpcomingQuantity?: boolean; }) => { // 2. If cus ent is not prepaid, skip const cusPrice = cusEntToCusPrice({ cusEnt }); @@ -30,7 +32,11 @@ export const cusEntToPrepaidQuantity = ({ if (!options) return 0; - const quantityWithUnits = new Decimal(options.quantity) + const quantity = useUpcomingQuantity + ? (options.upcoming_quantity ?? options.quantity ?? 0) + : (options.quantity ?? 0); + + const quantityWithUnits = new Decimal(quantity) .mul(cusPrice.price.config.billing_units ?? 1) .toNumber(); @@ -46,13 +52,19 @@ export const cusEntToPrepaidQuantity = ({ export const cusEntsToPrepaidQuantity = ({ cusEnts, sumAcrossEntities = false, + useUpcomingQuantity = false, }: { cusEnts: FullCusEntWithFullCusProduct[]; sumAcrossEntities?: boolean; + useUpcomingQuantity?: boolean; }) => { return sumValues( cusEnts.map((cusEnt) => - cusEntToPrepaidQuantity({ cusEnt, sumAcrossEntities }), + cusEntToPrepaidQuantity({ + cusEnt, + sumAcrossEntities, + useUpcomingQuantity, + }), ), ); }; diff --git a/shared/utils/cusEntUtils/classifyCusEntUtils.ts b/shared/utils/cusEntUtils/classifyCusEntUtils.ts index d3326c463..9d75011e4 100644 --- a/shared/utils/cusEntUtils/classifyCusEntUtils.ts +++ b/shared/utils/cusEntUtils/classifyCusEntUtils.ts @@ -1,5 +1,6 @@ import { InternalError } from "@api/errors"; import { ms } from "@utils/common"; +import { isVolumePrice } from "@utils/productUtils/priceUtils/classifyPriceUtils"; import type { EntityBalance, FullCustomerEntitlement, @@ -101,3 +102,9 @@ export const customerEntitlementShouldBeBilled = ({ return nextResetAt <= invoicePeriodEndMs + TOLERANCE_MS; }; + +export const isVolumeBasedCusEnt = (cusEnt: FullCusEntWithFullCusProduct) => { + const cusPrice = cusEntToCusPrice({ cusEnt }); + if (!cusPrice) return false; + return isVolumePrice(cusPrice.price); +}; diff --git a/shared/utils/cusEntUtils/convertCusEntUtils/cusEntToBillingObjects.ts b/shared/utils/cusEntUtils/convertCusEntUtils/cusEntToBillingObjects.ts new file mode 100644 index 000000000..e622b2b99 --- /dev/null +++ b/shared/utils/cusEntUtils/convertCusEntUtils/cusEntToBillingObjects.ts @@ -0,0 +1,44 @@ +import type { FullCusEntWithFullCusProduct } from "@models/cusProductModels/cusEntModels/cusEntWithProduct"; +import type { FullCustomerPrice } from "@models/cusProductModels/cusPriceModels/cusPriceModels"; +import type { + FeatureOptions, + FullCusProduct, +} from "@models/cusProductModels/cusProductModels"; +import type { EntitlementWithFeature } from "@models/productModels/entModels/entModels"; +import type { Price } from "@models/productModels/priceModels/priceModels"; +import type { FullProduct } from "@models/productModels/productModels"; +import { cusProductToProduct } from "@utils/cusProductUtils/convertCusProduct"; +import { cusEntToCusPrice } from "./cusEntToCusPrice"; +import { customerEntitlementToOptions } from "./customerEntitlementToOptions"; + +export type CusEntBillingObjects = { + cusProduct: FullCusProduct; + cusPrice: FullCustomerPrice; + price: Price; + product: FullProduct; + entitlement: EntitlementWithFeature; + options: FeatureOptions | undefined; +}; + +/** + * Extracts the core billing objects from a FullCusEntWithFullCusProduct. + * Returns null if cusProduct or cusPrice can't be resolved. + */ +export const cusEntToBillingObjects = ({ + cusEnt, +}: { + cusEnt: FullCusEntWithFullCusProduct; +}): CusEntBillingObjects | null => { + const cusProduct = cusEnt.customer_product; + if (!cusProduct) return null; + + const cusPrice = cusEntToCusPrice({ cusEnt }); + if (!cusPrice) return null; + + const price = cusPrice.price; + const product = cusProductToProduct({ cusProduct }); + const entitlement = cusEnt.entitlement; + const options = customerEntitlementToOptions({ customerEntitlement: cusEnt }); + + return { cusProduct, cusPrice, price, product, entitlement, options }; +}; diff --git a/shared/utils/cusEntUtils/index.ts b/shared/utils/cusEntUtils/index.ts index 3613bbee5..1f3915724 100644 --- a/shared/utils/cusEntUtils/index.ts +++ b/shared/utils/cusEntUtils/index.ts @@ -1,6 +1,10 @@ // Balance utils + +// Balance utils barrel +export * from "./balanceUtils"; export * from "./balanceUtils/cusEntsToBalance"; export * from "./balanceUtils/cusEntsToCurrentBalance"; +export * from "./balanceUtils/cusEntsToPrepaidInvoiceOverage"; export * from "./balanceUtils/cusEntsToPrepaidQuantity"; export * from "./balanceUtils/cusEntsToPurchasedBalance"; export * from "./balanceUtils/cusEntsToReset"; @@ -18,23 +22,19 @@ export * from "./balanceUtils/rollovers/cusEntsToRolloverBalance"; export * from "./balanceUtils/rollovers/cusEntsToRolloverGranted"; export * from "./balanceUtils/rollovers/cusEntsToRolloverUsage"; export * from "./balanceUtils/rollovers/cusEntsToRolloverUsage"; - -// Balance utils barrel -export * from "./balanceUtils"; - // Classify utils export * from "./classifyCusEntUtils"; - +// Convert utils barrel +export * from "./convertCusEntUtils"; // Convert utils export * from "./convertCusEntUtils/cusEntsToMaxPurchase"; export * from "./convertCusEntUtils/cusEntsToStartingBalance"; +export * from "./convertCusEntUtils/cusEntToBillingObjects"; export * from "./convertCusEntUtils/cusEntToCusPrice"; export * from "./convertCusEntUtils/cusEntToKey"; export * from "./convertCusEntUtils/cusEntToStripeIds"; // Convert utils barrel export * from "./convertCusEntUtils/customerEntitlementToOptions"; -// Convert utils barrel -export * from "./convertCusEntUtils"; // Core utils export * from "./cusEntUtils"; export * from "./filterCusEntUtils"; diff --git a/shared/utils/cusProductUtils/convertCusProduct/customerProductsToStripeSubscriptionIds.ts b/shared/utils/cusProductUtils/convertCusProduct/customerProductsToStripeSubscriptionIds.ts new file mode 100644 index 000000000..4007672f2 --- /dev/null +++ b/shared/utils/cusProductUtils/convertCusProduct/customerProductsToStripeSubscriptionIds.ts @@ -0,0 +1,11 @@ +import { deduplicateArray, type FullCusProduct } from "../../.."; + +export const customerProductsToStripeSubscriptionIds = ({ + customerProducts, +}: { + customerProducts: FullCusProduct[]; +}) => { + return deduplicateArray( + customerProducts.flatMap((cp) => cp.subscription_ids ?? []), + ); +}; diff --git a/shared/utils/cusProductUtils/index.ts b/shared/utils/cusProductUtils/index.ts index 4a1e218cf..94b41fa96 100644 --- a/shared/utils/cusProductUtils/index.ts +++ b/shared/utils/cusProductUtils/index.ts @@ -2,9 +2,10 @@ import { customerProductToFeaturesToCarryUsagesFor } from "@utils/cusProductUtil export * from "./classifyCustomerProduct/classifyCustomerProduct"; export * from "./classifyCustomerProduct/cpBuilder"; +export * from "./convertCusProduct"; export * from "./convertCusProduct/cusProductToConvertedFeatureOptions"; export * from "./convertCusProduct/cusProductToFeatureOptions"; -export * from "./convertCusProduct"; +export * from "./convertCusProduct/customerProductsToStripeSubscriptionIds"; export * from "./cusProductConstants"; export * from "./cusProductUtils"; export * from "./featureOptionUtils/findFeatureOptions";