Merge branch 'dev' into feat/store-line-items

This commit is contained in:
John Yeo
2026-02-27 17:11:12 +00:00
97 changed files with 5925 additions and 467 deletions

View File

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

View File

@@ -34,6 +34,7 @@ Write integration tests for the Autumn billing system using the `initScenario` p
- Use generic types with `AutumnInt`: `autumnV1.customers.get<ApiCustomerV3>()`, `autumnV1.check<CheckResponseV1>()`
- **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()`**

View File

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

View File

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

View File

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

View File

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

View File

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

66
scripts/dx.ts Normal file
View File

@@ -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<boolean> {
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<number> {
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);

View File

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

View File

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

View File

@@ -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<string, Record<string, string>>();
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<Stripe.SubscriptionSchedule> => {
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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<string, StripeItemSpec>();
const storedPriceSpecs = new Map<string, StripeItemSpec>();
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];
};

View File

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

View File

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

View File

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

View File

@@ -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<string, number | undefined>();
const storedPriceMap = new Map<string, number | undefined>();
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];
};
/**

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<UpdateSubscriptionV1Params>({
customer_id: customerId,
plan_id: proProd.id,
});
console.log(updateResult);
console.log("attach1", attach1);
return;
});

View File

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

View File

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

View File

@@ -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<ApiCustomerV3>(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<ApiEntityV0>(
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<ApiEntityV0>(
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<ApiCustomerV3>(customerId);
await expectCustomerInvoiceCorrect({
customer,
count: 2,
latestTotal: expectedTotal2,
});
await expectCustomerInvoiceCorrect({
customer,
count: 2,
invoiceIndex: 1,
latestTotal: expectedTotal1,
});
});

View File

@@ -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<ApiEntityV0>(
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<ApiEntityV0>(
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<ApiCustomerV3>(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<ApiEntityV0>(
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<ApiCustomerV3>(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 });
});

View File

@@ -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: 0500 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<ApiEntityV0>(
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<ApiEntityV0>(
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<ApiCustomerV3>(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 });
});

View File

@@ -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<ApiEntityV0>(
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<ApiCustomerV3>(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<ApiEntityV0>(
customerId,
entities[0].id,
);
const entity2 = await autumnV1.entities.get<ApiEntityV0>(
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<ApiCustomerV3>(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<ApiEntityV0>(
customerId,
entities[0].id,
);
const entity2 = await autumnV1.entities.get<ApiEntityV0>(
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<ApiCustomerV3>(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<ApiEntityV0>(
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<ApiEntityV0>(
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<ApiEntityV0>(
customerId,
entities[0].id,
);
const postCycleEntity2 = await autumnV1.entities.get<ApiEntityV0>(
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<ApiCustomerV3>(customerId);
await expectCustomerInvoiceCorrect({
customer: customerAfter,
count: 4,
latestTotal:
prepaidCost(freeQuantity) + PREMIUM_BASE + prepaidCost(premiumQuantity),
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<boolean> => {
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;
}
};

View File

@@ -0,0 +1,6 @@
export {
classifyPhaseScenario,
type PhaseScenario,
} from "./classifyPhaseScenario";
export { expectStripeSubscriptionCorrect } from "./expectStripeSubscriptionCorrect";
export type { ExpectStripeSubOptions, NormalizedItem } from "./types";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<Record<string, unknown>[]> {
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<string, unknown>);
}
export function exec({ cmd }: { cmd: string }): void {
execSync(cmd, { encoding: "utf-8", stdio: "inherit" });
}
export async function sleep({ ms }: { ms: number }): Promise<void> {
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<string, unknown>[]; 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<number | null> {
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<number> {
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<void> {
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<boolean> {
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<void> {
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`);
}

View File

@@ -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 <n> 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<number[]> {
const selected = new Set<number>();
// 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);
});

View File

@@ -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() ?? "";
}

View File

@@ -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 <n> Rows per page (default: ${DEFAULT_PAGE_SIZE})
--offset <n> 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<number> {
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<boolean> {
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);
});

View File

@@ -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<string, string>;
autumnPrice?: Price;
autumnEntitlement?: EntitlementWithFeature;
autumnProduct?: FullProduct;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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";

View File

@@ -0,0 +1,11 @@
import { deduplicateArray, type FullCusProduct } from "../../..";
export const customerProductsToStripeSubscriptionIds = ({
customerProducts,
}: {
customerProducts: FullCusProduct[];
}) => {
return deduplicateArray(
customerProducts.flatMap((cp) => cp.subscription_ids ?? []),
);
};

View File

@@ -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";