merged from dev

This commit is contained in:
John Yeo
2026-03-13 10:28:12 +00:00
1015 changed files with 66563 additions and 11272 deletions

View File

@@ -0,0 +1,56 @@
---
name: openlogs-server-logs
description: Fetch and inspect recent local server logs in repos that use openlogs or the `ol` CLI. Use when a user asks what happened in the server, wants recent dev-server output, needs startup errors or stack traces, or asks you to check backend logs from `openlogs tail`, command-specific logs, or `.openlogs/latest.txt`.
---
# Openlogs Server Logs
Use `openlogs tail` to retrieve recent server logs before asking the user to paste anything. Prefer the cleaned text log unless ANSI or raw terminal bytes matter.
## Quick Start
- Run `openlogs tail -n 200` to inspect the latest run in the project.
- If the user mentions a specific command or service, run `openlogs tail <query> -n 200` to get the most recent matching run.
- Use `ol tail -n 200` if the short alias is preferred.
- Read `.openlogs/latest.txt` directly only when file access is simpler than spawning the command and you specifically want the latest overall run.
- Use `openlogs tail --raw -n 200` only when color codes, cursor control, or exact terminal output matters.
- Use `openlogs tail -f` for live follow mode.
## Workflow
1. Try `openlogs tail -n 200`.
2. If the user names a command or service, try `openlogs tail <query> -n 200`.
3. If that fails, try `ol tail -n 200`.
4. If the CLI is unavailable but the workspace is accessible, read `.openlogs/latest.txt` or the matching command-specific file in `.openlogs/`.
5. If the log directory is missing, check whether the server was started with `openlogs <command>` or `ol <command>`.
6. If it was not, tell the user to relaunch the server through openlogs, then inspect the resulting logs.
## Common Commands
```bash
openlogs tail -n 100
openlogs tail dev -n 100
openlogs tail server -f
openlogs tail -f
openlogs tail --raw -n 100
openlogs tail --out-dir logs -n 200
openlogs bun dev
ol npm run dev
```
## Interpretation Rules
- Prefer the text log for analysis because it strips ANSI noise.
- `openlogs tail` without a query means the latest run overall in the current project.
- `openlogs tail <query>` means the latest run whose command or explicit name contains that query.
- Switch to `--raw` only when the cleaned log hides something important.
- Quote the exact failing lines or error block in your answer when useful.
- State whether you are looking at the latest captured run or a live-following stream.
- If the agent cannot access local gitignored files, ask the user to run `openlogs tail -n 200` and paste the output.
## Response Shape
- Start with the command or file you used.
- Summarize the likely issue in 1 to 3 sentences.
- Include the most relevant error lines.
- If logs are missing, say exactly what command the user should rerun under openlogs.

View File

@@ -0,0 +1,4 @@
interface:
display_name: "Openlogs Server Logs"
short_description: "Fetch and inspect recent server logs"
default_prompt: "Use $openlogs-server-logs to inspect the latest local server logs with openlogs tail, or query a specific command with openlogs tail <query>."

View File

@@ -1 +1 @@
1.3.2
1.3.10

View File

