Merge branch 'dev' into feat/store-line-items
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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()`**
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
```
|
||||
|
||||
|
||||
255
others/sequin/tinybird_mega_transform.ex
Normal file
255
others/sequin/tinybird_mega_transform.ex
Normal 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
|
||||
24
others/sequin/tinybird_routing.ex
Normal file
24
others/sequin/tinybird_routing.ex
Normal 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
|
||||
@@ -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",
|
||||
|
||||
@@ -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
66
scripts/dx.ts
Normal 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);
|
||||
@@ -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,
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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];
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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];
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
29
server/src/utils/corsOrigins.ts
Normal file
29
server/src/utils/corsOrigins.ts
Normal 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;
|
||||
};
|
||||
@@ -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",
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
15
server/tests/_groups/temp.ts
Normal file
15
server/tests/_groups/temp.ts
Normal 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",
|
||||
],
|
||||
};
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -1,17 +1,18 @@
|
||||
/**
|
||||
* Attach Prepaid Volume vs Graduated — Entity-Level Initial Attach Test
|
||||
*
|
||||
* Two entities share one customer. One is on graduated pricing, the other on
|
||||
* volume pricing, for the same tier structure. Confirms that the initial
|
||||
* invoice totals correctly reflect each pricing model independently.
|
||||
* Test 1: Two entities, one graduated, one volume, same tier structure.
|
||||
* 800 units, tier 2:
|
||||
* Graduated: 5×$10 + 3×$5 = $65
|
||||
* Volume: 8×$5 = $40
|
||||
*
|
||||
* Test 2: Volume prepaid with includedUsage — verifies that the included
|
||||
* usage acts as a free tier and the remaining purchased units are ALL
|
||||
* charged at the volume rate (the tier that the purchased quantity falls into).
|
||||
*
|
||||
* Tiers (billingUnits = 100):
|
||||
* Tier 1: 0–500 units @ $10/pack
|
||||
* Tier 2: 501+ units @ $5/pack
|
||||
*
|
||||
* 800 units, tier 2:
|
||||
* Graduated: 5×$10 + 3×$5 = $65
|
||||
* Volume: 8×$5 = $40 ← cheaper, all at tier-2 rate
|
||||
*/
|
||||
|
||||
import { expect, test } from "bun:test";
|
||||
@@ -19,6 +20,7 @@ import type { ApiCustomerV3, ApiEntityV0 } from "@autumn/shared";
|
||||
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
|
||||
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
|
||||
import { expectProductActive } from "@tests/integration/billing/utils/expectCustomerProductCorrect";
|
||||
import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect";
|
||||
import { TestFeature } from "@tests/setup/v2Features";
|
||||
import { items } from "@tests/utils/fixtures/items";
|
||||
import { products } from "@tests/utils/fixtures/products";
|
||||
@@ -67,7 +69,7 @@ test.concurrent(`${chalk.yellowBright("attach-prepaid-volume-entities: graduated
|
||||
const gradPro = products.pro({ id: "grad-pro-ent-800", items: [gradItem] });
|
||||
const volPro = products.pro({ id: "vol-pro-ent-800", items: [volItem] });
|
||||
|
||||
const { autumnV1, entities } = await initScenario({
|
||||
const { autumnV1, entities, ctx } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
@@ -152,4 +154,137 @@ test.concurrent(`${chalk.yellowBright("attach-prepaid-volume-entities: graduated
|
||||
invoiceIndex: 1,
|
||||
latestTotal: BASE_PRICE + gradExpectedPrepaid,
|
||||
});
|
||||
|
||||
await expectStripeSubscriptionCorrect({ ctx, customerId });
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 2: Volume prepaid with includedUsage — all purchased units at volume rate
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Volume prepaid with includedUsage = 200 (must be multiple of billingUnits=100).
|
||||
*
|
||||
* Entity 1: quantity 800 → 200 included free, 600 purchased
|
||||
* Purchased packs = 600/100 = 6 packs → falls in tier 2 (>5 packs)
|
||||
* Volume: ALL 6 packs at tier-2 rate = 6×$5 = $30
|
||||
* Invoice = $20 base + $30 = $50
|
||||
*
|
||||
* Entity 2: quantity 400 → 200 included free, 200 purchased
|
||||
* Purchased packs = 200/100 = 2 packs → falls in tier 1 (≤5 packs)
|
||||
* Volume: ALL 2 packs at tier-1 rate = 2×$10 = $20
|
||||
* Invoice = $20 base + $20 = $40
|
||||
*
|
||||
* Confirms includedUsage is subtracted before volume pricing is applied,
|
||||
* and that volume pricing charges ALL purchased units at the single tier rate.
|
||||
*/
|
||||
test.concurrent(`${chalk.yellowBright("attach-prepaid-volume-entities: volume with includedUsage (200 free, tier pricing on rest)")}`, async () => {
|
||||
const customerId = "vol-ent-included-200";
|
||||
const includedUsage = 200;
|
||||
const quantity1 = 800;
|
||||
const quantity2 = 400;
|
||||
|
||||
// Entity 1: 800 (including included usage) purchased
|
||||
const purchasedPacks1 = quantity1 / BILLING_UNITS;
|
||||
const volExpected1 = purchasedPacks1 * 5; // tier 2 rate (>5 packs)
|
||||
|
||||
// Entity 2: 400 (including included usage) purchased
|
||||
const purchasedPacks2 = quantity2 / BILLING_UNITS;
|
||||
const volExpected2 = purchasedPacks2 * 10; // tier 1 rate (≤5 packs)
|
||||
|
||||
const volItem = items.volumePrepaidMessages({
|
||||
includedUsage,
|
||||
billingUnits: BILLING_UNITS,
|
||||
tiers: TIERS,
|
||||
});
|
||||
|
||||
const volPro = products.pro({ id: "vol-pro-inc-200", items: [volItem] });
|
||||
|
||||
const { autumnV1, entities, ctx } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [volPro] }),
|
||||
s.entities({ count: 2, featureId: TestFeature.Users }),
|
||||
],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
// ── Preview entity 1: $20 base + $30 volume = $50 ──
|
||||
const preview1 = await autumnV1.billing.previewAttach({
|
||||
customer_id: customerId,
|
||||
product_id: volPro.id,
|
||||
entity_id: entities[0].id,
|
||||
options: [{ feature_id: TestFeature.Messages, quantity: quantity1 }],
|
||||
});
|
||||
expect(preview1.total).toBe(BASE_PRICE + volExpected1);
|
||||
|
||||
// ── Preview entity 2: $20 base + $20 volume = $40 ──
|
||||
const preview2 = await autumnV1.billing.previewAttach({
|
||||
customer_id: customerId,
|
||||
product_id: volPro.id,
|
||||
entity_id: entities[1].id,
|
||||
options: [{ feature_id: TestFeature.Messages, quantity: quantity2 }],
|
||||
});
|
||||
expect(preview2.total).toBe(BASE_PRICE + volExpected2);
|
||||
|
||||
// ── Attach both ──
|
||||
await autumnV1.billing.attach({
|
||||
customer_id: customerId,
|
||||
product_id: volPro.id,
|
||||
entity_id: entities[0].id,
|
||||
options: [{ feature_id: TestFeature.Messages, quantity: quantity1 }],
|
||||
redirect_mode: "if_required",
|
||||
});
|
||||
await autumnV1.billing.attach({
|
||||
customer_id: customerId,
|
||||
product_id: volPro.id,
|
||||
entity_id: entities[1].id,
|
||||
options: [{ feature_id: TestFeature.Messages, quantity: quantity2 }],
|
||||
redirect_mode: "if_required",
|
||||
});
|
||||
|
||||
// ── Assert entity 1: balance = 800 ──
|
||||
const entity1 = await autumnV1.entities.get<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 });
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
classifyPhaseScenario,
|
||||
type PhaseScenario,
|
||||
} from "./classifyPhaseScenario";
|
||||
export { expectStripeSubscriptionCorrect } from "./expectStripeSubscriptionCorrect";
|
||||
export type { ExpectStripeSubOptions, NormalizedItem } from "./types";
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
66
server/tests/unit/corsOrigins.test.ts
Normal file
66
server/tests/unit/corsOrigins.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 =
|
||||
|
||||
68
server/tinybird/copies/customer_entitlements_backfill.pipe
Normal file
68
server/tinybird/copies/customer_entitlements_backfill.pipe
Normal 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
|
||||
31
server/tinybird/copies/customer_prices_backfill.pipe
Normal file
31
server/tinybird/copies/customer_prices_backfill.pipe
Normal 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
|
||||
51
server/tinybird/copies/customer_products_backfill.pipe
Normal file
51
server/tinybird/copies/customer_products_backfill.pipe
Normal 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
|
||||
37
server/tinybird/copies/customers_backfill.pipe
Normal file
37
server/tinybird/copies/customers_backfill.pipe
Normal 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
|
||||
35
server/tinybird/copies/entities_backfill.pipe
Normal file
35
server/tinybird/copies/entities_backfill.pipe
Normal 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
|
||||
42
server/tinybird/copies/entitlements_backfill.pipe
Normal file
42
server/tinybird/copies/entitlements_backfill.pipe
Normal 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
|
||||
36
server/tinybird/copies/features_backfill.pipe
Normal file
36
server/tinybird/copies/features_backfill.pipe
Normal 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
|
||||
33
server/tinybird/copies/free_trials_backfill.pipe
Normal file
33
server/tinybird/copies/free_trials_backfill.pipe
Normal 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
|
||||
39
server/tinybird/copies/invoices_backfill.pipe
Normal file
39
server/tinybird/copies/invoices_backfill.pipe
Normal 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
|
||||
36
server/tinybird/copies/organizations_backfill.pipe
Normal file
36
server/tinybird/copies/organizations_backfill.pipe
Normal 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
|
||||
35
server/tinybird/copies/prices_backfill.pipe
Normal file
35
server/tinybird/copies/prices_backfill.pipe
Normal 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
|
||||
39
server/tinybird/copies/products_backfill.pipe
Normal file
39
server/tinybird/copies/products_backfill.pipe
Normal 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
|
||||
30
server/tinybird/copies/replaceables_backfill.pipe
Normal file
30
server/tinybird/copies/replaceables_backfill.pipe
Normal 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
|
||||
35
server/tinybird/copies/rollovers_backfill.pipe
Normal file
35
server/tinybird/copies/rollovers_backfill.pipe
Normal 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
|
||||
35
server/tinybird/copies/subscriptions_backfill.pipe
Normal file
35
server/tinybird/copies/subscriptions_backfill.pipe
Normal 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
|
||||
34
server/tinybird/datasources/customer_entitlements.datasource
Normal file
34
server/tinybird/datasources/customer_entitlements.datasource
Normal 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"
|
||||
22
server/tinybird/datasources/customer_prices.datasource
Normal file
22
server/tinybird/datasources/customer_prices.datasource
Normal 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"
|
||||
41
server/tinybird/datasources/customer_products.datasource
Normal file
41
server/tinybird/datasources/customer_products.datasource
Normal 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"
|
||||
26
server/tinybird/datasources/customers.datasource
Normal file
26
server/tinybird/datasources/customers.datasource
Normal 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"
|
||||
27
server/tinybird/datasources/entities.datasource
Normal file
27
server/tinybird/datasources/entities.datasource
Normal 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"
|
||||
31
server/tinybird/datasources/entitlements.datasource
Normal file
31
server/tinybird/datasources/entitlements.datasource
Normal 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"
|
||||
27
server/tinybird/datasources/features.datasource
Normal file
27
server/tinybird/datasources/features.datasource
Normal 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"
|
||||
24
server/tinybird/datasources/free_trials.datasource
Normal file
24
server/tinybird/datasources/free_trials.datasource
Normal 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"
|
||||
27
server/tinybird/datasources/invoices.datasource
Normal file
27
server/tinybird/datasources/invoices.datasource
Normal 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"
|
||||
25
server/tinybird/datasources/organizations.datasource
Normal file
25
server/tinybird/datasources/organizations.datasource
Normal 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"
|
||||
26
server/tinybird/datasources/prices.datasource
Normal file
26
server/tinybird/datasources/prices.datasource
Normal 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"
|
||||
30
server/tinybird/datasources/products.datasource
Normal file
30
server/tinybird/datasources/products.datasource
Normal 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"
|
||||
21
server/tinybird/datasources/replaceables.datasource
Normal file
21
server/tinybird/datasources/replaceables.datasource
Normal 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"
|
||||
23
server/tinybird/datasources/rollovers.datasource
Normal file
23
server/tinybird/datasources/rollovers.datasource
Normal 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"
|
||||
26
server/tinybird/datasources/subscriptions.datasource
Normal file
26
server/tinybird/datasources/subscriptions.datasource
Normal 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"
|
||||
284
server/tinybird/scripts/backfill_base.ts
Normal file
284
server/tinybird/scripts/backfill_base.ts
Normal 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`);
|
||||
}
|
||||
303
server/tinybird/scripts/backfill_cli.ts
Normal file
303
server/tinybird/scripts/backfill_cli.ts
Normal 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);
|
||||
});
|
||||
@@ -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() ?? "";
|
||||
}
|
||||
|
||||
170
server/tinybird/scripts/backfill_rollovers.ts
Normal file
170
server/tinybird/scripts/backfill_rollovers.ts
Normal 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);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
),
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
}),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { deduplicateArray, type FullCusProduct } from "../../..";
|
||||
|
||||
export const customerProductsToStripeSubscriptionIds = ({
|
||||
customerProducts,
|
||||
}: {
|
||||
customerProducts: FullCusProduct[];
|
||||
}) => {
|
||||
return deduplicateArray(
|
||||
customerProducts.flatMap((cp) => cp.subscription_ids ?? []),
|
||||
);
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user