@@ -32,6 +32,10 @@ const { autumnV1, otherCustomers } = await initScenario({
});
```
### 1.1. If You Are Testing Customer Creation, Do NOT Pass `customerId` to `initScenario`
`initScenario({ customerId, ... })` creates or registers that customer as part of setup. If the test itself is meant to call `autumn.customers.create(...)`, leave `customerId` out of `initScenario` or you will be testing re-create behavior instead of create behavior.
### 2. `s.billing.attach` and `s.attach` Already Have Timeouts
Both `s.billing.attach` (5-8s) and `s.attach` (4-5s) sleep after the API call. Do NOT add extra `await timeout()` after `initScenario` that already uses these in `actions`. Only add manual timeouts when calling `autumnV1.billing.attach()` directly in the test body.
@@ -166,3 +170,53 @@ const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
### 21. NEVER Run Tests Without Asking
Always ask the user for permission before running any test command. The user likely has a dev server running and needs to coordinate. Present the exact command you plan to run and wait for approval.
### 22. Use the Correct Param Types Per API Client Version
Each client version (`autumnV1`, `autumnV2`, `autumnV2_1`) expects different input/output types. Always pass the right types as generics and for local variables.
| Client | API version | Attach input | Attach output | Update subscription input |
|--------|------------|--------------|---------------|--------------------------|
| `autumnV1` | V1 (`/attach`, `/billing.attach`) | `AttachParamsV0Input` | `ApiCustomerV3` | `UpdateSubscriptionV0Params` |
| `autumnV2` | V2 (`/billing.attach`) | `AttachParamsV1Input` | `ApiCustomer` | `UpdateSubscriptionV1Params` |
| `autumnV2_1` | V2.1 (`/attach`, `/update_subscription`) | `AttachParamsV1Input` | `ApiCustomerV5` | `UpdateSubscriptionV1ParamsInput` |
Key differences between `AttachParamsV0Input` and `AttachParamsV1Input`:
- V0 (`autumnV1`): uses `product_id` + `options: [{ feature_id, quantity }]`
- V1 (`autumnV2`): uses `plan_id` + `feature_quantities: [{ feature_id, quantity }]`
```typescript
// ✅ CORRECT — autumnV1 uses AttachParamsV0Input
const params: AttachParamsV0Input = {
customer_id: customerId,
product_id: pro.id, // NOT plan_id
options: [{ feature_id: "messages", quantity: 200 }], // NOT feature_quantities
};
await autumnV1.billing.attach<AttachParamsV0Input>(params);
// ✅ CORRECT — autumnV2 uses AttachParamsV1Input
const params: AttachParamsV1Input = {
customer_id: customerId,
plan_id: pro.id, // NOT product_id
feature_quantities: [{ feature_id: "messages", quantity: 200 }], // NOT options
};
await autumnV2.billing.attach<AttachParamsV1Input>(params);
// ✅ CORRECT — autumnV2_1 uses V1-style attach/update-subscription params
await autumnV2_1.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: pro.id,
feature_quantities: [{ feature_id: "messages", quantity: 200 }],
});
await autumnV2_1.subscriptions.update<UpdateSubscriptionV1ParamsInput>({
customer_id: customerId,
plan_id: pro.id,
redirect_mode: "always",
});
// ❌ WRONG — mixing V1 param names with autumnV1 client
await autumnV1.billing.attach({ customer_id, plan_id: pro.id, feature_quantities: [...] });
```
Gotcha: if a test uses V1-style `attach` / `update_subscription` params like `plan_id`, `feature_quantities`, or `redirect_mode`, use `autumnV2_1`, not `autumnV2`.

View File

@@ -0,0 +1,246 @@
---
name: checkout
description: Understand, debug, and edit Autumn checkout flows. Covers how attach creates an Autumn checkout, how public checkout routes recompute previews, how confirmation executes billing, and when Autumn checkout vs Stripe checkout vs no checkout is chosen.
---
# Autumn Checkout Guide
## When to Use This Skill
- Debugging Autumn checkout creation, retrieval, preview, or confirmation
- Understanding why `billing.attach` returned an Autumn checkout URL
- Working on `server/src/internal/checkouts/`
- Changing how checkout previews are rendered or confirmed
- Determining whether a flow should use `autumn_checkout`, `stripe_checkout`, or no checkout
- Explaining the attach confirmation flow to another agent quickly
## Core Concept
Autumn checkout is **not** a separate billing engine. It is a thin confirmation layer around the existing V2 `attach` action.
- `attach()` still does the normal setup -> compute -> evaluate flow
- If the attach context resolves to `checkoutMode === "autumn_checkout"`, Autumn does **not** execute billing immediately
- Instead, it stores a lightweight checkout object containing the original attach params
- Public checkout routes later **re-run attach** from those stored params to build a fresh preview or to execute billing on confirm
This means the checkout does **not** persist a frozen billing plan. It persists the request, then recomputes from current state.
## Entry Point
Main path:
1. `server/src/internal/billing/v2/actions/attach/attach.ts`
2. `server/src/internal/billing/v2/actions/attach/createAutumnCheckout.ts`
3. `server/src/internal/billing/v2/utils/billingPlan/billingPlanToAutumnCheckout.ts`
4. `server/src/internal/checkouts/middleware/checkoutMiddleware.ts`
5. `server/src/internal/checkouts/handlers/handleGetCheckout.ts`
6. `server/src/internal/checkouts/handlers/handlePreviewCheckout.ts`
7. `server/src/internal/checkouts/handlers/handleConfirmCheckout.ts`
## Attach -> Autumn Checkout
`attach()` follows the normal V2 pipeline first:
```typescript
const billingContext = await setupAttachBillingContext(...)
const autumnBillingPlan = computeAttachPlan(...)
const stripeBillingPlan = await evaluateStripeBillingPlan(...)
await handleAttachV2Errors(...)
```
After that, the branch is simple:
```typescript
if (billingContext.checkoutMode === "autumn_checkout" && !skipAutumnCheckout) {
return await createAutumnCheckout(...)
}
return await executeBillingPlan(...)
```
Important consequences:
- Autumn checkout is decided **after** the billing plan exists
- Validation already ran before the checkout is created
- `skipAutumnCheckout: true` is the escape hatch used by confirm so the second attach call executes billing instead of creating another checkout
## What Gets Stored
`billingPlanToAutumnCheckout()` builds a `Checkout` record with:
- `id`
- `org_id`
- `env`
- `internal_customer_id`
- `customer_id`
- `action: "attach"`
- `params`
- `params_version`
- `status: "pending"`
- `created_at`
- `expires_at`
Storage model:
- Cache is the primary store via `setCheckoutCache()`
- Postgres is written as audit/backup via `checkoutRepo.insert()`
- TTL is 24 hours in cache, and `expires_at` is also set on the DB record
The returned billing response uses `checkoutToUrl()` so the caller gets `/c/:checkout_id` as `payment_url`.
## Public Checkout Flow
### Router + Middleware
`server/src/internal/checkouts/checkoutRouter.ts` exposes:
- `GET /:checkout_id`
- `POST /:checkout_id/preview`
- `POST /:checkout_id/confirm`
`checkoutMiddleware` does the shared setup:
- Rate limits by checkout ID
- Loads checkout from cache first
- Falls back to DB only to determine that the checkout exists but is unavailable
- Rejects completed or expired checkouts
- Marks expired DB records as `expired`
- Rehydrates public request context with the checkout's `org`, `env`, and `features`
Key behavior: if cache is missing, the middleware does **not** rebuild the checkout from DB. It throws unavailable after checking DB for audit state.
### GET /checkouts/:checkout_id
`handleGetCheckout.ts`:
- Only supports `CheckoutAction.Attach`
- Casts `checkout.params` back to `AttachParamsV1`
- Calls `billingActions.attach({ preview: true })`
- Recomputes the current billing plan from the stored params
- Converts that plan into an attach preview response for the UI
The checkout page therefore renders current computed pricing, not a persisted snapshot from creation time.
### POST /checkouts/:checkout_id/preview
`handlePreviewCheckout.ts` is the same idea as `GET`, but it merges updated `feature_quantities` into the stored params before re-running preview attach.
Use this when debugging quantity edits in checkout UI.
### POST /checkouts/:checkout_id/confirm
`handleConfirmCheckout.ts`:
1. Validates `action === "attach"`
2. Validates `status === "pending"`
3. Re-runs `attach({ preview: false, skipAutumnCheckout: true })`
4. Executes the real billing plan
5. Deletes the cache entry so the checkout is one-time-use
6. Marks the DB row as `completed`
7. Returns success metadata including `invoice_id`
Important error behavior:
- Cache is deleted **only after** successful execution
- On failure, the checkout stays pending and cached so the user can retry
- Non-`RecaseError` failures are wrapped as internal checkout failures
## Checkout Mode Decision Tree
The decision lives in `server/src/internal/billing/v2/actions/attach/setup/setupAttachCheckoutMode.ts`.
Possible outputs:
- `null`
- `"stripe_checkout"`
- `"autumn_checkout"`
### `redirect_mode: "never"`
Always returns `null`.
No checkout URL is returned, even if one would otherwise be required.
### First Pass: Should Stripe Checkout Be Required?
Stripe checkout is chosen when Autumn cannot or should not bill directly:
- Customer has **no** payment method and product is one-off
- Customer has **no** payment method, product is paid recurring, and customer does **not** already have a Stripe subscription
- Exception: if that first paid recurring product starts with a trial and `cardRequired === false`, it returns `null` instead of Stripe checkout
Two important suppressors:
- If a payment method already exists, this pass returns `null`
- If `invoiceMode` is enabled, this pass returns `null`
### Second Pass: Forced Redirects (`redirect_mode: "always"`)
If the first pass returned `null` and `redirect_mode === "always"`, Autumn forces a redirect-style flow:
- One-off product -> `"stripe_checkout"`
- Paid recurring product with **no** existing Stripe subscription -> `"stripe_checkout"`
- Everything else -> `"autumn_checkout"`
## When Autumn Checkout Applies
Autumn checkout is the fallback for `redirect_mode: "always"` when Stripe checkout is **not** required.
In practice, that means cases like:
- Customer already has a payment method, and you still want a confirmation page before applying attach
- Customer is changing an existing recurring subscription and you want a redirect/confirmation UX
- Customer is attaching something that is neither one-off nor the first paid recurring subscription, and Stripe checkout is unnecessary
- Invoice mode is enabled, `redirect_mode` is `"always"`, and you still want the user to land on an Autumn confirmation page
- Free-product attaches with `redirect_mode: "always"` also land here
The important mental model:
- `stripe_checkout` means Stripe still needs to collect payment details or own the checkout UX
- `autumn_checkout` means Autumn already has enough context to bill, but the API caller requested a confirmation step
## What Autumn Checkout Does Not Do
- It does not support arbitrary billing actions today; handlers currently accept only `CheckoutAction.Attach`
- It does not store a frozen `billingPlan`
- It does not bypass normal attach validation
- It does not delete the DB row on success; it marks it completed and removes the cache entry
- It does not recover a missing cache entry by restoring from DB
## Debugging Checklist
If a checkout link appears unexpectedly:
- Check `params.redirect_mode`
- Check whether `setupAttachCheckoutMode()` saw a payment method
- Check whether the product is one-off, free, or paid recurring
- Check whether the customer already has a Stripe subscription
- Check whether invoice mode or a no-card-required trial suppressed Stripe checkout
If the checkout preview looks different from the original attach response:
- Remember `GET /checkouts/:id` recomputes attach from stored params
- Compare customer state between creation time and retrieval time
- Check whether feature quantities were changed via preview
If confirmation creates a second checkout instead of charging:
- Confirm the code path uses `skipAutumnCheckout: true`
If a valid-looking checkout URL says unavailable:
- Check whether the cache entry expired or was deleted
- Check DB status for `completed` or `expired`
- Remember DB is audit/backup, not a recovery source for public use
## Current Scope
The data model allows `CheckoutAction.UpdateSubscription`, but the public handlers currently only support `attach`.
If you extend Autumn checkout beyond attach, update:
- checkout creation
- public handlers
- preview/response shaping
- middleware assumptions
- any skill docs that still describe attach-only behavior

View File

@@ -0,0 +1,323 @@
---
name: mutation-logs
description: Reference for Autumn balance mutation logs, lock receipts, ordered deduction provenance, and finalizeLock reconciliation semantics. Use when working on lock receipts, deduction provenance, mutation-log sync, or finalize/release flows.
---
# Mutation Logs Guide
## Summary
Autumn now treats ordered `mutation_logs` as the provenance source for balance deductions.
This replaces the earlier idea of encoding provenance inside:
- `DeductionUpdate.balance_delta`
- `DeductionUpdate.adjustment_delta`
- `DeductionUpdate.entity_deltas`
- `RolloverUpdate.balance_delta`
- `RolloverUpdate.usage_delta`
- `RolloverUpdate.entity_deltas`
Those additive delta fields were useful as an intermediate step, but they are not the right long-term model because they are:
- aggregated
- unordered
- not safe for reverse replay of partial lock releases
The correct source of truth is an ordered array of per-write mutation log items.
## Core Model
There are now two separate outputs from deduction:
### 1. Final-state updates
These remain:
- `DeductionUpdate`
- `RolloverUpdate`
They are for:
- applying updated balances to `FullCustomer`
- sync batching
- existing response helpers
They should describe final post-deduction state only.
### 2. Ordered mutation logs
These are the provenance layer.
They are for:
- lock receipt persistence
- reverse-order unwind in `finalizeLock`
- future mutation-log replay to Postgres
They must preserve the exact order in which Redis deductions were queued.
## Mutation Log Item Shape
TypeScript shape:
```ts
type MutationLogItem = {
target_type: "customer_entitlement" | "rollover";
customer_entitlement_id: string | null;
rollover_id: string | null;
entity_id: string | null;
balance_delta: number;
adjustment_delta: number;
usage_delta: number;
value_delta: number;
};
```
Field meanings:
- `target_type`
- whether the write targeted a `customer_entitlement` or a rollover
- `customer_entitlement_id`
- required for main balance writes and rollover parent linkage
- `rollover_id`
- present only for rollover items
- `entity_id`
- present for entity-scoped writes
- `balance_delta`
- exact Redis balance delta applied
- `adjustment_delta`
- exact granted/adjustment delta applied
- `usage_delta`
- exact rollover usage delta applied
- `value_delta`
- feature-unit amount represented by this step
## Why `value_delta` Exists
`balance_delta` is in credits.
`value_delta` is in the features own logical units.
Example:
- feature usage = `5`
- `credit_cost = 2`
- actual Redis balance change = `-10`
Then:
- `balance_delta = -10`
- `value_delta = 5`
This is needed because `finalizeLock` reconciles in feature/value units, not raw credits.
For one receipt, total locked value is:
```ts
sum(item.value_delta for item in receipt.items)
```
This total is signed:
- positive for deductions / tracked usage
- negative for refunds / credits
## Where Mutation Logs Are Created
Ordered mutation logs are appended during Lua deduction, not reconstructed later.
### Source of truth
`server/src/_luaScriptsV2/deductFromCustomerEntitlements/contextUtils.lua`
`init_context(...)` creates:
- `context.mutation_logs = {}`
### Append points
Mutation logs are appended from:
- `queue_balance_update(...)`
- `queue_rollover_update(...)`
This is the correct abstraction boundary because these functions already know:
- which logical bucket is being changed
- the exact Redis deltas
- the order in which writes are queued
Do not rebuild receipt items later from `updates` / `rollover_updates`.
That loses order.
## Lock Receipt Rules
Lock receipts are now stored from ordered mutation logs directly.
Relevant file:
- `server/src/_luaScriptsV2/deduction/lock/lockReceipt.lua`
Current rule:
- `receipt.items = mutation_logs`
not:
- rebuild from `updates`
- rebuild from `rollover_updates`
Lock receipts also store both:
- `lock_key`
- `hashed_key`
because:
- `lock_key` is the caller-facing logical key
- `hashed_key` is used to derive the Redis receipt key
## Redis Key Rules
The Redis receipt key is built from the hashed key, not the raw key.
Relevant helper:
- `server/src/internal/balances/utils/lock/buildLockReceiptKey.ts`
Current format:
```ts
`{${orgId}}:${env}:lock:${lockKey}`
```
The braces around `orgId` are intentional so the receipt key hashes to the same Redis cluster slot as the full-customer cache key.
## Lock Key Parsing Rules
Relevant helper:
- `server/src/internal/balances/utils/lock/parseCheckParamsForLock.ts`
Rules:
- if caller passes `lock.key`, keep it as the logical `key`
- also compute `hashed_key = Bun.hash(key).toString()`
- if no key is passed, generate a KSUID logical key and hash that
- if `key.length > 256`, throw
This means:
- user-facing API returns the logical `lock_key`
- internal Redis receipt storage uses the hashed key
## FinalizeLock Mental Model
Do not think in terms of “refund vs deduct”.
Think in terms of:
- current locked value
- desired final value
- reconcile from one to the other
### Correct abstraction
`finalizeLock` should reconcile from `locked_value` to `final_value`.
Cases:
1. Same sign, smaller magnitude
- unwind part of the existing receipt
- walk receipt items backward
2. Same sign, larger magnitude
- keep the existing receipt as-is
- deduct/refund the extra delta using the normal engine
3. Cross zero
- fully unwind existing receipt back to zero
- apply the remaining amount in the opposite direction using the normal engine
### Important rule
Do not implement finalize as:
- reverse the whole receipt
- re-run normal deduction for the final amount
That is not safe for provenance correctness when balances changed after the original lock.
## Why Ordered Logs Matter
Example original lock order:
- hourly `10`
- monthly `5`
- lifetime `2`
If finalize wants to reduce the locked value, the unwind order must be:
- lifetime first
- monthly second
- hourly last
This only works if receipt items are stored in actual deduction order.
Aggregated update maps cannot guarantee that.
## Redis vs Postgres Status
### Redis path
Redis deduction is now the authoritative ordered provenance path.
Relevant files:
- `server/src/_luaScriptsV2/deductFromCustomerEntitlements/contextUtils.lua`
- `server/src/_luaScriptsV2/deductFromCustomerEntitlements/deductFromMainBalance.lua`
- `server/src/_luaScriptsV2/deductFromCustomerEntitlements/deductFromRollovers.lua`
- `server/src/_luaScriptsV2/deductFromCustomerEntitlements/deductFromCustomerEntitlements.lua`
- `server/src/internal/balances/utils/deduction/executeRedisDeduction.ts`
`executeRedisDeduction` now exposes:
- `updates`
- `rolloverUpdates`
- `mutationLogs`
### Postgres path
Postgres deduction has not yet been upgraded to emit real ordered mutation logs.
Current behavior:
- `executePostgresDeduction` exposes `mutationLogs: []`
This is a compatibility placeholder so callers can converge on one return shape.
Future work:
- `performDeduction.sql` should emit ordered mutation log items directly
- do not reconstruct them later from final SQL updates
## Current Invariants
- lock receipts must persist ordered mutation logs directly
- mutation logs must be appended at the moment writes are queued
- final-state updates and mutation provenance are separate structures
- `value_delta` is required for partial reconcile logic
- Redis receipt keys use hashed keys and shared-slot formatting
- finalize must unwind receipt items backward for partial release
## When Editing This System
If you change deduction behavior, always check:
1. Are ordered mutation logs still appended in the true write order?
2. Does each mutation item still include correct `value_delta`?
3. Are lock receipts still persisted from `mutation_logs`, not rebuilt?
4. Did any change accidentally reintroduce provenance into aggregated update maps?
5. If touching Postgres deduction, did the SQL path preserve parity with the Redis executor return shape?

View File

@@ -29,7 +29,7 @@ jobs:
- name: Set up Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.2
bun-version: 1.3.10
- name: Install dependencies
run: bun install

View File

@@ -19,7 +19,7 @@ jobs:
- name: Set up Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.2
bun-version: 1.3.10
- name: Install dependencies
run: bun install

View File

@@ -62,7 +62,7 @@ jobs:
- name: Set up Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.2
bun-version: 1.3.10
- name: Set up Node.js (for npm OIDC)
uses: actions/setup-node@v4

View File

@@ -15,7 +15,7 @@ jobs:
- name: Set up Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.2
bun-version: 1.3.10
- name: Install dependencies
run: bun install

View File

@@ -15,7 +15,7 @@ jobs:
- name: Set up Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.2
bun-version: 1.3.10
- name: Install dependencies
run: bun install

View File

@@ -15,7 +15,7 @@ jobs:
- name: Set up Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.2
bun-version: 1.3.10
- name: Install dependencies
run: bun install
@@ -34,7 +34,7 @@ jobs:
- name: Set up Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.2
bun-version: 1.3.10
- name: Install dependencies
run: bun install

View File

@@ -15,7 +15,7 @@ jobs:
- name: Set up Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.2
bun-version: 1.3.10
- name: Install dependencies
run: bun install

5
.gitignore vendored
View File

@@ -124,3 +124,8 @@ server/autumn.config.ts
# Speakeasy
others/python-sdk/docs
packages/sdk/docs
TAKEHOME.md
.openlogs

View File

@@ -14,6 +14,16 @@
"planetscale": {
"type": "remote",
"url": "https://mcp.pscale.dev/mcp/planetscale"
},
"axiom": {
"type": "remote",
"url": "https://mcp.axiom.co/mcp"
},
"tinybird": {
"type": "remote",
"url": "https://mcp.tinybird.co?token={env:TINYBIRD_READ_TOKEN}",
"oauth": false,
"enabled": true
}
},

View File

@@ -0,0 +1,89 @@
# ALB Lambda Logging Fix
**Date:** 2026-03-04
**Status:** ✅ Fixed
## Problem
Express logs contain `req.id` (AWS ALB trace IDs) that don't exist in the `alb` Axiom dataset. Investigation showed ~40-60% of ALB logs for high-traffic orgs (e.g., `0pCIbS4AMAFDB1iBMNhARWZt2gDtVwQx`) were missing.
## Root Cause
The Lambda functions processing ALB logs (S3 → Lambda → Axiom) were **timing out and running out of memory** on large log files.
### Lambda Config
| Setting | Before | After |
|---------|--------|-------|
| Memory | 256 MB | 1024 MB |
| Timeout | 60 sec | 180 sec |
### Evidence
- CloudWatch showed ~100% error rate (72 invocations, 72 errors per hour)
- Errors: `Status: timeout` and `Runtime.OutOfMemory`
- Small files (164KB) processed successfully (~2000 logs in 3s)
- Large files (6-7MB compressed) failed consistently
- Same files retried multiple times before being abandoned
## Fix Applied
Lambda configuration updated via AWS Console:
- `alb-listener-us-east-2`: Memory 1024MB, Timeout 180s
- `alb-listener-us-west-2`: Check if same fix needed
## Verification (run after 24 hours)
### 1. Check Lambda errors are gone:
```bash
aws cloudwatch get-metric-statistics --region us-east-2 \
--namespace AWS/Lambda \
--metric-name Errors \
--dimensions Name=FunctionName,Value=alb-listener-us-east-2 \
--start-time $(date -u -v-24H +%Y-%m-%dT%H:%M:%SZ) \
--end-time $(date -u +%Y-%m-%dT%H:%M:%SZ) \
--period 3600 \
--statistics Sum
```
### 2. Compare ALB vs Express trace IDs in Axiom:
```apl
// Sample express trace IDs for an org
['express']
| where ['context.org_id'] == '0pCIbS4AMAFDB1iBMNhARWZt2gDtVwQx'
| where isnotnull(['req.id']) and ['req.id'] startswith "Root="
| where ['_time'] > ago(1h)
| summarize count() by ['req.id']
| take 10
// Then verify each exists in ALB
['alb']
| where ['trace_id'] == "<trace_id_from_above>"
```
### 3. Check invocation success rate:
```bash
# Invocations
aws cloudwatch get-metric-statistics --region us-east-2 \
--namespace AWS/Lambda --metric-name Invocations \
--dimensions Name=FunctionName,Value=alb-listener-us-east-2 \
--start-time $(date -u -v-6H +%Y-%m-%dT%H:%M:%SZ) \
--end-time $(date -u +%Y-%m-%dT%H:%M:%SZ) \
--period 3600 --statistics Sum
# Errors (should be 0 or near 0)
aws cloudwatch get-metric-statistics --region us-east-2 \
--namespace AWS/Lambda --metric-name Errors \
--dimensions Name=FunctionName,Value=alb-listener-us-east-2 \
--start-time $(date -u -v-6H +%Y-%m-%dT%H:%M:%SZ) \
--end-time $(date -u +%Y-%m-%dT%H:%M:%SZ) \
--period 3600 --statistics Sum
```
## Related Resources
- S3 Buckets: `autumn-alb-us-east-2`, `autumn-alb-us-west-2`
- Lambdas: `alb-listener-us-east-2`, `alb-listener-us-west-2`
- ALBs: `fc-server-oyknwa-9b105z0` (us-east-2), `fc-server-ndcdwy-65bl04in` (us-west-2)
## Notes
- Backfilling missed logs is possible but tedious (manual Lambda re-invocation per S3 file)
- No DLQ was configured, so failed events were discarded after retries
- Consider adding a DLQ in future to catch failed processing attempts

256
.plans/check-reserve.md Normal file
View File

@@ -0,0 +1,256 @@
# Check Reserve Plan
## Summary
Extend `balances.check` with an explicit reservation mode that:
- reserves credits instead of only doing today's `send_event: true` deduction,
- preserves the exact deduction provenance so later release/refund goes back to the same buckets,
- survives Redis cache eviction before Postgres sync,
- lets callers control reservation expiry with `expires_at`.
This plan is requirements-first. It captures the current codebase behavior, the schema direction discussed so far, and the engineering constraints we need to solve before implementation.
## Current Behavior In The Codebase
### `balances.check` tracked path today
- `server/src/internal/api/check/handleCheck.ts`
- `send_event: true` routes into `runCheckWithTrack`
- `server/src/internal/api/check/runCheckWithTrack.ts`
- turns check into a track-style deduction with `overage_behavior: "reject"`
- `server/src/internal/balances/track/runTrackV2.ts`
- loads `FullCustomer` from cache or DB through `getOrCreateCachedFullCustomer`
- then executes the Redis fast path via `runRedisTrack`
- `server/src/internal/balances/track/utils/runRedisTrack.ts`
- calls `executeRedisDeduction`
- on success, only queues async sync/event work afterward
- `server/src/internal/balances/utils/deduction/executeRedisDeduction.ts`
- prepares deduction inputs
- calls Redis Lua `deductFromCustomerEntitlements`
### Deduction order today
Deduction order is determined before Lua in:
- `server/src/internal/balances/utils/deduction/prepareFeatureDeduction.ts`
- `shared/utils/cusUtils/fullCusUtils/fullCustomerToCustomerEntitlements.ts`
- `shared/utils/cusEntUtils/sortCusEntsForDeduction.ts`
Current order:
1. rollovers first, oldest `expires_at` first
2. sorted `customer_entitlements`
3. Lua pass 1 deducts to `0`
4. Lua pass 2 allows negative balances only where `usage_allowed` is true
This means time-bounded balances like hourly/monthly can be consumed before lifetime balances, depending on the sorted entitlement order.
### Refund behavior today
Refunds currently do not replay the original deduction provenance.
In `server/src/_luaScriptsV2/deductFromCustomerEntitlements/deductFromCustomerEntitlements.lua`:
- negative amounts are treated as refunds,
- pass 1 refills toward `0`,
- pass 2 can refill balances up to `max_balance`.
The system does not persist a durable receipt of exactly which rollover / `customer_entitlement` / entity balance was consumed in the original deduction. That is the root cause of the current bucket-drift issue.
### Durability path today
Durability is async and cache-based:
- `server/src/internal/balances/utils/sync/SyncBatchingManagerV2.ts`
- batches modified `customer_entitlement` IDs / rollover IDs
- queues `SyncBalanceBatchV3`
- queue worker calls `server/src/internal/balances/utils/sync/syncItemV3.ts`
- `syncItemV3` re-reads the cached `FullCustomer`
- it then calls `sync_balances_v2(...)` to write current cache state to Postgres
### Resiliency problem today
`server/src/external/stripe/webhookMiddlewares/stripeWebhookRefreshMiddleware.ts` can clear the cached customer after webhook handling.
That creates a failure window:
1. Redis deduction succeeds
2. async sync job is queued
3. cache is deleted before `syncItemV3` runs
4. `syncItemV3` sees cache miss and skips
5. Postgres never sees the deduction
6. a later refund/release can add credits on top of stale Postgres state
This is why reserve cannot rely on Redis mutation plus best-effort later balance sync alone.
## Problem Statement
We need a new reservation-backed check flow that fixes three issues:
### A. Exact release correctness
If a customer has multiple balances for the same feature, for example hourly + monthly + lifetime, reserve must later release/refund back into the exact buckets that were consumed.
We cannot continue using a generic refund order that loses provenance.
### B. Resiliency against cache loss
A successful reservation must remain correct even if Redis cache is evicted before the async sync pipeline persists updated balances to Postgres.
We need durability for the reservation itself without turning `balances.check` into a slow synchronous balance-sync request.
### C. Caller-controlled expiry
Reserve needs an `expires_at` field so the caller controls how long the reservation stays held before automatic release.
## API Direction
### `balances.check` request
Add a new `reserve` field:
```ts
reserve: {
enabled: X,
key: X,
expires_at: X,
}
```
Current intent of the fields:
- `enabled`
- marks the request as a reserve flow instead of plain `send_event`
- `key`
- identifies the reservation so later operations can refer to it
- `expires_at`
- absolute release deadline; if the reservation is not finalized before this time it must be automatically released
Notes:
- this shape is the current draft, not the final contract
- whether `enabled` is necessary is still open
- whether `key` is caller-provided, server-generated, or both is still open
### Follow-up endpoint
Add a new follow-up endpoint in the balances family.
Current placeholder name:
- `autumn.balances.confirm`
Name is still TBD. Candidate directions to revisit later:
- `balances.confirm`
- `balances.settle`
- `balances.finalize`
### Follow-up request draft
Current draft input:
```ts
{
key: X,
refund: X,
final_value: X,
}
```
Current intent:
- `key`
- the reservation to operate on
- `refund`
- amount to release back from the originally reserved value
- `final_value`
- alternative to `refund`; specifies the final usage value to keep, overwriting the original reserved amount
Still unresolved:
- whether `refund` and `final_value` should both exist
- if both exist, whether they must be mutually exclusive
- what the default behavior is when neither is sent
- whether the endpoint is semantically "confirm" or "settle"
## Functional Requirements
### 1. Reservation provenance
- A successful reserve check must persist enough information to reconstruct the exact deduction path.
- Provenance must capture the exact:
- rollover rows used
- `customer_entitlement` rows used
- entity-scoped balance paths used
- deducted amount per bucket
- Later release/refund must use this stored provenance instead of rerunning generic negative-value deduction logic.
### 2. Reservation lifecycle
- reserve creates a held deduction
- follow-up endpoint finalizes the reservation into the final consumed amount
- any excess from the original reserve is released using stored provenance
- reservations expire automatically at `expires_at` if not finalized in time
- expired reservations become non-finalizable
### 3. Idempotency
- reserve must be safe to retry for the same logical reservation
- finalize/confirm must be idempotent
- release on expiry must be idempotent
- duplicate finalize or release must not double-deduct or double-refund
### 4. Durability and resiliency
- a reservation cannot exist only inside a transient Redis cache mutation
- reservation state must survive:
- full-customer cache eviction
- Stripe webhook refresh
- queue delay
- queue enqueue failure
- worker retry or replay
- the low-latency reserve path must not wait for full Redis-to-Postgres balance sync inside the request
### 5. Compatibility
- plain `balances.check` remains read-only
- existing `send_event: true` behavior remains backward compatible while reserve is introduced
- reserve is additive, not yet a silent redefinition of `send_event`
## Acceptance Scenarios
- reserve against hourly + lifetime balances, then finalize with a reduced final amount: released credits go back to the exact original buckets
- reserve consumes rollover balance, then release: the same rollover is restored
- reserve on entity-scoped balances, then release: the same entity-scoped balance is restored
- reserve succeeds in Redis, cache is evicted before async balance sync, then later finalize/release still behaves correctly
- duplicate finalize does not double-apply
- duplicate expiry release does not double-refund
- expired reservation cannot be finalized
- insufficient balance returns `allowed: false` and creates no reservation
## Implementation Constraints
- `balances.check` with reserve is expected to stay very low latency
- we cannot require inline Redis track + Postgres sync in the same request
- resiliency must come from a smarter reservation design, not from making the hot path synchronous
- any eventual background reconcile flow must be replay-safe
## Open Decisions
- final name of the follow-up endpoint
- final meaning of reservation `key`
- whether `reserve.enabled` stays
- whether `refund` and `final_value` both exist
- whether the follow-up endpoint is a pure confirm action or a broader settle/finalize action
- exact response schema for successful reserve and successful finalize
## Next Planning Pass
The next pass should narrow Requirement A only:
- finalize the reserve request schema
- finalize the follow-up endpoint name and semantics
- lock the reservation state machine
- define response shapes for reserve and finalize

View File

@@ -0,0 +1,437 @@
# Mutation Logs
## Summary
The first step in the reserve work is to move balance persistence from snapshot sync to mutation log replay.
Today, our fast path is:
1. deduct from Redis
2. queue sync to Postgres
3. sync worker re-reads cached `FullCustomer`
4. worker writes current snapshot state to Postgres
This breaks when the cached `FullCustomer` is deleted before the sync worker runs. In that case, the sync job sees a cache miss and skips, which means Postgres never sees the deduction.
To make reserve resilient, we should stop depending on replaying the cached snapshot and instead replay durable mutation receipts.
## Current Problem
Today, `track` and `check(send_event: true)` behave like this:
1. Redis deduction succeeds
2. `SyncBatchingManagerV2` queues a sync job
3. `syncItemV3` later re-reads the cached customer
4. the worker writes the resulting balances to Postgres
Failure case we are solving first:
- the cached `FullCustomer` is deleted before `syncItemV3` runs
- `syncItemV3` sees a cache miss and skips
- the deduction is lost from Postgres
- later refund/release can over-credit the customer
This step is only focused on the cache-miss branch of sync failure.
## Proposed Direction
We will replace snapshot sync with mutation log replay.
Instead of making the sync worker reconstruct the latest balances from cached `FullCustomer`, each successful deduction will append a compact mutation receipt to Redis.
The sync worker will then:
1. read pending mutation receipts
2. batch them together
3. apply the aggregated deltas to Postgres
4. mark those mutation receipts as processed
This makes sync independent from the `FullCustomer` cache blob.
## Why Mutation Logs
Mutation logs are a better fit than snapshot sync because:
- they survive deletion of the cached `FullCustomer` if stored separately
- they preserve the exact balance changes caused by each deduction
- they allow batching many writes together before touching Postgres
- they compose better under retries than absolute snapshot overwrites
The core design goal is:
- Redis remains the low-latency write path
- Postgres is updated by replaying ordered mutation deltas, not by reading a later cache snapshot
## Redis Structure
We should use Redis Streams for mutation logs.
We should not store receipts inside a giant JSON array on the cached customer object.
Why Streams:
- append-only log
- monotonic entry IDs
- natural batch cutoffs by stream ID
- better fit for high write concurrency than rewriting one large array value
Recommended logical model:
- one mutation stream per hot sync unit
- initial assumption: per customer
- possible future sharding: per customer + feature if a single customer becomes too hot
Example logical key:
- `balance_mutations:{org_id}:{env}:{customer_id}`
Each stream entry should contain compact identifiers and deltas, not snapshots.
Streams also remove the need for "atomic rotate" style queue draining.
With lists or string-backed pending keys, the worker would need to atomically rename a `pending` key into an `inflight` key to get a stable batch. With streams, the stable batch boundary comes from stream IDs instead:
- worker captures the current max stream ID
- worker processes only entries `<= cutoff_id`
- new writes get larger IDs automatically
- those writes are deferred to the next batch
This is the stream equivalent of taking a consistent snapshot of the pending work without freezing new writes.
## Mutation Receipt Shape
Each receipt should record the exact balance changes produced by one successful operation.
Use identifier-based deltas, not Redis JSON paths, as the canonical format.
Each receipt should be able to represent changes to:
- `customer_entitlements`
- rollovers
- entity-scoped balances
Example logical payload:
```json
{
"mutation_id": "mut_123",
"customer_id": "cus_123",
"feature_id": "feat_123",
"source": "track",
"items": [
{
"target_type": "customer_entitlement",
"customer_entitlement_id": "ce_1",
"entity_id": null,
"balance_delta": -4,
"adjustment_delta": 0
},
{
"target_type": "rollover",
"rollover_id": "ro_1",
"customer_entitlement_id": "ce_2",
"entity_id": null,
"balance_delta": -6,
"usage_delta": 6
}
]
}
```
The mutation log should store deltas only:
- not full cached balance objects
- not the entire `FullCustomer`
- not a copy of the snapshot sync payload
The `mutation_id` is required for replay safety. It is not optional bookkeeping.
Even if the same Redis stream entry is observed multiple times due to retry, we must be able to prove in Postgres whether that mutation has already been applied.
## Sync Worker Model
The worker should stop doing cache-snapshot sync for the reserve path.
Instead, for each customer:
1. acquire a per-customer sync lock
2. capture a stream cutoff ID
3. read all mutation receipts up to that cutoff
4. try to register each `mutation_id` inside Postgres in the same transaction as the balance apply
5. fold only the not-yet-applied mutations into one aggregated Postgres operation
6. apply the aggregated deltas to Postgres
6. mark those mutations as processed
7. trim or delete processed stream entries later
This gives us batching without requiring the `FullCustomer` cache to still exist.
The dedupe insert and the balance updates must happen in the same Postgres transaction.
If they do not, we can end up with one of two bad states:
- mutation recorded as applied, but balance updates never committed
- balance updates committed, but mutation not recorded as applied
Both cases break replay safety.
## Postgres Replay Model
Replay safety is handled in Postgres, not only in Redis.
We should introduce a small dedupe table for applied mutations. Example logical shape:
- `applied_balance_mutations`
- `mutation_id`
- `org_id`
- `env`
- `customer_id`
- `applied_at`
The unique key should be based on mutation identity, for example:
- `unique(org_id, env, mutation_id)`
Worker apply flow:
1. start a Postgres transaction
2. insert the batch's `mutation_id`s with `ON CONFLICT DO NOTHING`
3. determine which mutation IDs were newly inserted
4. fold only those newly inserted mutations into deltas
5. apply the folded deltas to balances
6. commit
This makes duplicate replay harmless:
- first replay inserts the mutation ID and applies the delta
- later replay sees the duplicate mutation ID and skips that mutation
We should dedupe per mutation, not only per batch.
Batch-level dedupe is not enough because:
- batch composition can change on retry
- one mutation may already be applied while others are not
- per-mutation dedupe is what preserves correctness under partial failure
## Race Conditions To Guard Against
### 1. Write happens during sync
A new deduction can happen while the worker is draining the mutation log.
We must not lose that write and must not partially include it in the current batch.
Streams solve this by using a cutoff ID:
- worker captures the current max stream ID
- worker processes only entries up to that ID
- new writes get higher IDs
- those writes stay for the next batch
This is the stream equivalent of getting a stable batch boundary.
### 2. Two workers sync the same customer
If two workers drain the same customer at once, the same mutations can be applied twice.
We need a per-customer sync lock, for example:
- `sync_lock:{org_id}:{env}:{customer_id}`
Only one worker can own that customers sync batch at a time.
If a worker fails to acquire the lock, that is not a terminal error.
Lock miss means:
- another worker is already syncing that customer
- the pending mutations must remain untouched
- the current worker should retry later or rely on a later sync trigger
Lock miss must never clear or acknowledge pending mutations.
### 3. Worker crashes after Postgres apply but before cleanup
If Postgres is updated but the worker dies before marking mutations as processed, the next worker can replay the same entries.
To make retries safe, Postgres apply must be idempotent per mutation.
That means each mutation needs a stable `mutation_id`, and Postgres needs to remember which mutation IDs have already been applied.
This is why the lock alone is not enough. The lock reduces concurrent replay, but only Postgres idempotency makes crash-retry replay safe.
### 4. Worker cleanup races with new writes
We must not delete or trim entries that were appended after the workers batch cutoff.
The worker should only mark or trim entries that are confirmed part of the applied batch.
### 5. Mutation log is replayed twice
The same mutation can be replayed twice even if the lock works correctly.
Examples:
- worker A applies to Postgres, then crashes before stream cleanup
- worker B later retries and sees the same stream entries
- the queue redelivers the same work after a transient failure
This is expected behavior. The system should tolerate it.
The protection is:
- stable `mutation_id`
- Postgres dedupe table
- dedupe insert and balance apply in one transaction
### 6. Very high throughput on a single customer
If one customer becomes extremely hot, one stream can become a hotspot.
Initial plan:
- start with one stream per customer
- keep receipts compact
- batch aggressively in the worker
- revisit sharding by customer + feature only if one customer becomes too hot in production
## Throughput Notes
Creating many mutation log entries is acceptable if:
- the entries are compact
- they are append-only
- they are drained continuously
- processed entries are cleaned up
What we should avoid is one giant array value that is constantly rewritten.
The first implementation should optimize for correctness first and high-throughput batching second.
## Entity-Scoped Balance Gotchas
The `customer_entitlements.entities` field is stored as JSONB in Postgres and as nested JSON in Redis.
This does not prevent atomic deductions, but it changes how replay must be implemented.
### Redis side
Entity-scoped deduction is already atomic in Lua today.
The deduction script updates nested entity paths inside the same script execution using Redis JSON path writes. That means entity-scoped balance mutation is already atomic at the Redis level.
### Postgres side
For replay, we should not:
- read the `entities` JSON into app code
- modify it in TypeScript
- write the whole blob back
That would reintroduce lost-update races.
Instead, replay should use SQL updates that derive the new nested value from the current row state, for example with `jsonb_set(...)`.
This is the JSONB equivalent of:
- `balance = balance - 1`
The atomicity comes from:
- row-level locking during `UPDATE`
- computing the new JSONB value inside SQL from the current row contents
This codebase already has working SQL patterns for nested JSONB balance updates in the deduction SQL helpers. The mutation-log replay path should reuse the same style of update.
### Practical implication for receipts
Mutation receipts must capture enough information to replay entity deltas safely:
- `customer_entitlement_id`
- `entity_id`
- `balance_delta`
- `adjustment_delta` when relevant
We should not make Redis JSON paths the canonical replay format. Identifier-based deltas map better onto Postgres row + JSONB update logic.
## Rolling Deploy Bridge
A plain wall-clock cutover timestamp is not safe for this migration.
Why:
- snapshot sync does not replay the state from when the job was queued
- it reads the latest Redis `FullCustomer` state when the worker runs
- an old snapshot job can therefore include newer deductions that happened after the intended cutover point
- replaying those newer mutation logs as well would double-apply them
A safer bridge is to make snapshot sync stream-aware.
### Snapshot checkpoint
When snapshot sync reads the cached customer, it should also atomically capture the current mutation-stream cutoff for that customer.
Conceptually, snapshot sync should obtain:
- `full_customer_snapshot`
- `snapshot_stream_cutoff_id`
These two values must be read together atomically from Redis so the snapshot and cutoff refer to the same point in time.
Meaning of `snapshot_stream_cutoff_id`:
- the Postgres snapshot already includes all mutation log entries up to this stream ID
### Replay rule
Mutation replay should only apply stream entries with:
- `stream_id > last_snapshot_stream_id`
This avoids replaying mutations that were already covered by the snapshot sync.
### Important constraint
This bridge only works after all writers are dual-writing:
- every Redis deduction must also append a mutation log entry
Otherwise the mutation stream watermark is incomplete and cannot safely define what the snapshot already covers.
### Practical use
This gives us a rolling-deploy migration path where:
1. all writers dual-write Redis balance mutations + mutation logs
2. snapshot sync becomes stream-aware and stores `last_snapshot_stream_id`
3. mutation replay only processes entries after that stored watermark
4. once stable, snapshot sync can be removed entirely
## Scope Of This Step
This step only changes how deductions are persisted to Postgres.
In scope:
- mutation log append on successful deduction
- stream-based worker replay
- batching and race-condition handling for sync
- Postgres idempotency for replay safety
- entity-scoped delta replay through SQL, not app-side blob rewrites
Out of scope for this doc:
- final reserve API shape
- confirm/finalize endpoint naming
- event insertion resiliency
- non-cache-miss sync conflict handling
- full reservation lifecycle semantics
## Acceptance Criteria
- sync no longer depends on the cached `FullCustomer` snapshot for reserve-backed deductions
- deleting the cached customer before the worker runs does not lose the deduction
- concurrent writes during sync are not lost
- concurrent workers do not double-apply the same mutations
- retries after worker failure do not double-apply Postgres changes
- many mutations for the same customer can be folded into one Postgres sync operation

View File

@@ -0,0 +1,219 @@
# Reservations
## Summary
This phase adds reservation-backed check and finalize flows without taking on mutation-log sync yet.
We will keep the current sync model for now. Reservation correctness comes from storing a reservation receipt in Redis at deduction time, using the same atomic flow as the Redis deduction itself. Finalize then reads that receipt and either confirms the reservation or releases it by replaying the stored provenance, not by using the generic refund order.
Automatic expiry is intentionally left to the end. This phase defines the receipt and expiry index shape so the sweeper can be added later without revisiting the reserve data model.
## API Changes
### `balances.check`
Add `reserve` to [checkParams.ts](/Users/johnyeocx/.superset/worktrees/autumn-main/reserve-and-confirm/shared/api/balances/check/checkParams.ts):
```ts
reserve: {
enabled: true,
key?: string,
expires_at?: string,
}
```
Behavior:
- if `reserve.enabled` is false or absent, current behavior stays unchanged
- if `reserve.enabled` is true and `reserve.key` is absent, generate a random key server-side
- return the resolved reserve key in the check response when reserve is enabled
- `expires_at` is optional at check time; when present it controls automatic expiry
### `autumn.balances.finalize`
Add a finalize action/handler with body:
```ts
{
finalize_action: "confirm" | "release",
overwrite_deduction?: number,
new_deduction?: number,
reserve_key: string,
}
```
Chosen defaults:
- `overwrite_deduction` and `new_deduction` are mutually exclusive
- if both are present, reject the request
- if neither is present on `confirm`, keep the full reserved deduction
- `release` ignores deduction override fields and fully releases the reservation
Initial response can stay minimal:
```ts
{ success: true }
```
## Reservation Receipt Model
Store one Redis receipt per reservation under a namespaced key such as:
- `reservation:{org_id}:{env}:{reserve_key}`
The reservation receipt is long-lived workflow state, not a mutation-log entry. It should contain:
- `reserve_key`
- `status: "pending" | "confirmed" | "released" | "expired"`
- `org_id`, `env`, `customer_id`
- `feature_id`
- `entity_id`
- `expires_at`
- request fingerprint fields needed for dedup/conflict detection
- normalized provenance `items` describing the exact deduction performed:
- `target_type`
- `customer_entitlement_id`
- `rollover_id`
- `entity_id`
- `balance_delta`
- `adjustment_delta`
- `usage_delta`
The `items` shape should deliberately match the future mutation-log delta shape so reservation work is reusable later. The receipt and future mutation log entries are related, but they are not the same object:
- reservation receipt = long-lived business state
- mutation log entry = short-lived durability event
## Shared Reserve Utilities
Add TypeScript reserve utilities under [server/src/internal/balances/reserve](/Users/johnyeocx/.superset/worktrees/autumn-main/reserve-and-confirm/server/src/internal/balances/reserve):
- key builders for reservation keys and expiry index keys
- `updates -> reserve receipt` conversion shared by Redis and Postgres deduction paths
- request fingerprint helpers
- reserve receipt types
Goal:
- Redis and Postgres paths both call the same receipt-building code
- only the persistence mechanism differs
Any Lua-related helpers for reserve should live under `server/src/_luaScriptsV2/reserve`.
## Redis Deduction Path
Extend [executeRedisDeduction.ts](/Users/johnyeocx/.superset/worktrees/autumn-main/reserve-and-confirm/server/src/internal/balances/utils/deduction/executeRedisDeduction.ts) to pass `reserve` into the Lua deduction flow.
Reserve creation requirements inside the deduction Lua path:
- reserve logic runs only after insufficient-balance validation passes
- create/store the reservation receipt before `apply_pending_writes`
- perform duplicate-key detection before mutating balances
- if the reservation key already exists:
- same fingerprint: treat as idempotent and return the existing reservation result
- different fingerprint: return conflict/error
- if receipt creation fails, do not apply pending writes
This preserves atomicity:
- deduct + receipt creation happen together
- or neither happens
The Lua reserve helpers should be responsible for:
- reservation key construction
- writing the receipt
- building the normalized receipt items from deduction updates
- later, handling finalize/release state transitions atomically
## Postgres Deduction Path
Extend [executePostgresDeduction.ts](/Users/johnyeocx/.superset/worktrees/autumn-main/reserve-and-confirm/server/src/internal/balances/utils/deduction/executePostgresDeduction.ts) to build and store the same reservation receipt shape from `DeductionUpdate`.
This path should reuse the shared TypeScript receipt-conversion utilities rather than having separate reserve receipt building logic.
The reserve persistence mechanism can differ from Redis internally, but the stored receipt schema should remain the same.
## Finalize Flow
Add the `autumn.balances.finalize` handler/action.
Flow:
1. load the reservation receipt by `reserve_key`
2. validate it exists and is in a valid state
3. ensure cached customer exists via `getOrCreateCachedFullCustomer`
4. invoke an atomic Lua finalize script
5. queue normal sync using the affected `customer_entitlement` and rollover IDs
6. return `{ success: true }`
Finalize semantics:
- `confirm` with no override fields:
- mark `pending -> confirmed`
- no balance change
- `confirm` with `overwrite_deduction` or `new_deduction`:
- compute the compensating release from the original receipt
- restore exact provenance rather than using generic refund ordering
- mark `confirmed`
- `release`:
- fully reverse the original provenance
- mark `released`
The finalize Lua flow must operate from the stored receipt provenance, not from current deduction ordering.
## Expiry Design
This phase defines expiry storage but leaves the sweeper worker for the end.
At reserve creation time:
- store `expires_at` on the receipt
- add the reservation key to a Redis ZSET such as:
- `reservation_expiries:{org_id}:{env}`
- score = `expires_at`
Important rule:
- do not rely on Redis key TTL deletion for expiry
- deleting the receipt would lose the provenance needed to release balances
Later sweeper phase:
- worker polls due keys from the ZSET
- worker runs the same atomic release/expire Lua path
- worker marks the receipt `expired`
- worker queues normal sync
The expiry mechanism should preserve the same atomic state transition guarantees as manual finalize:
- only `pending` reservations can expire
- confirm vs expire races are resolved by Lua atomicity
- one transition wins and the other becomes a no-op
## Tests And Scenarios
- reserve check creates a receipt and deducts balances atomically
- reserve check with no provided key generates a key and returns it
- duplicate reserve key with same fingerprint is idempotent
- duplicate reserve key with different fingerprint returns conflict
- reserve across hourly + lifetime balances stores exact provenance for both buckets
- reserve involving rollovers stores rollover provenance and usage deltas
- reserve involving entity-scoped balances stores entity-specific provenance
- `finalize_action = "confirm"` with no override keeps the full reservation and marks it confirmed
- `confirm` with `overwrite_deduction` or `new_deduction` partially releases only the difference, using stored provenance
- `finalize_action = "release"` fully restores the original buckets and marks the receipt released
- finalize on non-`pending` reservations is idempotent or safely rejected according to the chosen state transition rules
- reserve with `expires_at` writes the expiry index entry
- expiry/release logic reuses stored provenance rather than generic refund ordering
- Redis and Postgres deduction paths produce the same receipt shape from updates
## Assumptions
- this work intentionally keeps current snapshot sync and excludes mutation-log sync changes
- reservation receipts are long-lived business state
- future mutation-log entries may reuse the same normalized `items` shape, but they remain separate objects
- `overwrite_deduction` and `new_deduction` are mutually exclusive
- `confirm` without override fields means "keep the reserved deduction as-is"
- `release` fully reverses the reservation
- the expiry sweeper implementation will be added later, but receipt + ZSET indexing are part of this plan now

View File

@@ -0,0 +1,285 @@
# Deduction Result Shapes
## Summary
Additive delta/provenance fields need to be part of our deduction result objects so they can serve both:
- lock receipts now
- mutation log items later
The key rule is:
- keep the existing final-state fields unchanged in meaning
- add delta fields beside them
- compute those delta fields during deduction, not by diffing final snapshots later
This applies to both the Redis Lua path and the Postgres SQL path.
## Why This Change Is Needed
Today, our deduction results are mostly final-state objects.
For example, `DeductionUpdate` gives us:
- final `balance`
- final `adjustment`
- final `entities`
- aggregate `deducted`
That is enough for current snapshot-style consumers, but it is not enough for:
- exact lock receipts
- exact release/refund replay
- future mutation logs
What we need in addition is:
- top-level `balance_delta`
- top-level `adjustment_delta`
- sparse per-entity deltas
- rollover `balance_delta`
- rollover `usage_delta`
- parent `customer_entitlement` linkage for rollover updates
Without these fields, lock receipt creation would need to reconstruct provenance from final snapshots, which is more fragile than recording the change directly when the deduction happens.
## Result Shape Changes
### `DeductionUpdate`
Keep existing final-state fields:
- `balance`
- `additional_balance`
- `adjustment`
- `entities`
- `deducted`
Add:
```ts
balance_delta?: number;
adjustment_delta?: number;
entity_deltas?: Record<
string,
{
balance_delta: number;
adjustment_delta: number;
}
>;
```
Meaning:
- `balance`, `adjustment`, `entities`
- final post-deduction state
- `balance_delta`, `adjustment_delta`
- top-level change on the `customer_entitlement`
- `entity_deltas`
- sparse per-entity changes
### `RolloverUpdate`
Move `RolloverUpdate` into:
- `server/src/internal/balances/utils/types/rolloverUpdate.ts`
Keep existing final-state fields:
- `balance`
- `usage`
- `entities`
Add:
```ts
cus_ent_id?: string;
balance_delta?: number;
usage_delta?: number;
entity_deltas?: Record<
string,
{
balance_delta: number;
usage_delta: number;
}
>;
```
Meaning:
- `balance`, `usage`, `entities`
- final post-deduction rollover state
- `cus_ent_id`
- parent `customer_entitlement`
- `balance_delta`, `usage_delta`
- top-level rollover changes
- `entity_deltas`
- sparse per-entity rollover changes
## Shared Mutation Item Shape
We should treat the normalized `MutationItem` shape as the common bridge between:
- deduction results
- lock receipt `items`
- future mutation log `items`
Conceptual shape:
```ts
type MutationItem = {
target_type: "customer_entitlement" | "rollover";
customer_entitlement_id: string | null;
rollover_id: string | null;
entity_id: string | null;
balance_delta: number;
adjustment_delta: number;
usage_delta: number;
};
```
Lua conversion helpers should live in:
- `server/src/_luaScriptsV2/deduction/mutationItemUtils.lua`
Functions:
- `deduction_update_to_mutation_items(...)`
- `rollover_update_to_mutation_items(...)`
- `deduction_results_to_mutation_items(...)`
These functions should be used by:
- lock receipt creation now
- mutation log creation later
## Redis / Lua Path
The Redis Lua path should populate delta fields at write time.
### Core rule
Record deltas when we queue the write.
Do not reconstruct deltas later from final balances or final `entities` blobs.
### Main balance path
`queue_balance_update(...)` should:
1. queue the `JSON.NUMINCRBY` writes
2. update additive delta fields on the relevant `customer_entitlement`
Recommended in-memory context additions on `context.customer_entitlements[ent_id]`:
- `balance_delta`
- `adjustment_delta`
- `entity_deltas`
### Rollover path
`queue_rollover_update(...)` should:
1. queue the `balance` and `usage` writes
2. update additive delta fields on the relevant rollover
Recommended in-memory context additions on `context.rollovers[rollover_id]`:
- `cus_ent_id`
- `balance_delta`
- `usage_delta`
- `entity_deltas`
### Final Lua return value
When `deductFromCustomerEntitlements.lua` builds `updates` and `rollover_updates`, it should include:
- existing final-state fields
- the new delta fields
That keeps the return payload backward-compatible while making it rich enough for lock receipts and later mutation logs.
## Postgres / SQL Path
The Postgres deduction path should return the same conceptual shape as the Lua path.
Primary target:
- `server/src/internal/balances/utils/sql/performDeduction.sql`
### Required customer entitlement fields
For each `customer_entitlement`, SQL should return:
- `balance`
- `additional_balance`
- `adjustment`
- `entities`
- `deducted`
- `balance_delta`
- `adjustment_delta`
- `entity_deltas`
### Required rollover fields
For each rollover, SQL should return:
- `cus_ent_id`
- `balance`
- `usage`
- `entities`
- `balance_delta`
- `usage_delta`
- `entity_deltas`
### SQL computation rule
Just like the Lua path, SQL should compute these deltas during the deduction process.
It should not try to infer them afterward by diffing final snapshots.
That means `performDeduction.sql` and any helper SQL it uses should explicitly track:
- top-level `customer_entitlement` balance changes
- top-level adjustment changes
- per-entity balance / adjustment changes
- rollover balance / usage changes
- per-entity rollover balance / usage changes
## Compatibility
Existing snapshot-based consumers should keep working without reading the new delta fields.
This includes:
- `applyDeductionUpdateToFullCustomer`
- `applyRolloverUpdatesToFullCustomer`
- cache sync helpers
- logging helpers
- allocated invoice flows
Compatibility rule:
- current final-state fields remain authoritative for existing behavior
- new delta fields are additive only
## Test Cases
- Top-level deduction returns final `balance` plus `balance_delta`.
- Entity-scoped deduction returns final `entities` plus sparse `entity_deltas`.
- Granted-balance changes return matching `adjustment_delta`.
- Rollover deduction returns final `balance` / `usage` plus `balance_delta` / `usage_delta`.
- Entity-scoped rollover deduction returns sparse rollover `entity_deltas`.
- `deduction_results_to_mutation_items(...)` produces the expected flat `items` array from mixed updates and rollover updates.
- Reserve receipt creation can consume the same mutation item shape.
- Existing snapshot-based consumers continue to work while ignoring the new delta fields.
- Redis Lua and Postgres SQL paths converge on the same conceptual result shape.
## Assumptions
- This document covers deduction result shapes only, not full receipt persistence or finalize logic.
- Delta fields are additive and optional.
- Existing final-state fields must not change meaning.
- `MutationItem` is the shared intermediate format for:
- lock receipt items now
- mutation log items later

View File

@@ -0,0 +1 @@
Now, the other thing we need to thnk about is expireLock and finalizeLock happening on different Redis instances. The issue is that our workers where expireLock runs on lives in us-west, and the API server where finalizeLock runs on is in us-east. So technically, we could have these two running concurrently and we need to handle it. Any operations on Redis will be merged in Redis according to the following guide: [Pasted ~1 lines] Please read this carefully and think about the cases we need to handle.

56
.zed/settings.json Normal file
View File

@@ -0,0 +1,56 @@
{
"format_on_save": "on",
"file_scan_inclusions": [".env*"],
"lsp": {
"biome": {
"binary": {
"path": "node_modules/.bin/biome",
"arguments": ["lsp-proxy"]
},
"settings": {
"require_config_file": true
}
}
},
"languages": {
"TypeScript": {
"language_servers": ["tsgo", "biome", "!vtsls", "!typescript-language-server", "..."],
"formatter": { "language_server": { "name": "biome" } },
"prettier": { "allowed": false },
"code_actions_on_format": {
"source.fixAll.biome": true,
"source.organizeImports.biome": true
}
},
"TSX": {
"language_servers": ["tsgo", "biome", "!vtsls", "!typescript-language-server", "..."],
"formatter": { "language_server": { "name": "biome" } },
"prettier": { "allowed": false },
"code_actions_on_format": {
"source.fixAll.biome": true,
"source.organizeImports.biome": true
}
},
"JavaScript": {
"language_servers": ["tsgo", "biome", "!vtsls", "!typescript-language-server", "..."],
"formatter": { "language_server": { "name": "biome" } },
"prettier": { "allowed": false },
"code_actions_on_format": {
"source.fixAll.biome": true,
"source.organizeImports.biome": true
}
},
"JSON": {
"language_servers": ["biome", "..."],
"formatter": { "language_server": { "name": "biome" } }
},
"JSONC": {
"language_servers": ["biome", "..."],
"formatter": { "language_server": { "name": "biome" } }
},
"CSS": {
"language_servers": ["biome", "..."],
"formatter": { "language_server": { "name": "biome" } }
}
}
}

View File

@@ -15,7 +15,7 @@ in the test logs. Use your common sense
# Linting and Codebase rules
- You can access the biome linter by running `bunx biome check <folder or file path>`. Always specify a folder path, as the codebase is quite large and you will get out of scope errors that you are not burdened to correct. If you would like to let biome automatically fix as much as it can, use `bunx biome check --write <folder or file path>`
- Note, biome does not perform typechecking. In which case you need to, you may run `tsgo --noEmit --skipLibCheck <folder or file path>`
- Note, biome does not perform typechecking. For typechecks, either `cd` into the relevant workspace and run `bun ts`, or run `tsgo` directly. If you need to install it first, the npm package is `@typescript/native-preview`. Do not use `tsc` for workspace typechecks.
- The `server/src/_luaScriptsV2/` folder contains Lua scripts for Redis atomic operations. Redis uses **Lua 5.1** - there is NO `goto` statement (added in Lua 5.2), so use if/else blocks instead.
@@ -45,7 +45,7 @@ in the test logs. Use your common sense
- This codebase uses Bun for all of its operations in `/server`, `/vite` and `/shared`. It uses Bun for the package management, Bun for the workspace management and Bun for the runtime. Prefer Bun over PNPM. If you ever want to trace a package dependency tree, run `bun why <package name>` which will tell you why a certain package was installed and by who.
- Prefer Guard clauses "if(!admin) return;" over explicity "if(admin) do X;" Early returns are better
- Do not run "npx tsc" - run "tsc" instead as it is installed globally.
- If you need the TypeScript CLI directly, run `tsgo` itself (package: `@typescript/native-preview`), not `npx tsc`.
- **ALWAYS use `.meta()` for zod-openapi schema registration**, NOT `.openapi()`. Example: `ApiProductSchema.meta({ id: "Product" })`
@@ -98,7 +98,6 @@ in the test logs. Use your common sense
- Prefer Guard clauses "if(!admin) return;" over explicity "if(admin) do X;" Early returns are better
- Do not run "npx tsc" - run "tsc" instead as it is installed globally.
# Figma MCP guidance
- When you are using the Figma MCP server, you **must** follow our design system. Below is an example implementation of CVA with out design system
@@ -121,4 +120,4 @@ DON'T name files one word (like index.ts, model.ts, etc.). Give proper indicatio
- Consistency is key - if a pattern exists, use it rather than creating a new one.
## Form Elements
- When creating form input elements (inputs, selects, textareas, etc.) in the vite folder, ALWAYS read `vite/FORM_DESIGN_GUIDELINES.md` first to understand the atomic CSS class system.
- When creating form input elements (inputs, selects, textareas, etc.) in the vite folder, ALWAYS read `vite/FORM_DESIGN_GUIDELINES.md` first to understand the atomic CSS class system.

View File

@@ -16,7 +16,7 @@ in the test logs. Use your common sense
# Linting and Codebase rules
- You can access the biome linter by running `bunx biome check <folder or file path>`. Always specify a folder path, as the codebase is quite large and you will get out of scope errors that you are not burdened to correct. If you would like to let biome automatically fix as much as it can, use `bunx biome check --write <folder or file path>`
- Note, biome does not perform typechecking. For type checking, run `bun ts` from the `server/` directory (which runs `bunx tsgo --build --noEmit`). This checks the entire server project with proper path resolution.
- Note, biome does not perform typechecking. For typechecks, always `cd` into the relevant workspace and run `bun ts`. Do not run `bunx tsgo --build --noEmit` directly, and do not use `tsc` for workspace typechecks.
- The `server/src/_luaScriptsV2/` folder contains Lua scripts for Redis atomic operations. Redis uses **Lua 5.1** - there is NO `goto` statement (added in Lua 5.2), so use if/else blocks instead.

View File

@@ -2,9 +2,14 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/png" href="/autumn-logo-bg.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>vite-app</title>
<title>Checkout</title>
<script>
if (window.location.hostname === 'localhost') {
document.title = 'Checkout (Local)';
}
</script>
</head>
<body>
<div id="root"></div>

View File

@@ -6,7 +6,8 @@
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
"preview": "vite preview",
"ts": "bunx tsgo --build --noEmit"
},
"dependencies": {
"@autumn/shared": "workspace:*",
@@ -29,12 +30,15 @@
"tailwindcss": "^4.1.17",
"tw-animate-css": "^1.4.0",
"use-debounce": "^10.1.0",
"vite-tsconfig-paths": "^6.0.5"
"vite-tsconfig-paths": "^6.0.5",
"decimal.js": "catalog:"
},
"devDependencies": {
"@types/bun": "^1.3.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@typescript/native-preview": "catalog:",
"@vitejs/plugin-react": "^5.1.1",
"typescript": "~5.9.3",
"vite": "^7.2.4"

Binary file not shown.

After

Width:  |  Height:  |  Size: 466 KiB

View File

@@ -0,0 +1,6 @@
import { createElement } from "react";
import { CheckoutSharedContent } from "@/components/checkout/CheckoutSharedContent";
export function CheckoutUpdateContent() {
return createElement(CheckoutSharedContent);
}

View File

@@ -1,76 +1,14 @@
import { motion } from "motion/react";
import { Separator } from "@/components/ui/separator";
import { CheckoutAction } from "@autumn/shared";
import { useCheckoutContext } from "@/contexts/CheckoutContext";
import { STANDARD_TRANSITION, fadeUpVariants, listContainerVariants } from "@/lib/animations";
import { ConfirmSection } from "./confirm/ConfirmSection";
import { CheckoutBackground } from "./layout/CheckoutBackground";
import { CheckoutHeader } from "./layout/CheckoutHeader";
import { OrderSummarySection } from "./order-summary/OrderSummarySection";
import { PlanSection } from "./plan/PlanSection";
import { CheckoutErrorState } from "./states/CheckoutErrorState";
import { CheckoutSuccessState } from "./states/CheckoutSuccessState";
import { CheckoutUpdateContent } from "@/components/checkout-update/CheckoutUpdateContent";
import { CheckoutSharedContent } from "./CheckoutSharedContent";
export function CheckoutContent() {
const { confirmResult, status, isSandbox } = useCheckoutContext();
const { action } = useCheckoutContext();
// Handle success state
if (confirmResult) {
return (
<motion.div
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
transition={STANDARD_TRANSITION}
>
<CheckoutSuccessState result={confirmResult} />
</motion.div>
);
if (action === CheckoutAction.UpdateSubscription) {
return <CheckoutUpdateContent />;
}
// Handle error state
if (status.error) {
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={STANDARD_TRANSITION}
>
<CheckoutErrorState
message={
status.error instanceof Error
? status.error.message
: "Failed to load checkout"
}
/>
</motion.div>
);
}
// Main checkout view
return (
<CheckoutBackground isSandbox={isSandbox}>
<motion.div
className="flex flex-col gap-6 w-full"
initial="initial"
animate="animate"
variants={listContainerVariants}
>
{/* Header */}
<motion.div variants={fadeUpVariants} transition={STANDARD_TRANSITION}>
<CheckoutHeader />
</motion.div>
{/* Main content - single column */}
<div className="flex flex-col gap-6 w-full">
<Separator />
<PlanSection />
<Separator />
<OrderSummarySection />
</div>
<Separator />
<ConfirmSection />
</motion.div>
</CheckoutBackground>
);
return <CheckoutSharedContent />;
}

View File

@@ -0,0 +1,81 @@
import { motion } from "motion/react";
import { Separator } from "@/components/ui/separator";
import { useCheckoutContext } from "@/contexts/CheckoutContext";
import { STANDARD_TRANSITION, fadeUpVariants, listContainerVariants } from "@/lib/animations";
import { checkoutErrorToDisplay } from "@/utils/checkoutErrorUtils";
import { ConfirmSection } from "./confirm/ConfirmSection";
import { CheckoutBackground } from "./layout/CheckoutBackground";
import { CheckoutHeader } from "./layout/CheckoutHeader";
import { OrderSummarySection } from "./order-summary/OrderSummarySection";
import { PlanSection } from "./plan/PlanSection";
import { CheckoutActionRequiredDialog } from "./states/CheckoutActionRequiredDialog";
import { CheckoutErrorState } from "./states/CheckoutErrorState";
import { CheckoutSuccessState } from "./states/CheckoutSuccessState";
export function CheckoutSharedContent() {
const {
actionRequiredResponse,
confirmResult,
hasActionRequiredState,
status,
isSandbox,
} = useCheckoutContext();
if (confirmResult) {
return (
<motion.div
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
transition={STANDARD_TRANSITION}
>
<CheckoutSuccessState />
</motion.div>
);
}
if (status.error) {
const errorDisplay = checkoutErrorToDisplay({
error: status.error,
});
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={STANDARD_TRANSITION}
>
<CheckoutErrorState {...errorDisplay} />
</motion.div>
);
}
return (
<CheckoutBackground isSandbox={isSandbox}>
<motion.div
className="flex flex-col gap-6 w-full"
initial="initial"
animate="animate"
variants={listContainerVariants}
>
<motion.div variants={fadeUpVariants} transition={STANDARD_TRANSITION}>
<CheckoutHeader />
</motion.div>
<div className="flex flex-col gap-6 w-full">
<Separator />
<PlanSection />
<Separator />
<OrderSummarySection />
</div>
<Separator />
<ConfirmSection />
{hasActionRequiredState && actionRequiredResponse && (
<CheckoutActionRequiredDialog response={actionRequiredResponse} />
)}
</motion.div>
</CheckoutBackground>
);
}

View File

@@ -34,14 +34,18 @@ function getButtonText({
export function ConfirmSection() {
const {
hasActionRequiredState,
status,
total,
currency,
preview,
isSubscription,
hasActiveTrial,
isUnchangedQuantityUpdate,
handleConfirm,
} = useCheckoutContext();
const hasNextCycleUsage =
(preview?.next_cycle?.usage_line_items?.length ?? 0) > 0;
return (
<div className="flex flex-col gap-4">
@@ -77,14 +81,9 @@ export function ConfirmSection() {
</span>
<span className="tabular-nums">
{formatAmount(preview.next_cycle.total, currency)}
{hasNextCycleUsage ? " + usage" : ""}
</span>
</div>
{/* Credit note explaining reduced next cycle amount */}
{preview.credit && (
<span className="text-xs text-muted-foreground/60 text-right">
Includes {formatAmount(preview.credit.amount, currency)} credit from unused plan
</span>
)}
</div>
)}
</div>
@@ -98,7 +97,12 @@ export function ConfirmSection() {
<Button
className="w-full h-11 text-sm font-medium rounded-lg"
onClick={handleConfirm}
disabled={status.isConfirming || status.isUpdating}
disabled={
hasActionRequiredState ||
status.isConfirming ||
status.isUpdating ||
isUnchangedQuantityUpdate
}
>
{getButtonText({
isPending: status.isConfirming,

View File

@@ -1,4 +1,4 @@
import type { ReactNode } from "react";
import { useMemo, type ReactNode } from "react";
import { motion } from "motion/react";
import { BackgroundBeams } from "@/components/bg/background-beams";
import { SandboxBanner } from "@/components/checkout/layout/SandboxBanner";
@@ -7,16 +7,25 @@ import { SLOW_TRANSITION, SPRING_TRANSITION } from "@/lib/animations";
interface CheckoutBackgroundProps {
children: ReactNode;
isSandbox?: boolean;
containerClassName?: string;
contentClassName?: string;
}
/**
* Full-screen background wrapper with subtle diagonal gradients from primary color.
* Includes entrance animation for the content container.
*/
export function CheckoutBackground({ children, isSandbox }: CheckoutBackgroundProps) {
return (
<div className="h-screen bg-background relative overflow-hidden flex items-center justify-center p-8">
{/* Top-right diagonal gradient */}
export function CheckoutBackground({
children,
isSandbox,
containerClassName,
contentClassName,
}: CheckoutBackgroundProps) {
const AnimatedBackground = useMemo(() => {
return (
<>
{/* Top-right diagonal gradient */}
<motion.div
className="fixed inset-0 pointer-events-none bg-[linear-gradient(135deg,color-mix(in_oklch,var(--primary)_8%,var(--background))_0%,transparent_50%)]"
aria-hidden="true"
@@ -34,10 +43,18 @@ export function CheckoutBackground({ children, isSandbox }: CheckoutBackgroundPr
/>
{/* Animated beams */}
<BackgroundBeams className="fixed inset-0 pointer-events-none opacity-6" />
</>
);
}, []);
return (
<div className="h-screen bg-background relative overflow-hidden flex items-center justify-center p-8">
{AnimatedBackground}
{/* Frosted glass content container */}
<motion.div
layout
className="relative z-10 w-full max-w-xl max-h-full border border-border rounded-2xl bg-card/50 backdrop-blur-xl overflow-auto [scrollbar-width:thin] [scrollbar-color:color-mix(in_oklch,var(--foreground)_20%,transparent)_transparent] [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar]:bg-transparent [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:bg-foreground/20 [&::-webkit-scrollbar-thumb]:rounded-full"
className={`relative z-10 w-full max-w-xl max-h-full border border-border rounded-2xl bg-card/50 backdrop-blur-xl overflow-auto [scrollbar-width:thin] [scrollbar-color:color-mix(in_oklch,var(--foreground)_20%,transparent)_transparent] [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar]:bg-transparent [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:bg-foreground/20 [&::-webkit-scrollbar-thumb]:rounded-full ${containerClassName ?? ""}`}
initial={{ opacity: 0, y: 10, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{
@@ -47,10 +64,12 @@ export function CheckoutBackground({ children, isSandbox }: CheckoutBackgroundPr
...SLOW_TRANSITION,
}}
>
{/* Sandbox banner - outside padding, inside scrolling container */}
{isSandbox && <SandboxBanner />}
{/* Padded content wrapper */}
<div className="p-6">
<div className={contentClassName ?? "p-6"}>
{children}
</div>
</motion.div>

View File

@@ -2,10 +2,12 @@ import { motion } from "motion/react";
import { Skeleton } from "@/components/ui/skeleton";
import { useCheckoutContext } from "@/contexts/CheckoutContext";
import { GENTLE_SPRING, STANDARD_TRANSITION } from "@/lib/animations";
import { checkoutRouteTitle } from "@/utils/checkoutRouteMode";
export function CheckoutHeader() {
const { org, status, headerDescription } = useCheckoutContext();
const { org, routeMode, status, headerDescription } = useCheckoutContext();
const isLoading = status.isLoading;
const title = checkoutRouteTitle({ routeMode });
return (
<div className="flex flex-col gap-4">
{/* Org branding */}
@@ -37,9 +39,7 @@ export function CheckoutHeader() {
{/* Title and description */}
<div className="flex flex-col gap-2">
<h1 className="text-2xl text-foreground tracking-tight">
Confirm your order
</h1>
<h1 className="text-2xl text-foreground tracking-tight">{title}</h1>
{isLoading ? (
<div className="flex flex-col gap-1.5">
<Skeleton className="h-3.5 w-full" />

View File

@@ -1,4 +1,4 @@
import type { PreviewLineItem } from "@autumn/shared";
import { type PreviewLineItem } from "@autumn/shared";
import { motion } from "motion/react";
import { useMemo } from "react";
import { PlanGroupSection } from "@/components/checkout/plan/PlanGroupSection";
@@ -14,8 +14,15 @@ interface PlanGroup {
}
export function OrderSummary() {
const { preview, incoming = [], outgoing = [], freeTrial, hasActiveTrial, total, currency } =
useCheckoutContext();
const {
preview,
incoming = [],
outgoing = [],
freeTrial,
hasActiveTrial,
total,
currency,
} = useCheckoutContext();
if (!preview) return null;
@@ -46,11 +53,16 @@ export function OrderSummary() {
const planNameMap = useMemo(() => {
const map = new Map<string, string>();
for (const change of [...outgoing, ...incoming]) {
map.set(change.plan.id, change.plan.name || change.plan.id);
map.set(
change.plan_id,
change.plan?.name || change.plan_id,
);
}
return map;
}, [incoming, outgoing]);
// Group line items by plan_id
const planGroups = useMemo((): PlanGroup[] => {
const groupMap = new Map<string, PreviewLineItem[]>();
@@ -64,26 +76,29 @@ export function OrderSummary() {
}
// Convert to array, with outgoing plans first (credits), then incoming plans
const outgoingIds = new Set(outgoing.map((c) => c.plan.id));
const incomingIds = new Set(incoming.map((c) => c.plan.id));
const incomingIds = new Set<string>(incoming.map((c) => c.plan_id));
const visibleOutgoing = outgoing.filter(
(change) => !incomingIds.has(change.plan_id),
);
const outgoingIds = new Set<string>(visibleOutgoing.map((c) => c.plan_id));
const groups: PlanGroup[] = [];
// Add outgoing plan groups first (including those with no line items like free plans)
for (const change of outgoing) {
const planId = change.plan.id;
for (const change of visibleOutgoing) {
const planId = change.plan_id;
const items = groupMap.get(planId) || [];
groups.push({
planId,
planName: planNameMap.get(planId) || planId,
items,
type: "outgoing",
cancelledAt: change.period_end,
cancelledAt: change.effective_at ?? undefined,
});
}
// Add incoming plan groups (including those with no line items)
for (const change of incoming) {
const planId = change.plan.id;
const planId = change.plan_id;
const items = groupMap.get(planId) || [];
groups.push({
planId,
@@ -127,7 +142,11 @@ export function OrderSummary() {
cancelledAt={group.cancelledAt}
hasActiveTrial={isIncomingTrial}
freeTrial={group.type === "incoming" ? freeTrial : undefined}
nextCycleItems={isIncomingTrial ? nextCycleItemsByPlan.get(group.planId) : undefined}
nextCycleItems={
isIncomingTrial
? nextCycleItemsByPlan.get(group.planId)
: undefined
}
/>
);
})}

View File

@@ -8,13 +8,12 @@ import { formatTrialDuration } from "@/utils/trialUtils";
type PlanChangeType = "incoming" | "outgoing";
function LineItemAmount({ item, currency }: { item: PreviewLineItem; currency: string }) {
const totalDiscount = item.discounts.reduce((sum, d) => sum + d.amountOff, 0);
const hasDiscount = totalDiscount > 0;
const originalAmount = item.amount + totalDiscount;
const hasDiscount = item.subtotal !== item.total;
const originalAmount = item.subtotal;
return (
<motion.div
key={item.amount}
key={item.total}
className="flex items-center gap-1.5 shrink-0"
initial={{ opacity: 0.5 }}
animate={{ opacity: 1 }}
@@ -26,7 +25,7 @@ function LineItemAmount({ item, currency }: { item: PreviewLineItem; currency: s
</span>
)}
<span className="text-sm tabular-nums text-foreground">
{formatAmount(item.amount, currency)}
{formatAmount(item.total, currency)}
</span>
</motion.div>
);
@@ -59,31 +58,31 @@ export function PlanGroupSection({
}: PlanGroupSectionProps) {
// Sort items so base price appears first
const sortedItems = [...items].sort((a, b) => {
if (a.is_base && !b.is_base) return -1;
if (!a.is_base && b.is_base) return 1;
if (!a.feature_id && b.feature_id) return -1;
if (a.feature_id && !b.feature_id) return 1;
return 0;
});
// Format line item title with quantity
// Format line item label with quantity
const formatItemTitle = (item: PreviewLineItem): string => {
if (item.is_base) return "Base price";
const title = item.title;
if (!item.is_base && item.total_quantity > 1) {
return `${title} x${item.total_quantity}`;
if (!item.feature_id) return "Base price";
const title = item.display_name;
if (item.quantity > 1) {
return `${title} x${item.quantity}`;
}
return title;
};
// Check if all items share the same effective period
const itemsWithPeriod = sortedItems.filter((item) => item.effective_period);
// Check if all items share the same period
const itemsWithPeriod = sortedItems.filter((item) => item.period);
const allSamePeriod =
itemsWithPeriod.length > 0 &&
itemsWithPeriod.every(
(item) =>
item.effective_period?.start === itemsWithPeriod[0].effective_period?.start &&
item.effective_period?.end === itemsWithPeriod[0].effective_period?.end,
item.period?.start === itemsWithPeriod[0].period?.start &&
item.period?.end === itemsWithPeriod[0].period?.end,
);
const sharedPeriod = allSamePeriod ? itemsWithPeriod[0].effective_period : null;
const sharedPeriod = allSamePeriod ? itemsWithPeriod[0].period : null;
const headerRightText = (() => {
if (type === "outgoing" && cancelledAt) {
@@ -122,17 +121,20 @@ export function PlanGroupSection({
// Show next cycle line items for trial plans (what they'll pay after trial)
[...nextCycleItems]
.sort((a, b) => {
if (a.is_base && !b.is_base) return -1;
if (!a.is_base && b.is_base) return 1;
if (!a.feature_id && b.feature_id) return -1;
if (a.feature_id && !b.feature_id) return 1;
return 0;
})
.map((item, itemIndex) => (
<div key={`next-${item.title}-${itemIndex}`} className="flex items-center justify-between py-0.5">
<div
key={`next-${item.display_name}-${itemIndex}`}
className="flex items-center justify-between py-0.5"
>
<span className="text-sm text-muted-foreground truncate">
{formatItemTitle(item)}
</span>
<span className="text-sm tabular-nums text-foreground">
{formatAmount(item.amount, currency)}
{formatAmount(item.total, currency)}
</span>
</div>
))
@@ -147,11 +149,14 @@ export function PlanGroupSection({
</div>
) : (
sortedItems.map((item, itemIndex) => (
<div key={`${item.title}-${itemIndex}`} className="flex flex-col">
<div
key={`${item.display_name}-${itemIndex}`}
className="flex flex-col"
>
<div className="flex items-center justify-between py-0.5">
<div className="flex items-center gap-2 min-w-0">
<motion.span
key={`${item.title}-${item.total_quantity}`}
key={`${item.display_name}-${item.quantity}`}
className="text-sm text-muted-foreground truncate"
initial={{ opacity: 0.5 }}
animate={{ opacity: 1 }}
@@ -163,9 +168,9 @@ export function PlanGroupSection({
<LineItemAmount item={item} currency={currency} />
</div>
{/* Only show per-item period if items don't share the same period */}
{!sharedPeriod && item.effective_period && (
{!sharedPeriod && item.period && (
<span className="text-xs text-muted-foreground/60 pl-0">
{formatPeriodRange(item.effective_period.start, item.effective_period.end)}
{formatPeriodRange(item.period.start, item.period.end)}
</span>
)}
</div>

View File

@@ -0,0 +1,294 @@
import type { ApiPlanItemV1 } from "@autumn/shared";
import { CaretDownIcon, CaretRightIcon } from "@phosphor-icons/react";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { cn } from "@/lib/utils";
import { formatAmount } from "@/utils/formatUtils";
type PlanItemTier = NonNullable<NonNullable<ApiPlanItemV1["price"]>["tiers"]>[number];
function getBillingUnitsLabel({ billingUnits }: { billingUnits: number }) {
return billingUnits === 1 ? "unit" : `${billingUnits} units`;
}
function getSelectedQuantityLabel({ quantity }: { quantity: number }) {
return quantity === 1 ? "1 unit" : `${quantity} units`;
}
function normalizeGraduatedTiers({
planItem,
tiers,
}: {
planItem: ApiPlanItemV1;
tiers: PlanItemTier[];
}) {
const included = planItem.included ?? 0;
const isVolume = planItem.price?.tier_behavior === "volume";
if (included <= 0 || isVolume) {
return tiers;
}
return [{ to: included, amount: 0 }, ...tiers];
}
function isInfiniteTier({ to }: { to: number | "inf" }) {
return to === "inf" || to === -1;
}
function getVolumeTierText({
tiers,
selectedQuantity,
billingUnits,
currency,
}: {
tiers: PlanItemTier[];
selectedQuantity: number;
billingUnits: number;
currency: string;
}) {
const matchedTier =
tiers.find((tier) =>
isInfiniteTier({ to: tier.to })
? true
: selectedQuantity <= (tier.to as number),
) ?? tiers[tiers.length - 1];
if (!matchedTier) return "";
const flatAmount = matchedTier.flat_amount ?? 0;
const unitAmount = matchedTier.amount ?? 0;
if (unitAmount > 0 && flatAmount > 0) {
return `${formatAmount(unitAmount, currency)} per ${getBillingUnitsLabel({
billingUnits,
})} + ${formatAmount(flatAmount, currency)} flat fee`;
}
if (flatAmount > 0) {
return `${formatAmount(flatAmount, currency)} for ${getSelectedQuantityLabel({
quantity: selectedQuantity,
})}`;
}
if (unitAmount > 0) {
return `${formatAmount(unitAmount, currency)} per ${getBillingUnitsLabel({
billingUnits,
})}`;
}
return `${formatAmount(0, currency)} for ${getSelectedQuantityLabel({
quantity: selectedQuantity,
})}`;
}
function getTierRateText({
tier,
billingUnits,
currency,
}: {
tier: PlanItemTier;
billingUnits: number;
currency: string;
}) {
const rateText = `${formatAmount(tier.amount ?? 0, currency)} per ${getBillingUnitsLabel({ billingUnits })}`;
const flatAmount = tier.flat_amount ?? 0;
if (flatAmount > 0) {
return `${rateText} + ${formatAmount(flatAmount, currency)} flat`;
}
return rateText;
}
function getTierRangeText({
index,
tiers,
isVolume,
}: {
index: number;
tiers: PlanItemTier[];
isVolume: boolean;
}) {
const tier = tiers[index];
if (!tier) return "";
if (isVolume) {
if (index === 0) {
return isInfiniteTier({ to: tier.to }) ? "thereafter" : `for the first ${tier.to}`;
}
if (isInfiniteTier({ to: tier.to })) {
return "thereafter";
}
const previousTier = tiers[index - 1];
if (!previousTier || typeof previousTier.to !== "number") return "";
return `for the next ${tier.to - previousTier.to}`;
}
if (index === 0) {
return isInfiniteTier({ to: tier.to })
? "afterwards"
: `for the first ${tier.to}`;
}
if (isInfiniteTier({ to: tier.to })) {
return "afterwards";
}
const previousTier = tiers[index - 1];
if (!previousTier || typeof previousTier.to !== "number") return "";
return `for ${previousTier.to} - ${tier.to}`;
}
function getTierLineText({
index,
tiers,
billingUnits,
currency,
isVolume,
}: {
index: number;
tiers: PlanItemTier[];
billingUnits: number;
currency: string;
isVolume: boolean;
}) {
const tier = tiers[index];
if (!tier) return "";
return `${getTierRateText({ tier, billingUnits, currency })} ${getTierRangeText({
index,
tiers,
isVolume,
})}`.trim();
}
function getSingleRateText({
planItem,
currency,
}: {
planItem: ApiPlanItemV1;
currency: string;
}) {
const price = planItem.price;
if (!price) return "";
const rateText = `${formatAmount(price.amount || 0, currency)} per ${getBillingUnitsLabel({
billingUnits: price.billing_units || 1,
})}`;
const included = planItem.included ?? 0;
return included > 0 ? `${included} included then ${rateText}` : rateText;
}
export function PlanItemTierDetails({
planItem,
currency,
align = "left",
selectedQuantity,
}: {
planItem: ApiPlanItemV1;
currency: string;
align?: "left" | "right";
selectedQuantity?: number;
}) {
const price = planItem.price;
if (!price) return null;
const tiers = price.tiers;
if (!tiers || tiers.length <= 1) {
return (
<span className="text-xs text-muted-foreground/60 truncate">
{getSingleRateText({ planItem, currency })}
</span>
);
}
const billingUnits = price.billing_units || 1;
const displayTiers = normalizeGraduatedTiers({ planItem, tiers });
const isVolume = price.tier_behavior === "volume";
const currentQuantity = selectedQuantity ?? 0;
if (isVolume) {
return (
<span className="text-xs text-muted-foreground/60 truncate">
{getVolumeTierText({
tiers,
selectedQuantity: currentQuantity,
billingUnits,
currency,
})}
</span>
);
}
return (
<Accordion
type="single"
collapsible
className={cn(
"w-auto max-w-full",
align === "right" && "items-end text-right",
)}
>
<AccordionItem value={planItem.feature_id} className="border-none">
<AccordionTrigger
className={cn(
"inline-flex w-auto max-w-full flex-none items-center gap-0.5 rounded-none py-0 text-xs leading-4 font-normal text-muted-foreground/60 hover:text-muted-foreground/50 hover:no-underline focus-visible:border-transparent focus-visible:ring-0 [&>[data-slot=accordion-trigger-icon]]:hidden",
align === "left" ? "justify-start text-left" : "justify-end self-end text-right",
)}
>
<span className="inline-flex items-center gap-0.5 truncate leading-4">
<span className="truncate leading-4">
{getTierLineText({
index: 0,
tiers: displayTiers,
billingUnits,
currency,
isVolume,
})}
</span>
<CaretRightIcon className="size-3 shrink-0 self-center text-current group-aria-expanded/accordion-trigger:hidden" />
<CaretDownIcon className="hidden size-3 shrink-0 self-center text-current group-aria-expanded/accordion-trigger:inline" />
</span>
</AccordionTrigger>
<AccordionContent
className={cn(
"pt-0.5 pb-0",
align === "right" && "flex flex-col items-end text-right",
)}
>
<div
className={cn(
"flex flex-col gap-0.5",
align === "right" && "items-end text-right",
)}
>
{displayTiers.slice(1).map((_, index) => (
<span
key={`${planItem.feature_id}-${index + 1}`}
className="text-xs text-muted-foreground/60"
>
{getTierLineText({
index: index + 1,
tiers: displayTiers,
billingUnits,
currency,
isVolume,
})}
</span>
))}
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
);
}

View File

@@ -22,7 +22,10 @@ export function PlanSection() {
skeleton={<PlanSelectionCardSkeleton />}
>
{incoming?.map((change) => (
<PlanSelectionCard key={change.plan.id} change={change} />
<PlanSelectionCard
key={change.plan?.id ?? change.plan_id}
change={change}
/>
))}
</CrossfadeContainer>
</motion.div>

View File

@@ -1,14 +1,16 @@
import type { ApiPlanItemV1, CheckoutChange } from "@autumn/shared";
import { CheckIcon, Wallet, WalletIcon } from "@phosphor-icons/react";
import {
type ApiPlanItemV1,
type BillingPreviewChange,
} from "@autumn/shared";
import { CheckIcon, WalletIcon } from "@phosphor-icons/react";
import { AnimatePresence, motion } from "motion/react";
import { useCheckoutContext } from "@/contexts/CheckoutContext";
import {
FAST_TRANSITION,
LAYOUT_TRANSITION,
listContainerVariants,
listItemVariants,
} from "@/lib/animations";
import { formatAmount } from "@/utils/formatUtils";
import { PlanItemTierDetails } from "./PlanItemTierDetails";
import { QuantityInput } from "../shared/QuantityInput";
function categorizeFeatures(features: ApiPlanItemV1[]): {
@@ -36,43 +38,24 @@ function categorizeFeatures(features: ApiPlanItemV1[]): {
return { prepaid, payPerUse, included };
}
function formatInterval(interval: string): string {
switch (interval) {
case "month":
return "mo";
case "year":
return "yr";
case "week":
return "wk";
case "day":
return "day";
default:
return interval;
}
}
function getFeatureName(feature: ApiPlanItemV1): string {
return feature.feature?.name || feature.feature_id;
}
function getFeatureUnitDisplay(
feature: ApiPlanItemV1,
plural: boolean,
): string {
const display = feature.feature?.display;
if (display) {
return plural ? display.plural : display.singular;
}
return plural ? "units" : "unit";
function getFeatureName(planItem: ApiPlanItemV1): string {
return planItem.feature?.name || planItem.feature_id;
}
interface PlanSelectionCardProps {
change: CheckoutChange;
change: BillingPreviewChange;
}
export function PlanSelectionCard({ change }: PlanSelectionCardProps) {
const { currency, quantities, handleQuantityChange } = useCheckoutContext();
const {
adjustableFeatureIds,
currency,
quantities,
handleQuantityChange,
} = useCheckoutContext();
const { plan, feature_quantities } = change;
if (!plan) return null;
const { prepaid, payPerUse, included } = categorizeFeatures(plan.items);
const hasPricedFeatures = prepaid.length > 0 || payPerUse.length > 0;
const hasIncludedFeatures = included.length > 0;
@@ -99,30 +82,29 @@ export function PlanSelectionCard({ change }: PlanSelectionCardProps) {
>
{/* Prepaid features - show quantity selector */}
<AnimatePresence>
{prepaid.map((feature, index) => {
const price = feature.price;
{prepaid.map((planItem, index) => {
const price = planItem.price;
if (!price) return null;
const isTiered = (price.tiers?.length ?? 0) > 1;
const quantityInfo = feature_quantities.find(
(fq) => fq.feature_id === feature.feature_id,
(fq) => fq.feature_id === planItem.feature_id,
);
const currentQuantity =
quantities[feature.feature_id] ?? quantityInfo?.quantity ?? 0;
quantities[planItem.feature_id] ?? quantityInfo?.quantity ?? 0;
const isAdjustable = adjustableFeatureIds.includes(
planItem.feature_id,
);
const billingUnits = price.billing_units || 1;
const unitPrice = price.amount || 0;
const units = currentQuantity / billingUnits;
const totalPrice = units * unitPrice;
const intervalLabel = formatInterval(price.interval || "month");
return (
<motion.div
key={feature.feature_id}
key={planItem.feature_id}
variants={listItemVariants}
layout
transition={{ layout: LAYOUT_TRANSITION }}
layout={!isTiered}
transition={isTiered ? undefined : { layout: LAYOUT_TRANSITION }}
>
<div className="flex items-center justify-between gap-4 py-0.5">
<div className="flex items-start justify-between gap-4 py-0.5">
<div className="flex gap-2">
<motion.div
className="shrink-0 pt-1"
@@ -139,31 +121,21 @@ export function PlanSelectionCard({ change }: PlanSelectionCardProps) {
</motion.div>
<div className="flex flex-col gap-0.5 min-w-0">
<span className="text-sm text-muted-foreground truncate">
{getFeatureName(feature)}
</span>
<span className="text-xs text-muted-foreground/60 truncate">
{formatAmount(unitPrice, currency)} per{" "}
{billingUnits === 1
? getFeatureUnitDisplay(feature, false)
: `${billingUnits} ${getFeatureUnitDisplay(feature, true)}`}
{getFeatureName(planItem)}
</span>
<PlanItemTierDetails
planItem={planItem}
currency={currency}
selectedQuantity={currentQuantity}
/>
</div>
</div>
<div className="flex items-center gap-3 shrink-0">
<motion.span
key={totalPrice}
className="text-sm text-muted-foreground tabular-nums"
initial={{ opacity: 0.5 }}
animate={{ opacity: 1 }}
transition={FAST_TRANSITION}
>
{formatAmount(totalPrice, currency)}/{intervalLabel}
</motion.span>
<div className="flex items-start gap-3 shrink-0 pt-0.5">
<QuantityInput
value={currentQuantity}
onChange={(value) =>
handleQuantityChange(
feature.feature_id,
planItem.feature_id,
value,
billingUnits,
)
@@ -175,6 +147,7 @@ export function PlanSelectionCard({ change }: PlanSelectionCardProps) {
: undefined
}
step={billingUnits}
disabled={!isAdjustable}
/>
</div>
</div>
@@ -188,31 +161,23 @@ export function PlanSelectionCard({ change }: PlanSelectionCardProps) {
{payPerUse.map((feature, index) => {
const price = feature.price;
if (!price) return null;
const billingUnits = price.billing_units || 1;
// Handle tiered pricing
let priceDisplay: string;
if (price.tiers && price.tiers.length > 0) {
const firstTier = price.tiers[0];
const tierPrice =
firstTier?.unit_price ?? firstTier?.flat_price ?? 0;
priceDisplay = `From ${formatAmount(tierPrice, currency)}`;
} else {
priceDisplay = formatAmount(price.amount || 0, currency);
}
const isTiered = (price.tiers?.length ?? 0) > 1;
return (
<motion.div
key={feature.feature_id}
variants={listItemVariants}
layout
transition={{ layout: LAYOUT_TRANSITION }}
layout={!isTiered}
transition={isTiered ? undefined : { layout: LAYOUT_TRANSITION }}
>
<div className="flex items-center justify-between gap-4 py-0.5">
<div className="flex items-center gap-2 min-w-0">
<div
className={`flex justify-between gap-4 py-0.5 ${isTiered ? "items-start" : "items-center"}`}
>
<div
className={`flex gap-2 min-w-0 ${isTiered ? "" : "items-center"}`}
>
<motion.div
className="shrink-0"
className={`shrink-0 ${isTiered ? "pt-0.5" : ""}`}
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{
@@ -224,16 +189,31 @@ export function PlanSelectionCard({ change }: PlanSelectionCardProps) {
>
<CheckIcon className="h-3.5 w-3.5 text-muted-foreground" />
</motion.div>
<span className="text-sm text-muted-foreground truncate">
{getFeatureName(feature)}
</span>
{isTiered ? (
<div className="flex flex-col gap-0.5 min-w-0">
<span className="text-sm text-muted-foreground truncate">
{getFeatureName(feature)}
</span>
<PlanItemTierDetails
planItem={feature}
currency={currency}
/>
</div>
) : (
<span className="text-sm text-muted-foreground truncate">
{getFeatureName(feature)}
</span>
)}
</div>
<span className="text-sm text-muted-foreground shrink-0">
{priceDisplay} per{" "}
{billingUnits === 1
? getFeatureUnitDisplay(feature, false)
: `${billingUnits} ${getFeatureUnitDisplay(feature, true)}`}
</span>
{!isTiered && (
<div className="shrink-0">
<PlanItemTierDetails
planItem={feature}
currency={currency}
align="right"
/>
</div>
)}
</div>
</motion.div>
);

View File

@@ -0,0 +1,58 @@
import type { BillingResponse } from "@autumn/shared";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { getCheckoutActionRequiredCopy } from "./checkoutActionRequiredCopy";
export function CheckoutActionRequiredDialog({
response,
}: {
response: BillingResponse;
}) {
const paymentUrl = response.payment_url;
const requiredAction = response.required_action;
if (!paymentUrl || !requiredAction) {
return null;
}
const copy = getCheckoutActionRequiredCopy({
code: requiredAction.code,
});
return (
<Dialog open>
<DialogContent
showCloseButton={false}
className="max-w-[calc(100%-2rem)] gap-0 rounded-2xl border-border/80 bg-card/95 p-0 shadow-[0_24px_80px_-24px_rgba(15,23,42,0.5)] backdrop-blur-2xl sm:max-w-[30rem]"
>
<div className="flex flex-col gap-5 p-6">
<DialogHeader className="gap-2">
<DialogTitle className="text-[1.45rem] leading-tight tracking-tight text-foreground">
{copy.title}
</DialogTitle>
<DialogDescription className="text-[0.98rem] leading-7 text-foreground/78">
{copy.description}
</DialogDescription>
</DialogHeader>
</div>
<div className="px-6 pb-6">
<Button
className="h-11 w-full rounded-xl text-sm font-medium sm:w-full"
onClick={() => {
window.location.assign(paymentUrl);
}}
>
{copy.ctaLabel}
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,23 +1,32 @@
import { WarningIcon } from "@phosphor-icons/react";
import { motion } from "motion/react";
import { CheckoutBackground } from "@/components/checkout/layout/CheckoutBackground";
import { STANDARD_TRANSITION } from "@/lib/animations";
import {
CheckIcon,
TimerIcon,
WarningIcon,
} from "@phosphor-icons/react";
import { CheckoutTerminalState } from "./CheckoutTerminalState";
export function CheckoutErrorState({
title,
message,
variant,
}: {
title: string;
message: string;
variant: "completed" | "expired" | "unavailable" | "generic";
}) {
const Icon =
variant === "completed"
? CheckIcon
: variant === "expired"
? TimerIcon
: WarningIcon;
export function CheckoutErrorState({ message }: { message: string }) {
return (
<CheckoutBackground>
<motion.div
className="flex flex-col items-start gap-1"
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={STANDARD_TRANSITION}
>
<div className="flex items-center gap-2">
<WarningIcon className="h-4 w-4 text-destructive shrink-0" weight="bold" />
<span className="text-foreground tracking-tight">Something went wrong</span>
</div>
<p className="text-xs text-muted-foreground pl-6">{message}</p>
</motion.div>
</CheckoutBackground>
<CheckoutTerminalState
title={title}
message={message}
Icon={Icon}
iconClassName={variant === "generic" ? "text-destructive/85" : undefined}
/>
);
}

View File

@@ -1,31 +1,13 @@
import type { ConfirmCheckoutResponse } from "@autumn/shared";
import { CheckIcon } from "@phosphor-icons/react";
import { motion } from "motion/react";
import { CheckoutBackground } from "@/components/checkout/layout/CheckoutBackground";
import { STANDARD_TRANSITION } from "@/lib/animations";
import { CheckoutTerminalState } from "./CheckoutTerminalState";
export function CheckoutSuccessState({
result,
}: {
result: ConfirmCheckoutResponse;
}) {
export function CheckoutSuccessState() {
return (
<CheckoutBackground>
<motion.div
className="flex flex-col items-start gap-1"
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={STANDARD_TRANSITION}
>
<div className="flex items-center gap-2">
<CheckIcon className="h-4 w-4 text-primary shrink-0" weight="bold" />
<span className="text-foreground tracking-tight">Purchase complete</span>
</div>
<p className="text-xs text-muted-foreground pl-6">
Your order has been confirmed
{result.invoice_id && <span className="text-muted-foreground"> · {result.invoice_id}</span>}
</p>
</motion.div>
</CheckoutBackground>
<CheckoutTerminalState
title="Purchase complete"
message="Your order has been confirmed"
Icon={CheckIcon}
iconClassName="text-primary"
/>
);
}

View File

@@ -0,0 +1,45 @@
import type { Icon } from "@phosphor-icons/react";
import { motion } from "motion/react";
import { CheckoutBackground } from "@/components/checkout/layout/CheckoutBackground";
import { STANDARD_TRANSITION } from "@/lib/animations";
export function CheckoutTerminalState({
title,
message,
Icon,
iconClassName,
}: {
title: string;
message: string;
Icon: Icon;
iconClassName?: string;
}) {
return (
<CheckoutBackground
containerClassName="max-w-lg"
contentClassName="p-8 sm:p-9"
>
<motion.div
className="flex w-full items-center justify-start"
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={STANDARD_TRANSITION}
>
<div className="flex max-w-md flex-col gap-2.5 text-left">
<div className="flex items-center gap-2.5">
<Icon
className={`h-[1.4rem] w-[1.4rem] shrink-0 text-foreground/70 ${iconClassName ?? ""}`}
weight="regular"
/>
<h2 className="text-[1.35rem] leading-tight tracking-tight text-foreground">
{title}
</h2>
</div>
<p className="text-[1rem] leading-7 text-muted-foreground">
{message}
</p>
</div>
</motion.div>
</CheckoutBackground>
);
}

View File

@@ -0,0 +1,32 @@
import type { PaymentFailureCode } from "@autumn/shared";
export const getCheckoutActionRequiredCopy = ({
code,
}: {
code: PaymentFailureCode;
}) => {
switch (code) {
case "payment_method_required":
return {
title: "Couldn't complete payment",
description:
"There isn't a payment method on file for this purchase. Add one to continue.",
ctaLabel: "Complete payment",
};
case "3ds_required":
return {
title: "Verification required",
description:
"This payment needs extra verification before it can go through.",
ctaLabel: "Complete payment",
};
case "payment_failed":
default:
return {
title: "Couldn't complete payment",
description:
"Your payment was unsuccessful. Review your payment details to complete payment.",
ctaLabel: "Complete payment",
};
}
};

View File

@@ -0,0 +1,78 @@
import { Accordion as AccordionPrimitive } from "@base-ui/react/accordion"
import { cn } from "@/lib/utils"
import { CaretDownIcon, CaretRightIcon } from "@phosphor-icons/react"
function Accordion({ className, ...props }: AccordionPrimitive.Root.Props) {
return (
<AccordionPrimitive.Root
data-slot="accordion"
className={cn("flex w-full flex-col", className)}
{...props}
/>
)
}
function AccordionItem({ className, ...props }: AccordionPrimitive.Item.Props) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("not-last:border-b", className)}
{...props}
/>
)
}
function AccordionTrigger({
className,
children,
...props
}: AccordionPrimitive.Trigger.Props) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"group/accordion-trigger relative flex flex-1 items-start justify-between gap-2 rounded-lg border border-transparent py-2.5 text-left text-sm font-medium transition-colors outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:after:border-ring aria-disabled:pointer-events-none aria-disabled:opacity-50 **:data-[slot=accordion-trigger-icon]:size-3 **:data-[slot=accordion-trigger-icon]:text-current",
className
)}
{...props}
>
{children}
<CaretRightIcon
data-slot="accordion-trigger-icon"
className="pointer-events-none shrink-0 self-center leading-none group-aria-expanded/accordion-trigger:hidden"
/>
<CaretDownIcon
data-slot="accordion-trigger-icon"
className="pointer-events-none hidden shrink-0 self-center leading-none group-aria-expanded/accordion-trigger:inline"
/>
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
}
function AccordionContent({
className,
children,
...props
}: AccordionPrimitive.Panel.Props) {
return (
<AccordionPrimitive.Panel
data-slot="accordion-content"
className="overflow-hidden text-sm data-open:animate-accordion-down data-closed:animate-accordion-up"
{...props}
>
<div
className={cn(
"h-(--accordion-panel-height) pt-0 pb-2.5 data-ending-style:h-0 data-starting-style:h-0 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
className
)}
>
{children}
</div>
</AccordionPrimitive.Panel>
)
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View File

@@ -0,0 +1,157 @@
"use client"
import * as React from "react"
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "@phosphor-icons/react"
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: DialogPrimitive.Backdrop.Props) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-background p-4 text-sm ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
render={
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close render={<Button variant="outline" />}>
Close
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-base leading-none font-medium", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: DialogPrimitive.Description.Props) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@@ -1,17 +1,23 @@
import type { ReactNode } from "react";
import { createContext, useContext } from "react";
import { type CheckoutState, useCheckoutState } from "@/hooks/useCheckoutState";
import type { CheckoutRouteMode } from "@/utils/checkoutRouteMode";
const CheckoutContext = createContext<CheckoutState | null>(null);
export function CheckoutProvider({
checkoutId,
routeMode,
children,
}: {
checkoutId: string;
routeMode: CheckoutRouteMode;
children: ReactNode;
}) {
const state = useCheckoutState({ checkoutId });
const state = useCheckoutState({
checkoutId,
routeMode,
});
return (
<CheckoutContext.Provider value={state}>
{children}

View File

@@ -1,17 +1,44 @@
import type { GetCheckoutResponse } from "@autumn/shared";
import {
CheckoutErrorCode,
type ConfirmCheckoutParams,
type GetCheckoutResponse,
} from "@autumn/shared";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { checkoutApi } from "@/api/checkoutClient";
import { getCheckoutApiErrorCode } from "@/utils/checkoutApiErrorUtils";
export const checkoutKeys = {
all: ["checkout"] as const,
detail: (checkoutId: string) => [...checkoutKeys.all, checkoutId] as const,
};
const shouldRetryCheckoutQuery = ({
error,
failureCount,
}: {
error: unknown;
failureCount: number;
}) => {
const errorCode = getCheckoutApiErrorCode({ error });
if (
errorCode === CheckoutErrorCode.CheckoutCompleted ||
errorCode === CheckoutErrorCode.CheckoutExpired ||
errorCode === CheckoutErrorCode.CheckoutUnavailable
) {
return false;
}
return failureCount < 1;
};
export function useCheckout({ checkoutId }: { checkoutId: string }) {
return useQuery({
return useQuery<GetCheckoutResponse>({
queryKey: checkoutKeys.detail(checkoutId),
queryFn: () => checkoutApi.getCheckout({ checkout_id: checkoutId }),
enabled: !!checkoutId,
retry: (failureCount, error) =>
shouldRetryCheckoutQuery({ error, failureCount }),
});
}
@@ -19,8 +46,8 @@ export function usePreviewCheckout({ checkoutId }: { checkoutId: string }) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (options: { feature_id: string; quantity: number }[]) =>
checkoutApi.previewCheckout({ checkout_id: checkoutId, options }),
mutationFn: (body: ConfirmCheckoutParams) =>
checkoutApi.previewCheckout({ checkout_id: checkoutId, ...body }),
onSuccess: (data) => {
// Update the checkout query cache with new preview data
queryClient.setQueryData(
@@ -35,7 +62,8 @@ export function useConfirmCheckout({ checkoutId }: { checkoutId: string }) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => checkoutApi.confirmCheckout({ checkout_id: checkoutId }),
mutationFn: (body: ConfirmCheckoutParams) =>
checkoutApi.confirmCheckout({ checkout_id: checkoutId, ...body }),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: checkoutKeys.detail(checkoutId),

View File

@@ -1,36 +1,78 @@
import type { ConfirmCheckoutResponse } from "@autumn/shared";
import { useCallback, useMemo, useState } from "react";
import {
type BillingPreviewChange,
type BillingResponse,
CheckoutAction,
type ConfirmCheckoutResponse,
CheckoutStatus,
} from "@autumn/shared";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useDebouncedCallback } from "use-debounce";
import {
useCheckout,
useConfirmCheckout,
usePreviewCheckout,
} from "@/hooks/useCheckout";
import type { CheckoutRouteMode } from "@/utils/checkoutRouteMode";
import { buildHeaderDescription } from "@/utils/buildHeaderDescription";
function buildOptionsArray(
incoming: { feature_quantities: { feature_id: string; quantity: number }[] }[],
const SUCCESS_REDIRECT_DELAY_MS = 2000;
function haveMatchingQuantities({
incoming,
outgoing,
}: {
incoming: BillingPreviewChange;
outgoing: BillingPreviewChange;
}) {
if (incoming.feature_quantities.length !== outgoing.feature_quantities.length) {
return false;
}
const outgoingQuantities = new Map(
outgoing.feature_quantities.map((featureQuantity) => [
featureQuantity.feature_id,
featureQuantity.quantity,
]),
);
return incoming.feature_quantities.every(
(featureQuantity) =>
outgoingQuantities.get(featureQuantity.feature_id) ===
featureQuantity.quantity,
);
}
function buildFeatureQuantities(
incoming: BillingPreviewChange[],
quantities: Record<string, number>,
): { feature_id: string; quantity: number }[] {
const options: { feature_id: string; quantity: number }[] = [];
const featureQuantities: { feature_id: string; quantity: number }[] = [];
for (const change of incoming) {
for (const fq of change.feature_quantities) {
const quantity = quantities[fq.feature_id] ?? fq.quantity;
options.push({
featureQuantities.push({
feature_id: fq.feature_id,
quantity,
});
}
}
return options;
return featureQuantities;
}
export function useCheckoutState({ checkoutId }: { checkoutId: string }) {
export function useCheckoutState({
checkoutId,
routeMode,
}: {
checkoutId: string;
routeMode: CheckoutRouteMode;
}) {
// === Raw state ===
const [confirmResult, setConfirmResult] =
useState<ConfirmCheckoutResponse | null>(null);
const [actionRequiredResponse, setActionRequiredResponse] =
useState<BillingResponse | null>(null);
const [quantities, setQuantities] = useState<Record<string, number>>({});
// === API hooks ===
@@ -38,17 +80,60 @@ export function useCheckoutState({ checkoutId }: { checkoutId: string }) {
const previewMutation = usePreviewCheckout({ checkoutId });
const confirmMutation = useConfirmCheckout({ checkoutId });
useEffect(() => {
if (checkoutData?.status === CheckoutStatus.ActionRequired) {
setActionRequiredResponse(checkoutData.response);
return;
}
if (checkoutData) {
setActionRequiredResponse(null);
}
}, [checkoutData]);
useEffect(() => {
if (!confirmResult?.success_url) {
return;
}
const timeoutId = window.setTimeout(() => {
window.location.assign(confirmResult.success_url);
}, SUCCESS_REDIRECT_DELAY_MS);
return () => {
window.clearTimeout(timeoutId);
};
}, [confirmResult]);
// === Debounced preview ===
const debouncedPreview = useDebouncedCallback(
(options: { feature_id: string; quantity: number }[]) => {
previewMutation.mutate(options);
(feature_quantities: { feature_id: string; quantity: number }[]) => {
previewMutation.mutate({ feature_quantities });
},
600,
);
// === Derived values ===
const derivedState = useMemo(() => {
const { env, preview, incoming, outgoing, org, entity } = checkoutData ?? {};
const { action, env, preview, org, entity, status: checkoutStatus } =
checkoutData ?? {};
const adjustableFeatureIds = checkoutData?.adjustable_feature_ids ?? [];
const incoming = preview?.incoming;
const outgoing = preview?.outgoing;
const isUpdateQuantityIntent =
preview?.object === "update_subscription_preview" &&
preview.intent === "update_quantity";
const matchingOutgoingChange = incoming?.[0]
? outgoing?.find((change) => change.plan_id === incoming[0].plan_id)
: undefined;
const isUnchangedQuantityUpdate =
isUpdateQuantityIntent &&
Boolean(incoming?.[0]) &&
Boolean(matchingOutgoingChange) &&
haveMatchingQuantities({
incoming: incoming[0],
outgoing: matchingOutgoingChange,
});
const incomingPlan = incoming?.[0]?.plan;
const freeTrial = incomingPlan?.free_trial;
const hasActiveTrial = !!freeTrial;
@@ -63,7 +148,10 @@ export function useCheckoutState({ checkoutId }: { checkoutId: string }) {
});
return {
action: action ?? CheckoutAction.Attach,
routeMode,
env,
checkoutStatus: checkoutStatus ?? CheckoutStatus.Pending,
preview,
incoming,
outgoing,
@@ -71,36 +159,54 @@ export function useCheckoutState({ checkoutId }: { checkoutId: string }) {
entity,
currency: preview?.currency ?? "usd",
total: preview?.total ?? 0,
primaryPlanName: incomingPlan?.name || "Order",
isSubscription: incoming?.some((c) => c.plan.price?.interval) ?? false,
primaryPlanName: incomingPlan?.name ?? incoming?.[0]?.plan_id ?? "Order",
isSubscription: incoming?.some((c) => c.plan?.price?.interval) ?? false,
freeTrial,
hasActiveTrial,
isSandbox: env === "sandbox",
headerDescription,
adjustableFeatureIds,
isUnchangedQuantityUpdate,
};
}, [checkoutData]);
}, [checkoutData, routeMode]);
// === Callbacks ===
const handleQuantityChange = useCallback(
(featureId: string, quantity: number, _billingUnits: number) => {
setQuantities((prev) => ({ ...prev, [featureId]: quantity }));
if (checkoutData) {
if (checkoutData?.preview?.incoming) {
const newQuantities = { ...quantities, [featureId]: quantity };
const options = buildOptionsArray(checkoutData.incoming, newQuantities);
debouncedPreview(options);
const featureQuantities = buildFeatureQuantities(
checkoutData.preview.incoming,
newQuantities,
);
debouncedPreview(featureQuantities);
}
},
[checkoutData, quantities, debouncedPreview],
);
const handleConfirm = useCallback(() => {
confirmMutation.mutate(undefined, {
const featureQuantities = checkoutData?.preview?.incoming
? buildFeatureQuantities(checkoutData.preview.incoming, quantities)
: [];
confirmMutation.mutate({ feature_quantities: featureQuantities }, {
onSuccess: (result) => {
if (!result.success) {
setActionRequiredResponse(result);
return;
}
setConfirmResult(result);
},
});
}, [confirmMutation]);
}, [checkoutData, confirmMutation, quantities]);
const hasActionRequiredState = !!(
actionRequiredResponse?.payment_url && actionRequiredResponse.required_action
);
// === Status flags ===
const status = useMemo(
@@ -124,6 +230,8 @@ export function useCheckoutState({ checkoutId }: { checkoutId: string }) {
checkoutId,
...derivedState,
quantities,
actionRequiredResponse,
hasActionRequiredState,
confirmResult,
status,
handleQuantityChange,

View File

@@ -1,9 +1,7 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createRoot } from "react-dom/client";
import { BrowserRouter, Route, Routes } from "react-router-dom";
import { CheckoutBackground } from "./components/checkout/layout/CheckoutBackground";
import { ThemeProvider } from "./components/theme-provider";
import { Card, CardContent, CardHeader, CardTitle } from "./components/ui/card";
import { useDevThemeToggle } from "./hooks/useDevThemeToggle";
import { CheckoutPage } from "./pages/CheckoutPage";
import "./index.css";
@@ -28,7 +26,14 @@ createRoot(document.getElementById("root")!).render(
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<Routes>
<Route path="/c/:checkoutId" element={<CheckoutPage />} />
<Route
path="/c/:checkoutId"
element={<CheckoutPage routeMode="attach" />}
/>
<Route
path="/u/:checkoutId"
element={<CheckoutPage routeMode="update_subscription" />}
/>
<Route path="*" element={<NotFound />} />
</Routes>
</BrowserRouter>
@@ -37,20 +42,6 @@ createRoot(document.getElementById("root")!).render(
);
function NotFound() {
return (
<CheckoutBackground>
<div className="min-h-screen flex items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>Page not found</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">
The checkout page you're looking for doesn't exist.
</p>
</CardContent>
</Card>
</div>
</CheckoutBackground>
);
window.location.href = "https://useautumn.com";
return null;
}

View File

@@ -1,18 +1,25 @@
import { useEffect } from "react";
import { useParams } from "react-router-dom";
import { CheckoutContent } from "@/components/checkout/CheckoutContent";
import { CheckoutErrorState } from "@/components/checkout/states/CheckoutErrorState";
import { CheckoutProvider } from "@/contexts/CheckoutContext";
import type { CheckoutRouteMode } from "@/utils/checkoutRouteMode";
export function CheckoutPage() {
export function CheckoutPage({ routeMode }: { routeMode: CheckoutRouteMode }) {
const { checkoutId: checkoutIdParam } = useParams<{ checkoutId: string }>();
const checkoutId = checkoutIdParam ?? "";
useEffect(() => {
if (!checkoutId) {
window.location.href = "https://useautumn.com";
}
}, [checkoutId]);
if (!checkoutId) {
return <CheckoutErrorState message="Missing checkout ID" />;
return null;
}
return (
<CheckoutProvider checkoutId={checkoutId}>
<CheckoutProvider checkoutId={checkoutId} routeMode={routeMode}>
<CheckoutContent />
</CheckoutProvider>
);

View File

@@ -1,12 +1,14 @@
import type {
ApiFreeTrialV2,
AttachPreviewResponse,
BillingPreviewChange,
BillingPreviewResponse,
CheckoutChange,
CheckoutEntity,
LineItemDiscount,
PreviewUpdateSubscriptionResponse,
} from "@autumn/shared";
import { format } from "date-fns";
import { formatAmount } from "./formatUtils";
import { getCheckoutPreviewIntent } from "./getCheckoutPreviewIntent";
/**
* Builds a phrase describing applied discounts.
@@ -19,14 +21,17 @@ function buildDiscountPhrase({
lineItems: BillingPreviewResponse["line_items"];
currency: string;
}): string {
type PreviewLineItemDiscount =
BillingPreviewResponse["line_items"][number]["discounts"][number];
// Collect all discounts from line items
const allDiscounts = lineItems.flatMap((item) => item.discounts);
if (allDiscounts.length === 0) return "";
// Deduplicate by couponName (or stripeCouponId as fallback)
const uniqueDiscounts = new Map<string, LineItemDiscount>();
// Deduplicate by reward_name (or reward_id as fallback)
const uniqueDiscounts = new Map<string, PreviewLineItemDiscount>();
for (const discount of allDiscounts) {
const key = discount.couponName || discount.stripeCouponId || "unknown";
const key = discount.reward_name || discount.reward_id || "unknown";
if (!uniqueDiscounts.has(key)) {
uniqueDiscounts.set(key, discount);
}
@@ -36,12 +41,12 @@ function buildDiscountPhrase({
if (discountList.length === 0) return "";
// Format each discount
const formatDiscount = (d: LineItemDiscount): string => {
const name = d.couponName || d.stripeCouponId || "Discount";
if (d.percentOff) {
return `${name} applied for ${d.percentOff}% off`;
const formatDiscount = (d: PreviewLineItemDiscount): string => {
const name = d.reward_name || d.reward_id || "Discount";
if (d.percent_off) {
return `${name} applied for ${d.percent_off}% off`;
}
return `${name} applied for ${formatAmount(d.amountOff, currency)} off`;
return `${name} applied for ${formatAmount(d.amount_off, currency)} off`;
};
if (discountList.length === 1) {
@@ -98,6 +103,21 @@ function formatTrialDuration(freeTrial: ApiFreeTrialV2): string {
return `${duration_length}-${duration_type}`;
}
function formatNextCycleAmount({
nextCycle,
currency,
}: {
nextCycle?: BillingPreviewResponse["next_cycle"];
currency: string;
}): string {
if (!nextCycle) return formatAmount(0, currency);
const amount = formatAmount(nextCycle.total, currency);
const hasUsage = (nextCycle.usage_line_items?.length ?? 0) > 0;
return hasUsage ? `${amount} + usage` : amount;
}
/**
* Builds the header description for the checkout page.
* Returns a natural sentence describing the checkout action, amount, and timing.
@@ -110,21 +130,26 @@ export function buildHeaderDescription({
freeTrial,
hasActiveTrial,
}: {
preview?: BillingPreviewResponse;
incoming?: CheckoutChange[];
outgoing?: CheckoutChange[];
preview?: AttachPreviewResponse | PreviewUpdateSubscriptionResponse;
incoming?: BillingPreviewChange[];
outgoing?: BillingPreviewChange[];
entity?: CheckoutEntity;
freeTrial?: ApiFreeTrialV2 | null;
hasActiveTrial?: boolean;
}): string | undefined {
if (!preview) return undefined;
const isUpdateSubscriptionPreview =
preview.object === "update_subscription_preview";
const previewIntent = getCheckoutPreviewIntent({ preview });
const { total, currency, line_items, next_cycle } = preview;
const change = incoming?.[0];
const scenario = change?.plan.customer_eligibility?.scenario;
const outgoingPlanName = outgoing?.[0]?.plan.name;
const incomingPlanName = change?.plan.name;
const isRecurring = !!change?.plan.price?.interval;
const scenario = change?.plan?.customer_eligibility?.scenario;
const outgoingPlanName =
outgoing?.[0]?.plan?.name || outgoing?.[0]?.plan_id;
const incomingPlanName = change?.plan?.name || change?.plan_id;
const isRecurring = !!change?.plan?.price?.interval;
const entityName = entity?.name || entity?.id;
// Determine if this is a scheduled change (no immediate charges, changes next cycle)
@@ -132,39 +157,51 @@ export function buildHeaderDescription({
line_items.length === 0 && total === 0 && next_cycle;
// Build discount phrase
const discountPhrase = buildDiscountPhrase({ lineItems: line_items, currency });
// Build the action phrase
let action = buildActionPhrase({
scenario,
outgoingPlanName,
incomingPlanName,
isRecurring,
const discountPhrase = buildDiscountPhrase({
lineItems: line_items,
currency,
});
// Build the action phrase
let action = isUpdateSubscriptionPreview
? incomingPlanName
? `Update plan ${incomingPlanName}`
: "Update plan"
: buildActionPhrase({
scenario,
outgoingPlanName,
incomingPlanName,
isRecurring,
});
// Add entity if present
if (entityName) {
if (entityName && !isUpdateSubscriptionPreview) {
action += ` for ${entityName}`;
}
// Build trial phrase if applicable
const trialDuration = hasActiveTrial
? formatTrialDuration(freeTrial)
: null;
const trialDuration =
hasActiveTrial && freeTrial ? formatTrialDuration(freeTrial) : null;
// Handle credit from excess refund (unused time on previous plan exceeds new charge)
const credit = preview.credit;
const credit = preview.total < 0 ? Math.abs(preview.total) : 0;
if (credit) {
const creditAmount = formatAmount(credit.amount, currency);
const creditAmount = formatAmount(credit, currency);
let sentence = `${action}.${discountPhrase ? ` ${discountPhrase}` : ""} You'll receive a ${creditAmount} credit applied to your next invoice.`;
if (hasActiveTrial && next_cycle) {
const nextDate = format(new Date(next_cycle.starts_at), "do MMMM yyyy");
const nextAmount = formatAmount(next_cycle.total, currency);
const nextAmount = formatNextCycleAmount({
nextCycle: next_cycle,
currency,
});
sentence += ` Includes a ${trialDuration} free trial, then you'll be charged ${nextAmount} on ${nextDate}.`;
} else if (next_cycle) {
const nextDate = format(new Date(next_cycle.starts_at), "do MMMM yyyy");
const nextAmount = formatAmount(next_cycle.total, currency);
const nextAmount = formatNextCycleAmount({
nextCycle: next_cycle,
currency,
});
sentence += ` Your next charge of ${nextAmount} is on ${nextDate}.`;
}
@@ -174,12 +211,19 @@ export function buildHeaderDescription({
// Handle free trial (no immediate payment, trial starts)
if (hasActiveTrial && next_cycle) {
const nextDate = format(new Date(next_cycle.starts_at), "do MMMM yyyy");
const nextAmount = formatAmount(next_cycle.total, currency);
const nextAmount = formatNextCycleAmount({
nextCycle: next_cycle,
currency,
});
return `${action}.${discountPhrase ? ` ${discountPhrase}` : ""} Includes a ${trialDuration} free trial, then you'll be charged ${nextAmount} on ${nextDate}.`;
}
// Handle scheduled changes (no immediate charges)
if (isScheduledChange) {
if (previewIntent === "update_quantity") {
return `${action}.${discountPhrase ? ` ${discountPhrase}` : ""} ${formatAmount(total, currency)} due today.`;
}
const effectiveDate = format(new Date(next_cycle.starts_at), "do MMMM yyyy");
return `${action}.${discountPhrase ? ` ${discountPhrase}` : ""} ${formatAmount(total, currency)} due today. Changes take effect ${effectiveDate}.`;
}

View File

@@ -0,0 +1,112 @@
const getORPCErrorBody = ({ error }: { error: unknown }) => {
if (
error &&
typeof error === "object" &&
"data" in error &&
error.data &&
typeof error.data === "object" &&
"body" in error.data &&
error.data.body &&
typeof error.data.body === "object"
) {
return error.data.body;
}
return undefined;
};
export const getCheckoutApiErrorCode = ({ error }: { error: unknown }) => {
if (!error || typeof error !== "object") {
return undefined;
}
const orpcBody = getORPCErrorBody({ error });
if (
orpcBody &&
"code" in orpcBody &&
typeof orpcBody.code === "string"
) {
return orpcBody.code;
}
if ("code" in error && typeof error.code === "string") {
return error.code;
}
if (
"data" in error &&
error.data &&
typeof error.data === "object" &&
"code" in error.data &&
typeof error.data.code === "string"
) {
return error.data.code;
}
if (
"response" in error &&
error.response &&
typeof error.response === "object" &&
"data" in error.response &&
error.response.data &&
typeof error.response.data === "object" &&
"code" in error.response.data &&
typeof error.response.data.code === "string"
) {
return error.response.data.code;
}
return undefined;
};
export const getCheckoutApiErrorMessage = ({
error,
fallbackMessage,
}: {
error: unknown;
fallbackMessage: string;
}) => {
if (!error || typeof error !== "object") {
return fallbackMessage;
}
const orpcBody = getORPCErrorBody({ error });
if (
orpcBody &&
"message" in orpcBody &&
typeof orpcBody.message === "string"
) {
return orpcBody.message;
}
if (
"data" in error &&
error.data &&
typeof error.data === "object" &&
"message" in error.data &&
typeof error.data.message === "string"
) {
return error.data.message;
}
if (
"response" in error &&
error.response &&
typeof error.response === "object" &&
"data" in error.response &&
error.response.data &&
typeof error.response.data === "object" &&
"message" in error.response.data &&
typeof error.response.data.message === "string"
) {
return error.response.data.message;
}
if ("message" in error && typeof error.message === "string") {
return error.message;
}
return fallbackMessage;
};

View File

@@ -0,0 +1,41 @@
import { CheckoutErrorCode } from "@autumn/shared";
import {
getCheckoutApiErrorCode,
getCheckoutApiErrorMessage,
} from "@/utils/checkoutApiErrorUtils";
const FALLBACK_MESSAGE = "Failed to load checkout";
export const checkoutErrorToDisplay = ({ error }: { error: unknown }) => {
const code = getCheckoutApiErrorCode({ error });
switch (code) {
case CheckoutErrorCode.CheckoutCompleted:
return {
variant: "completed" as const,
title: "Checkout complete",
message: "This checkout link has already been completed.",
};
case CheckoutErrorCode.CheckoutExpired:
return {
variant: "expired" as const,
title: "Checkout expired",
message: "This checkout link has expired. Create a new checkout to continue.",
};
case CheckoutErrorCode.CheckoutUnavailable:
return {
variant: "unavailable" as const,
title: "Checkout unavailable",
message: "This checkout link is invalid or no longer available.",
};
default:
return {
variant: "generic" as const,
title: "Something went wrong",
message: getCheckoutApiErrorMessage({
error,
fallbackMessage: FALLBACK_MESSAGE,
}),
};
}
};

View File

@@ -0,0 +1,11 @@
export type CheckoutRouteMode = "attach" | "update_subscription";
export const checkoutRouteTitle = ({
routeMode,
}: {
routeMode: CheckoutRouteMode;
}) => {
return routeMode === "update_subscription"
? "Confirm your update"
: "Confirm your order";
};

View File

@@ -0,0 +1,16 @@
import type {
AttachPreviewResponse,
PreviewUpdateSubscriptionResponse,
} from "@autumn/shared";
export const getCheckoutPreviewIntent = ({
preview,
}: {
preview?: AttachPreviewResponse | PreviewUpdateSubscriptionResponse;
}) => {
if (!preview || preview.object !== "update_subscription_preview") {
return undefined;
}
return preview.intent;
};

1
apps/checkout/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -1,31 +1,33 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"lib": ["ES2021", "DOM", "DOM.Iterable"],
"module": "Preserve",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": false,
"noUncheckedSideEffectImports": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
"@/*": ["./src/*"],
"@api/*": ["../../shared/api/*"],
"@models/*": ["../../shared/models/*"],
"@utils/*": ["../../shared/utils/*"]
}
},
"include": ["src"]

View File

@@ -5,7 +5,6 @@
{ "path": "./tsconfig.node.json" }
],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}

View File

@@ -1,25 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"target": "ES2020",
"lib": ["ES2023"],
"module": "ESNext",
"module": "Preserve",
"types": ["node"],
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": false,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]

View File

@@ -21,12 +21,12 @@ import { DynamicResponseExample } from "/components/dynamic-response-example.jsx
The ID of the entity for entity-scoped balances (e.g., per-seat limits).
</DynamicParamField>
<DynamicParamField body="included" type="number">
<DynamicParamField body="included_grant" type="number">
The initial balance amount to grant. For metered features, this is the number of units the customer can use.
</DynamicParamField>
<DynamicParamField body="unlimited" type="boolean">
If true, the balance has unlimited usage. Cannot be combined with 'included'.
If true, the balance has unlimited usage. Cannot be combined with 'included_grant'.
</DynamicParamField>
<DynamicParamField body="reset" type="object">
@@ -47,6 +47,10 @@ import { DynamicResponseExample } from "/components/dynamic-response-example.jsx
Unix timestamp (milliseconds) when the balance expires. Mutually exclusive with reset.
</DynamicParamField>
<DynamicParamField body="balance_id" type="string">
A unique identifier for this balance. Use this to target the balance in future update / delete calls.
</DynamicParamField>
### Response

View File

@@ -0,0 +1,35 @@
---
title: "Delete Balance"
openapi: "openapi POST /v1/balances.delete"
---
import { DynamicParamField } from "/components/dynamic-param-field.jsx";
import { DynamicResponseField } from "/components/dynamic-response-field.jsx";
import { DynamicResponseExample } from "/components/dynamic-response-example.jsx";
### Body Parameters
<DynamicParamField body="customer_id" type="string" required>
The ID of the customer.
</DynamicParamField>
<DynamicParamField body="entity_id" type="string">
The ID of the entity.
</DynamicParamField>
<DynamicParamField body="feature_id" type="string">
The ID of the feature.
</DynamicParamField>
<DynamicParamField body="balance_id" type="string">
The ID of the balance to delete.
</DynamicParamField>
<DynamicParamField body="interval" type="'one_off' | 'minute' | 'hour' | 'day' | 'week' | 'month' | 'quarter' | 'semi_annual' | 'year'">
Target a specific balance by its reset interval. Use when the customer has multiple balances for the same feature with different reset intervals.
</DynamicParamField>
### Response
<DynamicResponseField name="success" type="boolean" />

View File

@@ -0,0 +1,31 @@
---
title: "Finalize Lock"
openapi: "openapi POST /v1/balances.finalize"
---
import { DynamicParamField } from "/components/dynamic-param-field.jsx";
import { DynamicResponseField } from "/components/dynamic-response-field.jsx";
import { DynamicResponseExample } from "/components/dynamic-response-example.jsx";
### Body Parameters
<DynamicParamField body="lock_id" type="string" required>
The lock ID that was passed into the previous check call.
</DynamicParamField>
<DynamicParamField body="action" type="'confirm' | 'release'" required>
Use 'confirm' to commit the deduction, or 'release' to return the held balance.
</DynamicParamField>
<DynamicParamField body="override_value" type="number">
Additional properties to attach to this finalize lock event.
</DynamicParamField>
<DynamicParamField body="properties" type="object">
Additional properties to attach to this finalize lock event.
</DynamicParamField>
### Response
<DynamicResponseField name="success" type="boolean" />

View File

@@ -37,6 +37,18 @@ import { DynamicResponseExample } from "/components/dynamic-response-example.jsx
Target a specific balance by its reset interval. Use when the customer has multiple balances for the same feature with different reset intervals.
</DynamicParamField>
<DynamicParamField body="included_grant" type="number">
Set the granted balance to this exact value.
</DynamicParamField>
<DynamicParamField body="balance_id" type="string">
Target a specific balance by its ID (set on create). Use when the customer has multiple balances for the same feature.
</DynamicParamField>
<DynamicParamField body="next_reset_at" type="number">
The next reset time for the balance. If there are multiple breakdowns, this will update the breakdown with the next reset time.
</DynamicParamField>
### Response

View File

@@ -150,9 +150,9 @@ const response = await autumn.billing.attach({
<Expandable title="properties">
<DynamicParamField body="to" type="number" required />
<DynamicParamField body="amount" type="number" required />
<DynamicParamField body="amount" type="number" />
<DynamicParamField body="flat_amount" type="number | null" />
<DynamicParamField body="flat_amount" type="number" />
</Expandable>
</DynamicParamField>
@@ -260,6 +260,10 @@ const response = await autumn.billing.attach({
How to handle proration when updating an existing subscription. 'prorate_immediately' charges/credits prorated amounts now, 'none' skips creating any charges.
</DynamicParamField>
<DynamicParamField body="subscription_id" type="string">
A unique ID to identify this subscription. Can be used to target specific subscriptions in update operations when a customer has multiple products with the same plan.
</DynamicParamField>
<DynamicParamField body="discounts" type="object[]">
List of discounts to apply. Each discount can be an Autumn reward ID, Stripe coupon ID, or Stripe promotion code.
<Expandable title="properties">
@@ -290,6 +294,24 @@ const response = await autumn.billing.attach({
Additional parameters to pass into the creation of the Stripe checkout session.
</DynamicParamField>
<DynamicParamField body="custom_line_items" type="object[]">
Custom line items that override the auto-generated proration invoice. Only valid for immediate plan changes (eg. upgrades or one off plans).
<Expandable title="properties">
<DynamicParamField body="amount" type="number" required>
Amount in dollars for this line item (e.g. 10.50). Can be negative for credits.
</DynamicParamField>
<DynamicParamField body="description" type="string" required>
Description for the line item.
</DynamicParamField>
</Expandable>
</DynamicParamField>
<DynamicParamField body="processor_subscription_id" type="string">
The processor subscription ID to link. Use this to attach an existing Stripe subscription instead of creating a new one.
</DynamicParamField>
### Response

View File

@@ -51,8 +51,8 @@ const response = await autumn.billing.update({
The ID of the entity to attach the plan to.
</DynamicParamField>
<DynamicParamField body="plan_id" type="string" required>
The ID of the plan.
<DynamicParamField body="plan_id" type="string">
The ID of the plan to update. Optional if subscription_id is provided, or if the customer has only one product.
</DynamicParamField>
<DynamicParamField body="feature_quantities" type="object[]">
@@ -139,9 +139,9 @@ const response = await autumn.billing.update({
<Expandable title="properties">
<DynamicParamField body="to" type="number" required />
<DynamicParamField body="amount" type="number" required />
<DynamicParamField body="amount" type="number" />
<DynamicParamField body="flat_amount" type="number | null" />
<DynamicParamField body="flat_amount" type="number" />
</Expandable>
</DynamicParamField>
@@ -249,10 +249,18 @@ const response = await autumn.billing.update({
How to handle proration when updating an existing subscription. 'prorate_immediately' charges/credits prorated amounts now, 'none' skips creating any charges.
</DynamicParamField>
<DynamicParamField body="subscription_id" type="string">
A unique ID to identify this subscription. Can be used to target specific subscriptions in update operations when a customer has multiple products with the same plan.
</DynamicParamField>
<DynamicParamField body="cancel_action" type="'cancel_immediately' | 'cancel_end_of_cycle' | 'uncancel'">
Action to perform for cancellation. 'cancel_immediately' cancels now with prorated refund, 'cancel_end_of_cycle' cancels at period end, 'uncancel' reverses a pending cancellation.
</DynamicParamField>
<DynamicParamField body="no_billing_changes" type="boolean">
If true, the subscription is updated internally without applying billing changes in Stripe.
</DynamicParamField>
### Response

View File

@@ -86,9 +86,9 @@ import { DynamicResponseExample } from "/components/dynamic-response-example.jsx
<Expandable title="properties">
<DynamicParamField body="to" type="number" required />
<DynamicParamField body="amount" type="number" required />
<DynamicParamField body="amount" type="number" />
<DynamicParamField body="flat_amount" type="number | null" />
<DynamicParamField body="flat_amount" type="number" />
</Expandable>
</DynamicParamField>
@@ -178,6 +178,10 @@ import { DynamicResponseExample } from "/components/dynamic-response-example.jsx
The version of the plan to attach.
</DynamicParamField>
<DynamicParamField body="subscription_id" type="string">
A unique ID to identify this subscription. Useful when attaching the same plan multiple times.
</DynamicParamField>
</Expandable>
</DynamicParamField>
@@ -282,6 +286,70 @@ import { DynamicResponseExample } from "/components/dynamic-response-example.jsx
Whether to send email receipts to this customer
</DynamicParamField>
<DynamicParamField body="billing_controls" type="object">
Billing controls for the customer (auto top-ups, etc.)
<Expandable title="properties">
<DynamicParamField body="auto_topups" type="object[]">
List of auto top-up configurations per feature.
<Expandable title="properties">
<DynamicParamField body="feature_id" type="string" required>
The ID of the feature (credit balance) to auto top-up.
</DynamicParamField>
<DynamicParamField body="enabled" type="boolean">
Whether auto top-up is enabled.
</DynamicParamField>
<DynamicParamField body="threshold" type="number" required>
When the balance drops below this threshold, an auto top-up will be purchased.
</DynamicParamField>
<DynamicParamField body="quantity" type="number" required>
Amount of credits to add per auto top-up.
</DynamicParamField>
<DynamicParamField body="purchase_limit" type="object">
Optional rate limit to cap how often auto top-ups occur.
<Expandable title="properties">
<DynamicParamField body="interval" type="'hour' | 'day' | 'week' | 'month'" required>
The time interval for the purchase limit window.
</DynamicParamField>
<DynamicParamField body="interval_count" type="number">
Number of intervals in the purchase limit window.
</DynamicParamField>
<DynamicParamField body="limit" type="number" required>
Maximum number of auto top-ups allowed within the interval.
</DynamicParamField>
</Expandable>
</DynamicParamField>
</Expandable>
</DynamicParamField>
<DynamicParamField body="spend_limits" type="object[]">
List of overage spend limits per feature.
<Expandable title="properties">
<DynamicParamField body="feature_id" type="string">
Optional feature ID this spend limit applies to.
</DynamicParamField>
<DynamicParamField body="enabled" type="boolean">
Whether this spend limit is enabled.
</DynamicParamField>
<DynamicParamField body="overage_limit" type="number">
Maximum allowed overage spend for the target feature.
</DynamicParamField>
</Expandable>
</DynamicParamField>
</Expandable>
</DynamicParamField>
</Expandable>
</DynamicParamField>
@@ -295,6 +363,30 @@ import { DynamicResponseExample } from "/components/dynamic-response-example.jsx
Name of the entity
</DynamicParamField>
<DynamicParamField body="billing_controls" type="object">
Billing controls for the entity.
<Expandable title="properties">
<DynamicParamField body="spend_limits" type="object[]">
List of overage spend limits per feature.
<Expandable title="properties">
<DynamicParamField body="feature_id" type="string">
Optional feature ID this spend limit applies to.
</DynamicParamField>
<DynamicParamField body="enabled" type="boolean">
Whether this spend limit is enabled.
</DynamicParamField>
<DynamicParamField body="overage_limit" type="number">
Maximum allowed overage spend for the target feature.
</DynamicParamField>
</Expandable>
</DynamicParamField>
</Expandable>
</DynamicParamField>
</Expandable>
</DynamicParamField>

View File

@@ -105,9 +105,9 @@ import { DynamicResponseExample } from "/components/dynamic-response-example.jsx
<Expandable title="properties">
<DynamicParamField body="to" type="number" required />
<DynamicParamField body="amount" type="number" required />
<DynamicParamField body="amount" type="number" />
<DynamicParamField body="flat_amount" type="number | null" />
<DynamicParamField body="flat_amount" type="number" />
</Expandable>
</DynamicParamField>
@@ -215,6 +215,10 @@ import { DynamicResponseExample } from "/components/dynamic-response-example.jsx
How to handle proration when updating an existing subscription. 'prorate_immediately' charges/credits prorated amounts now, 'none' skips creating any charges.
</DynamicParamField>
<DynamicParamField body="subscription_id" type="string">
A unique ID to identify this subscription. Can be used to target specific subscriptions in update operations when a customer has multiple products with the same plan.
</DynamicParamField>
<DynamicParamField body="discounts" type="object[]">
List of discounts to apply. Each discount can be an Autumn reward ID, Stripe coupon ID, or Stripe promotion code.
<Expandable title="properties">
@@ -245,6 +249,24 @@ import { DynamicResponseExample } from "/components/dynamic-response-example.jsx
Additional parameters to pass into the creation of the Stripe checkout session.
</DynamicParamField>
<DynamicParamField body="custom_line_items" type="object[]">
Custom line items that override the auto-generated proration invoice. Only valid for immediate plan changes (eg. upgrades or one off plans).
<Expandable title="properties">
<DynamicParamField body="amount" type="number" required>
Amount in dollars for this line item (e.g. 10.50). Can be negative for credits.
</DynamicParamField>
<DynamicParamField body="description" type="string" required>
Description for the line item.
</DynamicParamField>
</Expandable>
</DynamicParamField>
<DynamicParamField body="processor_subscription_id" type="string">
The processor subscription ID to link. Use this to attach an existing Stripe subscription instead of creating a new one.
</DynamicParamField>
### Response

View File

@@ -86,9 +86,9 @@ import { DynamicResponseExample } from "/components/dynamic-response-example.jsx
<Expandable title="properties">
<DynamicParamField body="to" type="number" required />
<DynamicParamField body="amount" type="number" required />
<DynamicParamField body="amount" type="number" />
<DynamicParamField body="flat_amount" type="number | null" />
<DynamicParamField body="flat_amount" type="number" />
</Expandable>
</DynamicParamField>
@@ -178,6 +178,10 @@ import { DynamicResponseExample } from "/components/dynamic-response-example.jsx
The version of the plan to attach.
</DynamicParamField>
<DynamicParamField body="subscription_id" type="string">
A unique ID to identify this subscription. Useful when attaching the same plan multiple times.
</DynamicParamField>
</Expandable>
</DynamicParamField>
@@ -282,6 +286,70 @@ import { DynamicResponseExample } from "/components/dynamic-response-example.jsx
Whether to send email receipts to this customer
</DynamicParamField>
<DynamicParamField body="billing_controls" type="object">
Billing controls for the customer (auto top-ups, etc.)
<Expandable title="properties">
<DynamicParamField body="auto_topups" type="object[]">
List of auto top-up configurations per feature.
<Expandable title="properties">
<DynamicParamField body="feature_id" type="string" required>
The ID of the feature (credit balance) to auto top-up.
</DynamicParamField>
<DynamicParamField body="enabled" type="boolean">
Whether auto top-up is enabled.
</DynamicParamField>
<DynamicParamField body="threshold" type="number" required>
When the balance drops below this threshold, an auto top-up will be purchased.
</DynamicParamField>
<DynamicParamField body="quantity" type="number" required>
Amount of credits to add per auto top-up.
</DynamicParamField>
<DynamicParamField body="purchase_limit" type="object">
Optional rate limit to cap how often auto top-ups occur.
<Expandable title="properties">
<DynamicParamField body="interval" type="'hour' | 'day' | 'week' | 'month'" required>
The time interval for the purchase limit window.
</DynamicParamField>
<DynamicParamField body="interval_count" type="number">
Number of intervals in the purchase limit window.
</DynamicParamField>
<DynamicParamField body="limit" type="number" required>
Maximum number of auto top-ups allowed within the interval.
</DynamicParamField>
</Expandable>
</DynamicParamField>
</Expandable>
</DynamicParamField>
<DynamicParamField body="spend_limits" type="object[]">
List of overage spend limits per feature.
<Expandable title="properties">
<DynamicParamField body="feature_id" type="string">
Optional feature ID this spend limit applies to.
</DynamicParamField>
<DynamicParamField body="enabled" type="boolean">
Whether this spend limit is enabled.
</DynamicParamField>
<DynamicParamField body="overage_limit" type="number">
Maximum allowed overage spend for the target feature.
</DynamicParamField>
</Expandable>
</DynamicParamField>
</Expandable>
</DynamicParamField>
</Expandable>
</DynamicParamField>
@@ -295,6 +363,30 @@ import { DynamicResponseExample } from "/components/dynamic-response-example.jsx
Name of the entity
</DynamicParamField>
<DynamicParamField body="billing_controls" type="object">
Billing controls for the entity.
<Expandable title="properties">
<DynamicParamField body="spend_limits" type="object[]">
List of overage spend limits per feature.
<Expandable title="properties">
<DynamicParamField body="feature_id" type="string">
Optional feature ID this spend limit applies to.
</DynamicParamField>
<DynamicParamField body="enabled" type="boolean">
Whether this spend limit is enabled.
</DynamicParamField>
<DynamicParamField body="overage_limit" type="number">
Maximum allowed overage spend for the target feature.
</DynamicParamField>
</Expandable>
</DynamicParamField>
</Expandable>
</DynamicParamField>
</Expandable>
</DynamicParamField>

View File

@@ -17,8 +17,8 @@ import { DynamicResponseExample } from "/components/dynamic-response-example.jsx
The ID of the entity to attach the plan to.
</DynamicParamField>
<DynamicParamField body="plan_id" type="string" required>
The ID of the plan.
<DynamicParamField body="plan_id" type="string">
The ID of the plan to update. Optional if subscription_id is provided, or if the customer has only one product.
</DynamicParamField>
<DynamicParamField body="feature_quantities" type="object[]">
@@ -105,9 +105,9 @@ import { DynamicResponseExample } from "/components/dynamic-response-example.jsx
<Expandable title="properties">
<DynamicParamField body="to" type="number" required />
<DynamicParamField body="amount" type="number" required />
<DynamicParamField body="amount" type="number" />
<DynamicParamField body="flat_amount" type="number | null" />
<DynamicParamField body="flat_amount" type="number" />
</Expandable>
</DynamicParamField>
@@ -215,10 +215,18 @@ import { DynamicResponseExample } from "/components/dynamic-response-example.jsx
How to handle proration when updating an existing subscription. 'prorate_immediately' charges/credits prorated amounts now, 'none' skips creating any charges.
</DynamicParamField>
<DynamicParamField body="subscription_id" type="string">
A unique ID to identify this subscription. Can be used to target specific subscriptions in update operations when a customer has multiple products with the same plan.
</DynamicParamField>
<DynamicParamField body="cancel_action" type="'cancel_immediately' | 'cancel_end_of_cycle' | 'uncancel'">
Action to perform for cancellation. 'cancel_immediately' cancels now with prorated refund, 'cancel_end_of_cycle' cancels at period end, 'uncancel' reverses a pending cancellation.
</DynamicParamField>
<DynamicParamField body="no_billing_changes" type="boolean">
If true, the subscription is updated internally without applying billing changes in Stripe.
</DynamicParamField>
### Response

View File

@@ -105,9 +105,9 @@ import { DynamicResponseExample } from "/components/dynamic-response-example.jsx
<Expandable title="properties">
<DynamicParamField body="to" type="number" required />
<DynamicParamField body="amount" type="number" required />
<DynamicParamField body="amount" type="number" />
<DynamicParamField body="flat_amount" type="number | null" />
<DynamicParamField body="flat_amount" type="number" />
</Expandable>
</DynamicParamField>
@@ -197,6 +197,10 @@ import { DynamicResponseExample } from "/components/dynamic-response-example.jsx
How to handle proration when updating an existing subscription. 'prorate_immediately' charges/credits prorated amounts now, 'none' skips creating any charges.
</DynamicParamField>
<DynamicParamField body="subscription_id" type="string">
A unique ID to identify this subscription. Can be used to target specific subscriptions in update operations when a customer has multiple products with the same plan.
</DynamicParamField>
<DynamicParamField body="discounts" type="object[]">
List of discounts to apply. Each discount can be an Autumn reward ID, Stripe coupon ID, or Stripe promotion code.
<Expandable title="properties">
@@ -219,6 +223,24 @@ import { DynamicResponseExample } from "/components/dynamic-response-example.jsx
Additional parameters to pass into the creation of the Stripe checkout session.
</DynamicParamField>
<DynamicParamField body="custom_line_items" type="object[]">
Custom line items that override the auto-generated proration invoice. Only valid for immediate plan changes (eg. upgrades or one off plans).
<Expandable title="properties">
<DynamicParamField body="amount" type="number" required>
Amount in dollars for this line item (e.g. 10.50). Can be negative for credits.
</DynamicParamField>
<DynamicParamField body="description" type="string" required>
Description for the line item.
</DynamicParamField>
</Expandable>
</DynamicParamField>
<DynamicParamField body="processor_subscription_id" type="string">
The processor subscription ID to link. Use this to attach an existing Stripe subscription instead of creating a new one.
</DynamicParamField>
### Response

View File

@@ -65,6 +65,24 @@ const { allowed } = await autumn.check({
If true, atomically records a usage event while checking access. The required_balance value is used as the usage amount. Combines check + track in one call.
</DynamicParamField>
<DynamicParamField body="lock" type="object">
Reserve units of a feature upfront by passing a lock_id, then call balances.finalize to confirm or release the hold.
<Expandable title="properties">
<DynamicParamField body="lock_id" type="string" required>
A unique identifier for this lock. Used to finalize the lock later via balances.finalize.
</DynamicParamField>
<DynamicParamField body="enabled" type="any" required>
Must be true to enable locking.
</DynamicParamField>
<DynamicParamField body="expires_at" type="number">
Unix timestamp (ms) when the lock automatically expires and releases the held balance.
</DynamicParamField>
</Expandable>
</DynamicParamField>
<DynamicParamField body="with_preview" type="boolean">
If true, includes upgrade/upsell information in the response when access is denied. Useful for displaying paywalls.
</DynamicParamField>
@@ -237,16 +255,8 @@ const { allowed } = await autumn.check({
The per-unit price amount.
</DynamicResponseField>
<DynamicResponseField name="tiers" type="object[]">
<DynamicResponseField name="tiers" type="any[]">
Tiered pricing configuration if applicable.
<Expandable title="properties">
<DynamicResponseField name="to" type="number" />
<DynamicResponseField name="amount" type="number" />
<DynamicResponseField name="flat_amount" type="number | null" />
</Expandable>
</DynamicResponseField>
<DynamicResponseField name="tier_behavior" type="'graduated' | 'volume'">
@@ -385,22 +395,8 @@ const { allowed } = await autumn.check({
The price of the product item. Should be `null` if tiered pricing is set.
</DynamicResponseField>
<DynamicResponseField name="tiers" type="object[] | null">
<DynamicResponseField name="tiers" type="any[] | null">
Tiered pricing for the product item. Not applicable for fixed price items.
<Expandable title="properties">
<DynamicResponseField name="to" type="number">
The maximum amount of usage for this tier.
</DynamicResponseField>
<DynamicResponseField name="amount" type="number">
The price of the product item for this tier.
</DynamicResponseField>
<DynamicResponseField name="flat_amount" type="number | null">
A flat fee charged for this tier, in addition to the per-unit amount.
</DynamicResponseField>
</Expandable>
</DynamicResponseField>
<DynamicResponseField name="tier_behavior" type="'graduated' | 'volume'">

View File

@@ -68,6 +68,23 @@ await autumn.track({
Additional properties to attach to this usage event.
</DynamicParamField>
<DynamicParamField body="lock" type="object">
<Expandable title="properties">
<DynamicParamField body="lock_id" type="string" required>
A unique identifier for this lock. Used to finalize the lock later via balances.finalize.
</DynamicParamField>
<DynamicParamField body="enabled" type="any" required>
Must be true to enable locking.
</DynamicParamField>
<DynamicParamField body="expires_at" type="number">
Unix timestamp (ms) when the lock automatically expires and releases the held balance.
</DynamicParamField>
</Expandable>
</DynamicParamField>
### Response
@@ -236,16 +253,8 @@ await autumn.track({
The per-unit price amount.
</DynamicResponseField>
<DynamicResponseField name="tiers" type="object[]">
<DynamicResponseField name="tiers" type="any[]">
Tiered pricing configuration if applicable.
<Expandable title="properties">
<DynamicResponseField name="to" type="number" />
<DynamicResponseField name="amount" type="number" />
<DynamicResponseField name="flat_amount" type="number | null" />
</Expandable>
</DynamicResponseField>
<DynamicResponseField name="tier_behavior" type="'graduated' | 'volume'">
@@ -440,16 +449,8 @@ await autumn.track({
The per-unit price amount.
</DynamicResponseField>
<DynamicResponseField name="tiers" type="object[]">
<DynamicResponseField name="tiers" type="any[]">
Tiered pricing configuration if applicable.
<Expandable title="properties">
<DynamicResponseField name="to" type="number" />
<DynamicResponseField name="amount" type="number" />
<DynamicResponseField name="flat_amount" type="number | null" />
</Expandable>
</DynamicResponseField>
<DynamicResponseField name="tier_behavior" type="'graduated' | 'volume'">

View File

@@ -49,6 +49,70 @@ import { DynamicResponseExample } from "/components/dynamic-response-example.jsx
Whether to send email receipts to this customer
</DynamicParamField>
<DynamicParamField body="billing_controls" type="object">
Billing controls for the customer (auto top-ups, etc.)
<Expandable title="properties">
<DynamicParamField body="auto_topups" type="object[]">
List of auto top-up configurations per feature.
<Expandable title="properties">
<DynamicParamField body="feature_id" type="string" required>
The ID of the feature (credit balance) to auto top-up.
</DynamicParamField>
<DynamicParamField body="enabled" type="boolean">
Whether auto top-up is enabled.
</DynamicParamField>
<DynamicParamField body="threshold" type="number" required>
When the balance drops below this threshold, an auto top-up will be purchased.
</DynamicParamField>
<DynamicParamField body="quantity" type="number" required>
Amount of credits to add per auto top-up.
</DynamicParamField>
<DynamicParamField body="purchase_limit" type="object">
Optional rate limit to cap how often auto top-ups occur.
<Expandable title="properties">
<DynamicParamField body="interval" type="'hour' | 'day' | 'week' | 'month'" required>
The time interval for the purchase limit window.
</DynamicParamField>
<DynamicParamField body="interval_count" type="number">
Number of intervals in the purchase limit window.
</DynamicParamField>
<DynamicParamField body="limit" type="number" required>
Maximum number of auto top-ups allowed within the interval.
</DynamicParamField>
</Expandable>
</DynamicParamField>
</Expandable>
</DynamicParamField>
<DynamicParamField body="spend_limits" type="object[]">
List of overage spend limits per feature.
<Expandable title="properties">
<DynamicParamField body="feature_id" type="string">
Optional feature ID this spend limit applies to.
</DynamicParamField>
<DynamicParamField body="enabled" type="boolean">
Whether this spend limit is enabled.
</DynamicParamField>
<DynamicParamField body="overage_limit" type="number">
Maximum allowed overage spend for the target feature.
</DynamicParamField>
</Expandable>
</DynamicParamField>
</Expandable>
</DynamicParamField>
<DynamicParamField body="expand" type="('invoices' | 'trials_used' | 'rewards' | 'entities' | 'referrals' | 'payment_method' | 'subscriptions.plan' | 'purchases.plan' | 'balances.feature')[]">
Customer expand options
</DynamicParamField>
@@ -92,9 +156,77 @@ import { DynamicResponseExample } from "/components/dynamic-response-example.jsx
Whether to send email receipts to the customer.
</DynamicResponseField>
<DynamicResponseField name="billing_controls" type="object">
Billing controls for the customer (auto top-ups, etc.)
<Expandable title="properties">
<DynamicResponseField name="auto_topups" type="object[]">
List of auto top-up configurations per feature.
<Expandable title="properties">
<DynamicResponseField name="feature_id" type="string">
The ID of the feature (credit balance) to auto top-up.
</DynamicResponseField>
<DynamicResponseField name="enabled" type="boolean">
Whether auto top-up is enabled.
</DynamicResponseField>
<DynamicResponseField name="threshold" type="number">
When the balance drops below this threshold, an auto top-up will be purchased.
</DynamicResponseField>
<DynamicResponseField name="quantity" type="number">
Amount of credits to add per auto top-up.
</DynamicResponseField>
<DynamicResponseField name="purchase_limit" type="object">
Optional rate limit to cap how often auto top-ups occur.
<Expandable title="properties">
<DynamicResponseField name="interval" type="'hour' | 'day' | 'week' | 'month'">
The time interval for the purchase limit window.
</DynamicResponseField>
<DynamicResponseField name="interval_count" type="number">
Number of intervals in the purchase limit window.
</DynamicResponseField>
<DynamicResponseField name="limit" type="number">
Maximum number of auto top-ups allowed within the interval.
</DynamicResponseField>
</Expandable>
</DynamicResponseField>
</Expandable>
</DynamicResponseField>
<DynamicResponseField name="spend_limits" type="object[]">
List of overage spend limits per feature.
<Expandable title="properties">
<DynamicResponseField name="feature_id" type="string">
Optional feature ID this spend limit applies to.
</DynamicResponseField>
<DynamicResponseField name="enabled" type="boolean">
Whether this spend limit is enabled.
</DynamicResponseField>
<DynamicResponseField name="overage_limit" type="number">
Maximum allowed overage spend for the target feature.
</DynamicResponseField>
</Expandable>
</DynamicResponseField>
</Expandable>
</DynamicResponseField>
<DynamicResponseField name="subscriptions" type="object[]">
Active and scheduled recurring plans that this customer has attached.
<Expandable title="properties">
<DynamicResponseField name="id" type="string">
The unique identifier of this subscription. If a subscription_id was provided at attach time, it is used; otherwise, falls back to the internal ID.
</DynamicResponseField>
<DynamicResponseField name="plan" type="object">
The full plan object if expanded.
<Expandable title="properties">
@@ -244,16 +376,8 @@ import { DynamicResponseExample } from "/components/dynamic-response-example.jsx
Price per billing_units after included usage is consumed. Mutually exclusive with tiers.
</DynamicResponseField>
<DynamicResponseField name="tiers" type="object[]">
<DynamicResponseField name="tiers" type="any[]">
Tiered pricing configuration. Each tier's 'to' INCLUDES the included amount. Either 'tiers' or 'amount' is required.
<Expandable title="properties">
<DynamicResponseField name="to" type="number" />
<DynamicResponseField name="amount" type="number" />
<DynamicResponseField name="flat_amount" type="number | null" />
</Expandable>
</DynamicResponseField>
<DynamicResponseField name="tier_behavior" type="'graduated' | 'volume'" />
@@ -569,16 +693,8 @@ import { DynamicResponseExample } from "/components/dynamic-response-example.jsx
Price per billing_units after included usage is consumed. Mutually exclusive with tiers.
</DynamicResponseField>
<DynamicResponseField name="tiers" type="object[]">
<DynamicResponseField name="tiers" type="any[]">
Tiered pricing configuration. Each tier's 'to' INCLUDES the included amount. Either 'tiers' or 'amount' is required.
<Expandable title="properties">
<DynamicResponseField name="to" type="number" />
<DynamicResponseField name="amount" type="number" />
<DynamicResponseField name="flat_amount" type="number | null" />
</Expandable>
</DynamicResponseField>
<DynamicResponseField name="tier_behavior" type="'graduated' | 'volume'" />
@@ -859,16 +975,8 @@ import { DynamicResponseExample } from "/components/dynamic-response-example.jsx
The per-unit price amount.
</DynamicResponseField>
<DynamicResponseField name="tiers" type="object[]">
<DynamicResponseField name="tiers" type="any[]">
Tiered pricing configuration if applicable.
<Expandable title="properties">
<DynamicResponseField name="to" type="number" />
<DynamicResponseField name="amount" type="number" />
<DynamicResponseField name="flat_amount" type="number | null" />
</Expandable>
</DynamicResponseField>
<DynamicResponseField name="tier_behavior" type="'graduated' | 'volume'">
@@ -949,8 +1057,6 @@ import { DynamicResponseExample } from "/components/dynamic-response-example.jsx
<DynamicResponseField name="entities" type="object[]">
<Expandable title="properties">
<DynamicResponseField name="autumn_id" type="string" />
<DynamicResponseField name="id" type="string | null">
The unique identifier of the entity
</DynamicResponseField>
@@ -1081,6 +1187,9 @@ import { DynamicResponseExample } from "/components/dynamic-response-example.jsx
"env": "sandbox",
"metadata": {},
"sendEmailReceipts": false,
"billingControls": {
"autoTopups": []
},
"subscriptions": [
{
"planId": "pro_plan",

View File

@@ -77,9 +77,77 @@ import { DynamicResponseExample } from "/components/dynamic-response-example.jsx
Whether to send email receipts to the customer.
</DynamicResponseField>
<DynamicResponseField name="billing_controls" type="object">
Billing controls for the customer (auto top-ups, etc.)
<Expandable title="properties">
<DynamicResponseField name="auto_topups" type="object[]">
List of auto top-up configurations per feature.
<Expandable title="properties">
<DynamicResponseField name="feature_id" type="string">
The ID of the feature (credit balance) to auto top-up.
</DynamicResponseField>
<DynamicResponseField name="enabled" type="boolean">
Whether auto top-up is enabled.
</DynamicResponseField>
<DynamicResponseField name="threshold" type="number">
When the balance drops below this threshold, an auto top-up will be purchased.
</DynamicResponseField>
<DynamicResponseField name="quantity" type="number">
Amount of credits to add per auto top-up.
</DynamicResponseField>
<DynamicResponseField name="purchase_limit" type="object">
Optional rate limit to cap how often auto top-ups occur.
<Expandable title="properties">
<DynamicResponseField name="interval" type="'hour' | 'day' | 'week' | 'month'">
The time interval for the purchase limit window.
</DynamicResponseField>
<DynamicResponseField name="interval_count" type="number">
Number of intervals in the purchase limit window.
</DynamicResponseField>
<DynamicResponseField name="limit" type="number">
Maximum number of auto top-ups allowed within the interval.
</DynamicResponseField>
</Expandable>
</DynamicResponseField>
</Expandable>
</DynamicResponseField>
<DynamicResponseField name="spend_limits" type="object[]">
List of overage spend limits per feature.
<Expandable title="properties">
<DynamicResponseField name="feature_id" type="string">
Optional feature ID this spend limit applies to.
</DynamicResponseField>
<DynamicResponseField name="enabled" type="boolean">
Whether this spend limit is enabled.
</DynamicResponseField>
<DynamicResponseField name="overage_limit" type="number">
Maximum allowed overage spend for the target feature.
</DynamicResponseField>
</Expandable>
</DynamicResponseField>
</Expandable>
</DynamicResponseField>
<DynamicResponseField name="subscriptions" type="object[]">
Active and scheduled recurring plans that this customer has attached.
<Expandable title="properties">
<DynamicResponseField name="id" type="string">
The unique identifier of this subscription. If a subscription_id was provided at attach time, it is used; otherwise, falls back to the internal ID.
</DynamicResponseField>
<DynamicResponseField name="plan" type="object">
The full plan object if expanded.
<Expandable title="properties">
@@ -229,16 +297,8 @@ import { DynamicResponseExample } from "/components/dynamic-response-example.jsx
Price per billing_units after included usage is consumed. Mutually exclusive with tiers.
</DynamicResponseField>
<DynamicResponseField name="tiers" type="object[]">
<DynamicResponseField name="tiers" type="any[]">
Tiered pricing configuration. Each tier's 'to' INCLUDES the included amount. Either 'tiers' or 'amount' is required.
<Expandable title="properties">
<DynamicResponseField name="to" type="number" />
<DynamicResponseField name="amount" type="number" />
<DynamicResponseField name="flat_amount" type="number | null" />
</Expandable>
</DynamicResponseField>
<DynamicResponseField name="tier_behavior" type="'graduated' | 'volume'" />
@@ -554,16 +614,8 @@ import { DynamicResponseExample } from "/components/dynamic-response-example.jsx
Price per billing_units after included usage is consumed. Mutually exclusive with tiers.
</DynamicResponseField>
<DynamicResponseField name="tiers" type="object[]">
<DynamicResponseField name="tiers" type="any[]">
Tiered pricing configuration. Each tier's 'to' INCLUDES the included amount. Either 'tiers' or 'amount' is required.
<Expandable title="properties">
<DynamicResponseField name="to" type="number" />
<DynamicResponseField name="amount" type="number" />
<DynamicResponseField name="flat_amount" type="number | null" />
</Expandable>
</DynamicResponseField>
<DynamicResponseField name="tier_behavior" type="'graduated' | 'volume'" />
@@ -844,16 +896,8 @@ import { DynamicResponseExample } from "/components/dynamic-response-example.jsx
The per-unit price amount.
</DynamicResponseField>
<DynamicResponseField name="tiers" type="object[]">
<DynamicResponseField name="tiers" type="any[]">
Tiered pricing configuration if applicable.
<Expandable title="properties">
<DynamicResponseField name="to" type="number" />
<DynamicResponseField name="amount" type="number" />
<DynamicResponseField name="flat_amount" type="number | null" />
</Expandable>
</DynamicResponseField>
<DynamicResponseField name="tier_behavior" type="'graduated' | 'volume'">
@@ -933,6 +977,9 @@ import { DynamicResponseExample } from "/components/dynamic-response-example.jsx
"env": "sandbox",
"metadata": {},
"sendEmailReceipts": false,
"billingControls": {
"autoTopups": []
},
"subscriptions": [
{
"planId": "pro_plan",

View File

@@ -37,6 +37,70 @@ import { DynamicResponseExample } from "/components/dynamic-response-example.jsx
Whether to send email receipts to this customer
</DynamicParamField>
<DynamicParamField body="billing_controls" type="object">
Billing controls for the customer (auto top-ups, etc.)
<Expandable title="properties">
<DynamicParamField body="auto_topups" type="object[]">
List of auto top-up configurations per feature.
<Expandable title="properties">
<DynamicParamField body="feature_id" type="string" required>
The ID of the feature (credit balance) to auto top-up.
</DynamicParamField>
<DynamicParamField body="enabled" type="boolean">
Whether auto top-up is enabled.
</DynamicParamField>
<DynamicParamField body="threshold" type="number" required>
When the balance drops below this threshold, an auto top-up will be purchased.
</DynamicParamField>
<DynamicParamField body="quantity" type="number" required>
Amount of credits to add per auto top-up.
</DynamicParamField>
<DynamicParamField body="purchase_limit" type="object">
Optional rate limit to cap how often auto top-ups occur.
<Expandable title="properties">
<DynamicParamField body="interval" type="'hour' | 'day' | 'week' | 'month'" required>
The time interval for the purchase limit window.
</DynamicParamField>
<DynamicParamField body="interval_count" type="number">
Number of intervals in the purchase limit window.
</DynamicParamField>
<DynamicParamField body="limit" type="number" required>
Maximum number of auto top-ups allowed within the interval.
</DynamicParamField>
</Expandable>
</DynamicParamField>
</Expandable>
</DynamicParamField>
<DynamicParamField body="spend_limits" type="object[]">
List of overage spend limits per feature.
<Expandable title="properties">
<DynamicParamField body="feature_id" type="string">
Optional feature ID this spend limit applies to.
</DynamicParamField>
<DynamicParamField body="enabled" type="boolean">
Whether this spend limit is enabled.
</DynamicParamField>
<DynamicParamField body="overage_limit" type="number">
Maximum allowed overage spend for the target feature.
</DynamicParamField>
</Expandable>
</DynamicParamField>
</Expandable>
</DynamicParamField>
<DynamicParamField body="new_customer_id" type="string">
New ID for the customer
</DynamicParamField>
@@ -80,9 +144,77 @@ import { DynamicResponseExample } from "/components/dynamic-response-example.jsx
Whether to send email receipts to the customer.
</DynamicResponseField>
<DynamicResponseField name="billing_controls" type="object">
Billing controls for the customer (auto top-ups, etc.)
<Expandable title="properties">
<DynamicResponseField name="auto_topups" type="object[]">
List of auto top-up configurations per feature.
<Expandable title="properties">
<DynamicResponseField name="feature_id" type="string">
The ID of the feature (credit balance) to auto top-up.
</DynamicResponseField>
<DynamicResponseField name="enabled" type="boolean">
Whether auto top-up is enabled.
</DynamicResponseField>
<DynamicResponseField name="threshold" type="number">
When the balance drops below this threshold, an auto top-up will be purchased.
</DynamicResponseField>
<DynamicResponseField name="quantity" type="number">
Amount of credits to add per auto top-up.
</DynamicResponseField>
<DynamicResponseField name="purchase_limit" type="object">
Optional rate limit to cap how often auto top-ups occur.
<Expandable title="properties">
<DynamicResponseField name="interval" type="'hour' | 'day' | 'week' | 'month'">
The time interval for the purchase limit window.
</DynamicResponseField>
<DynamicResponseField name="interval_count" type="number">
Number of intervals in the purchase limit window.
</DynamicResponseField>
<DynamicResponseField name="limit" type="number">
Maximum number of auto top-ups allowed within the interval.
</DynamicResponseField>
</Expandable>
</DynamicResponseField>
</Expandable>
</DynamicResponseField>
<DynamicResponseField name="spend_limits" type="object[]">
List of overage spend limits per feature.
<Expandable title="properties">
<DynamicResponseField name="feature_id" type="string">
Optional feature ID this spend limit applies to.
</DynamicResponseField>
<DynamicResponseField name="enabled" type="boolean">
Whether this spend limit is enabled.
</DynamicResponseField>
<DynamicResponseField name="overage_limit" type="number">
Maximum allowed overage spend for the target feature.
</DynamicResponseField>
</Expandable>
</DynamicResponseField>
</Expandable>
</DynamicResponseField>
<DynamicResponseField name="subscriptions" type="object[]">
Active and scheduled recurring plans that this customer has attached.
<Expandable title="properties">
<DynamicResponseField name="id" type="string">
The unique identifier of this subscription. If a subscription_id was provided at attach time, it is used; otherwise, falls back to the internal ID.
</DynamicResponseField>
<DynamicResponseField name="plan" type="object">
The full plan object if expanded.
<Expandable title="properties">
@@ -232,16 +364,8 @@ import { DynamicResponseExample } from "/components/dynamic-response-example.jsx
Price per billing_units after included usage is consumed. Mutually exclusive with tiers.
</DynamicResponseField>
<DynamicResponseField name="tiers" type="object[]">
<DynamicResponseField name="tiers" type="any[]">
Tiered pricing configuration. Each tier's 'to' INCLUDES the included amount. Either 'tiers' or 'amount' is required.
<Expandable title="properties">
<DynamicResponseField name="to" type="number" />
<DynamicResponseField name="amount" type="number" />
<DynamicResponseField name="flat_amount" type="number | null" />
</Expandable>
</DynamicResponseField>
<DynamicResponseField name="tier_behavior" type="'graduated' | 'volume'" />
@@ -557,16 +681,8 @@ import { DynamicResponseExample } from "/components/dynamic-response-example.jsx
Price per billing_units after included usage is consumed. Mutually exclusive with tiers.
</DynamicResponseField>
<DynamicResponseField name="tiers" type="object[]">
<DynamicResponseField name="tiers" type="any[]">
Tiered pricing configuration. Each tier's 'to' INCLUDES the included amount. Either 'tiers' or 'amount' is required.
<Expandable title="properties">
<DynamicResponseField name="to" type="number" />
<DynamicResponseField name="amount" type="number" />
<DynamicResponseField name="flat_amount" type="number | null" />
</Expandable>
</DynamicResponseField>
<DynamicResponseField name="tier_behavior" type="'graduated' | 'volume'" />
@@ -847,16 +963,8 @@ import { DynamicResponseExample } from "/components/dynamic-response-example.jsx
The per-unit price amount.
</DynamicResponseField>
<DynamicResponseField name="tiers" type="object[]">
<DynamicResponseField name="tiers" type="any[]">
Tiered pricing configuration if applicable.
<Expandable title="properties">
<DynamicResponseField name="to" type="number" />
<DynamicResponseField name="amount" type="number" />
<DynamicResponseField name="flat_amount" type="number | null" />
</Expandable>
</DynamicResponseField>
<DynamicResponseField name="tier_behavior" type="'graduated' | 'volume'">
@@ -915,6 +1023,9 @@ import { DynamicResponseExample } from "/components/dynamic-response-example.jsx
"env": "sandbox",
"metadata": {},
"sendEmailReceipts": false,
"billingControls": {
"autoTopups": []
},
"subscriptions": [
{
"planId": "pro_plan",

View File

@@ -17,6 +17,30 @@ import { DynamicResponseExample } from "/components/dynamic-response-example.jsx
The ID of the feature this entity is associated with
</DynamicParamField>
<DynamicParamField body="billing_controls" type="object">
Billing controls for the entity.
<Expandable title="properties">
<DynamicParamField body="spend_limits" type="object[]">
List of overage spend limits per feature.
<Expandable title="properties">
<DynamicParamField body="feature_id" type="string">
Optional feature ID this spend limit applies to.
</DynamicParamField>
<DynamicParamField body="enabled" type="boolean">
Whether this spend limit is enabled.
</DynamicParamField>
<DynamicParamField body="overage_limit" type="number">
Maximum allowed overage spend for the target feature.
</DynamicParamField>
</Expandable>
</DynamicParamField>
</Expandable>
</DynamicParamField>
<DynamicParamField body="customer_data" type="object">
Customer attributes used to resolve the customer when customer_id is not provided.
<Expandable title="properties">
@@ -52,6 +76,70 @@ import { DynamicResponseExample } from "/components/dynamic-response-example.jsx
Whether to send email receipts to this customer
</DynamicParamField>
<DynamicParamField body="billing_controls" type="object">
Billing controls for the customer (auto top-ups, etc.)
<Expandable title="properties">
<DynamicParamField body="auto_topups" type="object[]">
List of auto top-up configurations per feature.
<Expandable title="properties">
<DynamicParamField body="feature_id" type="string" required>
The ID of the feature (credit balance) to auto top-up.
</DynamicParamField>
<DynamicParamField body="enabled" type="boolean">
Whether auto top-up is enabled.
</DynamicParamField>
<DynamicParamField body="threshold" type="number" required>
When the balance drops below this threshold, an auto top-up will be purchased.
</DynamicParamField>
<DynamicParamField body="quantity" type="number" required>
Amount of credits to add per auto top-up.
</DynamicParamField>
<DynamicParamField body="purchase_limit" type="object">
Optional rate limit to cap how often auto top-ups occur.
<Expandable title="properties">
<DynamicParamField body="interval" type="'hour' | 'day' | 'week' | 'month'" required>
The time interval for the purchase limit window.
</DynamicParamField>
<DynamicParamField body="interval_count" type="number">
Number of intervals in the purchase limit window.
</DynamicParamField>
<DynamicParamField body="limit" type="number" required>
Maximum number of auto top-ups allowed within the interval.
</DynamicParamField>
</Expandable>
</DynamicParamField>
</Expandable>
</DynamicParamField>
<DynamicParamField body="spend_limits" type="object[]">
List of overage spend limits per feature.
<Expandable title="properties">
<DynamicParamField body="feature_id" type="string">
Optional feature ID this spend limit applies to.
</DynamicParamField>
<DynamicParamField body="enabled" type="boolean">
Whether this spend limit is enabled.
</DynamicParamField>
<DynamicParamField body="overage_limit" type="number">
Maximum allowed overage spend for the target feature.
</DynamicParamField>
</Expandable>
</DynamicParamField>
</Expandable>
</DynamicParamField>
</Expandable>
</DynamicParamField>
@@ -66,8 +154,6 @@ import { DynamicResponseExample } from "/components/dynamic-response-example.jsx
### Response
<DynamicResponseField name="autumn_id" type="string" />
<DynamicResponseField name="id" type="string | null">
The unique identifier of the entity
</DynamicResponseField>
@@ -94,6 +180,10 @@ import { DynamicResponseExample } from "/components/dynamic-response-example.jsx
<DynamicResponseField name="subscriptions" type="object[]">
<Expandable title="properties">
<DynamicResponseField name="id" type="string">
The unique identifier of this subscription. If a subscription_id was provided at attach time, it is used; otherwise, falls back to the internal ID.
</DynamicResponseField>
<DynamicResponseField name="plan" type="object">
The full plan object if expanded.
<Expandable title="properties">
@@ -243,16 +333,8 @@ import { DynamicResponseExample } from "/components/dynamic-response-example.jsx
Price per billing_units after included usage is consumed. Mutually exclusive with tiers.
</DynamicResponseField>
<DynamicResponseField name="tiers" type="object[]">
<DynamicResponseField name="tiers" type="any[]">
Tiered pricing configuration. Each tier's 'to' INCLUDES the included amount. Either 'tiers' or 'amount' is required.
<Expandable title="properties">
<DynamicResponseField name="to" type="number" />
<DynamicResponseField name="amount" type="number" />
<DynamicResponseField name="flat_amount" type="number | null" />
</Expandable>
</DynamicResponseField>
<DynamicResponseField name="tier_behavior" type="'graduated' | 'volume'" />
@@ -567,16 +649,8 @@ import { DynamicResponseExample } from "/components/dynamic-response-example.jsx
Price per billing_units after included usage is consumed. Mutually exclusive with tiers.
</DynamicResponseField>
<DynamicResponseField name="tiers" type="object[]">
<DynamicResponseField name="tiers" type="any[]">
Tiered pricing configuration. Each tier's 'to' INCLUDES the included amount. Either 'tiers' or 'amount' is required.
<Expandable title="properties">
<DynamicResponseField name="to" type="number" />
<DynamicResponseField name="amount" type="number" />
<DynamicResponseField name="flat_amount" type="number | null" />
</Expandable>
</DynamicResponseField>
<DynamicResponseField name="tier_behavior" type="'graduated' | 'volume'" />
@@ -856,16 +930,8 @@ import { DynamicResponseExample } from "/components/dynamic-response-example.jsx
The per-unit price amount.
</DynamicResponseField>
<DynamicResponseField name="tiers" type="object[]">
<DynamicResponseField name="tiers" type="any[]">
Tiered pricing configuration if applicable.
<Expandable title="properties">
<DynamicResponseField name="to" type="number" />
<DynamicResponseField name="amount" type="number" />
<DynamicResponseField name="flat_amount" type="number | null" />
</Expandable>
</DynamicResponseField>
<DynamicResponseField name="tier_behavior" type="'graduated' | 'volume'">
@@ -911,6 +977,30 @@ import { DynamicResponseExample } from "/components/dynamic-response-example.jsx
</Expandable>
</DynamicResponseField>
<DynamicResponseField name="billing_controls" type="object">
Billing controls for the entity.
<Expandable title="properties">
<DynamicResponseField name="spend_limits" type="object[]">
List of overage spend limits per feature.
<Expandable title="properties">
<DynamicResponseField name="feature_id" type="string">
Optional feature ID this spend limit applies to.
</DynamicResponseField>
<DynamicResponseField name="enabled" type="boolean">
Whether this spend limit is enabled.
</DynamicResponseField>
<DynamicResponseField name="overage_limit" type="number">
Maximum allowed overage spend for the target feature.
</DynamicResponseField>
</Expandable>
</DynamicResponseField>
</Expandable>
</DynamicResponseField>
<DynamicResponseField name="invoices" type="object[]">
Invoices for this entity (only included when expand=invoices)
<Expandable title="properties">

View File

@@ -20,8 +20,6 @@ import { DynamicResponseExample } from "/components/dynamic-response-example.jsx
### Response
<DynamicResponseField name="autumn_id" type="string" />
<DynamicResponseField name="id" type="string | null">
The unique identifier of the entity
</DynamicResponseField>
@@ -48,6 +46,10 @@ import { DynamicResponseExample } from "/components/dynamic-response-example.jsx
<DynamicResponseField name="subscriptions" type="object[]">
<Expandable title="properties">
<DynamicResponseField name="id" type="string">
The unique identifier of this subscription. If a subscription_id was provided at attach time, it is used; otherwise, falls back to the internal ID.
</DynamicResponseField>
<DynamicResponseField name="plan" type="object">
The full plan object if expanded.
<Expandable title="properties">
@@ -197,16 +199,8 @@ import { DynamicResponseExample } from "/components/dynamic-response-example.jsx
Price per billing_units after included usage is consumed. Mutually exclusive with tiers.
</DynamicResponseField>
<DynamicResponseField name="tiers" type="object[]">
<DynamicResponseField name="tiers" type="any[]">
Tiered pricing configuration. Each tier's 'to' INCLUDES the included amount. Either 'tiers' or 'amount' is required.
<Expandable title="properties">
<DynamicResponseField name="to" type="number" />
<DynamicResponseField name="amount" type="number" />
<DynamicResponseField name="flat_amount" type="number | null" />
</Expandable>
</DynamicResponseField>
<DynamicResponseField name="tier_behavior" type="'graduated' | 'volume'" />
@@ -521,16 +515,8 @@ import { DynamicResponseExample } from "/components/dynamic-response-example.jsx
Price per billing_units after included usage is consumed. Mutually exclusive with tiers.
</DynamicResponseField>
<DynamicResponseField name="tiers" type="object[]">
<DynamicResponseField name="tiers" type="any[]">
Tiered pricing configuration. Each tier's 'to' INCLUDES the included amount. Either 'tiers' or 'amount' is required.
<Expandable title="properties">
<DynamicResponseField name="to" type="number" />
<DynamicResponseField name="amount" type="number" />
<DynamicResponseField name="flat_amount" type="number | null" />
</Expandable>
</DynamicResponseField>
<DynamicResponseField name="tier_behavior" type="'graduated' | 'volume'" />
@@ -810,16 +796,8 @@ import { DynamicResponseExample } from "/components/dynamic-response-example.jsx
The per-unit price amount.
</DynamicResponseField>
<DynamicResponseField name="tiers" type="object[]">
<DynamicResponseField name="tiers" type="any[]">
Tiered pricing configuration if applicable.
<Expandable title="properties">
<DynamicResponseField name="to" type="number" />
<DynamicResponseField name="amount" type="number" />
<DynamicResponseField name="flat_amount" type="number | null" />
</Expandable>
</DynamicResponseField>
<DynamicResponseField name="tier_behavior" type="'graduated' | 'volume'">
@@ -865,6 +843,30 @@ import { DynamicResponseExample } from "/components/dynamic-response-example.jsx
</Expandable>
</DynamicResponseField>
<DynamicResponseField name="billing_controls" type="object">
Billing controls for the entity.
<Expandable title="properties">
<DynamicResponseField name="spend_limits" type="object[]">
List of overage spend limits per feature.
<Expandable title="properties">
<DynamicResponseField name="feature_id" type="string">
Optional feature ID this spend limit applies to.
</DynamicResponseField>
<DynamicResponseField name="enabled" type="boolean">
Whether this spend limit is enabled.
</DynamicResponseField>
<DynamicResponseField name="overage_limit" type="number">
Maximum allowed overage spend for the target feature.
</DynamicResponseField>
</Expandable>
</DynamicResponseField>
</Expandable>
</DynamicResponseField>
<DynamicResponseField name="invoices" type="object[]">
Invoices for this entity (only included when expand=invoices)
<Expandable title="properties">

View File

@@ -0,0 +1,987 @@
---
title: "Update Entity"
openapi: "openapi POST /v1/entities.update"
---
import { DynamicParamField } from "/components/dynamic-param-field.jsx";
import { DynamicResponseField } from "/components/dynamic-response-field.jsx";
import { DynamicResponseExample } from "/components/dynamic-response-example.jsx";
### Body Parameters
<DynamicParamField body="customer_id" type="string">
The ID of the customer that owns the entity.
</DynamicParamField>
<DynamicParamField body="entity_id" type="string" required>
The ID of the entity.
</DynamicParamField>
<DynamicParamField body="billing_controls" type="object">
Billing controls to replace on the entity.
<Expandable title="properties">
<DynamicParamField body="spend_limits" type="object[]">
List of overage spend limits per feature.
<Expandable title="properties">
<DynamicParamField body="feature_id" type="string">
Optional feature ID this spend limit applies to.
</DynamicParamField>
<DynamicParamField body="enabled" type="boolean">
Whether this spend limit is enabled.
</DynamicParamField>
<DynamicParamField body="overage_limit" type="number">
Maximum allowed overage spend for the target feature.
</DynamicParamField>
</Expandable>
</DynamicParamField>
</Expandable>
</DynamicParamField>
### Response
<DynamicResponseField name="id" type="string | null">
The unique identifier of the entity
</DynamicResponseField>
<DynamicResponseField name="name" type="string | null">
The name of the entity
</DynamicResponseField>
<DynamicResponseField name="customer_id" type="string | null">
The customer ID this entity belongs to
</DynamicResponseField>
<DynamicResponseField name="feature_id" type="string | null">
The feature ID this entity belongs to
</DynamicResponseField>
<DynamicResponseField name="created_at" type="number">
Unix timestamp when the entity was created
</DynamicResponseField>
<DynamicResponseField name="env" type="'sandbox' | 'live'">
The environment (sandbox/live)
</DynamicResponseField>
<DynamicResponseField name="subscriptions" type="object[]">
<Expandable title="properties">
<DynamicResponseField name="id" type="string">
The unique identifier of this subscription. If a subscription_id was provided at attach time, it is used; otherwise, falls back to the internal ID.
</DynamicResponseField>
<DynamicResponseField name="plan" type="object">
The full plan object if expanded.
<Expandable title="properties">
<DynamicResponseField name="id" type="string">
Unique identifier for the plan.
</DynamicResponseField>
<DynamicResponseField name="name" type="string">
Display name of the plan.
</DynamicResponseField>
<DynamicResponseField name="description" type="string | null">
Optional description of the plan.
</DynamicResponseField>
<DynamicResponseField name="group" type="string | null">
Group identifier for organizing related plans. Plans in the same group are mutually exclusive.
</DynamicResponseField>
<DynamicResponseField name="version" type="number">
Version number of the plan. Incremented when plan configuration changes.
</DynamicResponseField>
<DynamicResponseField name="add_on" type="boolean">
Whether this is an add-on plan that can be attached alongside a main plan.
</DynamicResponseField>
<DynamicResponseField name="auto_enable" type="boolean">
If true, this plan is automatically attached when a customer is created. Used for free plans.
</DynamicResponseField>
<DynamicResponseField name="price" type="object | null">
Base recurring price for the plan. Null for free plans or usage-only plans.
<Expandable title="properties">
<DynamicResponseField name="amount" type="number">
Base price amount for the plan.
</DynamicResponseField>
<DynamicResponseField name="interval" type="'one_off' | 'week' | 'month' | 'quarter' | 'semi_annual' | 'year'">
Billing interval (e.g. 'month', 'year').
</DynamicResponseField>
<DynamicResponseField name="interval_count" type="number">
Number of intervals per billing cycle. Defaults to 1.
</DynamicResponseField>
<DynamicResponseField name="display" type="object">
Display text for showing this price in pricing pages.
<Expandable title="properties">
<DynamicResponseField name="primary_text" type="string">
Main display text (e.g. '$10' or '100 messages').
</DynamicResponseField>
<DynamicResponseField name="secondary_text" type="string">
Secondary display text (e.g. 'per month' or 'then $0.5 per 100').
</DynamicResponseField>
</Expandable>
</DynamicResponseField>
</Expandable>
</DynamicResponseField>
<DynamicResponseField name="items" type="object[]">
Feature configurations included in this plan. Each item defines included units, pricing, and reset behavior for a feature.
<Expandable title="properties">
<DynamicResponseField name="feature_id" type="string">
The ID of the feature this item configures.
</DynamicResponseField>
<DynamicResponseField name="feature" type="object">
The full feature object if expanded.
<Expandable title="properties">
<DynamicResponseField name="id" type="string">
The ID of the feature, used to refer to it in other API calls like /track or /check.
</DynamicResponseField>
<DynamicResponseField name="name" type="string | null">
The name of the feature.
</DynamicResponseField>
<DynamicResponseField name="type" type="'static' | 'boolean' | 'single_use' | 'continuous_use' | 'credit_system'">
The type of the feature
</DynamicResponseField>
<DynamicResponseField name="display" type="object | null">
Singular and plural display names for the feature.
<Expandable title="properties">
<DynamicResponseField name="singular" type="string">
The singular display name for the feature.
</DynamicResponseField>
<DynamicResponseField name="plural" type="string">
The plural display name for the feature.
</DynamicResponseField>
</Expandable>
</DynamicResponseField>
<DynamicResponseField name="credit_schema" type="object[] | null">
Credit cost schema for credit system features.
<Expandable title="properties">
<DynamicResponseField name="metered_feature_id" type="string">
The ID of the metered feature (should be a single_use feature).
</DynamicResponseField>
<DynamicResponseField name="credit_cost" type="number">
The credit cost of the metered feature.
</DynamicResponseField>
</Expandable>
</DynamicResponseField>
<DynamicResponseField name="archived" type="boolean | null">
Whether or not the feature is archived.
</DynamicResponseField>
</Expandable>
</DynamicResponseField>
<DynamicResponseField name="included" type="number">
Number of free units included. For consumable features, balance resets to this number each interval.
</DynamicResponseField>
<DynamicResponseField name="unlimited" type="boolean">
Whether the customer has unlimited access to this feature.
</DynamicResponseField>
<DynamicResponseField name="reset" type="object | null">
Reset configuration for consumable features. Null for non-consumable features like seats where usage persists across billing cycles.
<Expandable title="properties">
<DynamicResponseField name="interval" type="'one_off' | 'minute' | 'hour' | 'day' | 'week' | 'month' | 'quarter' | 'semi_annual' | 'year'">
The interval at which the feature balance resets (e.g. 'month', 'year'). For consumable features, usage resets to 0 and included units are restored.
</DynamicResponseField>
<DynamicResponseField name="interval_count" type="number">
Number of intervals between resets. Defaults to 1.
</DynamicResponseField>
</Expandable>
</DynamicResponseField>
<DynamicResponseField name="price" type="object | null">
Pricing configuration for usage beyond included units. Null if feature is entirely free.
<Expandable title="properties">
<DynamicResponseField name="amount" type="number">
Price per billing_units after included usage is consumed. Mutually exclusive with tiers.
</DynamicResponseField>
<DynamicResponseField name="tiers" type="any[]">
Tiered pricing configuration. Each tier's 'to' INCLUDES the included amount. Either 'tiers' or 'amount' is required.
</DynamicResponseField>
<DynamicResponseField name="tier_behavior" type="'graduated' | 'volume'" />
<DynamicResponseField name="interval" type="'one_off' | 'week' | 'month' | 'quarter' | 'semi_annual' | 'year'">
Billing interval for this price. For consumable features, should match reset.interval.
</DynamicResponseField>
<DynamicResponseField name="interval_count" type="number">
Number of intervals per billing cycle. Defaults to 1.
</DynamicResponseField>
<DynamicResponseField name="billing_units" type="number">
Number of units per price increment. Usage is rounded UP to the nearest billing_units when billed (e.g. billing_units=100 means 101 usage rounds to 200).
</DynamicResponseField>
<DynamicResponseField name="billing_method" type="'prepaid' | 'usage_based'">
'prepaid' for features like seats where customers pay upfront, 'usage_based' for pay-as-you-go after included usage.
</DynamicResponseField>
<DynamicResponseField name="max_purchase" type="number | null">
Maximum units a customer can purchase beyond included. E.g. if included=100 and max_purchase=300, customer can use up to 400 total before usage is capped. Null for no limit.
</DynamicResponseField>
</Expandable>
</DynamicResponseField>
<DynamicResponseField name="display" type="object">
Display text for showing this item in pricing pages.
<Expandable title="properties">
<DynamicResponseField name="primary_text" type="string">
Main display text (e.g. '$10' or '100 messages').
</DynamicResponseField>
<DynamicResponseField name="secondary_text" type="string">
Secondary display text (e.g. 'per month' or 'then $0.5 per 100').
</DynamicResponseField>
</Expandable>
</DynamicResponseField>
<DynamicResponseField name="rollover" type="object">
Rollover configuration for unused units. If set, unused included units roll over to the next period.
<Expandable title="properties">
<DynamicResponseField name="max" type="number | null">
Maximum rollover units. Null for unlimited rollover.
</DynamicResponseField>
<DynamicResponseField name="expiry_duration_type" type="'month' | 'forever'">
When rolled over units expire.
</DynamicResponseField>
<DynamicResponseField name="expiry_duration_length" type="number">
Number of periods before expiry.
</DynamicResponseField>
</Expandable>
</DynamicResponseField>
</Expandable>
</DynamicResponseField>
<DynamicResponseField name="free_trial" type="object">
Free trial configuration. If set, new customers can try this plan before being charged.
<Expandable title="properties">
<DynamicResponseField name="duration_length" type="number">
Number of duration_type periods the trial lasts.
</DynamicResponseField>
<DynamicResponseField name="duration_type" type="'day' | 'month' | 'year'">
Unit of time for the trial duration ('day', 'month', 'year').
</DynamicResponseField>
<DynamicResponseField name="card_required" type="boolean">
Whether a payment method is required to start the trial. If true, customer will be charged after trial ends.
</DynamicResponseField>
</Expandable>
</DynamicResponseField>
<DynamicResponseField name="created_at" type="number">
Unix timestamp (ms) when the plan was created.
</DynamicResponseField>
<DynamicResponseField name="env" type="'sandbox' | 'live'">
Environment this plan belongs to ('sandbox' or 'live').
</DynamicResponseField>
<DynamicResponseField name="archived" type="boolean">
Whether the plan is archived. Archived plans cannot be attached to new customers.
</DynamicResponseField>
<DynamicResponseField name="base_variant_id" type="string | null">
If this is a variant, the ID of the base plan it was created from.
</DynamicResponseField>
<DynamicResponseField name="customer_eligibility" type="object">
<Expandable title="properties">
<DynamicResponseField name="trial_available" type="boolean">
Whether a free trial is available for this customer.
</DynamicResponseField>
<DynamicResponseField name="scenario" type="'scheduled' | 'active' | 'new' | 'renew' | 'upgrade' | 'downgrade' | 'cancel' | 'expired' | 'past_due'">
The attach scenario for this customer (e.g. new_subscription, upgrade, downgrade).
</DynamicResponseField>
</Expandable>
</DynamicResponseField>
</Expandable>
</DynamicResponseField>
<DynamicResponseField name="plan_id" type="string">
The unique identifier of the subscribed plan.
</DynamicResponseField>
<DynamicResponseField name="auto_enable" type="boolean">
Whether the plan was automatically enabled for the customer.
</DynamicResponseField>
<DynamicResponseField name="add_on" type="boolean">
Whether this is an add-on plan rather than a base subscription.
</DynamicResponseField>
<DynamicResponseField name="status" type="'active' | 'scheduled'">
Current status of the subscription.
</DynamicResponseField>
<DynamicResponseField name="past_due" type="boolean">
Whether the subscription has overdue payments.
</DynamicResponseField>
<DynamicResponseField name="canceled_at" type="number | null">
Timestamp when the subscription was canceled, or null if not canceled.
</DynamicResponseField>
<DynamicResponseField name="expires_at" type="number | null">
Timestamp when the subscription will expire, or null if no expiry set.
</DynamicResponseField>
<DynamicResponseField name="trial_ends_at" type="number | null">
Timestamp when the trial period ends, or null if not on trial.
</DynamicResponseField>
<DynamicResponseField name="started_at" type="number">
Timestamp when the subscription started.
</DynamicResponseField>
<DynamicResponseField name="current_period_start" type="number | null">
Start timestamp of the current billing period.
</DynamicResponseField>
<DynamicResponseField name="current_period_end" type="number | null">
End timestamp of the current billing period.
</DynamicResponseField>
<DynamicResponseField name="quantity" type="number">
Number of units of this subscription (for per-seat plans).
</DynamicResponseField>
</Expandable>
</DynamicResponseField>
<DynamicResponseField name="purchases" type="object[]">
<Expandable title="properties">
<DynamicResponseField name="plan" type="object">
The full plan object if expanded.
<Expandable title="properties">
<DynamicResponseField name="id" type="string">
Unique identifier for the plan.
</DynamicResponseField>
<DynamicResponseField name="name" type="string">
Display name of the plan.
</DynamicResponseField>
<DynamicResponseField name="description" type="string | null">
Optional description of the plan.
</DynamicResponseField>
<DynamicResponseField name="group" type="string | null">
Group identifier for organizing related plans. Plans in the same group are mutually exclusive.
</DynamicResponseField>
<DynamicResponseField name="version" type="number">
Version number of the plan. Incremented when plan configuration changes.
</DynamicResponseField>
<DynamicResponseField name="add_on" type="boolean">
Whether this is an add-on plan that can be attached alongside a main plan.
</DynamicResponseField>
<DynamicResponseField name="auto_enable" type="boolean">
If true, this plan is automatically attached when a customer is created. Used for free plans.
</DynamicResponseField>
<DynamicResponseField name="price" type="object | null">
Base recurring price for the plan. Null for free plans or usage-only plans.
<Expandable title="properties">
<DynamicResponseField name="amount" type="number">
Base price amount for the plan.
</DynamicResponseField>
<DynamicResponseField name="interval" type="'one_off' | 'week' | 'month' | 'quarter' | 'semi_annual' | 'year'">
Billing interval (e.g. 'month', 'year').
</DynamicResponseField>
<DynamicResponseField name="interval_count" type="number">
Number of intervals per billing cycle. Defaults to 1.
</DynamicResponseField>
<DynamicResponseField name="display" type="object">
Display text for showing this price in pricing pages.
<Expandable title="properties">
<DynamicResponseField name="primary_text" type="string">
Main display text (e.g. '$10' or '100 messages').
</DynamicResponseField>
<DynamicResponseField name="secondary_text" type="string">
Secondary display text (e.g. 'per month' or 'then $0.5 per 100').
</DynamicResponseField>
</Expandable>
</DynamicResponseField>
</Expandable>
</DynamicResponseField>
<DynamicResponseField name="items" type="object[]">
Feature configurations included in this plan. Each item defines included units, pricing, and reset behavior for a feature.
<Expandable title="properties">
<DynamicResponseField name="feature_id" type="string">
The ID of the feature this item configures.
</DynamicResponseField>
<DynamicResponseField name="feature" type="object">
The full feature object if expanded.
<Expandable title="properties">
<DynamicResponseField name="id" type="string">
The ID of the feature, used to refer to it in other API calls like /track or /check.
</DynamicResponseField>
<DynamicResponseField name="name" type="string | null">
The name of the feature.
</DynamicResponseField>
<DynamicResponseField name="type" type="'static' | 'boolean' | 'single_use' | 'continuous_use' | 'credit_system'">
The type of the feature
</DynamicResponseField>
<DynamicResponseField name="display" type="object | null">
Singular and plural display names for the feature.
<Expandable title="properties">
<DynamicResponseField name="singular" type="string">
The singular display name for the feature.
</DynamicResponseField>
<DynamicResponseField name="plural" type="string">
The plural display name for the feature.
</DynamicResponseField>
</Expandable>
</DynamicResponseField>
<DynamicResponseField name="credit_schema" type="object[] | null">
Credit cost schema for credit system features.
<Expandable title="properties">
<DynamicResponseField name="metered_feature_id" type="string">
The ID of the metered feature (should be a single_use feature).
</DynamicResponseField>
<DynamicResponseField name="credit_cost" type="number">
The credit cost of the metered feature.
</DynamicResponseField>
</Expandable>
</DynamicResponseField>
<DynamicResponseField name="archived" type="boolean | null">
Whether or not the feature is archived.
</DynamicResponseField>
</Expandable>
</DynamicResponseField>
<DynamicResponseField name="included" type="number">
Number of free units included. For consumable features, balance resets to this number each interval.
</DynamicResponseField>
<DynamicResponseField name="unlimited" type="boolean">
Whether the customer has unlimited access to this feature.
</DynamicResponseField>
<DynamicResponseField name="reset" type="object | null">
Reset configuration for consumable features. Null for non-consumable features like seats where usage persists across billing cycles.
<Expandable title="properties">
<DynamicResponseField name="interval" type="'one_off' | 'minute' | 'hour' | 'day' | 'week' | 'month' | 'quarter' | 'semi_annual' | 'year'">
The interval at which the feature balance resets (e.g. 'month', 'year'). For consumable features, usage resets to 0 and included units are restored.
</DynamicResponseField>
<DynamicResponseField name="interval_count" type="number">
Number of intervals between resets. Defaults to 1.
</DynamicResponseField>
</Expandable>
</DynamicResponseField>
<DynamicResponseField name="price" type="object | null">
Pricing configuration for usage beyond included units. Null if feature is entirely free.
<Expandable title="properties">
<DynamicResponseField name="amount" type="number">
Price per billing_units after included usage is consumed. Mutually exclusive with tiers.
</DynamicResponseField>
<DynamicResponseField name="tiers" type="any[]">
Tiered pricing configuration. Each tier's 'to' INCLUDES the included amount. Either 'tiers' or 'amount' is required.
</DynamicResponseField>
<DynamicResponseField name="tier_behavior" type="'graduated' | 'volume'" />
<DynamicResponseField name="interval" type="'one_off' | 'week' | 'month' | 'quarter' | 'semi_annual' | 'year'">
Billing interval for this price. For consumable features, should match reset.interval.
</DynamicResponseField>
<DynamicResponseField name="interval_count" type="number">
Number of intervals per billing cycle. Defaults to 1.
</DynamicResponseField>
<DynamicResponseField name="billing_units" type="number">
Number of units per price increment. Usage is rounded UP to the nearest billing_units when billed (e.g. billing_units=100 means 101 usage rounds to 200).
</DynamicResponseField>
<DynamicResponseField name="billing_method" type="'prepaid' | 'usage_based'">
'prepaid' for features like seats where customers pay upfront, 'usage_based' for pay-as-you-go after included usage.
</DynamicResponseField>
<DynamicResponseField name="max_purchase" type="number | null">
Maximum units a customer can purchase beyond included. E.g. if included=100 and max_purchase=300, customer can use up to 400 total before usage is capped. Null for no limit.
</DynamicResponseField>
</Expandable>
</DynamicResponseField>
<DynamicResponseField name="display" type="object">
Display text for showing this item in pricing pages.
<Expandable title="properties">
<DynamicResponseField name="primary_text" type="string">
Main display text (e.g. '$10' or '100 messages').
</DynamicResponseField>
<DynamicResponseField name="secondary_text" type="string">
Secondary display text (e.g. 'per month' or 'then $0.5 per 100').
</DynamicResponseField>
</Expandable>
</DynamicResponseField>
<DynamicResponseField name="rollover" type="object">
Rollover configuration for unused units. If set, unused included units roll over to the next period.
<Expandable title="properties">
<DynamicResponseField name="max" type="number | null">
Maximum rollover units. Null for unlimited rollover.
</DynamicResponseField>
<DynamicResponseField name="expiry_duration_type" type="'month' | 'forever'">
When rolled over units expire.
</DynamicResponseField>
<DynamicResponseField name="expiry_duration_length" type="number">
Number of periods before expiry.
</DynamicResponseField>
</Expandable>
</DynamicResponseField>
</Expandable>
</DynamicResponseField>
<DynamicResponseField name="free_trial" type="object">
Free trial configuration. If set, new customers can try this plan before being charged.
<Expandable title="properties">
<DynamicResponseField name="duration_length" type="number">
Number of duration_type periods the trial lasts.
</DynamicResponseField>
<DynamicResponseField name="duration_type" type="'day' | 'month' | 'year'">
Unit of time for the trial duration ('day', 'month', 'year').
</DynamicResponseField>
<DynamicResponseField name="card_required" type="boolean">
Whether a payment method is required to start the trial. If true, customer will be charged after trial ends.
</DynamicResponseField>
</Expandable>
</DynamicResponseField>
<DynamicResponseField name="created_at" type="number">
Unix timestamp (ms) when the plan was created.
</DynamicResponseField>
<DynamicResponseField name="env" type="'sandbox' | 'live'">
Environment this plan belongs to ('sandbox' or 'live').
</DynamicResponseField>
<DynamicResponseField name="archived" type="boolean">
Whether the plan is archived. Archived plans cannot be attached to new customers.
</DynamicResponseField>
<DynamicResponseField name="base_variant_id" type="string | null">
If this is a variant, the ID of the base plan it was created from.
</DynamicResponseField>
<DynamicResponseField name="customer_eligibility" type="object">
<Expandable title="properties">
<DynamicResponseField name="trial_available" type="boolean">
Whether a free trial is available for this customer.
</DynamicResponseField>
<DynamicResponseField name="scenario" type="'scheduled' | 'active' | 'new' | 'renew' | 'upgrade' | 'downgrade' | 'cancel' | 'expired' | 'past_due'">
The attach scenario for this customer (e.g. new_subscription, upgrade, downgrade).
</DynamicResponseField>
</Expandable>
</DynamicResponseField>
</Expandable>
</DynamicResponseField>
<DynamicResponseField name="plan_id" type="string">
The unique identifier of the purchased plan.
</DynamicResponseField>
<DynamicResponseField name="expires_at" type="number | null">
Timestamp when the purchase expires, or null for lifetime access.
</DynamicResponseField>
<DynamicResponseField name="started_at" type="number">
Timestamp when the purchase was made.
</DynamicResponseField>
<DynamicResponseField name="quantity" type="number">
Number of units purchased.
</DynamicResponseField>
</Expandable>
</DynamicResponseField>
<DynamicResponseField name="balances.{key}" type="object">
<Expandable title="properties">
<DynamicResponseField name="feature_id" type="string">
The feature ID this balance is for.
</DynamicResponseField>
<DynamicResponseField name="feature" type="object">
The full feature object if expanded.
<Expandable title="properties">
<DynamicResponseField name="id" type="string">
The unique identifier for this feature, used in /check and /track calls.
</DynamicResponseField>
<DynamicResponseField name="name" type="string">
Human-readable name displayed in the dashboard and billing UI.
</DynamicResponseField>
<DynamicResponseField name="type" type="'boolean' | 'metered' | 'credit_system'">
Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools.
</DynamicResponseField>
<DynamicResponseField name="consumable" type="boolean">
For metered features: true if usage resets periodically (API calls, credits), false if allocated persistently (seats, storage).
</DynamicResponseField>
<DynamicResponseField name="event_names" type="string[]">
Event names that trigger this feature's balance. Allows multiple features to respond to a single event.
</DynamicResponseField>
<DynamicResponseField name="credit_schema" type="object[]">
For credit_system features: maps metered features to their credit costs.
<Expandable title="properties">
<DynamicResponseField name="metered_feature_id" type="string">
ID of the metered feature that draws from this credit system.
</DynamicResponseField>
<DynamicResponseField name="credit_cost" type="number">
Credits consumed per unit of the metered feature.
</DynamicResponseField>
</Expandable>
</DynamicResponseField>
<DynamicResponseField name="display" type="object">
Display names for the feature in billing UI and customer-facing components.
<Expandable title="properties">
<DynamicResponseField name="singular" type="string | null">
Singular form for UI display (e.g., 'API call', 'seat').
</DynamicResponseField>
<DynamicResponseField name="plural" type="string | null">
Plural form for UI display (e.g., 'API calls', 'seats').
</DynamicResponseField>
</Expandable>
</DynamicResponseField>
<DynamicResponseField name="archived" type="boolean">
Whether the feature is archived and hidden from the dashboard.
</DynamicResponseField>
</Expandable>
</DynamicResponseField>
<DynamicResponseField name="granted" type="number">
Total balance granted (included + prepaid).
</DynamicResponseField>
<DynamicResponseField name="remaining" type="number">
Remaining balance available for use.
</DynamicResponseField>
<DynamicResponseField name="usage" type="number">
Total usage consumed in the current period.
</DynamicResponseField>
<DynamicResponseField name="unlimited" type="boolean">
Whether this feature has unlimited usage.
</DynamicResponseField>
<DynamicResponseField name="overage_allowed" type="boolean">
Whether usage beyond the granted balance is allowed (with overage charges).
</DynamicResponseField>
<DynamicResponseField name="max_purchase" type="number | null">
Maximum quantity that can be purchased as a top-up, or null for unlimited.
</DynamicResponseField>
<DynamicResponseField name="next_reset_at" type="number | null">
Timestamp when the balance will reset, or null for no reset.
</DynamicResponseField>
<DynamicResponseField name="breakdown" type="object[]">
Detailed breakdown of balance sources when stacking multiple plans or grants.
<Expandable title="properties">
<DynamicResponseField name="id" type="string">
The unique identifier for this balance breakdown.
</DynamicResponseField>
<DynamicResponseField name="plan_id" type="string | null">
The plan ID this balance originates from, or null for standalone balances.
</DynamicResponseField>
<DynamicResponseField name="included_grant" type="number">
Amount granted from the plan's included usage.
</DynamicResponseField>
<DynamicResponseField name="prepaid_grant" type="number">
Amount granted from prepaid purchases or top-ups.
</DynamicResponseField>
<DynamicResponseField name="remaining" type="number">
Remaining balance available for use.
</DynamicResponseField>
<DynamicResponseField name="usage" type="number">
Amount consumed in the current period.
</DynamicResponseField>
<DynamicResponseField name="unlimited" type="boolean">
Whether this balance has unlimited usage.
</DynamicResponseField>
<DynamicResponseField name="reset" type="object | null">
Reset configuration for this balance, or null if no reset.
<Expandable title="properties">
<DynamicResponseField name="interval" type="'one_off' | 'minute' | 'hour' | 'day' | 'week' | 'month' | 'quarter' | 'semi_annual' | 'year'">
The reset interval (hour, day, week, month, etc.) or 'multiple' if combined from different intervals.
</DynamicResponseField>
<DynamicResponseField name="interval_count" type="number">
Number of intervals between resets (eg. 2 for bi-monthly).
</DynamicResponseField>
<DynamicResponseField name="resets_at" type="number | null">
Timestamp when the balance will next reset.
</DynamicResponseField>
</Expandable>
</DynamicResponseField>
<DynamicResponseField name="price" type="object | null">
Pricing configuration if this balance has usage-based pricing.
<Expandable title="properties">
<DynamicResponseField name="amount" type="number">
The per-unit price amount.
</DynamicResponseField>
<DynamicResponseField name="tiers" type="any[]">
Tiered pricing configuration if applicable.
</DynamicResponseField>
<DynamicResponseField name="tier_behavior" type="'graduated' | 'volume'">
How tiers are applied: graduated (split across bands) or volume (flat rate for the matched tier).
</DynamicResponseField>
<DynamicResponseField name="billing_units" type="number">
The number of units per billing increment (eg. $9 / 250 units).
</DynamicResponseField>
<DynamicResponseField name="billing_method" type="'prepaid' | 'usage_based'">
Whether usage is prepaid or billed pay-per-use.
</DynamicResponseField>
<DynamicResponseField name="max_purchase" type="number | null">
Maximum quantity that can be purchased, or null for unlimited.
</DynamicResponseField>
</Expandable>
</DynamicResponseField>
<DynamicResponseField name="expires_at" type="number | null">
Timestamp when this balance expires, or null for no expiration.
</DynamicResponseField>
</Expandable>
</DynamicResponseField>
<DynamicResponseField name="rollovers" type="object[]">
Rollover balances carried over from previous periods.
<Expandable title="properties">
<DynamicResponseField name="balance" type="number">
Amount of balance rolled over from a previous period.
</DynamicResponseField>
<DynamicResponseField name="expires_at" type="number">
Timestamp when the rollover balance expires.
</DynamicResponseField>
</Expandable>
</DynamicResponseField>
</Expandable>
</DynamicResponseField>
<DynamicResponseField name="billing_controls" type="object">
Billing controls for the entity.
<Expandable title="properties">
<DynamicResponseField name="spend_limits" type="object[]">
List of overage spend limits per feature.
<Expandable title="properties">
<DynamicResponseField name="feature_id" type="string">
Optional feature ID this spend limit applies to.
</DynamicResponseField>
<DynamicResponseField name="enabled" type="boolean">
Whether this spend limit is enabled.
</DynamicResponseField>
<DynamicResponseField name="overage_limit" type="number">
Maximum allowed overage spend for the target feature.
</DynamicResponseField>
</Expandable>
</DynamicResponseField>
</Expandable>
</DynamicResponseField>
<DynamicResponseField name="invoices" type="object[]">
Invoices for this entity (only included when expand=invoices)
<Expandable title="properties">
<DynamicResponseField name="plan_ids" type="string[]">
Array of plan IDs included in this invoice
</DynamicResponseField>
<DynamicResponseField name="stripe_id" type="string">
The Stripe invoice ID
</DynamicResponseField>
<DynamicResponseField name="status" type="string">
The status of the invoice
</DynamicResponseField>
<DynamicResponseField name="total" type="number">
The total amount of the invoice
</DynamicResponseField>
<DynamicResponseField name="currency" type="string">
The currency code for the invoice
</DynamicResponseField>
<DynamicResponseField name="created_at" type="number">
Timestamp when the invoice was created
</DynamicResponseField>
<DynamicResponseField name="hosted_invoice_url" type="string | null">
URL to the Stripe-hosted invoice page
</DynamicResponseField>
</Expandable>
</DynamicResponseField>
<ResponseExample>
```json 200
{
"id": "seat_42",
"name": "Seat 42",
"customer_id": "cus_123",
"feature_id": "seats",
"created_at": 1771409161016,
"env": "sandbox",
"subscriptions": [
{
"plan_id": "pro_plan",
"auto_enable": true,
"add_on": false,
"status": "active",
"past_due": false,
"canceled_at": null,
"expires_at": null,
"trial_ends_at": null,
"started_at": 1771431921437,
"current_period_start": 1771431921437,
"current_period_end": 1771999921437,
"quantity": 1
}
],
"purchases": [],
"balances": {
"messages": {
"feature_id": "messages",
"granted": 100,
"remaining": 72,
"usage": 28,
"unlimited": false,
"overage_allowed": false,
"max_purchase": null,
"next_reset_at": 1773851121437,
"breakdown": [
{
"id": "cus_ent_39qmLooixXLAqMywgXywjAz96rV",
"plan_id": "pro_plan",
"included_grant": 100,
"prepaid_grant": 0,
"remaining": 72,
"usage": 28,
"unlimited": false,
"reset": {
"interval": "month",
"resets_at": 1773851121437
},
"price": null,
"expires_at": null
}
]
}
},
"invoices": []
}
```
</ResponseExample>

View File

@@ -7,7 +7,7 @@ import { DynamicParamField } from "/components/dynamic-param-field.jsx";
import { DynamicResponseField } from "/components/dynamic-response-field.jsx";
import { DynamicResponseExample } from "/components/dynamic-response-example.jsx";
Creates a new plan with optional base price and feature configurations. See [How plans work](/documentation/pricing/plans) for concepts and [Adding features to plans](/documentation/pricing/plan-features) for item configuration.
Creates a new plan with optional base price and feature configurations. See [Plans](/documentation/concepts/plans) for concepts and [Plan Items](/documentation/concepts/plan-items) for item configuration.
### Plan Configuration
@@ -223,9 +223,9 @@ await autumn.plans.create({
<Expandable title="properties">
<DynamicParamField body="to" type="number" required />
<DynamicParamField body="amount" type="number" required />
<DynamicParamField body="amount" type="number" />
<DynamicParamField body="flat_amount" type="number | null" />
<DynamicParamField body="flat_amount" type="number" />
</Expandable>
</DynamicParamField>
@@ -457,16 +457,8 @@ await autumn.plans.create({
Price per billing_units after included usage is consumed. Mutually exclusive with tiers.
</DynamicResponseField>
<DynamicResponseField name="tiers" type="object[]">
<DynamicResponseField name="tiers" type="any[]">
Tiered pricing configuration. Each tier's 'to' INCLUDES the included amount. Either 'tiers' or 'amount' is required.
<Expandable title="properties">
<DynamicResponseField name="to" type="number" />
<DynamicResponseField name="amount" type="number" />
<DynamicResponseField name="flat_amount" type="number | null" />
</Expandable>
</DynamicResponseField>
<DynamicResponseField name="tier_behavior" type="'graduated' | 'volume'" />

View File

@@ -187,16 +187,8 @@ const plan = await autumn.plans.get({
Price per billing_units after included usage is consumed. Mutually exclusive with tiers.
</DynamicResponseField>
<DynamicResponseField name="tiers" type="object[]">
<DynamicResponseField name="tiers" type="any[]">
Tiered pricing configuration. Each tier's 'to' INCLUDES the included amount. Either 'tiers' or 'amount' is required.
<Expandable title="properties">
<DynamicResponseField name="to" type="number" />
<DynamicResponseField name="amount" type="number" />
<DynamicResponseField name="flat_amount" type="number | null" />
</Expandable>
</DynamicResponseField>
<DynamicResponseField name="tier_behavior" type="'graduated' | 'volume'" />

View File

@@ -204,16 +204,8 @@ const plans = await autumn.plans.list({
Price per billing_units after included usage is consumed. Mutually exclusive with tiers.
</DynamicResponseField>
<DynamicResponseField name="tiers" type="object[]">
<DynamicResponseField name="tiers" type="any[]">
Tiered pricing configuration. Each tier's 'to' INCLUDES the included amount. Either 'tiers' or 'amount' is required.
<Expandable title="properties">
<DynamicResponseField name="to" type="number" />
<DynamicResponseField name="amount" type="number" />
<DynamicResponseField name="flat_amount" type="number | null" />
</Expandable>
</DynamicResponseField>
<DynamicResponseField name="tier_behavior" type="'graduated' | 'volume'" />

View File

@@ -7,7 +7,7 @@ import { DynamicParamField } from "/components/dynamic-param-field.jsx";
import { DynamicResponseField } from "/components/dynamic-response-field.jsx";
import { DynamicResponseExample } from "/components/dynamic-response-example.jsx";
Updates an existing plan. By default, creates a new version of the plan. See [Adding features to plans](/documentation/pricing/plan-features) for item configuration.
Updates an existing plan. By default, creates a new version of the plan. See [Plan Items](/documentation/concepts/plan-items) for item configuration.
<Note>
Updates create a new plan version by default. Existing customers remain on their current version until their subscription renews or they explicitly upgrade.
@@ -150,9 +150,9 @@ await autumn.plans.update({
<Expandable title="properties">
<DynamicParamField body="to" type="number" required />
<DynamicParamField body="amount" type="number" required />
<DynamicParamField body="amount" type="number" />
<DynamicParamField body="flat_amount" type="number | null" />
<DynamicParamField body="flat_amount" type="number" />
</Expandable>
</DynamicParamField>
@@ -392,16 +392,8 @@ await autumn.plans.update({
Price per billing_units after included usage is consumed. Mutually exclusive with tiers.
</DynamicResponseField>
<DynamicResponseField name="tiers" type="object[]">
<DynamicResponseField name="tiers" type="any[]">
Tiered pricing configuration. Each tier's 'to' INCLUDES the included amount. Either 'tiers' or 'amount' is required.
<Expandable title="properties">
<DynamicResponseField name="to" type="number" />
<DynamicResponseField name="amount" type="number" />
<DynamicResponseField name="flat_amount" type="number | null" />
</Expandable>
</DynamicResponseField>
<DynamicResponseField name="tier_behavior" type="'graduated' | 'volume'" />

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 381 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 432 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

View File

@@ -0,0 +1,259 @@
---
title: "Command reference"
description: "Every command, flag, and option available in the atmn CLI"
---
## Global flags
These flags work with any command:
| Flag | Description |
|------|-------------|
| `-p, --prod` | Target production instead of sandbox |
| `-l, --local` | Use `localhost:8080` API server |
| `--headless` | Force non-interactive mode (for CI/agents) |
| `-c, --config <path>` | Path to config file (default: `autumn.config.ts`) |
| `-v, --version` | Show CLI version |
Flags can be combined -- for example, `atmn push -lp` targets production on a local API server.
## Authentication
### `atmn login`
Authenticate with Autumn via OAuth. Opens your browser, lets you select an organization, and saves sandbox + production API keys to `.env`.
```bash
atmn login
```
In non-TTY environments (CI), it prints a URL you can open manually.
### `atmn logout`
Remove `AUTUMN_SECRET_KEY` and `AUTUMN_PROD_SECRET_KEY` from your `.env` file.
```bash
atmn logout
```
### `atmn env`
Show your current organization and environment:
```bash
atmn env
```
```
Organization: Acme Corp
Slug: acme-corp
Environment: Sandbox
```
## Configuration
### `atmn init`
Create an `autumn.config.ts` from a starter template. Prompts for login if you haven't authenticated yet.
```bash
atmn init
```
### `atmn push`
Push your local `autumn.config.ts` to Autumn.
```bash
atmn push [options]
```
| Flag | Description |
|------|-------------|
| `-y, --yes` | Auto-confirm all prompts |
The CLI compares your local config with what's in Autumn and shows a summary of changes before applying. If plans with existing customers are modified, it will prompt about versioning.
### `atmn pull`
Pull plans and features from Autumn into your local `autumn.config.ts`.
```bash
atmn pull [options]
```
| Flag | Description |
|------|-------------|
| `-f, --force` | Overwrite config instead of smart in-place update |
By default, `pull` does a smart in-place update -- it adds new features and plans, updates existing ones, and removes deleted ones while preserving your formatting. Use `--force` to overwrite the entire file.
`pull` also generates an `@useautumn-sdk.d.ts` file with typed `FeatureIds` and `PlanIds` for IDE autocompletion.
### `atmn preview`
Render a pricing table from your local config without making any API calls.
```bash
atmn preview [options]
```
| Flag | Description |
|------|-------------|
| `--plan <id>` | Preview a specific plan |
| `--currency <code>` | Currency for display (default: `USD`) |
### `atmn nuke`
Permanently delete all data in your **sandbox**. This command refuses to run with `--prod`.
```bash
atmn nuke
```
| Flag | Description |
|------|-------------|
| `--dangerously-skip-all-confirmation-prompts` | Skip all safety prompts |
<Warning>
This is irreversible. The flag name is intentionally long to prevent accidental use.
</Warning>
## Data browsing
These commands open a full interactive TUI for browsing and inspecting your Autumn data. Use the `--headless` flag to get structured data instead.
### `atmn customers`
```bash
atmn customers [options]
```
| Flag | Description |
|------|-------------|
| `--id <id>` | Get a specific customer |
| `--search <query>` | Filter customers |
| `--page <n>` | Page number (default: `1`) |
| `--limit <n>` | Results per page (default: `50`) |
| `--format <fmt>` | Output: `text`, `json`, `csv` (default: `text`) |
### `atmn plans`
```bash
atmn plans [options]
```
| Flag | Description |
|------|-------------|
| `--id <id>` | Get a specific plan |
| `--search <query>` | Filter plans |
| `--include-archived` | Include archived plans |
| `--page <n>` | Page number (default: `1`) |
| `--limit <n>` | Results per page (default: `50`) |
| `--format <fmt>` | Output: `text`, `json`, `csv` (default: `text`) |
<Note>
`atmn products` is an alias for `atmn plans`.
</Note>
### `atmn features`
```bash
atmn features [options]
```
| Flag | Description |
|------|-------------|
| `--id <id>` | Get a specific feature |
| `--search <query>` | Filter features |
| `--include-archived` | Include archived features |
| `--page <n>` | Page number (default: `1`) |
| `--limit <n>` | Results per page (default: `50`) |
| `--format <fmt>` | Output: `text`, `json`, `csv` (default: `text`) |
### `atmn events`
```bash
atmn events [options]
```
| Flag | Description |
|------|-------------|
| `--customer <id>` | Filter by customer |
| `--feature <id>` | Filter by feature (comma-separated for multiple) |
| `--time <range>` | Time range: `24h`, `7d`, `30d`, `90d` (default: `7d`) |
| `--mode <mode>` | `list` or `aggregate` (default: `list`) |
| `--bin <size>` | Bin size for aggregate: `hour`, `day`, `month` |
| `--group-by <prop>` | Group by property in aggregate mode |
| `--page <n>` | Page number (default: `1`) |
| `--limit <n>` | Results per page (default: `100`) |
| `--format <fmt>` | Output: `text`, `json`, `csv` (default: `text`) |
## Configuration
### `atmn config`
View and manage persistent CLI settings.
```bash
atmn config # Show help and config file location
atmn config --global # Same as above
atmn config --global <key> # Read a setting
atmn config --global <key> <value> # Write a setting
```
Running `atmn config` with no arguments shows the full path to your config file, supported keys, and usage info.
| Flag | Description |
|------|-------------|
| `-g, --global` | Use global config |
#### Available keys
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `noDeclarationFile` | `boolean` | `false` | Skip generating `@useautumn-sdk.d.ts` on `atmn pull` |
#### Priority order
Settings are resolved in this order: **CLI flag** → **global config** → **default value**. For example, `--no-declaration-file` on `atmn pull` always takes priority over the global `noDeclarationFile` setting.
#### Config file location
Running `atmn config` or `atmn config --global` (with no key) prints the exact path to your config file on disk.
| OS | Path |
|----|------|
| macOS | `~/Library/Preferences/atmn/config.json` |
| Linux | `~/.config/atmn/config.json` (or `$XDG_CONFIG_HOME`) |
| Windows | `%APPDATA%\atmn\config.json` |
## Utilities
### `atmn dashboard`
Open the Autumn dashboard in your browser.
```bash
atmn dashboard
```
### `atmn version`
Print the CLI version. Alias: `atmn v`.
```bash
atmn version
```
## Headless mode
The CLI automatically detects non-TTY environments and switches to headless mode with plain text output and no interactive prompts. You can also force it with `--headless`.
### Exit codes
| Code | Meaning |
|------|---------|
| `0` | Success |
| `1` | Error (network, auth, validation, or confirmation required) |

View File

@@ -0,0 +1,364 @@
---
title: "Configuration reference"
description: "Define features, plans, and pricing in autumn.config.ts"
---
Your `autumn.config.ts` file is the source of truth for your pricing. It exports features and plans using helper functions from the `atmn` package.
```ts autumn.config.ts
import { feature, item, plan } from 'atmn';
export const messages = feature({ ... });
export const pro = plan({ ... });
```
Push changes with `atmn push`, or pull existing config with `atmn pull`.
## Features
Features define what can be gated, metered, or billed in your app.
### `feature(config)`
<ParamField body="id" type="string" required>
Unique identifier used in API calls (`check`, `track`, etc).
</ParamField>
<ParamField body="name" type="string" required>
Display name shown in the dashboard and billing UI.
</ParamField>
<ParamField body="type" type="enum" required>
`"boolean"` | `"metered"` | `"credit_system"`
</ParamField>
<ParamField body="consumable" type="boolean">
**Required for `metered` features.**
- `true` -- usage is consumed (messages, API calls, credits)
- `false` -- usage is ongoing (seats, storage, workspaces)
</ParamField>
<ParamField body="creditSchema" type="array">
**Required for `credit_system` features.** Maps metered features to credit costs.
Each entry: `{ meteredFeatureId: string, creditCost: number }`
</ParamField>
### Feature types
**Boolean** -- simple on/off flag:
```ts
export const sso = feature({
id: 'sso',
name: 'SSO Authentication',
type: 'boolean',
});
```
**Metered, consumable** -- used up and replenished (messages, API calls):
```ts
export const messages = feature({
id: 'messages',
name: 'Messages',
type: 'metered',
consumable: true,
});
```
**Metered, non-consumable** -- ongoing usage (seats, storage):
```ts
export const seats = feature({
id: 'seats',
name: 'Seats',
type: 'metered',
consumable: false,
});
```
**Credit system** -- maps multiple metered features to credit costs:
```ts
export const basicModel = feature({
id: 'basic_model',
name: 'Basic Model',
type: 'metered',
consumable: true,
});
export const premiumModel = feature({
id: 'premium_model',
name: 'Premium Model',
type: 'metered',
consumable: true,
});
export const credits = feature({
id: 'credits',
name: 'AI Credits',
type: 'credit_system',
creditSchema: [
{ meteredFeatureId: basicModel.id, creditCost: 1 },
{ meteredFeatureId: premiumModel.id, creditCost: 5 },
],
});
```
<Tip>
If you set the price per credit to 1 cent, credits become monetary credits (eg, 5 credits = $0.05 per premium message).
</Tip>
## Plans
Plans combine features with pricing to create your subscription tiers, add-ons, and top-ups.
### `plan(config)`
<ParamField body="id" type="string" required>
Unique identifier used in checkout and subscription APIs.
</ParamField>
<ParamField body="name" type="string" required>
Display name shown in pricing tables and billing.
</ParamField>
<ParamField body="price" type="object">
Base subscription price:
- `amount: number` -- price amount (eg, `20` for $20)
- `interval: string` -- `"month"` | `"quarter"` | `"semi_annual"` | `"year"` | `"one_off"`
</ParamField>
<ParamField body="items" type="array">
Array of `item()` objects defining what's included.
</ParamField>
<ParamField body="autoEnable" type="boolean" default="false">
Automatically assign this plan to new customers. Typically used for free plans.
</ParamField>
<ParamField body="addOn" type="boolean" default="false">
Allow this plan to be purchased alongside other plans (instead of replacing them).
</ParamField>
<ParamField body="freeTrial" type="object">
Free trial before billing starts:
- `durationLength: number` -- eg, `14`
- `durationType: string` -- `"day"` | `"month"` | `"year"`
- `cardRequired: boolean` -- whether a card is needed to start the trial
</ParamField>
<ParamField body="group" type="string">
Group related plans together. Plans in the same group replace each other on upgrade/downgrade.
</ParamField>
## Plan items
Plan items define what each plan includes -- usage limits, pricing, and billing behavior.
### `item(config)`
<ParamField body="featureId" type="string" required>
The `id` of the feature to include.
</ParamField>
<ParamField body="included" type="number">
Amount included for free. Omit for boolean features.
</ParamField>
<ParamField body="unlimited" type="boolean">
Grant unlimited usage of this feature.
</ParamField>
<ParamField body="reset" type="object">
How often the included amount resets:
- `interval: string` -- `"hour"` | `"day"` | `"week"` | `"month"` | `"quarter"` | `"semi_annual"` | `"year"`
- `intervalCount: number` -- defaults to `1`
</ParamField>
<ParamField body="price" type="object">
Pricing for usage beyond the included amount. See [pricing patterns](#pricing-patterns) below.
</ParamField>
<ParamField body="proration" type="object">
How to handle mid-cycle quantity changes:
- `onIncrease:` `"prorate"` | `"charge_immediately"`
- `onDecrease:` `"prorate"` | `"refund_immediately"` | `"no_action"`
</ParamField>
<ParamField body="rollover" type="object">
Carry unused balance forward:
- `max: number` -- maximum rollover amount
- `expiryDurationType:` `"month"` | `"forever"`
- `expiryDurationLength: number` -- ignored if type is `"forever"`
</ParamField>
### Pricing patterns
The `price` object on a plan item supports different billing models:
**Usage-based** -- charge based on actual usage:
```ts
item({
featureId: seats.id,
included: 5,
price: {
amount: 10,
interval: 'month',
billingMethod: 'usage_based',
billingUnits: 1,
},
})
```
**Prepaid** -- customer buys a fixed quantity upfront:
```ts
item({
featureId: credits.id,
price: {
amount: 5,
billingUnits: 100,
billingMethod: 'prepaid',
},
})
```
**Tiered** -- price changes based on usage volume:
```ts
item({
featureId: apiCalls.id,
price: {
tiers: [
{ to: 1000, amount: 0.01 },
{ to: 10000, amount: 0.008 },
{ to: 'inf', amount: 0.005 },
],
billingMethod: 'usage_based',
interval: 'month',
},
})
```
#### Price fields
<ParamField body="amount" type="number">
Price per `billingUnits`. Mutually exclusive with `tiers`.
</ParamField>
<ParamField body="tiers" type="array">
Tiered pricing. Each entry: `{ to: number | "inf", amount: number }`. Mutually exclusive with `amount`.
</ParamField>
<ParamField body="billingMethod" type="enum" required>
`"usage_based"` | `"prepaid"`
</ParamField>
<ParamField body="interval" type="enum">
`"week"` | `"month"` | `"quarter"` | `"semi_annual"` | `"year"`. Omit for one-time charges. Not needed if the plan item has a top-level `reset`.
</ParamField>
<ParamField body="billingUnits" type="number" default="1">
Units per price. Eg, $5 per 100 credits = `amount: 5, billingUnits: 100`.
</ParamField>
<ParamField body="maxPurchase" type="number">
Maximum quantity that can be purchased.
</ParamField>
## Full example
A complete config with a free plan, a paid plan with a trial, and a credits top-up add-on:
```ts autumn.config.ts
import { feature, item, plan } from 'atmn';
// Features
export const messages = feature({
id: 'messages',
name: 'Messages',
type: 'metered',
consumable: true,
});
export const seats = feature({
id: 'seats',
name: 'Seats',
type: 'metered',
consumable: false,
});
export const sso = feature({
id: 'sso',
name: 'SSO',
type: 'boolean',
});
// Plans
export const free = plan({
id: 'free',
name: 'Free',
autoEnable: true,
items: [
item({
featureId: messages.id,
included: 5,
reset: { interval: 'month' },
}),
item({
featureId: seats.id,
included: 1,
}),
],
});
export const pro = plan({
id: 'pro',
name: 'Pro',
price: { amount: 20, interval: 'month' },
freeTrial: {
durationLength: 14,
durationType: 'day',
cardRequired: true,
},
items: [
item({
featureId: messages.id,
included: 1000,
reset: { interval: 'month' },
}),
item({
featureId: seats.id,
included: 5,
price: {
amount: 10,
interval: 'month',
billingMethod: 'usage_based',
billingUnits: 1,
},
}),
item({
featureId: sso.id,
}),
],
});
export const topUp = plan({
id: 'top_up',
name: 'Message Top-Up',
addOn: true,
items: [
item({
featureId: messages.id,
price: {
amount: 5,
billingUnits: 100,
billingMethod: 'prepaid',
},
}),
],
});
```

View File

@@ -0,0 +1,121 @@
---
title: "Getting started"
description: "Install the CLI, authenticate, and sync your pricing config"
---
The `atmn` CLI lets you define your pricing plans in code via an `autumn.config.ts` file, and sync them to Autumn with a single command.
## Installation
Run the CLI directly with your package manager:
<CodeGroup>
```bash bunx
bunx atmn
```
```bash pnpm
pnpm dlx atmn
```
```bash npx
npx atmn
```
</CodeGroup>
## Login
Authenticate with your Autumn account:
```bash
bunx atmn login
```
This opens your browser, lets you pick an organization, and saves API keys for both sandbox and production to your `.env` file:
```bash .env
AUTUMN_SECRET_KEY=am_sk_test_...
AUTUMN_PROD_SECRET_KEY=am_sk_live_...
```
<Check>
You can verify your setup at any time with `bunx atmn env`, which shows your current organization and environment.
</Check>
## Initialize a project
Run `atmn init` in your project root to create an `autumn.config.ts` file:
```bash
bunx atmn init
```
You'll be prompted to choose a starter template:
| Template | Pricing model |
|----------|--------------|
| **T3 Chat** | Freemium with message limits and a credits add-on |
| **Railway** | Credit-based infrastructure pricing with overage |
| **Linear** | Per-seat pricing with team limits |
| **OpenAI** | Credit system mapping multiple AI models |
Pick whichever is closest to your use case, or start from scratch and build your own using the [config reference](/api-reference/cli/config).
## Push and pull
Once you have an `autumn.config.ts`, sync it with Autumn:
```bash
# Push local config to Autumn
bunx atmn push
# Pull remote config into your local file
bunx atmn pull
```
`push` reads your config file, compares it with what's in Autumn, and applies the changes. In interactive mode you'll see a summary of what will be created, updated, or deleted before confirming.
`pull` fetches your plans and features from Autumn and writes them into your local `autumn.config.ts`. If the file already exists, it does a smart in-place update that preserves your local formatting and comments where possible.
<Tip>
Use `bunx atmn pull` to generate an `autumn.config.ts` from plans you've already created in the dashboard.
</Tip>
## Preview locally
You can preview your plans without pushing anything:
```bash
bunx atmn preview
```
This renders a pricing table from your local config.
## Environments
By default, all commands target your **sandbox** environment. Add the `-p` flag to target production:
```bash
# Push to production
bunx atmn push -p
# Pull from production
bunx atmn pull -p
```
<Warning>
Pushing to production will prompt for confirmation. Use `--yes` to skip the prompt automatically.
</Warning>
**Next: Configuration reference**
Learn how to define features, plans, and pricing in your `autumn.config.ts`.
<Card
title="Configuration reference"
href="/api-reference/cli/config"
>
Complete reference for features, plans, and plan features
</Card>

View File

@@ -45,50 +45,81 @@
"groups": [
{
"group": " ",
"pages": ["welcome"]
"pages": ["welcome", "documentation/getting-started/migration"]
},
{
"group": "Getting Started",
"pages": [
{
"group": "Setup and payments",
"pages": [
"documentation/getting-started/setup/react",
"documentation/getting-started/setup/sdk",
"documentation/getting-started/setup/convex"
]
},
"documentation/getting-started/setup",
"documentation/getting-started/gating",
"documentation/getting-started/display-billing"
]
},
{
"group": "Configure Pricing",
"group": "Concepts",
"pages": [
"documentation/pricing/plans",
"documentation/pricing/features",
"documentation/pricing/plan-features",
"documentation/pricing/credits",
"documentation/pricing/versioning",
"documentation/pricing/rewards"
"documentation/concepts/overview",
"documentation/concepts/plans",
"documentation/concepts/features",
"documentation/concepts/plan-items",
"documentation/concepts/subscriptions",
"documentation/concepts/balances",
"documentation/concepts/stripe"
]
},
{
"group": "Manage Customers",
"group": "Modelling Pricing",
"pages": [
"documentation/customers/check",
"documentation/customers/tracking-usage",
"documentation/customers/balances",
"documentation/customers/feature-entities",
"documentation/customers/attaching-plans",
"documentation/customers/updating-subscriptions",
"documentation/customers/creating-customers",
"documentation/customers/managing-customers"
"documentation/modelling-pricing/recurring",
"documentation/modelling-pricing/one-off-purchases",
"documentation/modelling-pricing/free-plans",
"documentation/modelling-pricing/trials",
"documentation/modelling-pricing/credit-systems",
"documentation/modelling-pricing/per-unit-pricing",
"documentation/modelling-pricing/usage-based-pricing",
"documentation/modelling-pricing/rollovers",
"documentation/modelling-pricing/proration",
"documentation/modelling-pricing/spend-limits",
"documentation/modelling-pricing/auto-top-ups",
"documentation/modelling-pricing/add-ons",
"documentation/modelling-pricing/graduated-pricing",
"documentation/modelling-pricing/volume-based-tiers",
"documentation/modelling-pricing/sub-entity-balances",
"documentation/modelling-pricing/sub-entity-plans",
"documentation/modelling-pricing/rewards"
]
},
{
"group": "Billing & Subscriptions",
"pages": [
"documentation/customers/payment-flow",
"documentation/customers/subscription-lifecycle",
"documentation/customers/updating-subscriptions",
"documentation/customers/custom-plans",
"documentation/customers/versioning"
]
},
{
"group": "Customers",
"pages": [
"documentation/customers/creating-customers",
"documentation/customers/managing-customers",
"documentation/customers/check",
"documentation/customers/tracking-usage",
"documentation/customers/balance-locking",
"documentation/customers/managing-balances",
"documentation/customers/feature-entities"
]
},
{
"group": "Additional Resources",
"pages": ["documentation/external-providers/revenuecat"]
"pages": [
"documentation/edge-cases",
"documentation/webhooks",
"documentation/external-providers/convex",
"documentation/external-providers/revenuecat",
"documentation/external-providers/vercel-marketplace"
]
}
]
},
@@ -96,15 +127,18 @@
"tab": "Examples",
"icon": "graduation-cap",
"pages": [
"examples/credits",
"examples/monetary-credits",
"examples/prepaid",
"examples/per-seat",
"examples/pay-as-you-go-overages",
"examples/entity-balances",
"examples/trial-card-required",
"examples/trial-card-not-required"
]
},
{
"tab": "React",
"icon": "image",
"icon": "react",
"groups": [
{
"group": "React Hooks",
@@ -122,9 +156,14 @@
}
]
},
{
"tab": "CLI",
"icon": "square-terminal",
"pages": ["cli/getting-started", "cli/config", "cli/commands"]
},
{
"tab": "API Reference",
"icon": "rectangle-terminal",
"icon": "display-code",
"groups": [
{
"group": "Billing",
@@ -145,7 +184,9 @@
"api-reference/core/check",
"api-reference/core/track",
"api-reference/balances/createBalance",
"api-reference/balances/updateBalance"
"api-reference/balances/updateBalance",
"api-reference/balances/deleteBalance",
"api-reference/balances/finalizeLock"
]
},
{
@@ -169,6 +210,7 @@
"pages": [
"api-reference/entities/getEntity",
"api-reference/entities/createEntity",
"api-reference/entities/updateEntity",
"api-reference/entities/deleteEntity"
]
},

View File

@@ -0,0 +1,168 @@
---
title: "Balances"
description: "Understanding how feature balances work in Autumn"
---
Balances determine what features a customer can use, and track how much they have used.
Balances are created in two ways:
1. **Automatically from plans**: When a plan is attached to a customer, each feature in the plan becomes a balance for that customer.
2. **Standalone via API**: You can create balances directly using the API, independent of any plan. See [Managing Balances](/documentation/customers/managing-balances) for details.
```mermaid
flowchart LR
F[Feature] -->|added to plan| PF[Plan Item]
PF -->|plan attached to customer| B[Customer Balance]
```
## Core Fields
Each balance has the following key fields:
| Field | Description |
|-------|-------------|
| `included_usage` | The amount granted by the plan, or a purchased quantity |
| `balance` | The remaining amount available |
| `usage` | The amount that has been consumed |
When you retrieve a customer, their balances will be included in the response.
<Tip>
For the complete balance schema including reset configuration, overage settings, and breakdown details, see the [Get Customer API reference](/api-reference/customers/get-customer).
</Tip>
<Expandable title="example customer response">
```json
{
"balances": {
"messages": {
"granted_balance": 1000,
"current_balance": 750,
"usage": 250,
"unlimited": false,
"reset": {
"interval": "month",
"resets_at": 1745193600000
}
},
"premium-support": {
"unlimited": true
}
}
}
```
</Expandable>
## Feature Types and Balances
When you create a feature, you define its type. This affects how balances behave.
### Consumable Features
Features that are used up and can be replenished. Examples: credits, API requests, AI tokens.
Consumable features support **reset intervals** - the balance resets to the granted amount on a regular schedule.
Available reset intervals:
- `hour`, `day`, `week`, `month`, `quarter`, `semi_annual`, `year`
- `one_off` - the balance never resets (useful for one-time grants or top-ups)
### Non-Consumable Features
Features with persistent, continuous usage. Examples: seats, workspaces, storage.
Non-consumable features don't reset. Instead, they support **proration** when quantities change mid-billing cycle.
### Credit Systems
A [credit system](/documentation/modelling-pricing/credit-systems) lets multiple features draw from a single shared balance.
When you check or track usage, you use the underlying feature ID (e.g., `premium_message`), but the balance is deducted from the credit system.
When you track usage for a feature in a credit system, Autumn:
1. Looks up the credit cost for that feature that you defined
2. Multiplies the usage value by the credit cost
3. Deducts from the credit system balance
For example, if you have a credit system with a credit cost of 2 credits per API request, and a customer uses 10 API requests, Autumn will deduct 20 credits from the balance.
## Positive and Negative Balances
A balance can be positive or negative:
- **Positive balance**: Customer has unused allowance remaining
- **Negative balance**: Customer has used more than their allowance (only possible if [overage](/documentation/concepts/plan-items#priced-features) is enabled)
<Note>
Features can only have a negative balance if they have a usage-based price that allows overage. Otherwise, tracking stops when balance reaches 0.
</Note>
## Balance Stacking
A single feature can have balances from multiple sources - different plans, add-ons, or standalone grants.
Autumn combines these into a single parent balance while tracking each source separately in a `breakdown` array, grouped by plan and interval.
> **Example** <br />
> A customer has a feature, `messages`, with the following balances:
> - Pro plan: 500 messages per month
> - Top-up add-on: 200 lifetime messages
>
> Their total available balance is 700 `messages`.
### The Breakdown Array
Each balance source is tracked separately in the `breakdown` array. This lets you see exactly where the balance came from and how much remains from each source.
```json expandable
{
"balances": {
"messages": {
"included_usage": 700,
"balance": 700,
"usage": 0,
"breakdown": [
{
"id": "ent_abc123",
"product_id": "pro",
"included_usage": 500,
"balance": 500,
"usage": 0,
"interval": "month",
"next_reset_at": 1745193600000
},
{
"id": "ent_def456",
"product_id": "top-up",
"included_usage": 200,
"balance": 200,
"usage": 0,
"interval": "one_off",
"next_reset_at": null
}
]
}
}
}
```
### Deduction Order
When usage is tracked, Autumn deducts from balances in a specific order based on their reset interval. **Shorter intervals are deducted first** by default.
The order is: `hour` (shortest) > `day` > `week` > `month` > `quarter` > `semi_annual` > `year` > `one_off` (lifetime - never resets).
This ensures that expiring balances are used before permanent ones.
<Note>
If you need the deduction order reversed (longest interval first), please [contact us](https://discord.gg/STqxY92zuS).
</Note>
> **Example** <br />
> Suppose a customer has two balances for messages: 500 monthly and 200 lifetime. They have a total of 700 messages. <br />
> - The customer uses 400 messages. The monthly balance (the shorter interval) is used up first, leaving 100 in monthly and 200 in lifetime (300 total). <br />
> - The customer uses another 200 messages. The remaining 100 monthly is depleted, and the next 100 is deducted from the lifetime balance. Now, monthly is 0, lifetime is 100 (100 total). <br />
> - On the next cycle, the monthly balance resets to 500, and the lifetime remains at 100, for a new total of 600. <br />

View File

@@ -1,5 +1,5 @@
---
title: "How features work"
title: "Features"
description: "Learn about features in Autumn and how to create them"
---
@@ -20,7 +20,7 @@ When adding features to a plan, you will be able to set reset cycles for `consum
Under the "advanced" section of the feature creation sheet, you can also define [event names](/documentation/customers/tracking-usage#using-event-names). This gives you more control over how events interact with customer balances in Autumn.
Metered features can each act as their own, standlone balance, or be added to a [credit system](/documentation/pricing/credits). This lets you define credit costs per feature, and let many features draw from a common credit balance.
Metered features can each act as their own, standlone balance, or be added to a [credit system](/documentation/modelling-pricing/credit-systems). This lets you define credit costs per feature, and let many features draw from a common credit balance.
## Boolean features

View File

@@ -0,0 +1,107 @@
---
title: "How It Works"
sidebarTitle: "Overview"
description: "How features, plans, subscriptions and balances fit together"
---
Autumn's data model has a clear pipeline: you define **features**, bundle them into **plans** with pricing, and when a plan is attached to a customer, it creates a **subscription** and provisions **balances** that you can check and track in real-time.
```mermaid actions={false}
%%{init: {'flowchart': {'padding': 6, 'nodeSpacing': 10, 'rankSpacing': 20, 'subGraphTitleMargin': {'top': 4, 'bottom': 12}}} }%%
flowchart LR
subgraph features["**Features**"]
F3["AI Credits"]:::credit
end
subgraph plan["**Plan**"]
direction TB
subgraph price["Price"]
P1["$200/year"]:::pricing
end
subgraph planItems["Plan items"]
PI1["200 AI credits/month"]:::credit
end
price ~~~ planItems
end
subgraph customer["**Customer**"]
direction TB
subgraph customerPlans["Subscription"]
C1["$200/year"]:::pricing
end
subgraph balances["Balances"]
B1["146/200 AI credits left"]:::credit
end
customerPlans ~~~ balances
end
features ~~~ plan ~~~ customer
classDef credit fill:#22c55e30,stroke:#22c55e
classDef pricing fill:#ec489930,stroke:#ec4899
style features fill:#eab30820,stroke:#eab308
style plan fill:#7c3aed10,stroke:#7c3aed
style customer fill:#0ea5e910,stroke:#0ea5e9
style price fill:none,stroke:none
style planItems fill:none,stroke:none
style customerPlans fill:none,stroke:none
style balances fill:none,stroke:none
```
## Features
Features represent the parts of your product you want to control access to. There are three types: **boolean** (on/off flags like premium analytics), **consumable** (usage that resets, like API requests or credits), and **non-consumable** (persistent quantities like seats or storage).
Features are the atomic building blocks — everything else is built on top of them.
<Card title="Features" icon="puzzle-piece" href="/documentation/concepts/features">
Learn about feature types and how to create them
</Card>
## Plans
Plans bundle features together with a base price. Each plan represents a distinct pricing tier or package you offer — free, pro, enterprise, or any add-on. You define which features are included, how they're priced, and any properties like trials or auto-enable.
<Card title="Plans" icon="layer-group" href="/documentation/concepts/plans">
Learn about plan pricing, properties and groups
</Card>
## Plan Items
When you add a feature to a plan, it becomes a **plan item** with its own configuration. Included items grant a usage amount at no extra cost. Priced items add billing — either prepaid or usage-based — with options for billing units, tiers, and proration.
Plan items are where the "what" (features) meets the "how much" (pricing).
<Card title="Plan Items" icon="sliders" href="/documentation/concepts/plan-items">
Configure grants, pricing and usage models
</Card>
## Subscriptions
When you attach a plan to a customer, Autumn creates a Stripe subscription under the hood and provisions balances for each feature in the plan. Subscriptions track status (active, trialing, past_due, etc.) and handle the payment lifecycle.
<Card title="Subscriptions" icon="arrows-repeat" href="/documentation/concepts/subscriptions">
How Autumn manages Stripe subscriptions
</Card>
## Balances
Balances are the customer-facing result of everything above. Each plan item becomes a balance that tracks what the customer has been granted, what they've used, and what remains. Balances from multiple sources (plans, add-ons, top-ups) stack together, with shorter-interval balances consumed first.
Your app interacts with Autumn primarily through balances — calling `/check` to gate access and `/track` to record usage.
<Card title="Balances" icon="scale-balanced" href="/documentation/concepts/balances">
Understand balance stacking, resets and deduction order
</Card>
## Runtime
Once your features, plans and pricing are configured, your app interacts with Autumn through four core endpoints.
- **[Customer](/api-reference/customers/getOrCreateCustomer)**: Idempotent get-or-create. Call on every login/signup and Autumn returns the existing customer or creates a new one. Aggregates subscriptions, balances, invoices and payment methods in a single response.
- **[Attach](/api-reference/billing/attach)**: Subscribe a customer to a plan or purchase a one-time product. Handles new subscriptions, upgrades, downgrades and add-ons, creating the Stripe subscription and provisioning balances automatically.
- **[Check](/api-reference/core/check)**: Feature gate. Returns whether a customer has access based on their active plans and remaining balance. Set `send_event` to atomically deduct usage while checking.
- **[Track](/api-reference/core/track)**: Record usage against a customer's balance. Each call decrements the remaining allowance for a feature, powering metered billing and usage limits.

View File

@@ -1,5 +1,5 @@
---
title: "Adding features to plans"
title: "Plan Items"
description: "Configure what customers get access to when they purchase a plan"
---
@@ -9,6 +9,8 @@ There are 2 types of plan features:
- **Included Features**: features provided at no additional cost, either as a granted usage limit or a boolean flag
- **Priced Features**: features that are billed for, either as a prepaid quantity or a usage-based price. Priced features can also have an included amount.
When a customer purchases a plan, the items in the plan become [balances](/documentation/concepts/balances) under the customer.
## Included Features

View File

@@ -1,5 +1,5 @@
---
title: "How plans work"
title: "Plans"
description: "Learn about plans in Autumn and how to create them"
---
@@ -17,7 +17,7 @@ When you create a plan, you can set its price:
## Plan Features
Plans are made up of a list of [features](/documentation/pricing/features). These can be:
Plans are made up of a list of [features](/documentation/concepts/features). These can be:
- **Included Features** - features that come with the plan for no additional cost. These can be boolean flags, or metered features with a limit.
- **Priced Features** - features that are billable based on usage of a feature. These can also have an included amount, and a prepaid or usage-based price.

Some files were not shown because too many files have changed in this diff Show More