Merge branch 'dev' into feature/usage-alerts-existing-threshold-migration-and-test

This commit is contained in:
John Yeo
2026-03-23 12:46:03 +00:00
153 changed files with 7972 additions and 2263 deletions

View File

@@ -3,68 +3,127 @@ title: "Vercel Marketplace"
description: "Set up the Vercel Marketplace integration in Autumn"
---
When you use Autumn, you can also add your product to [Vercel's Marketplace](https://vercel.com/marketplace) without any additional code. Follow these steps to connect the Autumn partner API and webhooks to Vercel.
Autumn lets you add your product to [Vercel's Marketplace](https://vercel.com/marketplace) without any additional code. This guide walks through connecting the Autumn partner API and webhooks to Vercel, and setting up Stripe's custom payment method for accurate billing.
<Note>
This is currently in beta, please reach out to us on [Discord](https://discord.gg/STqxY92zuS) or email us at hey@useautumn.com to get access.
This is currently in beta. Reach out on [Discord](https://discord.gg/STqxY92zuS) or email hey@useautumn.com to get access.
</Note>
## Prerequisites
- An Autumn account with a connected Stripe account
- A Vercel integration entry in the [Vercel Integration Console](https://vercel.com/dashboard/integrations)
## Setup
<Steps>
<Step>
### Step 1: Gather integration credentials and IDs
### Open the Integration Console
In your Vercel Integration Console, open your integration entry.
Navigate to the Vercel dashboard, then go to **Integrations → Browse Marketplace → Integration Console**.
- Copy the **Client Integration ID**.
- Copy the **Client Integration Secret**.
In Stripe:
- Open **Settings > Custom payment methods**.
- Create a custom payment method named **Vercel Marketplace**.
- Upload the Vercel logo.
- Copy the resulting **Custom Payment Method ID**.
In the Autumn Dashboard:
- Open **Developer > Vercel Webhook**.
- Add a new endpoint.
- Set the endpoint URL to your production API endpoint plus `/autumn/webhooks/vercel`.
- Confirm the router path is current before saving.
<Frame>
<img src="/images/vercel-marketplace/step-1-integrations-console.png" alt="Vercel Integration Console" />
</Frame>
</Step>
<Step>
### Step 2: Configure values in Autumn
### Copy your Vercel credentials
In the Autumn Dashboard, open **Developer > Vercel** and set:
Scroll to the bottom of the Integration Console page to find your **Client (Integration) ID** and **Client (Integration) Secret**. You'll need both of these for the Autumn dashboard.
- `Client Integration ID`
- `Client Integration Secret`
- `Custom Payment Method ID`
Also save the **Base URL** shown in this section.
<Frame>
<img src="/images/vercel-marketplace/step-2-credentials.png" alt="Vercel Client ID and Secret" />
</Frame>
</Step>
<Step>
### Step 3: Configure values in Vercel
### Copy the Base URL from Autumn
In the Vercel Integration Console, paste the Autumn **Base URL** into:
In the Autumn dashboard, open **Developer → Vercel**. Enter the Client ID and Client Secret from the previous step, then copy the **Base URL** that Autumn generates for you.
- **Partner API Base URL**
- **Webhooks URL**
This ensures Vercel can call Autumn when onboarding users and when key lifecycle events occur.
<Frame>
<img src="/images/vercel-marketplace/step-3-base-url.png" alt="Autumn Base URL" />
</Frame>
</Step>
<Step>
### Step 4: Validate the webhook
### Set the Webhook URL in Vercel
Confirm webhook delivery in both systems:
Back in the Vercel Integration Console, paste the Autumn Base URL into the **External Integration Settings → Webhook URL** field.
- Trigger a test event flow in Vercel.
- Verify Vercel receives callback requests for **key create/delete** events.
- Verify Autumn logs/receives the payload and updates API keys as expected.
<Frame>
<img src="/images/vercel-marketplace/step-4-webhook-url.png" alt="Vercel Webhook URL" />
</Frame>
</Step>
If events are not delivered, confirm the exact webhook route in your deployment (for example `.../autumn/webhooks/vercel`) and update both sides if it has changed.
<Step>
### Set the Marketplace Base URL in Vercel
Paste the same Autumn Base URL into **Marketplace Integration Settings → Base URL**.
<Frame>
<img src="/images/vercel-marketplace/step-5-marketplace-base-url.png" alt="Vercel Marketplace Base URL" />
</Frame>
</Step>
<Step>
### Create a Stripe custom payment method
Autumn uses Stripe's billing clock for accurate billing cycles and invoice reporting via the **Custom Payment Methods** system. In the Stripe dashboard, go to **Settings → Payments → Custom payment methods**, then click **Create a custom payment method**.
<Frame>
<img src="/images/vercel-marketplace/step-6-custom-payment-methods.png" alt="Stripe Custom Payment Methods" />
</Frame>
</Step>
<Step>
### Provide a custom name and icon
Select **Provide a custom name and icon** at the bottom of the payment method selection dialog.
<Frame>
<img src="/images/vercel-marketplace/step-7-provide-custom-name.png" alt="Provide custom name and icon" />
</Frame>
</Step>
<Step>
### Name the payment method
Enter **Vercel Marketplace** as the display name and upload the Vercel logo.
<Frame>
<img src="/images/vercel-marketplace/step-8-vercel-marketplace-name.png" alt="Vercel Marketplace payment method name and logo" />
</Frame>
</Step>
<Step>
### Copy the custom payment method ID
After creating the payment method, Stripe will display a `custom.type ID` (starting with `cpmt_`). Copy this ID and paste it into the **Custom Payment Method ID** field in the Autumn dashboard.
<Frame>
<img src="/images/vercel-marketplace/step-9-custom-payment-id.png" alt="Custom payment method ID" />
</Frame>
</Step>
<Step>
### Configure webhook events
Finally, set up the webhook endpoint for events that Autumn sends to your application. In the Autumn dashboard under **Developer → Vercel**, use the Svix webhook iframe to create a new endpoint. Set your endpoint URL and subscribe to all **Vercel** events. You can ignore the standalone "Webhook URL" setting and leave it empty — the Svix iframe handles this instead.
<Frame>
<img src="/images/vercel-marketplace/step-10-webhook-events.png" alt="Webhook event configuration" />
</Frame>
</Step>
</Steps>
## Validation
Once everything is configured, trigger a test event flow in Vercel to confirm end-to-end delivery. Verify that:
- Vercel receives callback requests for key lifecycle events
- Autumn logs the incoming payload and processes it correctly
- Your application receives the forwarded webhook events
If events aren't being delivered, double-check that the Base URL is identical in both the Webhook URL and Marketplace Base URL fields in Vercel.

View File

@@ -128,61 +128,6 @@ app.use(
import { autumnHandler } from "autumn-js/webStandard";
import { Elysia } from "elysia";
const app = new Elysia()
.mount(
autumnHandler({
identify: async (request) => {
// get the user from your auth provider (example: better-auth)
const session = await auth.api.getSession({
headers: request.headers,
});
return {
customerId: session?.user.id,
customerData: {
name: session?.user.name,
email: session?.user.email,
},
};
},
}),
)
.listen(3002);
```
```typescript Express
import express from "express";
import { autumnHandler } from "autumn-js/express";
const app = express();
// Body parser is required before the Autumn handler
app.use(express.json());
app.use(
"/api/autumn",
autumnHandler({
identify: async (req) => {
// get the user from your auth provider (example: better-auth)
const session = await auth.api.getSession({
headers: req.headers,
});
return {
customerId: session?.user.id,
customerData: {
name: session?.user.name,
email: session?.user.email,
},
};
},
}),
);
```
```typescript Elysia
import { autumnHandler } from "autumn-js/webStandard";
import { Elysia } from "elysia";
const app = new Elysia()
.mount(
autumnHandler({

View File

@@ -6,12 +6,12 @@ description: Automatically replenish customer balances when they run low
Auto top-ups automatically purchase additional balance for a customer when their usage drops below a configured threshold. This prevents service interruptions for customers who don't want to manually manage their balance.
> **Example** <br />
> A customer has 500 credits. When their balance drops below 100, Autumn automatically purchases 500 more credits using their saved payment method.
> A customer on the Standard plan gets 5,000 credits per month. When their balance drops below 500, Autumn automatically purchases 1,000 more credits at $10 using the plan's one-off prepaid price.
## Prerequisites
Auto top-ups require:
1. A [one-off prepaid plan](/documentation/modelling-pricing/one-off-purchases) (the top-up plan) that the customer has purchased at least once
1. A plan with a [one-off prepaid](/documentation/modelling-pricing/one-off-purchases) item for the feature you want to auto top-up
2. The customer must have a saved payment method on file
## Setting up
@@ -19,7 +19,7 @@ Auto top-ups require:
<Tabs>
<Tab title="CLI">
Auto top-ups are configured per customer, not in `autumn.config.ts`. First, create a top-up plan:
Auto top-ups are configured per customer, not in `autumn.config.ts`. Your plan needs a one-off prepaid item for the feature you want to auto top-up:
```ts autumn.config.ts
import { feature, item, plan } from 'atmn';
@@ -31,16 +31,21 @@ export const credits = feature({
consumable: true,
});
export const creditTopUp = plan({
id: 'credit_top_up',
name: 'Credit Top-Up',
addOn: true,
export const standard = plan({
id: 'standard',
name: 'Standard',
price: { amount: 50, interval: 'month' },
items: [
item({
featureId: credits.id,
included: 5000,
reset: { interval: 'month' },
}),
item({
featureId: credits.id,
price: {
amount: 10,
billingUnits: 500,
billingUnits: 1000,
interval: 'one_off',
billingMethod: 'prepaid',
},
@@ -49,17 +54,21 @@ export const creditTopUp = plan({
});
```
Then configure auto top-ups per customer via the API (see below).
The one-off prepaid item (`$10 per 1,000 credits`) is what Autumn uses to replenish the balance. Configure auto top-ups per customer via the API (see below).
</Tab>
<Tab title="Dashboard">
1. Navigate to the **Customers** page
2. Click on a customer
3. Under their balance for a feature, configure **Auto Top-Up**:
- **Threshold**: the balance level that triggers a top-up
- **Quantity**: how many units to purchase each time
4. The customer must have a saved payment method and have previously purchased a prepaid top-up plan for that feature
1. Navigate to the **Plans** page and select (or create) the plan you want to add auto top-ups to
2. Add a new item for the feature with:
- **Interval** set to **One-Off**
- **Billing method** set to **Prepaid**
- Configure the price and billing units (e.g. $10 per 1,000 credits)
3. Configure auto top-ups per customer via the API (see below)
<Note>
The same feature can appear as multiple items on a plan. For example, you might have a monthly allowance of 5,000 credits **and** a one-off prepaid item for top-ups — both referencing the same feature.
</Note>
</Tab>
</Tabs>
@@ -81,8 +90,8 @@ await autumn.customers.update({
autoTopups: [{
featureId: "credits",
enabled: true,
threshold: 100,
quantity: 500,
threshold: 500,
quantity: 1000,
}],
},
});
@@ -99,8 +108,8 @@ await autumn.customers.update(
"auto_topups": [{
"feature_id": "credits",
"enabled": True,
"threshold": 100,
"quantity": 500,
"threshold": 500,
"quantity": 1000,
}],
},
)
@@ -116,8 +125,8 @@ curl -X POST "https://api.useautumn.com/v1/customers/update" \
"auto_topups": [{
"feature_id": "credits",
"enabled": true,
"threshold": 100,
"quantity": 500
"threshold": 500,
"quantity": 1000
}]
}
}'
@@ -155,7 +164,7 @@ This limits the customer to 5 auto top-ups per month. Supported intervals: `hour
1. After every usage event (via `track`), Autumn checks the customer's remaining balance
2. If the balance falls below the configured `threshold`, an auto top-up is triggered
3. Autumn creates an invoice for the configured `quantity` using the existing prepaid top-up plan
3. Autumn creates an invoice for the configured `quantity` using the one-off prepaid price from the customer's plan
4. The invoice is charged to the customer's saved payment method
5. The balance is replenished with the purchased amount

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 335 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

2399
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -13,6 +13,7 @@ COPY scripts/package.json ./scripts/package.json
COPY apps/checkout/package.json ./apps/checkout/package.json
COPY packages/autumn-js/package.json ./packages/autumn-js/package.json
COPY packages/atmn/package.json ./packages/atmn/package.json
COPY packages/atmn-tests/package.json ./packages/atmn-tests/package.json
COPY packages/openapi/package.json ./packages/openapi/package.json
COPY packages/ksuid/package.json ./packages/ksuid/package.json
COPY packages/sdk/package.json ./packages/sdk/package.json

View File

@@ -11,6 +11,7 @@
"apps/docs",
"apps/sdk-test",
"packages/atmn",
"packages/atmn-tests",
"packages/sdk",
"packages/autumn-js",
"packages/openapi",
@@ -67,6 +68,7 @@
"t": "infisical run --env=dev -- bun scripts/testScripts/testDispatcher.ts",
"cm": "cd server && bun cm",
"d": "lsof -ti:8080 -ti:3000 | xargs kill -9 2>/dev/null || true; ENV_FILE=.env infisical run --env=dev -- bun scripts/dev.ts",
"d:prod": "lsof -ti:8080 -ti:3000 | xargs kill -9 2>/dev/null || true; ENV_FILE=.env infisical run --env=dev -- bun scripts/dev.ts --production",
"dx": "bun scripts/dx.ts",
"d:test": "lsof -ti:8080 -ti:3000 | xargs kill -9 2>/dev/null || true; ENV_FILE=.env infisical run --env=test -- bun scripts/dev.ts",
"p": "ENV_FILE=.env.prod infisical run --env=prod -- bun scripts/dev.ts",

View File

@@ -0,0 +1,513 @@
---
name: autumn-billing-page
description: |
Build a billing page and manage subscriptions with Autumn.
Use this skill when the user wants to:
- Display active plans or subscription status
- Show usage balances to customers
- Build a pricing page with upgrade/downgrade buttons
- Implement plan switching (upgrades/downgrades)
- Add cancel/uncancel subscription functionality
- Open the Stripe billing portal
- Display usage history charts
- Add prepaid top-ups or credit purchases
---
# Build Your Billing Page
Software applications typically ship with a billing page. This allows customers to change plan, cancel subscription and view their usage.
> Your Autumn configuration is in `autumn.config.ts`. If it doesn't exist, run `npx atmn init` to log in and generate the file.
## Step 1: Detect Integration Type
Check if the codebase already has Autumn set up:
- If there's an `AutumnProvider` and `autumnHandler` mounted: **Path A: React**
- If there's just an `Autumn` client initialized: **Path B: Backend SDK**
Before implementing:
1. Tell the user which path you'll follow before proceeding.
2. Tell them you will be building billing page components, and ask for any guidance or input.
---
## Active Plans
Display the plan the user is currently on. Users can have multiple active subscriptions and purchases (e.g., main plan and add-ons).
- **`subscriptions`** - Free and paid recurring plans
- **`purchases`** - One-off plans (e.g., credit top-ups)
### React
```tsx
import { useCustomer } from "autumn-js/react";
const { data: customer } = useCustomer();
const active = customer?.subscriptions.filter(
(sub) => sub.status === "active"
);
console.log(active?.map((sub) => sub.planId).join(", "));
```
### TypeScript
```typescript
import { Autumn } from "autumn-js";
const autumn = new Autumn({ secretKey: "am_sk_test_xxx" });
const customer = await autumn.customers.getOrCreate({
customerId: "user_123",
});
const active = customer.subscriptions?.filter(
(sub) => sub.status === "active"
);
console.log(active?.map((sub) => sub.planId).join(", "));
```
### Python
```python
from autumn_sdk import Autumn
autumn = Autumn("am_sk_test_xxx")
customer = await autumn.customers.get_or_create(
customer_id="user_123"
)
active = [s for s in customer.subscriptions if s.status == "active"]
print([s.plan_id for s in active])
```
---
## Usage Balances
Metered features have `granted`, `usage`, and `remaining` fields. Use these to display current usage and remaining balance.
### React
```tsx
import { useCustomer } from "autumn-js/react";
const { data: customer, refetch } = useCustomer();
const messages = customer?.balances.messages;
console.log(\`\${messages?.remaining} / \${messages?.granted}\`);
// After tracking usage or changing plans, call refetch() to update balances
await refetch();
```
### TypeScript
```typescript
const customer = await autumn.customers.getOrCreate({
customerId: "user_123",
});
const messages = customer.balances?.messages;
console.log(\`\${messages?.remaining} / \${messages?.granted}\`);
```
### Python
```python
customer = await autumn.customers.get_or_create(
customer_id="user_123"
)
messages = customer.balances.get("messages")
print(f"{messages.remaining} / {messages.granted}")
```
---
## Customer Eligibility
When building a pricing page, you need to know what each plan means for the current customer -- is it an upgrade, a downgrade, or their current plan? Is a free trial available?
Pass a `customerId` when listing plans and each plan will include a `customerEligibility` object:
- **`attachAction`** -- What happens when this plan is attached: `"activate"`, `"upgrade"`, `"downgrade"`, `"purchase"`, or `"none"`
- **`status`** -- The customer's current relationship to this plan: `"active"`, `"scheduled"`, or undefined if none
- **`trialAvailable`** -- Whether the customer is eligible for the plan's free trial
### React
```tsx
import { useListPlans, useCustomer } from "autumn-js/react";
const labels = {
activate: "Subscribe",
upgrade: "Upgrade",
downgrade: "Downgrade",
purchase: "Purchase",
};
const getLabel = (eligibility) => {
if (eligibility?.attachAction === "none") {
return eligibility.status === "scheduled" ? "Plan Scheduled" : "Current plan";
}
if (labels[eligibility?.attachAction]) {
return labels[eligibility.attachAction];
}
return "Get started";
};
export default function PricingPage() {
const { data: plans } = useListPlans();
const { attach } = useCustomer();
return plans?.map((plan) => (
<button
key={plan.id}
disabled={plan.customerEligibility?.attachAction === "none"}
onClick={() => attach({ planId: plan.id, redirectMode: "always" })}
>
{getLabel(plan.customerEligibility)}
</button>
));
}
```
The React `useListPlans` hook automatically includes customer context from `AutumnProvider`, so `customerEligibility` is populated on every plan without extra configuration.
### TypeScript
```typescript
const { list: plans } = await autumn.plans.list({
customerId: "user_123",
});
for (const plan of plans) {
console.log(plan.name, plan.customerEligibility?.attachAction);
// e.g. "Free" "downgrade", "Pro" "none", "Enterprise" "upgrade"
}
```
### Python
```python
plans = await autumn.plans.list(customer_id="user_123")
for plan in plans.list:
print(plan.name, plan.customer_eligibility.attach_action)
```
---
## Switching Plans
Use `attach` to switch between plans. This handles upgrades, downgrades, and new subscriptions.
### React
```tsx
import { useCustomer } from "autumn-js/react";
export default function UpgradeButton() {
const { attach } = useCustomer();
return (
<button onClick={() => attach({ planId: "pro" })}>
Upgrade to Pro
</button>
);
}
```
### TypeScript
```typescript
const response = await autumn.billing.attach({
customerId: "user_123",
planId: "pro",
});
redirect(response.paymentUrl);
```
### Python
```python
response = await autumn.billing.attach(
customer_id="user_123",
plan_id="pro",
)
# Redirect to response.payment_url
```
---
## Cancelling a Plan
Cancel a subscription using `billing.update` with a `cancelAction`.
### React
```tsx
import { useCustomer } from "autumn-js/react";
const { updateSubscription } = useCustomer();
// Cancel at end of billing cycle
await updateSubscription({
planId: "pro",
cancelAction: "cancel_end_of_cycle",
});
```
### TypeScript
```typescript
await autumn.billing.update({
customerId: "user_123",
planId: "pro",
cancelAction: "cancel_end_of_cycle",
});
```
### Python
```python
await autumn.billing.update(
customer_id="user_123",
plan_id="pro",
cancel_action="cancel_end_of_cycle",
)
```
---
## Uncancelling a Plan
If a subscription has a pending cancellation (when `canceledAt` is not null while the subscription is still `active`), you can reverse it:
### React
```tsx
import { useCustomer } from "autumn-js/react";
export default function BillingPage() {
const { data: customer, updateSubscription } = useCustomer();
const cancellingSub = customer?.subscriptions.find(
(sub) => sub.status === "active" && sub.canceledAt !== null
);
return cancellingSub ? (
<button onClick={() => updateSubscription({
planId: cancellingSub.planId,
cancelAction: "uncancel"
})}>
Keep plan
</button>
) : null;
}
```
### TypeScript
```typescript
const customer = await autumn.customers.getOrCreate({
customerId: "user_123",
});
const cancellingSub = customer.subscriptions?.find(
(sub) => sub.status === "active" && sub.canceledAt !== null
);
if (cancellingSub) {
await autumn.billing.update({
customerId: "user_123",
planId: cancellingSub.planId,
cancelAction: "uncancel",
});
}
```
### Python
```python
customer = await autumn.customers.get_or_create(
customer_id="user_123"
)
cancelling_sub = next(
(s for s in customer.subscriptions
if s.status == "active" and s.canceled_at is not None),
None,
)
if cancelling_sub:
await autumn.billing.update(
customer_id="user_123",
plan_id=cancelling_sub.plan_id,
cancel_action="uncancel",
)
```
---
## Stripe Billing Portal
The Stripe billing portal lets users manage their payment method, view past invoices, and cancel their plan. Enable the billing portal in your Stripe settings first.
### React
```tsx
import { useCustomer } from "autumn-js/react";
const { openCustomerPortal } = useCustomer();
await openCustomerPortal({
returnUrl: "https://your-app.com/billing"
});
```
### TypeScript
```typescript
const { url } = await autumn.billing.openCustomerPortal({
customerId: "user_123",
returnUrl: "https://your-app.com/billing",
});
redirect(url);
```
### Python
```python
response = await autumn.billing.open_customer_portal(
customer_id="user_123",
return_url="https://your-app.com/billing",
)
# Redirect to response.url
```
---
## Usage History Chart
Autumn provides aggregate time series queries for usage data. Pass the response to a charting library like Recharts.
### React
```tsx
import { useAggregateEvents } from "autumn-js/react";
const { list, total } = useAggregateEvents({
featureId: "messages",
range: "30d",
});
// list: [{ period: 1234567890, values: { messages: 42 } }, ...]
// total: { messages: { count: 100, sum: 500 } }
```
### TypeScript
```typescript
const { list, total } = await autumn.events.aggregate({
customerId: "user_123",
featureId: "messages",
range: "30d",
});
```
### Python
```python
response = await autumn.events.aggregate(
customer_id="user_123",
feature_id="messages",
range="30d",
)
# response.list, response.total
```
---
## Prepaid Top-ups Reference
Let customers purchase prepaid packages and top-ups. If a user hits a usage limit, they may be willing to purchase a top-up. These are typically one-time purchases that grant a fixed amount of usage.
### React
```tsx
import { useCustomer } from "autumn-js/react";
export default function TopUpButton() {
const { attach } = useCustomer();
return (
<button
onClick={async () => {
await attach({
planId: "top_up",
options: [{
featureId: "messages",
quantity: 200,
}],
});
}}
>
Buy More Messages
</button>
);
}
```
### TypeScript
```typescript
const response = await autumn.billing.attach({
customerId: "user_or_org_id_from_auth",
planId: "top_up",
options: [{
featureId: "messages",
quantity: 200,
}],
});
if (response.paymentUrl) {
redirect(response.paymentUrl);
}
```
### Python
```python
response = await autumn.billing.attach(
customer_id="user_or_org_id_from_auth",
plan_id="top_up",
options=[{
"feature_id": "messages",
"quantity": 200,
}],
)
```
---
## Important Notes
- This handles all upgrades, downgrades, renewals, and uncancellations automatically
- Plan IDs come from the Autumn configuration
- Your Autumn configuration is in `autumn.config.ts` in your project root
**Docs:** https://docs.useautumn.com/llms.txt

View File

@@ -0,0 +1,261 @@
---
name: autumn-gating
description: |
Add usage tracking and feature gating with the Autumn SDK.
Use this skill when asked to:
- Add usage tracking or metering
- Implement feature limits or gating
- Check feature access or entitlements
- Track API calls, messages, or other usage
- Implement credit systems
- Add paywalls or upgrade prompts
- Enforce usage limits server-side
---
# Checking and Tracking Usage
Autumn handles your customer's payments and grants them the features defined in your plan configuration. There are 2 functions you need to enforce limits and gating:
- `check` for feature access, before allowing a user to do something
- `track` the usage in Autumn afterwards (if needed)
> Your Autumn configuration is in `autumn.config.ts`. If it doesn't exist, run `npx atmn init` to log in and generate the file.
## Step 1: Detect Integration Type
Check if the codebase already has Autumn set up:
- If there's an `AutumnProvider` and `autumnHandler` mounted -> **React hooks available** (can use for UX)
- Backend SDK should **always** be used to enforce limits server-side
Report what you detected before proceeding.
---
## Checking Feature Access
Check if a user has enough remaining balance before executing an action. The `feature_id` used here is defined by you when you create the feature in Autumn.
### Backend Check (Required for Security)
**Always check on the backend** before executing any protected action. Frontend checks can be bypassed.
**TypeScript:**
```typescript
import { Autumn } from "autumn-js";
const autumn = new Autumn({
secretKey: process.env.AUTUMN_SECRET_KEY,
});
const { allowed } = await autumn.check({
customerId: "user_or_org_id_from_auth",
featureId: "messages",
requiredBalance: 1,
});
if (!allowed) {
console.log("User has run out of messages");
return;
}
```
**Python:**
```python
from autumn_sdk import Autumn
autumn = Autumn('am_sk_test_xxx')
response = await autumn.check(
customer_id="user_or_org_id_from_auth",
feature_id="messages",
required_balance=1,
)
if not response.allowed:
raise HTTPException(status_code=403, detail="Usage limit reached")
```
**cURL:**
```bash
curl -X POST 'https://api.useautumn.com/v1/check' \
-H 'Authorization: Bearer am_sk_test_xxx' \
-H 'Content-Type: application/json' \
-d '{
"customer_id": "user_or_org_id_from_auth",
"feature_id": "messages",
"required_balance": 1
}'
```
You can also use `check` to gate boolean features (non-metered features), such as access to "premium AI models".
### Frontend Check (React Hooks - UX Only)
When using React hooks, you have access to the customer object which you can use to display billing data. You can use the client-side `check` function to gate features and show paywalls. Permissions are determined by reading the local `data` state, so no call to Autumn's API is made.
```tsx
import { useCustomer } from "autumn-js/react";
export function SendChatMessage() {
const { check, refetch } = useCustomer();
const handleSendMessage = async () => {
const { allowed } = check({ featureId: "messages" });
if (!allowed) {
alert("You're out of messages");
} else {
// Send chatbot message
// Then refresh customer usage data
await refetch();
}
};
}
```
---
## Tracking Usage
After the user has successfully used a feature, record the usage in Autumn. This will decrement their balance.
**TypeScript:**
```typescript
await autumn.track({
customerId: "user_or_org_id_from_auth",
featureId: "messages",
value: 1,
});
```
**Python:**
```python
await autumn.track(
customer_id="user_or_org_id_from_auth",
feature_id="messages",
value=1,
)
```
**cURL:**
```bash
curl -X POST 'https://api.useautumn.com/v1/track' \
-H 'Authorization: Bearer am_sk_test_xxx' \
-H 'Content-Type: application/json' \
-d '{
"customer_id": "user_or_org_id_from_auth",
"feature_id": "messages",
"value": 1
}'
```
You should always handle access checks and usage tracking server-side for security. Users can manipulate client-side code using devtools.
---
## Key Concepts
- **Frontend checks** = UX (show/hide UI, display limits) - can be bypassed by users
- **Backend checks** = Security (enforce limits) - required before any protected action
- **Pattern**: check -> do work -> track (only track after successful completion)
- Feature IDs come from the Autumn configuration
- Current usage and total limit are available from the Customer object
---
## Credit Systems Reference
Grant users a currency-based balance of credits that various features can draw from. When you have multiple features that cost different amounts, use a credit system to deduct usage from a single balance.
### Example Case
AI chatbot product with 2 different models:
- Basic message: $1 per 100 messages
- Premium message: $10 per 100 messages
Plans:
- Free tier: $5 credits per month for free
- Pro tier: $10 credits per month, at $10 per month
### Checking Access with Credits
The `required_balance` parameter converts the number of messages to credits. For example, passing `required_balance: 5` for basic messages returns `allowed: true` if the user has at least 0.05 USD credits remaining.
**Important:** Interact with the underlying features (`basic_messages`, `premium_messages`) - not the credit system directly.
#### React
```tsx
import { useCustomer } from "autumn-js/react";
export function CheckBasicMessage() {
const { check, refetch } = useCustomer();
const handleCheckAccess = async () => {
const { allowed } = check({ featureId: "basic_messages", requiredBalance: 1 });
if (!allowed) {
alert("You've run out of basic message credits");
} else {
// proceed with sending message
await refetch();
}
};
}
```
#### TypeScript
```typescript
const { allowed } = await autumn.check({
customerId: "user_or_org_id_from_auth",
featureId: "basic_messages",
requiredBalance: 1,
});
if (!allowed) {
console.log("User has run out of basic message credits");
return;
}
```
#### Python
```python
response = await autumn.check(
customer_id="user_or_org_id_from_auth",
feature_id="basic_messages",
required_balance=1,
)
if not response.allowed:
print("User has run out of basic message credits")
return
```
### Tracking Usage with Credits
```typescript
await autumn.track({
customerId: "user_or_org_id_from_auth",
featureId: "basic_messages",
value: 2,
});
```
This uses 2 basic messages, which costs 0.02 USD credits.
---
**Note:** Autumn configuration is typically in `autumn.config.ts` in the project root.
**Docs:** https://docs.useautumn.com/llms.txt

View File

@@ -0,0 +1,500 @@
---
name: autumn-modelling-pricing-plans
description: |
Helps design pricing models for Autumn using the autumn.config.ts configuration file.
Use this skill when:
- Designing pricing tiers, plans, or features for Autumn
- Creating autumn.config.ts configuration
- Setting up usage-based, subscription, or credit-based pricing
- Configuring features like API calls, seats, storage, or credits
- Understanding Autumn feature types (metered, boolean, credit_system)
- Working with plan items, metered billing, or tiered pricing
---
# Autumn Pricing Model Design
This guide helps you design your pricing model for Autumn. Autumn uses a configuration file (`autumn.config.ts`) to define your features and plans.
> **Before starting:** Check for an `autumn.config.ts` in the project root. If it doesn't exist, run `npx atmn init` to log in and generate the file. If you already have a config you want to modify, run `atmn pull` to sync it from Autumn first.
## Step 1: Understand Your Pricing Needs
Before building, consider:
1. What features do you want to offer? (API calls, seats, storage, etc.)
2. What plans do you want? (Free, Pro, etc.)
3. How should usage be measured and limited?
---
## Features
Features define what can be gated, metered, or billed in your app.
### `feature(config)`
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `id` | string | Yes | Unique identifier used in API calls (`check`, `track`, etc). |
| `name` | string | Yes | Display name shown in the dashboard and billing UI. |
| `type` | enum | Yes | `"boolean"` \| `"metered"` \| `"credit_system"` |
| `consumable` | boolean | For metered | `true` = consumed (messages, API calls), `false` = ongoing (seats, storage). |
| `eventNames` | string[] | No | Event names that trigger this feature. Allows multiple features to respond to a single event. |
| `creditSchema` | array | For credit_system | Maps metered features to credit costs. Each entry: `{ meteredFeatureId, creditCost }`. |
### Feature Types
**Boolean** -- simple on/off flag:
```typescript
export const sso = feature({
id: 'sso',
name: 'SSO Authentication',
type: 'boolean',
});
```
**Metered, consumable** -- used up and replenished (messages, API calls):
```typescript
export const messages = feature({
id: 'messages',
name: 'Messages',
type: 'metered',
consumable: true,
});
```
**Metered, non-consumable** -- ongoing usage (seats, storage):
```typescript
export const seats = feature({
id: 'seats',
name: 'Seats',
type: 'metered',
consumable: false,
});
```
**Credit system** -- maps multiple metered features to credit costs:
```typescript
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 },
],
});
```
If you set the price per credit to 1 cent, credits become monetary credits (eg, 5 credits = $0.05 per premium message).
---
## Plans
Plans combine features with pricing to create your subscription tiers, add-ons, and top-ups.
### `plan(config)`
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `id` | string | Yes | Unique identifier used in checkout and subscription APIs. |
| `name` | string | Yes | Display name shown in pricing tables and billing. |
| `price` | object | No | Base subscription price: `{ amount, interval }`. |
| `items` | array | No | Array of `item()` objects defining what's included. |
| `autoEnable` | boolean | No | Automatically assign to new customers. Typically used for free plans. |
| `addOn` | boolean | No | Allow purchase alongside other plans (instead of replacing them). |
| `freeTrial` | object | No | `{ durationLength, durationType, cardRequired }`. |
| `group` | string | No | Group related plans. Plans in the same group replace each other on upgrade/downgrade. |
Price intervals: `"month"` | `"quarter"` | `"semi_annual"` | `"year"` | `"one_off"`
Trial duration types: `"day"` | `"month"` | `"year"`
---
## Plan Items
Plan items define what each plan includes -- usage limits, pricing, and billing behavior.
### `item(config)`
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `featureId` | string | Yes | The `id` of the feature to include. |
| `included` | number | No | Amount included for free. Omit for boolean features. |
| `unlimited` | boolean | No | Grant unlimited usage of this feature. |
| `reset` | object | No | How often the included amount resets: `{ interval, intervalCount? }`. |
| `price` | object | No | Pricing for usage beyond the included amount. |
| `proration` | object | No | Mid-cycle changes: `{ onIncrease, onDecrease }`. |
| `rollover` | object | No | Carry unused balance: `{ max, expiryDurationType, expiryDurationLength }`. |
Reset intervals: `"hour"` | `"day"` | `"week"` | `"month"` | `"quarter"` | `"semi_annual"` | `"year"`
Proration options:
- `onIncrease`: `"prorate"` | `"charge_immediately"`
- `onDecrease`: `"prorate"` | `"refund_immediately"` | `"no_action"`
---
## Pricing Patterns
The `price` object on a plan item supports different billing models.
### Usage-based -- charge based on actual usage
```typescript
item({
featureId: seats.id,
included: 5,
price: {
amount: 10,
interval: 'month',
billingMethod: 'usage_based',
billingUnits: 1,
},
})
```
### Prepaid -- customer buys a fixed quantity upfront
```typescript
item({
featureId: credits.id,
price: {
amount: 5,
billingUnits: 100,
billingMethod: 'prepaid',
},
})
```
### Tiered -- price changes based on usage volume
```typescript
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 Reference
| Param | Type | Description |
|-------|------|-------------|
| `amount` | number | Price per `billingUnits`. Mutually exclusive with `tiers`. |
| `tiers` | array | Tiered pricing. Each entry: `{ to: number \| "inf", amount }`. Mutually exclusive with `amount`. |
| `billingMethod` | enum | `"usage_based"` \| `"prepaid"`. Required. |
| `interval` | enum | `"week"` \| `"month"` \| `"quarter"` \| `"semi_annual"` \| `"year"`. Omit for one-time charges. |
| `billingUnits` | number | Units per price (default 1). Eg, $5 per 100 credits = `amount: 5, billingUnits: 100`. |
| `maxPurchase` | number | Maximum quantity that can be purchased. |
---
## Common Patterns
### Free Plan with Usage Limits
```typescript
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,
}),
],
});
```
### Paid Plan with Flat Fee + Overage
```typescript
export const pro = plan({
id: 'pro',
name: 'Pro',
price: { amount: 20, interval: 'month' },
items: [
item({
featureId: messages.id,
included: 1000,
reset: { interval: 'month' },
price: {
amount: 0.01,
interval: 'month',
billingMethod: 'usage_based',
},
}),
],
});
```
### Per-Unit Pricing (e.g., per seat)
For any "per-X" pricing (like "$Y per seat"), use a base fee + unit allocation:
```typescript
export const team = plan({
id: 'team',
name: 'Team',
price: { amount: 10, interval: 'month' },
items: [
item({
featureId: seats.id,
included: 1,
price: {
amount: 10,
interval: 'month',
billingMethod: 'usage_based',
billingUnits: 1,
},
}),
],
});
```
This creates: $10/month base price that includes 1 seat, then $10 per additional seat.
### Plan with Free Trial
```typescript
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: sso.id }),
],
});
```
### Add-on / Top-up (One-time Prepaid)
```typescript
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',
},
}),
],
});
```
### Annual Plan Variant
For annual variants, create a separate plan with annual price interval:
```typescript
export const proAnnual = plan({
id: 'pro_annual',
name: 'Pro - Annual',
group: 'pro',
price: { amount: 192, interval: 'year' },
items: [
item({ featureId: messages.id, included: 1000, reset: { interval: 'month' } }),
],
});
```
---
## Full Example
A complete config with a free plan, a paid plan with a trial, and a credits top-up add-on:
```typescript
// 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',
},
}),
],
});
```
---
## Guidelines
### Start Simple
- If the user describes more than 3 features, start with the 3 most important (prioritize metered features) and ask them to confirm before adding more
- Inform them you kept it simple to start with, but they can add more later
### Disambiguate Pricing Model
- When the user mentions a price for a feature, ask whether it should be **usage-based** (pay as you go, billed at the end of the cycle) or **prepaid** (buy a fixed quantity upfront)
- Don't assume one or the other without asking
### Don't Fabricate Capabilities
- If the user asks about pricing or functionality you're not sure Autumn supports, do NOT make it up or assume it can be done
- Point them to Discord (https://discord.gg/atmn) or docs (https://docs.useautumn.com/llms.txt) instead
### Naming Conventions
- Feature and plan IDs should be lowercase with underscores (e.g., `pro_plan`, `chat_messages`)
### Features vs Plan Items
- Features define WHAT can be tracked (e.g., "credits")
- Plan items define HOW a feature is granted in a plan (recurring, one-time, free, paid)
- Never create duplicate features for the same underlying resource
- Example: "monthly tokens" and "one-time tokens" should be the SAME feature ("tokens"), referenced by different plan items with different intervals
### Default Plans
- **Never** set `autoEnable: true` for plans with prices
- Default plans must be free
### Enterprise Plans
- Ignore "Enterprise" plans with custom pricing in the config
- Custom plans can be created per-customer in the Autumn dashboard
### Currency
- Currency can be changed in the Autumn dashboard under Developer > Stripe
## Previewing and Pushing Changes
After updating `autumn.config.ts`:
1. **Preview first**: Run `atmn preview` to lint, validate and preview your plans. Show the output to the user so they can review the full configuration.
2. **Get confirmation**: Ask the user to review, edit, and confirm the preview output before pushing. Do NOT push until the user explicitly confirms.
3. **Push**: Once the user is happy, run `atmn push` to sync the configuration to Autumn.
4. Test in sandbox mode before going live. You can push to production with `atmn push -p`.
## Resources
- Discord support: https://discord.gg/atmn (very responsive)
- Documentation: https://docs.useautumn.com
- LLM-friendly docs: https://docs.useautumn.com/llms.txt

View File

@@ -0,0 +1,339 @@
---
name: autumn-setup
description: |
Sets up Autumn billing integration: installs the SDK, creates a customer, and adds the payment flow.
Use this skill when the user wants to:
- Set up Autumn billing
- Create an Autumn customer
- Integrate Autumn into their app
- Add billing/entitlements with Autumn
- Configure Autumn SDK
- Add payment flow or checkout
---
# Set up Autumn Billing
Autumn is a billing and entitlements layer over Stripe. This skill walks through installing the SDK, creating an Autumn customer, and wiring up the payment flow.
> **Before starting:** Check for an `autumn.config.ts` in the project root. If it doesn't exist, run `npx atmn init` to log in and generate the file (this saves your API key and syncs your config). Then refer to `autumn.config.ts` for your product and feature IDs.
## Step 1: Analyze the Codebase
Before making changes, detect:
- **Language**: TypeScript/JavaScript, Python, or other
- **If TS/JS - Framework**: Next.js, Hono, or other
- **If TS/JS - React frontend?**: Check for React in package.json
- **Customer model**: Look at the auth setup to determine whether customers map to individual users or organizations. Check for org/team/workspace models in the codebase.
If it's clear from the codebase (e.g., there's an org model and team-based auth), state your assumption. If it's ambiguous, ask the user:
> **Should Autumn customers be individual users, or organizations?**
> - **Users (B2C)**: Each user has their own plan and limits
> - **Organizations (B2B)**: Plans and limits are shared across an org
## Step 2: Create a Plan and Confirm
Before writing any integration code, present a short plan to the user covering:
- What stack/framework you detected
- Whether customers are users or orgs (and why you think so)
- Which path you're following (React fullstack vs backend-only)
- Which files you'll create or modify
- Where the handler / provider / customer creation will go
- Where the payment flow will be wired up
Ask the user to **read, edit, and confirm** the plan before proceeding. Do NOT start coding until the user approves.
---
## Path A: React + Node.js (Fullstack TypeScript)
Use this path if there's a React frontend with a Node.js backend.
### A1. Install the SDK
Use the package manager already installed (npm, yarn, pnpm, bun):
```bash
npm install autumn-js
```
### A2. Mount the Handler (Server-Side)
This creates endpoints at `/api/autumn/*` that the React hooks will call. The `identify` function should return either the user ID or org ID from your auth provider, depending on how you're using Autumn.
#### Next.js (App Router)
```typescript
// app/api/autumn/[...all]/route.ts
import { autumnHandler } from "autumn-js/next";
export const { GET, POST } = autumnHandler({
identify: async (request) => {
// Get user/org from your auth provider
const session = await auth.api.getSession({ headers: request.headers });
return {
customerId: session?.user.id, // or session?.org.id for B2B
customerData: {
name: session?.user.name,
email: session?.user.email,
},
};
},
});
```
#### Hono
```typescript
import { autumnHandler } from "autumn-js/hono";
app.use("/api/autumn/*", autumnHandler({
identify: async (c) => {
const session = await auth.api.getSession({ headers: c.req.raw.headers });
return {
customerId: session?.user.id, // or session?.org.id for B2B
customerData: { name: session?.user.name, email: session?.user.email },
};
},
}));
```
#### Other Frameworks (Generic Handler)
For any framework not listed above, use the generic handler:
```typescript
import { autumnHandler } from "autumn-js/backend";
// Mount this handler onto the /api/autumn/* path in your backend
const handleRequest = async (request) => {
const session = await auth.api.getSession({ headers: request.headers });
let body = null;
if (request.method !== "GET") {
body = await request.json();
}
const { statusCode, response } = await autumnHandler({
customerId: session?.user.id,
customerData: {
name: session?.user.name,
email: session?.user.email,
},
request: {
url: request.url,
method: request.method,
body: body,
},
});
return new Response(JSON.stringify(response), {
status: statusCode,
headers: { "Content-Type": "application/json" },
});
};
```
### A3. Add the Provider (Client-Side)
Wrap your app with `AutumnProvider`:
```tsx
import { AutumnProvider } from "autumn-js/react";
export default function RootLayout({ children }) {
return (
<AutumnProvider>
{children}
</AutumnProvider>
);
}
```
If your backend is on a different URL (e.g., Vite + separate server), pass `backendUrl`:
```tsx
<AutumnProvider backendUrl={import.meta.env.VITE_BACKEND_URL}>
```
### A4. Create a Customer
Add this hook to any component. It automatically creates an Autumn customer for new users and fetches existing customer state:
```tsx
import { useCustomer } from "autumn-js/react";
const { data } = useCustomer();
console.log("Autumn customer:", data);
```
Autumn's customer ID is the same as your internal user or org ID from your auth provider. No need to store any extra IDs.
### A5. Stripe Payment Flow
Call `attach` when the customer wants to purchase a plan. This returns a Stripe payment URL. Once they pay, Autumn grants access to the features defined in the plan.
```tsx
import { useCustomer } from "autumn-js/react";
export default function PurchaseButton() {
const { attach } = useCustomer();
return (
<button
onClick={async () => {
await attach({
planId: "pro",
redirectMode: "always",
});
}}
>
Select Pro Plan
</button>
);
}
```
This handles all plan change scenarios (upgrades, downgrades, one-time topups, renewals, etc).
The `redirectMode: "always"` flag always returns a payment URL:
- New purchases redirect to Stripe Checkout to enter payment details
- Subsequent charges redirect to an Autumn hosted, one-click confirmation page
---
## Path B: Backend Only (Node.js, Python, or Other)
Use this path if there's no React frontend, or you prefer server-side only.
### B1. Install the SDK
**Node.js:**
```bash
npm install autumn-js
```
**Python:**
```bash
pip install autumn-sdk
```
### B2. Initialize the Client
**TypeScript/JavaScript:**
```typescript
import { Autumn } from "autumn-js";
const autumn = new Autumn({
secretKey: process.env.AUTUMN_SECRET_KEY,
});
```
**Python:**
```python
from autumn_sdk import Autumn
autumn = Autumn('am_sk_test_xxx')
```
### B3. Create a Customer
When the customer signs up, create an Autumn customer. Autumn will automatically enable any `autoEnable` plan (typically Free).
**TypeScript:**
```typescript
const customer = await autumn.customers.getOrCreate({
customerId: "user_or_org_id_from_auth",
name: "John Doe",
email: "john@example.com",
});
```
**Python:**
```python
customer = await autumn.customers.get_or_create(
customer_id="user_or_org_id_from_auth",
name="John Doe",
email="john@example.com",
)
```
**cURL:**
```bash
curl -X POST https://api.useautumn.com/v1/customers \
-H "Authorization: Bearer am_sk_test_xxx" \
-H "Content-Type: application/json" \
-d '{"customer_id": "user_or_org_id_from_auth", "name": "John Doe", "email": "john@example.com"}'
```
Autumn's customer ID is the same as your internal user or org ID from your auth provider. No need to store any extra IDs.
### B4. Stripe Payment Flow
Call `attach` when the customer wants to purchase a plan. This returns a Stripe payment URL. Redirect the customer to complete payment.
**TypeScript:**
```typescript
const response = await autumn.billing.attach({
customerId: "user_or_org_id_from_auth",
planId: "pro",
redirectMode: "always",
});
redirect(response.paymentUrl);
```
**Python:**
```python
response = await autumn.billing.attach(
customer_id="user_or_org_id_from_auth",
plan_id="pro",
redirect_mode="always",
)
# Redirect to response.payment_url
```
**cURL:**
```bash
curl -X POST 'https://api.useautumn.com/v1/attach' \
-H 'Authorization: Bearer am_sk_test_xxx' \
-H 'Content-Type: application/json' \
-d '{
"customer_id": "user_or_org_id_from_auth",
"plan_id": "pro",
"redirect_mode": "always"
}'
```
This handles all plan change scenarios (upgrades, downgrades, one-time topups, renewals, etc).
The `redirectMode: "always"` flag always returns a payment URL:
- New purchases redirect to Stripe Checkout to enter payment details
- Subsequent charges redirect to an Autumn hosted, one-click confirmation page
---
## Verification
After setup, report to the user:
1. What stack you detected
2. Which path you followed
3. What files you created/modified
4. That the Autumn customer is logged in browser, and to check in the Autumn dashboard
**Note:** Your Autumn configuration is in `autumn.config.ts` in your project root.
**Documentation:** https://docs.useautumn.com/llms.txt

View File

@@ -0,0 +1 @@
../../CLAUDE.md

34
packages/atmn-tests/.gitignore vendored Normal file
View File

@@ -0,0 +1,34 @@
# dependencies (bun install)
node_modules
# output
out
dist
*.tgz
# code coverage
coverage
*.lcov
# logs
logs
_.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# caches
.eslintcache
.cache
*.tsbuildinfo
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store

14
packages/atmn-tests/@useautumn-sdk.d.ts vendored Normal file
View File

@@ -0,0 +1,14 @@
// AUTO-GENERATED by atmn pull
// DO NOT EDIT MANUALLY
declare module '@useautumn/sdk' {
// Features
export const messages: Feature;
// Plans
export const free: Plan;
// Base types
export type Feature = import('./autumn.config').Feature;
export type Plan = import('./autumn.config').Plan;
}

View File

@@ -0,0 +1,111 @@
---
description: Use Bun instead of Node.js, npm, pnpm, or vite.
globs: "*.ts, *.tsx, *.html, *.css, *.js, *.jsx, package.json"
alwaysApply: false
---
Default to using Bun instead of Node.js.
- Use `bun <file>` instead of `node <file>` or `ts-node <file>`
- Use `bun test` instead of `jest` or `vitest`
- Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild`
- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install`
- Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>`
- Use `bunx <package> <command>` instead of `npx <package> <command>`
- Bun automatically loads .env, so don't use dotenv.
## APIs
- `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`.
- `bun:sqlite` for SQLite. Don't use `better-sqlite3`.
- `Bun.redis` for Redis. Don't use `ioredis`.
- `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`.
- `WebSocket` is built-in. Don't use `ws`.
- Prefer `Bun.file` over `node:fs`'s readFile/writeFile
- Bun.$`ls` instead of execa.
## Testing
Use `bun test` to run tests.
```ts#index.test.ts
import { test, expect } from "bun:test";
test("hello world", () => {
expect(1).toBe(1);
});
```
## Frontend
Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind.
Server:
```ts#index.ts
import index from "./index.html"
Bun.serve({
routes: {
"/": index,
"/api/users/:id": {
GET: (req) => {
return new Response(JSON.stringify({ id: req.params.id }));
},
},
},
// optional websocket support
websocket: {
open: (ws) => {
ws.send("Hello, world!");
},
message: (ws, message) => {
ws.send(message);
},
close: (ws) => {
// handle close
}
},
development: {
hmr: true,
console: true,
}
})
```
HTML files can import .tsx, .jsx or .js files directly and Bun's bundler will transpile & bundle automatically. `<link>` tags can point to stylesheets and Bun's CSS bundler will bundle.
```html#index.html
<html>
<body>
<h1>Hello, world!</h1>
<script type="module" src="./frontend.tsx"></script>
</body>
</html>
```
With the following `frontend.tsx`:
```tsx#frontend.tsx
import React from "react";
import { createRoot } from "react-dom/client";
// import .css files directly and it works
import './index.css';
const root = createRoot(document.body);
export default function Frontend() {
return <h1>Hello, world!</h1>;
}
root.render(<Frontend />);
```
Then, run index.ts
```sh
bun --hot ./index.ts
```
For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`.

View File

@@ -0,0 +1,15 @@
# atmn-tests
To install dependencies:
```bash
bun install
```
To run:
```bash
bun run index.ts
```
This project was created using `bun init` in bun v1.3.10. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.

View File

@@ -0,0 +1,24 @@
import { feature, item, plan } from "atmn";
// Features
export const messages = feature({
id: 'messages',
name: 'Messages',
type: 'metered',
consumable: true,
});
// Plans
export const free = plan({
id: 'free',
name: 'Free',
items: [
item({
featureId: messages.id,
included: 10,
reset: {
interval: 'one_off',
},
}),
],
});

View File

@@ -0,0 +1,26 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "atmn-tests",
"devDependencies": {
"@types/bun": "latest",
},
"peerDependencies": {
"typescript": "^5",
},
},
},
"packages": {
"@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="],
"@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
"bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
}
}

View File

@@ -0,0 +1 @@
console.log("Hello via Bun!");

View File

@@ -0,0 +1,13 @@
{
"name": "atmn-tests",
"module": "index.ts",
"type": "module",
"private": true,
"devDependencies": {
"atmn": "workspace:*",
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5"
}
}

View File

@@ -0,0 +1,29 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
}
}

View File

@@ -31,4 +31,5 @@ async function build() {
console.timeEnd(`Generating type declarations`);
}
build();
await build();
process.exit(0);

View File

@@ -1,6 +1,6 @@
{
"name": "atmn",
"version": "1.1.4",
"version": "1.1.7",
"license": "MIT",
"bin": {
"atmn": "dist/cli.js"
@@ -44,8 +44,6 @@
"README.md"
],
"dependencies": {
"@autumn/shared": "workspace:*",
"@inkjs/ui": "^2.0.0",
"@inquirer/prompts": "^7.6.0",
"@mishieck/ink-titled-box": "^0.3.0",
"@tanstack/react-query": "^5.90.17",
@@ -78,6 +76,7 @@
"conf": "^13.0.1"
},
"devDependencies": {
"@autumn/shared": "workspace:*",
"@sindresorhus/tsconfig": "^3.0.1",
"@types/bun": "^1.3.10",
"@types/node": "^24.0.10",

View File

@@ -0,0 +1,261 @@
// AUTO-GENERATED - DO NOT EDIT MANUALLY
// Generated from @autumn/shared display utilities
// Run `pnpm gen:atmn` to regenerate
/**
* Minimal Feature type for display functions
* Matches the shape expected by @autumn/shared display utils
*/
export interface FeatureForDisplay {
name: string;
display?: {
singular?: string;
plural?: string;
} | null;
}
/**
* Format currency amount
* Adapted from @autumn/shared/utils/common/formatUtils/formatAmount.ts
*/
export const formatAmount = ({
amount,
currency = "USD",
maxFractionDigits = 10,
minFractionDigits = 0,
}: {
amount: number;
currency?: string;
maxFractionDigits?: number;
minFractionDigits?: number;
}): string => {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency,
minimumFractionDigits: minFractionDigits,
maximumFractionDigits: maxFractionDigits,
}).format(amount);
};
/**
* Format billing interval
* Copied from @autumn/shared/utils/common/formatUtils/formatInterval.ts
*/
export const formatInterval = ({
interval,
intervalCount = 1,
prefix = "per ",
}: {
interval?: string;
intervalCount?: number;
prefix?: string;
}): string => {
if (!interval) return "";
// Handle one_off (show "one time")
if (interval === "one_off") {
return "one-off";
}
// Handle lifetime (no interval string)
if (interval === "lifetime") {
return "";
}
let intervalStr: string = interval;
// Handle special case for semi_annual
if (interval === "semi_annual") {
intervalStr = "half year";
}
if (intervalCount === 1) {
return `${prefix}${intervalStr}`;
}
return `${prefix}${intervalCount} ${intervalStr}s`;
};
/**
* Get feature name with singular/plural handling
* Copied from @autumn/shared/utils/displayUtils.ts
*/
export const getFeatureName = ({
feature,
plural,
units,
capitalize = false,
}: {
feature?: FeatureForDisplay;
plural?: boolean;
units?: any;
capitalize?: boolean;
}) => {
if (!feature) {
return "";
}
let featureName = feature.name || "";
if (feature.display) {
let finalPlural: boolean | undefined;
// Case 1: If units and nullish plural
if (plural !== undefined) {
finalPlural = plural;
} else {
finalPlural = units !== 1;
}
if (finalPlural) {
featureName = feature.display.plural || featureName;
} else {
featureName = feature.display.singular || featureName;
}
}
if (capitalize) {
featureName = featureName
.split(" ")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ");
}
return featureName;
};
/**
* Get feature name with first letter capitalized
* Copied from @autumn/shared/utils/displayUtils.ts
*/
export const getFeatureNameWithCapital = ({
feature,
}: {
feature: FeatureForDisplay;
}) => {
if (feature.name && feature.name.length > 0) {
return `${feature.name.charAt(0).toUpperCase()}${feature.name.slice(1)}`;
}
return feature.name;
};
/**
* Get both singular and plural forms of feature name
* Copied from @autumn/shared/utils/displayUtils.ts
*/
export const getSingularAndPlural = ({
feature,
capitalize = false,
}: {
feature: FeatureForDisplay;
capitalize?: boolean;
}) => {
return {
singular: getFeatureName({ feature, plural: false, capitalize }),
plural: getFeatureName({ feature, plural: true, capitalize }),
};
};
/**
* Format a number with commas
* Copied from @autumn/shared/utils/displayUtils.ts
*/
export const numberWithCommas = (x: number) => {
return new Intl.NumberFormat("en-US", { maximumFractionDigits: 20 }).format(
x,
);
};
/**
* Get feature name based on usage count (singular/plural)
* Copied from @autumn/shared/utils/displayUtils.ts
*/
export const usageToFeatureName = ({
usage,
feature,
}: {
usage: number;
feature: FeatureForDisplay;
}) => {
const { singular, plural } = getSingularAndPlural({ feature });
if (usage === 1) {
return singular;
}
return plural;
};
/**
* Get invoice description for a feature
* Adapted from @autumn/shared/utils/displayUtils.ts
* Note: Simplified to remove date-fns dependency
*/
export const getFeatureInvoiceDescription = ({
feature,
usage,
billingUnits = 1,
prodName,
isPrepaid = false,
}: {
feature: FeatureForDisplay;
usage: number;
billingUnits?: number | null;
prodName?: string;
isPrepaid?: boolean;
}) => {
const { singular, plural } = getSingularAndPlural({ feature });
const usageStr = numberWithCommas(Math.ceil(usage));
let result = "";
if (isPrepaid && billingUnits && billingUnits > 1) {
result = `${usageStr} x ${billingUnits} ${plural}`; // eg. 4 x 100 credits
} else {
if (usage === 1) {
result = `${usageStr} ${singular}`; // eg. 1 credit
} else {
result = `${usageStr} ${plural}`; // eg. 4 credits
}
}
if (prodName) {
result = `${prodName} - ${result}`;
}
return result;
};
/**
* Format tiered pricing range
*/
export const formatTiers = ({
tiers,
currency = "USD",
}: {
tiers: Array<{ to: number | "inf"; amount: number }>;
currency?: string;
}): string => {
if (tiers.length === 0) return "";
if (tiers.length === 1) {
return formatAmount({ amount: tiers[0].amount, currency });
}
const firstAmount = formatAmount({ amount: tiers[0].amount, currency });
const lastAmount = formatAmount({ amount: tiers[tiers.length - 1].amount, currency });
return `${firstAmount} - ${lastAmount}`;
};

View File

@@ -4,7 +4,7 @@ import {
formatInterval,
getFeatureName,
numberWithCommas,
} from "@autumn/shared";
} from "./displayUtils.js";
import {
type FeatureLike,
featureToDisplayFeature,

View File

@@ -23,7 +23,10 @@ import {
transformApiFeature,
transformApiPlan,
} from "../../lib/transforms/apiToSdk/index.js";
import { transformFeatureToApi, transformPlanToApi } from "../../lib/transforms/sdkToApi/index.js";
import {
transformFeatureToApi,
transformPlanToApi,
} from "../../lib/transforms/sdkToApi/index.js";
import type {
FeatureDeleteInfo,
PlanDeleteInfo,
@@ -156,10 +159,9 @@ async function checkPlanForVersioning(
};
}
const missingFeatureIds =
(plan.items || [])
.map((item) => item.featureId)
.filter((featureId) => !remoteFeatureIds.has(featureId));
const missingFeatureIds = (plan.items || [])
.map((item) => item.featureId)
.filter((featureId) => !remoteFeatureIds.has(featureId));
const missingLocalFeatureIds = missingFeatureIds.filter((featureId) =>
localFeatureIds.has(featureId),
@@ -169,15 +171,15 @@ async function checkPlanForVersioning(
);
if (missingLocalFeatureIds.length > 0) {
if (missingUnknownFeatureIds.length > 0) {
console.log(
`[checkPlanForVersioning] plan=${plan.id} has mixed missing features. Local-first features: ${missingLocalFeatureIds.join(", ")}; missing unknown: ${missingUnknownFeatureIds.join(", ")}.`,
);
} else {
console.log(
`[checkPlanForVersioning] plan=${plan.id} has local-only feature refs (${missingLocalFeatureIds.join(", ")}). Deferring versioning check until after feature upsert.`,
);
}
// if (missingUnknownFeatureIds.length > 0) {
// console.log(
// `[checkPlanForVersioning] plan=${plan.id} has mixed missing features. Local-first features: ${missingLocalFeatureIds.join(", ")}; missing unknown: ${missingUnknownFeatureIds.join(", ")}.`,
// );
// } else {
// console.log(
// `[checkPlanForVersioning] plan=${plan.id} has local-only feature refs (${missingLocalFeatureIds.join(", ")}). Deferring versioning check until after feature upsert.`,
// );
// }
return {
plan,
@@ -209,9 +211,10 @@ async function checkPlanForVersioning(
const responseMessage =
(response && (response.message as string | undefined)) || "";
const missingFeatureMatch = /Feature\s+["']?([a-zA-Z0-9_-]+)["']?\s+not\s+found/i.exec(
responseMessage,
);
const missingFeatureMatch =
/Feature\s+["']?([a-zA-Z0-9_-]+)["']?\s+not\s+found/i.exec(
responseMessage,
);
const missingFeature =
response?.feature || response?.feature_id || missingFeatureMatch?.[1];
@@ -223,19 +226,19 @@ async function checkPlanForVersioning(
responseMessage,
)
) {
if (missingUnknownFeatureIds.length > 0) {
console.log(
`[checkPlanForVersioning] plan=${plan.id} failed versioning check: feature "${missingFeature || "unknown"}" not found and not in local config`,
);
} else if (missingFeature) {
console.log(
`[checkPlanForVersioning] plan=${plan.id} deferring versioning check due feature_not_found for feature "${missingFeature}", will recheck after feature upsert`,
);
} else {
console.log(
`[checkPlanForVersioning] plan=${plan.id} deferring versioning check due feature_not_found`,
);
}
// if (missingUnknownFeatureIds.length > 0) {
// console.log(
// `[checkPlanForVersioning] plan=${plan.id} failed versioning check: feature "${missingFeature || "unknown"}" not found and not in local config`,
// );
// } else if (missingFeature) {
// console.log(
// `[checkPlanForVersioning] plan=${plan.id} deferring versioning check due feature_not_found for feature "${missingFeature}", will recheck after feature upsert`,
// );
// } else {
// console.log(
// `[checkPlanForVersioning] plan=${plan.id} deferring versioning check due feature_not_found`,
// );
// }
return {
plan,
@@ -339,9 +342,7 @@ function normalizeFeatureForCompare(f: Feature): Record<string, unknown> {
if (f.creditSchema && f.creditSchema.length > 0) {
result.creditSchema = [...f.creditSchema]
.sort((a, b) =>
a.meteredFeatureId.localeCompare(b.meteredFeatureId),
)
.sort((a, b) => a.meteredFeatureId.localeCompare(b.meteredFeatureId))
.map((cs) => ({
meteredFeatureId: cs.meteredFeatureId,
creditCost: cs.creditCost,
@@ -355,9 +356,7 @@ function normalizeFeatureForCompare(f: Feature): Record<string, unknown> {
* Normalize a plan item to a canonical form for comparison.
* Strips default values (unlimited: false, billingUnits: 1, intervalCount: 1).
*/
function normalizePlanFeatureForCompare(
pf: PlanItem,
): Record<string, unknown> {
function normalizePlanFeatureForCompare(pf: PlanItem): Record<string, unknown> {
const f = pf as Record<string, unknown>;
const result: Record<string, unknown> = {
featureId: pf.featureId,
@@ -379,8 +378,7 @@ function normalizePlanFeatureForCompare(
if (price != null) {
const p: Record<string, unknown> = {};
if (price.amount != null) p.amount = price.amount;
if (price.billingMethod != null)
p.billingMethod = price.billingMethod;
if (price.billingMethod != null) p.billingMethod = price.billingMethod;
if (price.interval != null) p.interval = price.interval;
if (price.intervalCount != null && price.intervalCount !== 1) {
p.intervalCount = price.intervalCount;
@@ -493,9 +491,7 @@ export async function analyzePush(
const localFeatureIds = new Set(localFeatures.map((f) => f.id));
const localPlanIds = new Set(localPlans.map((p) => p.id));
const remoteFeaturesById = new Map(
remoteData.features.map((f) => [f.id, f]),
);
const remoteFeaturesById = new Map(remoteData.features.map((f) => [f.id, f]));
const remotePlansById = new Map(remoteData.plans.map((p) => [p.id, p]));
const localPlansById = new Map(localPlans.map((p) => [p.id, p]));
@@ -514,15 +510,14 @@ export async function analyzePush(
const archivedFeatures = localFeatures.filter((f) => {
const remote = remoteFeaturesById.get(f.id);
const localArchived = (f as Feature & { archived?: boolean }).archived;
const remoteArchived = remote && (remote as Feature & { archived?: boolean }).archived;
const remoteArchived =
remote && (remote as Feature & { archived?: boolean }).archived;
// Prompt to unarchive only if remote is archived but local doesn't explicitly want it archived
return remoteArchived && !localArchived;
});
// Find plans to create and update (only actually changed plans)
const plansToCreate = localPlans.filter(
(p) => !remotePlansById.has(p.id),
);
const plansToCreate = localPlans.filter((p) => !remotePlansById.has(p.id));
const plansToUpdateLocal = localPlans.filter((p) => {
const remotePlan = remotePlansById.get(p.id);
if (!remotePlan) return false;
@@ -532,7 +527,12 @@ export async function analyzePush(
// Check versioning info for each plan to update
const remoteFeatureIds = new Set(remoteData.features.map((f) => f.id));
const planUpdatePromises = plansToUpdateLocal.map((plan) =>
checkPlanForVersioning(plan, remoteData.plans, localFeatureIds, remoteFeatureIds),
checkPlanForVersioning(
plan,
remoteData.plans,
localFeatureIds,
remoteFeatureIds,
),
);
const plansToUpdate = await Promise.all(planUpdatePromises);
@@ -556,7 +556,8 @@ export async function analyzePush(
const archivedPlans = localPlans.filter((p) => {
const remote = remotePlansById.get(p.id);
const localArchived = (p as Plan & { archived?: boolean }).archived;
const remoteArchived = remote && (remote as Plan & { archived?: boolean }).archived;
const remoteArchived =
remote && (remote as Plan & { archived?: boolean }).archived;
// Prompt to unarchive only if remote is archived but local doesn't explicitly want it archived
return remoteArchived && !localArchived;
});
@@ -593,10 +594,7 @@ export async function analyzePush(
if (plansRemovingFeature) {
plansRemovingFeature.add(remotePlan.id);
} else {
plansRemovingFeatureById.set(
featureId,
new Set([remotePlan.id]),
);
plansRemovingFeatureById.set(featureId, new Set([remotePlan.id]));
}
}
}
@@ -626,7 +624,8 @@ export async function analyzePush(
return info;
}
const remotePlansForFeature = remoteFeaturePlanRefs.get(info.id) ?? new Set();
const remotePlansForFeature =
remoteFeaturePlanRefs.get(info.id) ?? new Set();
let hasBlockingPlan = false;
for (const planId of remotePlansForFeature) {

View File

@@ -52,7 +52,7 @@ export const PlanItemSchema = z.object({
}),
tiers: z.array(UsageTierSchema).optional().meta({
description:
"Tiered pricing. Each tier's 'to' does NOT include included amount. Either 'amount' or 'tiers' is required.",
"Tiered pricing. Either 'amount' or 'tiers' is required.",
}),
tier_behavior: z.union([z.literal("graduated"), z.literal("volume")]).optional(),
@@ -245,88 +245,67 @@ type PriceWithAmount = PriceBaseFields & {
tiers?: never;
};
// Price with graduated tiered pricing (no flat amount per tier)
type PriceWithGraduatedTiers = PriceBaseFields & {
// Price with tiered pricing (no flat amount)
type PriceWithTiers = PriceBaseFields & {
/** Cannot have flat amount when using tiers */
amount?: never;
/** Graduated tiered pricing: each tier's amount applies only to units within that tier */
/** Tiered pricing structure based on usage ranges */
tiers: Array<{ to: number | "inf"; amount: number }>;
/** Graduated: each tier's rate applies only to usage within that tier */
tierBehavior: "graduated";
/** Required when tiers is defined: how tiers are applied */
tierBehaviour: "graduated" | "volume";
};
// Price with volume tiered pricing (flat amount per tier)
type PriceWithVolumeTiers = Omit<PriceBaseFields, "billingMethod"> & {
/** Volume pricing does not support usage_based billing — use 'prepaid' */
billingMethod: Exclude<BillingMethod, "usage_based">;
/** Cannot have flat amount when using tiers */
amount?: never;
/** Volume tiered pricing: the tier the total usage falls into applies to all units */
tiers: Array<{ to: number | "inf"; amount: number; flatAmount?: number }>;
/** Volume: the rate of the tier the total usage falls into applies to all units */
tierBehavior: "volume";
};
type PriceWithTiers = PriceWithGraduatedTiers | PriceWithVolumeTiers;
// Price must have either amount OR tiers (not both, not neither)
type PriceAmountOrTiers = PriceWithAmount | PriceWithTiers;
// Price when reset IS defined - interval is forbidden
type PriceWithoutInterval = PriceAmountOrTiers & {
/** Cannot have interval when using top-level reset */
interval?: never;
intervalCount?: never;
};
// Price when reset is NOT defined - interval is required
type PriceWithInterval = PriceAmountOrTiers & {
/** Billing interval - required when no top-level reset */
interval: BillingInterval;
// Price type - interval is optional (omit for one-off/non-recurring)
type Price = PriceAmountOrTiers & {
/** Billing interval - omit for one-off pricing */
interval?: BillingInterval;
/** Number of intervals between billing cycles (default: 1) */
intervalCount?: number;
};
/**
* Plan item with top-level reset configuration.
* Use this for free allocations or features that reset but aren't priced per-use.
* Plan item with a reset cycle (e.g. 100 messages per month).
* Cannot have price — reset and price are mutually exclusive.
*/
export type PlanItemWithReset = PlanItemBaseFields & {
/** Reset configuration for usage limits */
/** Reset configuration for the included allowance */
reset: ResetConfig;
/** Optional pricing (cannot have price.interval when using top-level reset) */
price?: PriceWithoutInterval;
/** Cannot have price when using reset — use price.interval instead */
price?: never;
};
/**
* Plan item with pricing that includes interval configuration.
* Use this for usage-based pricing where interval determines billing cycle.
* Plan item with usage-based pricing (e.g. $0.10/message, billed monthly).
* price.interval encodes the billing cycle, so reset is not allowed.
*/
export type PlanItemWithPriceInterval = PlanItemBaseFields & {
/** Cannot have top-level reset when using price.interval */
export type PlanItemWithPrice = PlanItemBaseFields & {
/** Cannot have reset when using price — price.interval encodes the billing cycle */
reset?: never;
/** Pricing configuration with billing interval */
price: PriceWithInterval;
/** Pricing configuration */
price: Price;
};
/**
* Plan item without any reset configuration.
* Use this for continuous-use features (like seats) that don't reset.
* Plan item with no reset and no price.
* Use for continuous-use or boolean features (e.g. seats, feature flags).
*/
export type PlanItemNoReset = PlanItemBaseFields & {
/** No reset for continuous-use features */
reset?: never;
/** Pricing with required interval (since no top-level reset) */
price?: PriceWithInterval;
/** No price for free/boolean features */
price?: never;
};
/**
* Plan item configuration with mutually exclusive reset patterns:
* - PlanItemWithReset: Top-level reset (for free allocations)
* - PlanItemWithPriceInterval: price.interval (for usage-based pricing billing cycle)
* - PlanItemNoReset: No reset (for continuous-use features like seats)
* Plan item configuration. reset and price are mutually exclusive:
* - PlanItemWithReset: included allowance that resets on an interval (e.g. 100/month free)
* - PlanItemWithPrice: usage-based pricing with its own billing cycle
* - PlanItemNoReset: no reset, no price (continuous-use or boolean features)
*/
export type PlanItem = PlanItemWithReset | PlanItemWithPriceInterval | PlanItemNoReset;
export type PlanItem = PlanItemWithReset | PlanItemWithPrice | PlanItemNoReset;
// Override Plan type to use PlanItem discriminated union

View File

@@ -2,7 +2,7 @@
// Generated from @autumn/shared API schemas
// Run typegen to regenerate
import type { ApiFeatureV1 } from "@api/features/apiFeatureV1.js";
import type { ApiFeatureV1 } from "../../../../../../shared/api/features/apiFeatureV1.js";
/**
* ApiFeature - Raw API response type

View File

@@ -2,7 +2,7 @@
// Generated from @autumn/shared API schemas
// Run typegen to regenerate
import type { ApiPlanV1 } from "@api/products/apiPlanV1.js";
import type { ApiPlanV1 } from "../../../../../../shared/api/products/apiPlanV1.js";
/**
* ApiPlan - Raw API response type

View File

@@ -2,7 +2,7 @@
// Generated from @autumn/shared API schemas
// Run typegen to regenerate
import type { ApiPlanItemV1 } from "@api/products/items/apiPlanItemV1.js";
import type { ApiPlanItemV1 } from "../../../../../../shared/api/products/items/apiPlanItemV1.js";
/**
* ApiPlanItem - Raw API response type

View File

@@ -58,33 +58,33 @@ export const plans: Plan[] = [
name: "Hobby",
price: { amount: 5, interval: "month" },
items: [
{
featureId: "credits",
included: 500,
reset: { interval: "month" },
price: {
amount: 0.01,
billingMethod: "usage_based",
billingUnits: 1,
},
{
featureId: "credits",
included: 500,
price: {
amount: 0.01,
billingMethod: "usage_based",
billingUnits: 1,
interval: "month",
},
],
},
{
id: "pro",
name: "Pro",
price: { amount: 20, interval: "month" },
items: [
{
featureId: "credits",
included: 2000,
reset: { interval: "month" },
price: {
amount: 0.01,
billingMethod: "usage_based",
billingUnits: 1,
},
},
],
},
{
id: "pro",
name: "Pro",
price: { amount: 20, interval: "month" },
items: [
{
featureId: "credits",
included: 2000,
price: {
amount: 0.01,
billingMethod: "usage_based",
billingUnits: 1,
interval: "month",
},
},
],
},
];

View File

@@ -82,15 +82,13 @@ function transformPlanItem(planItem: PlanItem): ApiPlanItemParams {
}
if (planItem.price) {
// Get interval from price.interval if available, otherwise from top-level reset
// Get interval from price.interval (reset and price are mutually exclusive)
const priceWithInterval = planItem.price as {
interval?: string;
intervalCount?: number;
};
const priceInterval = priceWithInterval.interval;
const priceIntervalCount = priceWithInterval.intervalCount;
const interval = priceInterval ?? planItem.reset?.interval;
const intervalCount = priceIntervalCount ?? planItem.reset?.intervalCount;
const interval = priceWithInterval.interval;
const intervalCount = priceWithInterval.intervalCount;
const priceWithBilling = planItem.price as {
billingUnits?: number;

View File

@@ -0,0 +1,97 @@
import { Box, Text, useInput } from "ink";
import { useMemo, useState } from "react";
interface MultiSelectOption {
label: string;
value: string;
}
interface MultiSelectProps {
options: MultiSelectOption[];
defaultValue?: string[];
visibleOptionCount?: number;
onChange?: (values: string[]) => void;
onSubmit?: (values: string[]) => void;
}
const POINTER = "\u276F";
const TICK = "\u2714";
const CIRCLE = "\u25CB";
export function MultiSelect({
options,
defaultValue = [],
visibleOptionCount = 5,
onChange,
onSubmit,
}: MultiSelectProps) {
const effectiveVisibleCount = Math.min(visibleOptionCount, options.length);
const [focusedIndex, setFocusedIndex] = useState(0);
const [selectedValues, setSelectedValues] = useState<Set<string>>(
() => new Set(defaultValue),
);
const [visibleFrom, setVisibleFrom] = useState(0);
const visibleOptions = useMemo(() => {
return options.slice(visibleFrom, visibleFrom + effectiveVisibleCount);
}, [options, visibleFrom, effectiveVisibleCount]);
useInput((input, key) => {
if (key.downArrow) {
const nextIndex = Math.min(focusedIndex + 1, options.length - 1);
setFocusedIndex(nextIndex);
const visibleTo = visibleFrom + effectiveVisibleCount;
if (nextIndex >= visibleTo) {
setVisibleFrom(nextIndex - effectiveVisibleCount + 1);
}
}
if (key.upArrow) {
const prevIndex = Math.max(focusedIndex - 1, 0);
setFocusedIndex(prevIndex);
if (prevIndex < visibleFrom) {
setVisibleFrom(prevIndex);
}
}
if (input === " ") {
const focusedOption = options[focusedIndex];
if (!focusedOption) return;
const newSelected = new Set(selectedValues);
if (newSelected.has(focusedOption.value)) {
newSelected.delete(focusedOption.value);
} else {
newSelected.add(focusedOption.value);
}
setSelectedValues(newSelected);
onChange?.([...newSelected]);
}
if (key.return) {
onSubmit?.([...selectedValues]);
}
});
return (
<Box flexDirection="column">
{visibleOptions.map((option, i) => {
const absoluteIndex = visibleFrom + i;
const isFocused = absoluteIndex === focusedIndex;
const isSelected = selectedValues.has(option.value);
return (
<Box key={option.value}>
<Text color={isFocused ? "cyan" : undefined}>
{isFocused ? POINTER : " "}{" "}
</Text>
<Text color={isSelected ? "green" : isFocused ? "cyan" : undefined}>
{isSelected ? TICK : CIRCLE} {option.label}
</Text>
</Box>
);
})}
</Box>
);
}

View File

@@ -0,0 +1,23 @@
import { Box, Text } from "ink";
interface ProgressBarProps {
value: number;
}
/** Simple progress bar for terminal display. Value is 0-100. */
export function ProgressBar({ value }: ProgressBarProps) {
const clamped = Math.max(0, Math.min(100, value));
const width = 20;
const filled = Math.round((clamped / 100) * width);
const empty = width - filled;
const color = clamped >= 90 ? "red" : clamped >= 70 ? "yellow" : "green";
return (
<Box>
<Text color={color}>{"█".repeat(filled)}</Text>
<Text dimColor>{"░".repeat(empty)}</Text>
<Text dimColor> {clamped}%</Text>
</Box>
);
}

View File

@@ -6,6 +6,8 @@ export {
export { Card } from "./Card.js";
export { KeyValue } from "./KeyValue.js";
export { LoadingText } from "./LoadingText.js";
export { MultiSelect } from "./MultiSelect.js";
export { ProgressBar } from "./ProgressBar.js";
export { PromptCard } from "./PromptCard.js";
export { CardWidthProvider } from "./providers/CardWidthContext.js";
export {

View File

@@ -1,5 +1,5 @@
import { Spinner } from "@inkjs/ui";
import { Box, Text } from "ink";
import Spinner from "ink-spinner";
import type { ApiBalance, CustomerSheetProps } from "../types.js";
import { formatDate } from "../types.js";
import {
@@ -89,7 +89,9 @@ export function CustomerSheet({
{/* Loading state for expanded data */}
{isLoadingExpanded && (
<Box marginTop={1}>
<Spinner label="Loading details..." />
<Text>
<Spinner type="dots" /> Loading details...
</Text>
</Box>
)}

View File

@@ -1,5 +1,5 @@
import { ProgressBar } from "@inkjs/ui";
import { Box, Text } from "ink";
import { ProgressBar } from "../../../components/index.js";
import type { ApiBalance } from "../../types.js";
export interface BalancesSectionProps {

View File

@@ -1,4 +1,3 @@
import { MultiSelect } from "@inkjs/ui";
import { Box, Text } from "ink";
import { useEffect, useState } from "react";
import {
@@ -6,7 +5,11 @@ import {
type FileOption,
useAgentSetup,
} from "../../../../lib/hooks/index.js";
import { StatusLine, StepHeader } from "../../components/index.js";
import {
MultiSelect,
StatusLine,
StepHeader,
} from "../../components/index.js";
interface AgentStepProps {
step: number;

View File

@@ -1,9 +1,14 @@
import { MultiSelect, TextInput } from "@inkjs/ui";
import { Box, Text, useApp } from "ink";
import TextInput from "ink-text-input";
import open from "open";
import React, { useState } from "react";
import { useClipboard, useCreateSkills } from "../../../../lib/hooks/index.js";
import { SelectMenu, StatusLine, StepHeader } from "../../components/index.js";
import {
MultiSelect,
SelectMenu,
StatusLine,
StepHeader,
} from "../../components/index.js";
// System prompt for AI integration - will be copied to clipboard
const SYSTEM_PROMPT = `You are an expert AI assistant that helps users set up Autumn, a billing and entitlements layer over Stripe. The user has already installed Autumn Skills ready for you to use the load skill tool.
@@ -239,7 +244,7 @@ export function HandoffStep({
<Text color="gray">{">"} </Text>
<TextInput
placeholder={process.cwd()}
defaultValue={customPath}
value={customPath}
onChange={setCustomPath}
onSubmit={handleCustomPathSubmit}
/>

View File

@@ -13,6 +13,7 @@ const VITE_PORT = 3000 + portOffset;
const SERVER_PORT = 8080 + portOffset;
const CHECKOUT_PORT = 3001 + portOffset;
const skipWorkers = worktreeNum > 1;
const isProductionMode = process.argv.includes("--production");
/**
* Read environment variable from .env file
@@ -110,6 +111,8 @@ async function startDev() {
if (worktreeNum > 1) {
console.log(`Starting worktree ${worktreeNum} (no workers)...\n`);
} else if (isProductionMode) {
console.log("Starting local servers with NODE_ENV=production...\n");
} else {
console.log("Starting development servers...\n");
}
@@ -142,10 +145,12 @@ async function startDev() {
} else {
const names = ["server"];
const colors = ["green"];
const serverScript = isProductionMode ? "dev:prod" : "dev";
const workersScript = isProductionMode ? "workers:prod" : "workers:dev";
const cmds = [
isWindows
? `"cd server && set SERVER_PORT=${SERVER_PORT} && bun dev"`
: `"cd server && SERVER_PORT=${SERVER_PORT} bun dev"`,
? `"cd server && set SERVER_PORT=${SERVER_PORT} && bun ${serverScript}"`
: `"cd server && SERVER_PORT=${SERVER_PORT} bun ${serverScript}"`,
];
if (!skipWorkers) {
@@ -153,8 +158,8 @@ async function startDev() {
colors.push("yellow");
cmds.push(
isWindows
? `"cd server && bun workers:dev"`
: `"cd server && bun workers:dev"`,
? `"cd server && bun ${workersScript}"`
: `"cd server && bun ${workersScript}"`,
);
}
@@ -192,7 +197,12 @@ async function startDev() {
},
stdout: "inherit",
stderr: "inherit",
onExit(proc, exitCode, signalCode, error) {
onExit(
_proc: unknown,
exitCode: number | null,
_signalCode: number | null,
error: Error | null,
) {
if (error) {
console.error("Failed to start development servers:", error);
process.exit(1);

View File

@@ -19,6 +19,7 @@
"react": "^18.3.1"
},
"devDependencies": {
"@types/bun": "^1.3.11",
"@types/react": "^18.3.1",
"tsx": "^4.19.2",
"typescript": "^5.7.3"

View File

@@ -15,8 +15,6 @@
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"allowSyntheticDefaultImports": true,
"baseUrl": ".",
"paths": {
"@server/*": ["../server/src/*"],
"@shared/*": ["../shared/*"]

View File

@@ -13,7 +13,9 @@
"w:prod": "ENV_FILE=.env.prod infisical run --env=prod -- bun workers:dev",
"c:prod": "ENV_FILE=.env.prod NODE_ENV=development infisical run --env=prod -- bun cron",
"dev": "cross-env NODE_ENV=development bunx nodemon",
"dev:prod": "cross-env NODE_ENV=production bunx nodemon",
"workers:dev": "cross-env NODE_ENV=development bunx nodemon --exec bun src/workers.ts --signal SIGTERM --delay 500ms",
"workers:prod": "cross-env NODE_ENV=production bunx nodemon --exec bun src/workers.ts --signal SIGTERM --delay 500ms",
"start": "bun src/index.ts",
"workers": "bun src/workers.ts",
"cron": "bun src/cron.ts",
@@ -26,7 +28,11 @@
"clear-master": "ENV_FILE=.env infisical run --env=dev -- bun tests/clearMasterOrg.ts",
"cm": "ENV_FILE=.env infisical run --env=dev -- bun tests/clearMaster.ts",
"ts": "bunx tsgo --build --noEmit",
"test:integration": "ENV_FILE=.env infisical run --env=dev -- bun test --timeout 0 --preload ./tests/setup-integration-tests.ts"
"test:integration": "ENV_FILE=.env infisical run --env=dev -- bun test --timeout 0 --preload ./tests/setup-integration-tests.ts",
"loadtest": "ENV_FILE=.env infisical run --env=dev -- npx artillery run perf/load-test/artillery.yml",
"loadtest:leak": "ENV_FILE=.env infisical run --env=dev -- bun perf/load-test/runLeakTest.ts",
"loadtest:leak:summary": "bun perf/load-test/summarizeLeakSnapshots.ts",
"loadtest:setup": "ENV_FILE=.env infisical run --env=dev -- bun perf/load-test/setup.ts"
},
"mocha": {
"node-option": [
@@ -105,6 +111,7 @@
"kafkajs": "^2.2.4",
"ksuid": "^3.0.0",
"loops": "^5.0.1",
"lru-cache": "^11.2.7",
"mime-detect": "^1.3.0",
"nanoid": "^5.1.6",
"openai": "^4.85.2",
@@ -137,6 +144,7 @@
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@types/ws": "^8.18.1",
"artillery": "^2.0.30",
"cross-env": "^7.0.3",
"drizzle-kit": "catalog:",
"mocha": "^11.1.0",

1
server/perf/load-test/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
.customers.json

View File

@@ -0,0 +1,111 @@
# Load Testing
Artillery-based load tests for the Autumn server, with Stripe-integrated attach cycling.
## Quick Start
```bash
cd server
# 1. One-time setup (creates products + 500 customers with Stripe PMs)
bun loadtest:setup
# 2. Run load test
bun loadtest:leak # memory leak detection (11 min, ~30 req/s)
bun loadtest # general load test (4 min, up to ~75 req/s)
```
## Prerequisites
1. Server running on `localhost:8080` (`bun d`)
2. `UNIT_TEST_AUTUMN_SECRET_KEY` and `TESTS_ORG` available via infisical (the npm scripts handle this)
3. Setup script has been run at least once (`bun loadtest:setup`)
## Setup Script
`setup.ts` creates everything needed for the load test:
**Products** (all in the `load-test` group for upgrade/downgrade):
| Product ID | Type | Price | Features |
|---|---|---|---|
| `load-free` | Default (free) | $0 | dashboard (boolean), messages (100/mo) |
| `load-pro` | Subscription | $20/mo | dashboard, messages (1000/mo) |
| `load-premium` | Subscription | $50/mo | dashboard, messages (unlimited) |
| `load-messages` | Add-on (one-off) | $10 | messages (+500, one-time) |
**Customers**: 500 customers (`load-cus-001` to `load-cus-500`), each with:
- A Stripe customer with `tok_visa` payment method attached
- Auto-assigned `load-free` via default product group
- Mapping saved to `.customers.json` (gitignored)
The setup script is idempotent — safe to re-run.
## Scenarios
Both configs run two weighted scenarios:
### Core API loop (90% of virtual users)
Each VU makes 3 requests:
1. `POST /v1/check` — check feature access
2. `POST /v1/track` — track usage
3. `GET /v1/customers/:id` — fetch customer
### Attach flow (10% of virtual users)
Each VU makes 2 requests:
1. `POST /v1/billing.attach` — upgrade/downgrade to a random product
2. `POST /v1/check` — check feature access after attach
Since all subscription products share the `load-test` group, attaching a different product triggers an upgrade or downgrade through Stripe. Attaching the same product the customer already has is handled gracefully (no-op or returns existing).
## Load Profiles
### `memoryLeak.yml` — Memory leak detection
| Phase | Duration | VU/sec | ~Req/sec | Purpose |
|---|---|---|---|---|
| Warm-up | 30s | 1 -> 3 | 3-9 | Gentle start |
| Ramp | 60s | 3 -> 10 | 9-30 | Gradual increase |
| Sustained | 600s | 10 | ~30 | Leak detection window |
At sustained 10 VU/sec: ~9 core loop VUs (~27 req/s) + ~1 attach VU (~2 req/s).
### `artillery.yml` — General load test
| Phase | Duration | VU/sec | ~Req/sec | Purpose |
|---|---|---|---|---|
| Warm-up | 30s | 1 -> 5 | 3-15 | Warm caches, JIT |
| Ramp to peak | 60s | 5 -> 25 | 15-75 | Find breaking point |
| Sustained | 120s | 25 | ~75 | Sustained peak |
| Spike | 30s | 25 -> 50 | 75-150 | Brief spike |
## Memory Leak Debugging Workflow
1. Start server: `bun d`
2. Take baseline heap snapshot: `curl localhost:8080/debug/heap-snapshot`
3. Run: `bun loadtest:leak`
4. At ~5 min, take mid-test snapshot: `curl localhost:8080/debug/heap-snapshot`
5. When test ends, take final snapshot: `curl localhost:8080/debug/heap-snapshot`
6. Open Chrome DevTools > Memory tab > Load all 3 `.heapsnapshot` files
7. Select the latest snapshot > Summary > switch to Comparison
8. Look at the "Delta" column for objects accumulating between snapshots
Heap snapshots are saved to `server/perf/snapshots/` (gitignored).
## File Structure
```
server/perf/
├── load-test/
│ ├── setup.ts # Creates products + customers (run once)
│ ├── processor.mjs # Artillery helper functions (picks random customers/products)
│ ├── artillery.yml # General load test config
│ ├── memoryLeak.yml # Memory leak detection config
│ ├── .customers.json # Generated customer->stripeId mapping (gitignored)
│ ├── .gitignore
│ └── README.md
└── snapshots/
└── .gitignore # Ignores *.heapsnapshot files
```

View File

@@ -0,0 +1,73 @@
# General load test — ramps up to ~75 req/s peak with attach cycling, 4 min total.
# Usage: cd server && bun loadtest
#
# Prerequisites:
# Run setup first: cd server && bun loadtest:setup
config:
target: "http://localhost:8080"
processor: "./processor.mjs"
phases:
- duration: 30
arrivalRate: 1
rampTo: 5
name: "Warm-up"
- duration: 60
arrivalRate: 5
rampTo: 25
name: "Ramp to peak"
- duration: 120
arrivalRate: 25
name: "Sustained peak"
- duration: 30
arrivalRate: 25
rampTo: 50
name: "Spike"
defaults:
headers:
Authorization: "Bearer {{ $processEnvironment.UNIT_TEST_AUTUMN_SECRET_KEY }}"
Content-Type: "application/json"
scenarios:
# ~90% of VUs: lightweight check/track/get loop
- name: "Core API loop"
weight: 9
beforeScenario: "setCustomerContext"
flow:
- post:
url: "/v1/check"
json:
customer_id: "{{ customerId }}"
feature_id: "{{ featureId }}"
- post:
url: "/v1/track"
json:
customer_id: "{{ customerId }}"
feature_id: "{{ featureId }}"
value: 1
- get:
url: "/v1/customers/{{ customerId }}"
# ~10% of VUs: attach (upgrade/downgrade through Stripe)
- name: "Attach flow"
weight: 1
beforeScenario: "setAttachContext"
flow:
- post:
url: "/v1/billing.attach"
json:
customer_id: "{{ customerId }}"
product_id: "{{ productId }}"
- post:
url: "/v1/check"
json:
customer_id: "{{ customerId }}"
feature_id: "{{ featureId }}"

View File

@@ -0,0 +1,71 @@
# Memory leak detection — 2 min sustained load (snapshots handled by runLeakTest.ts).
# Usage: cd server && bun loadtest:leak
#
# This file is called by runLeakTest.ts which handles:
# 1. Baseline heap snapshot
# 2. Running this Artillery config
# 3. Target heap snapshot (right after load)
# 4. 60s cooldown for GC
# 5. Final heap snapshot
# 6. memlab analysis
config:
target: "http://localhost:8080"
processor: "./processor.mjs"
http:
timeout: 120
phases:
- duration: 15
arrivalRate: 1
rampTo: 5
name: "Warm-up"
- duration: 120
arrivalRate: 10
name: "Sustained (leak detection)"
defaults:
headers:
Authorization: "Bearer {{ $processEnvironment.UNIT_TEST_AUTUMN_SECRET_KEY }}"
Content-Type: "application/json"
scenarios:
# ~90% of VUs: lightweight check/track/get loop
- name: "Core API loop"
weight: 9
beforeScenario: "setCustomerContext"
flow:
- post:
url: "/v1/check"
json:
customer_id: "{{ customerId }}"
feature_id: "{{ featureId }}"
- post:
url: "/v1/track"
json:
customer_id: "{{ customerId }}"
feature_id: "{{ featureId }}"
value: 1
- get:
url: "/v1/customers/{{ customerId }}"
# ~10% of VUs: attach (upgrade/downgrade through Stripe)
- name: "Attach flow"
weight: 1
beforeScenario: "setAttachContext"
flow:
- post:
url: "/v1/billing.attach"
json:
customer_id: "{{ customerId }}"
product_id: "{{ productId }}"
- post:
url: "/v1/check"
json:
customer_id: "{{ customerId }}"
feature_id: "{{ featureId }}"

View File

@@ -0,0 +1,78 @@
/**
* Artillery processor functions.
*
* Artillery loads this file and calls exported functions
* as hooks during virtual user lifecycle.
*
* Note: this must be plain JS/MJS (not TypeScript) — Artillery loads it directly.
*/
import { readFileSync } from "fs";
import { join, dirname } from "path";
import { fileURLToPath } from "url";
const __dirname = dirname(fileURLToPath(import.meta.url));
// ── Load customer -> stripeId mapping (generated by setup.ts) ───
let customerMap = {};
try {
customerMap = JSON.parse(
readFileSync(join(__dirname, ".customers.json"), "utf-8")
);
} catch {
console.warn(
"[processor] No .customers.json found — attach scenarios will fail.\n" +
" Run: cd server && bun loadtest:setup"
);
}
const CUSTOMER_IDS = Object.keys(customerMap);
const CUSTOMER_COUNT = CUSTOMER_IDS.length || 500;
const CUSTOMER_PREFIX = "load-cus-";
/** Products in the load-test group (for upgrade/downgrade cycling). */
const PLAN_PRODUCTS = ["load-pro", "load-premium", "load-free"];
/** The main metered feature all products include. */
const FEATURE_ID = "messages";
function padNumber(n) {
return String(n).padStart(3, "0");
}
function pickRandom(arr) {
return arr[Math.floor(Math.random() * arr.length)];
}
function getRandomCustomerId() {
if (CUSTOMER_IDS.length > 0) {
return pickRandom(CUSTOMER_IDS);
}
// Fallback if .customers.json not loaded
const num = Math.floor(Math.random() * 500) + 1;
return `${CUSTOMER_PREFIX}${padNumber(num)}`;
}
/**
* Called before each "Core API loop" virtual user.
* Sets customer + feature for check/track/get requests.
*/
export function setCustomerContext(ctx, _events, done) {
ctx.vars.customerId = getRandomCustomerId();
ctx.vars.featureId = FEATURE_ID;
done();
}
/**
* Called before each "Attach flow" virtual user.
* Sets customer + a random product for upgrade/downgrade.
*/
export function setAttachContext(ctx, _events, done) {
const customerId = getRandomCustomerId();
ctx.vars.customerId = customerId;
ctx.vars.stripeId = customerMap[customerId] || "";
ctx.vars.productId = pickRandom(PLAN_PRODUCTS);
ctx.vars.featureId = FEATURE_ID;
done();
}

View File

@@ -0,0 +1,150 @@
/**
* Memory leak test orchestrator.
*
* Runs Artillery load, takes a snapshot before and after a GC cooldown period,
* then prints paths for Chrome DevTools analysis.
*
* Usage: cd server && bun loadtest:leak
*/
import { execSync } from "node:child_process";
import { existsSync, mkdirSync, readdirSync, unlinkSync } from "node:fs";
import { join } from "node:path";
const SNAPSHOTS_DIR = join(import.meta.dir, "../snapshots");
const ARTILLERY_CONFIG = join(import.meta.dir, "memoryLeak.yml");
const SUMMARY_SCRIPT = join(import.meta.dir, "summarizeLeakSnapshots.ts");
const SERVER_URL = "http://localhost:8080";
const SNAPSHOT_ENDPOINT = `${SERVER_URL}/debug/heap-snapshot`;
const COOLDOWN_SECONDS = 60;
const secretKey = process.env.UNIT_TEST_AUTUMN_SECRET_KEY;
if (!secretKey) {
console.error("UNIT_TEST_AUTUMN_SECRET_KEY is required");
process.exit(1);
}
const authHeader = `Bearer ${secretKey}`;
/** Take a heap snapshot and return the file path. */
async function takeSnapshot({ label }: { label: string }): Promise<string> {
console.log(`\n[snapshot] Taking ${label} snapshot...`);
const res = await fetch(SNAPSHOT_ENDPOINT, {
headers: { Authorization: authHeader },
});
if (!res.ok) {
throw new Error(
`Failed to take ${label} snapshot: ${res.status} ${res.statusText}`,
);
}
const data = (await res.json()) as { ok: boolean; file: string; path: string };
console.log(`[snapshot] ${label}: ${data.file}`);
return data.path;
}
/** Wait for n seconds with a countdown. */
async function wait({ seconds, label }: { seconds: number; label: string }) {
for (let i = seconds; i > 0; i--) {
process.stdout.write(`\r[${label}] ${i}s remaining...`);
await new Promise((resolve) => setTimeout(resolve, 1000));
}
process.stdout.write(`\r[${label}] done. \n`);
}
/** Clean old snapshots. */
function cleanSnapshots() {
if (!existsSync(SNAPSHOTS_DIR)) {
mkdirSync(SNAPSHOTS_DIR, { recursive: true });
return;
}
const files = readdirSync(SNAPSHOTS_DIR).filter((f) =>
f.endsWith(".heapsnapshot"),
);
for (const file of files) {
unlinkSync(join(SNAPSHOTS_DIR, file));
}
if (files.length > 0) {
console.log(`[cleanup] Removed ${files.length} old snapshot(s)`);
}
}
function runSnapshotSummary({
beforePath,
afterPath,
}: {
beforePath: string;
afterPath: string;
}) {
console.log("\n[summary] Generating heap diff report...\n");
try {
execSync(
`bun "${SUMMARY_SCRIPT}" --before "${beforePath}" --after "${afterPath}"`,
{
stdio: "inherit",
env: { ...process.env },
},
);
} catch {
console.error("\n[summary] Failed to generate heap diff report");
}
}
async function main() {
console.log("=== Memory Leak Test ===\n");
// 0. Verify server is running
try {
await fetch(SERVER_URL);
} catch {
console.error("Server is not running on localhost:8080. Start it with: bun d");
process.exit(1);
}
// 1. Clean old snapshots
cleanSnapshots();
// 2. Take baseline snapshot (before any load)
const beforePath = await takeSnapshot({ label: "before-load" });
// 3. Run Artillery load test
console.log("\n[artillery] Starting load test...\n");
try {
execSync(`npx artillery run ${ARTILLERY_CONFIG}`, {
stdio: "inherit",
env: { ...process.env },
});
} catch {
console.error("\n[artillery] Load test failed, continuing with snapshots...");
}
// 4. Cooldown — let GC clean up
await wait({ seconds: COOLDOWN_SECONDS, label: "cooldown" });
// 5. Take snapshot after load + GC cooldown
const afterPath = await takeSnapshot({ label: "after-cooldown" });
// 6. Generate terminal-friendly summary for agent/debugging workflows
runSnapshotSummary({ beforePath, afterPath });
console.log("\n=== Done ===");
console.log(`Snapshots saved in: ${SNAPSHOTS_DIR}`);
console.log(" before-load: ", beforePath);
console.log(" after-cooldown: ", afterPath);
console.log(
"\nGenerate a paste-friendly diff: bun perf/load-test/summarizeLeakSnapshots.ts --before \"" +
beforePath +
"\" --after \"" +
afterPath +
"\"",
);
console.log("\nOpen in Chrome DevTools: chrome://inspect → Open dedicated DevTools for Node → Memory → Load");
}
main().catch((err) => {
console.error("Fatal error:", err);
process.exit(1);
});

View File

@@ -0,0 +1,175 @@
/**
* Load test setup — creates products and 500 customers with Stripe payment methods.
*
* Run: cd server && bun loadtest:setup
* or: ENV_FILE=.env infisical run --env=dev -- bun perf/load-test/setup.ts
*/
import { loadLocalEnv } from "../../src/utils/envUtils.js";
loadLocalEnv();
import { writeFileSync } from "node:fs";
import { join } from "node:path";
import { ApiVersion, AppEnv, BillingInterval } from "@autumn/shared";
import { items } from "@tests/utils/fixtures/items.js";
import { products } from "@tests/utils/fixtures/products.js";
import { createProducts } from "@tests/utils/productUtils.js";
import { initDrizzle } from "@/db/initDrizzle.js";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { OrgService } from "@/internal/orgs/OrgService.js";
import { constructPriceItem } from "@/internal/products/product-items/productItemUtils.js";
import { attachPaymentMethod } from "@/utils/scriptUtils/initCustomer.js";
const CUSTOMER_COUNT = 500;
const CUSTOMER_PREFIX = "load-cus-";
const BATCH_SIZE = 50;
const GROUP = "load-test";
function padNumber(n: number) {
return String(n).padStart(3, "0");
}
async function main() {
const { db } = initDrizzle();
const orgSlug = process.env.TESTS_ORG;
if (!orgSlug) {
throw new Error("TESTS_ORG environment variable is required");
}
const org = await OrgService.getBySlug({ db, slug: orgSlug });
if (!org) {
throw new Error(`Org with slug "${orgSlug}" not found`);
}
const env = AppEnv.Sandbox;
const stripeCli = createStripeCli({ org, env });
const secretKey = process.env.UNIT_TEST_AUTUMN_SECRET_KEY;
if (!secretKey) {
throw new Error("UNIT_TEST_AUTUMN_SECRET_KEY is required");
}
const autumn = new AutumnInt({
version: ApiVersion.V1_2,
secretKey,
});
// ── 1. Create products ──────────────────────────────────────────
console.log("Creating load test products...");
const loadFree = products.base({
id: "load-free",
isDefault: true,
group: GROUP,
items: [items.dashboard(), items.monthlyMessages({ includedUsage: 100 })],
});
const loadPro = products.base({
id: "load-pro",
group: GROUP,
items: [
items.dashboard(),
items.monthlyMessages({ includedUsage: 1000 }),
constructPriceItem({ price: 20, interval: BillingInterval.Month }),
],
});
const loadPremium = products.base({
id: "load-premium",
group: GROUP,
items: [
items.dashboard(),
items.unlimitedMessages(),
constructPriceItem({ price: 50, interval: BillingInterval.Month }),
],
});
const loadMessages = products.oneOffAddOn({
id: "load-messages",
items: [items.oneOffMessages({ includedUsage: 500, price: 5 })],
});
await createProducts({
db,
orgId: org.id,
env,
autumn,
products: [loadFree, loadPro, loadPremium, loadMessages],
});
console.log(" load-free (default), load-pro ($20/mo), load-premium ($50/mo), load-messages (add-on)");
// ── 2. Create customers with Stripe payment methods ─────────────
console.log(`\nCreating ${CUSTOMER_COUNT} customers with Stripe payment methods...`);
const customerMap: Record<string, string> = {};
let created = 0;
for (let batch = 0; batch < CUSTOMER_COUNT; batch += BATCH_SIZE) {
const batchEnd = Math.min(batch + BATCH_SIZE, CUSTOMER_COUNT);
const promises = [];
for (let i = batch + 1; i <= batchEnd; i++) {
const customerId = `${CUSTOMER_PREFIX}${padNumber(i)}`;
promises.push(
(async () => {
// Delete existing Autumn customer (idempotent re-runs)
try {
await autumn.customers.delete(customerId);
} catch {}
// Create Stripe customer
const stripeCus = await stripeCli.customers.create({
email: `${customerId}@loadtest.local`,
name: `Load Test ${padNumber(i)}`,
});
// Attach payment method (tok_visa)
await attachPaymentMethod({
stripeCli,
stripeCusId: stripeCus.id,
type: "success",
});
// Create Autumn customer linked to Stripe, with default group
await autumn.customers.create({
id: customerId,
name: `Load Test ${padNumber(i)}`,
email: `${customerId}@loadtest.local`,
stripe_id: stripeCus.id,
internalOptions: {
default_group: GROUP,
},
});
customerMap[customerId] = stripeCus.id;
created++;
})(),
);
}
await Promise.all(promises);
console.log(` ${created}/${CUSTOMER_COUNT} customers created`);
}
// ── 3. Write customer mapping ───────────────────────────────────
const outputPath = join(import.meta.dir, ".customers.json");
writeFileSync(outputPath, JSON.stringify(customerMap, null, 2));
console.log(`\nWrote ${Object.keys(customerMap).length} customer mappings to .customers.json`);
console.log("\nSetup complete! Run: bun loadtest:leak");
}
main()
.catch((error) => {
console.error("Setup failed:", error);
process.exit(1);
})
.finally(() => {
process.exit(0);
});

File diff suppressed because it is too large Load Diff

1
server/perf/snapshots/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
*.heapsnapshot

View File

@@ -40,18 +40,18 @@ export const initDrizzle = ({
// -- Critical pool: used by check, track, getOrCreateCustomer --
export const { db: dbCritical, client: clientCritical } = initDrizzle({
connectTimeout: 2,
// connectTimeout: 10,
});
// -- General pool: used by all other endpoints --
export const { db: dbGeneral, client: clientGeneral } = initDrizzle({
connectTimeout: 5,
// connectTimeout: 5,
});
// -- Replica pool: used as fallback when primary is degraded --
// Only created if DATABASE_REPLICA_URL is configured.
const replicaResult = process.env.DATABASE_REPLICA_URL
? initDrizzle({ replica: true, maxConnections: 5, connectTimeout: 2 })
? initDrizzle({ replica: true, maxConnections: 5, connectTimeout: undefined })
: null;
export const dbReplica = replicaResult?.db ?? null;
export const clientReplica = replicaResult?.client ?? null;

View File

@@ -0,0 +1,52 @@
import type { AppEnv } from "@autumn/shared";
const getSecretFingerprint = ({ secret }: { secret: string }) => {
return Bun.hash(secret).toString();
};
/** Builds cache key for Stripe clients created via org secret key. */
export const buildSecretKeyCacheKey = ({
orgId,
env,
legacyVersion,
encryptedKey,
}: {
orgId: string;
env: AppEnv;
legacyVersion?: boolean;
encryptedKey: string;
}): string => {
return `sk:${orgId}:${env}:${legacyVersion ? 1 : 0}:${encryptedKey}`;
};
/** Builds cache key for Stripe clients created via Autumn's master Stripe keys (env vars). */
export const buildMasterCacheKey = ({
env,
accountId,
legacyVersion,
secretKey,
}: {
env?: AppEnv;
accountId?: string;
legacyVersion?: boolean;
secretKey: string;
}): string => {
return `master:${env || "sandbox"}:${accountId || "none"}:${legacyVersion ? 1 : 0}:${getSecretFingerprint({ secret: secretKey })}`;
};
/** Builds cache key for Stripe clients created via platform (master org) flow. */
export const buildPlatformCacheKey = ({
masterOrgId,
env,
accountId,
legacyVersion,
encryptedKey,
}: {
masterOrgId: string;
env: AppEnv;
accountId?: string;
legacyVersion?: boolean;
encryptedKey: string;
}): string => {
return `platform:${masterOrgId}:${env}:${accountId || "none"}:${legacyVersion ? 1 : 0}:${encryptedKey}`;
};

View File

@@ -0,0 +1,25 @@
import { LRUCache } from "lru-cache";
import type Stripe from "stripe";
const THIRTY_MINUTES_MS = 1000 * 60 * 30;
const stripeClientCache = new LRUCache<string, Stripe>({
max: 500,
ttl: THIRTY_MINUTES_MS,
});
/** Returns a cached Stripe client for the given key, or creates and caches a new one. */
export const getOrCreateStripeClient = ({
cacheKey,
create,
}: {
cacheKey: string;
create: () => Stripe;
}): Stripe => {
const cached = stripeClientCache.get(cacheKey);
if (cached) return cached;
const client = create();
stripeClientCache.set(cacheKey, client);
return client;
};

View File

@@ -8,6 +8,8 @@ import { isStripeConnected } from "@server/internal/orgs/orgUtils.js";
import { decryptData } from "@server/utils/encryptUtils.js";
import { instrumentStripe } from "@server/utils/otel/instrumentStripe.js";
import Stripe from "stripe";
import { buildSecretKeyCacheKey } from "./clientCache/cacheKeyUtils.js";
import { getOrCreateStripeClient } from "./clientCache/stripeClientCache.js";
import { orgToAccountId, shouldUseMaster } from "./connectUtils.js";
import { initMasterStripe, initPlatformStripe } from "./initStripeCli.js";
@@ -38,11 +40,26 @@ export const createStripeCli = ({
});
}
const decrypted = decryptData(encrypted);
return instrumentStripe({
client: new Stripe(decrypted, {
apiVersion: legacyVersion ? ("2025-02-24.acacia" as any) : undefined,
}),
const cacheKey = buildSecretKeyCacheKey({
orgId: org.id,
env,
legacyVersion,
encryptedKey: encrypted,
});
return getOrCreateStripeClient({
cacheKey,
create: () => {
const decrypted = decryptData(encrypted);
return instrumentStripe({
client: new Stripe(decrypted, {
apiVersion: legacyVersion
? // biome-ignore lint/suspicious/noExplicitAny: Need to cast to any to avoid type error
("2025-02-24.acacia" as any)
: undefined,
}),
});
},
});
}

View File

@@ -9,6 +9,11 @@ import { instrumentStripe } from "@server/utils/otel/instrumentStripe.js";
import "dotenv/config";
import type { DrizzleCli } from "@server/db/initDrizzle.js";
import Stripe from "stripe";
import {
buildMasterCacheKey,
buildPlatformCacheKey,
} from "./clientCache/cacheKeyUtils.js";
import { getOrCreateStripeClient } from "./clientCache/stripeClientCache.js";
import { getConnectWebhookSecret } from "./connectUtils.js";
export const initMasterStripe = (params?: {
@@ -34,17 +39,24 @@ export const initMasterStripe = (params?: {
secretKey = process.env.STRIPE_SANDBOX_SECRET_KEY;
}
// if (!params) {
// return new Stripe(secretKey);
// }
const cacheKey = buildMasterCacheKey({
env: params?.env,
accountId: params?.accountId,
legacyVersion: params?.legacyVersion,
secretKey,
});
return instrumentStripe({
client: new Stripe(secretKey, {
stripeAccount: params?.accountId,
apiVersion: params?.legacyVersion
? ("2025-02-24.acacia" as any)
: undefined,
}),
return getOrCreateStripeClient({
cacheKey,
create: () =>
instrumentStripe({
client: new Stripe(secretKey, {
stripeAccount: params?.accountId,
apiVersion: params?.legacyVersion
? ("2025-02-24.acacia" as any)
: undefined,
}),
}),
});
};
@@ -78,18 +90,31 @@ export const initPlatformStripe = ({
});
}
const decrypted = decryptData(encrypted);
if (!decrypted) {
throw new InternalError({
message: `Failed to decrypt master organization's Stripe secret key`,
});
}
const cacheKey = buildPlatformCacheKey({
masterOrgId: masterOrg.id,
env,
accountId,
legacyVersion,
encryptedKey: encrypted,
});
return instrumentStripe({
client: new Stripe(decrypted, {
stripeAccount: accountId || undefined,
apiVersion: legacyVersion ? ("2025-02-24.acacia" as any) : undefined,
}),
return getOrCreateStripeClient({
cacheKey,
create: () => {
const decrypted = decryptData(encrypted);
if (!decrypted) {
throw new InternalError({
message: "Failed to decrypt master organization's Stripe secret key",
});
}
return instrumentStripe({
client: new Stripe(decrypted, {
stripeAccount: accountId || undefined,
apiVersion: legacyVersion ? ("2025-02-24.acacia" as any) : undefined,
}),
});
},
});
};

View File

@@ -29,7 +29,6 @@ import {
UPSERT_INVOICE_IN_CUSTOMER_SCRIPT,
} from "../../_luaScriptsV2/luaScriptsV2.js";
import { instrumentRedis } from "../../utils/otel/instrumentRedis.js";
import { getActiveRedis, initFailover } from "./redisFailover.js";
// if (!process.env.CACHE_URL) {
// throw new Error("CACHE_URL (redis) is not set");
@@ -275,71 +274,21 @@ const primaryRedis = createRedisConnection({
region: currentRegion,
});
// Eagerly create failover instance (other region) for automatic failover
const failoverRegion =
ALL_REGIONS.find((r) => r !== currentRegion && regionToCacheUrl[r]) ?? null;
let failoverRedis: Redis | null = null;
if (failoverRegion) {
const failoverUrl = regionToCacheUrl[failoverRegion]!;
// Only create a separate instance if it's actually a different server
if (failoverUrl !== primaryCacheUrl) {
failoverRedis = createRedisConnection({
cacheUrl: failoverUrl,
region: failoverRegion,
});
}
}
// Initialize failover — monitors primary health and swaps `redis` automatically
initFailover({
primary: primaryRedis,
failover: failoverRedis,
failoverRegion,
currentRegion,
});
/**
* The active Redis instance. All consumer code imports this.
* Normally points to the primary (current region). During a primary outage,
* the failover module swaps this to the other region's instance automatically.
* Normally points to the primary (current region).
*
* This is a `let` so it's a live ES module binding — reassignments here
* are visible to all importers on their next access.
*/
export let redis: Redis = primaryRedis;
// Subscribe to failover state changes — keep the `redis` export in sync.
// We do this here (not in redisFailover.ts) because the module binding
// can only be reassigned in the module that declares it.
const syncRedisBinding = () => {
const active = getActiveRedis();
if (redis !== active) {
redis = active;
}
};
primaryRedis.on("error", syncRedisBinding);
primaryRedis.on("ready", syncRedisBinding);
if (failoverRedis) {
failoverRedis.on("error", syncRedisBinding);
failoverRedis.on("ready", syncRedisBinding);
}
// Also poll periodically to catch any edge cases with event timing
setInterval(syncRedisBinding, 2000);
export const redis: Redis = primaryRedis;
// Lazy-loaded regional Redis instances for cross-region sync
const regionalRedisInstances: Map<string, Redis> = new Map();
// Pre-populate with eagerly created instances
if (failoverRedis && failoverRegion) {
regionalRedisInstances.set(failoverRegion, failoverRedis);
}
/** Get Redis instance for a specific region (lazy-loaded) */
export const getRegionalRedis = (region: string): Redis => {
// Always return the actual primary for the current region (not the active/failover)
// so cross-region sync logic isn't affected by failover state.
// If requesting current region, return primary instance
if (region === currentRegion) {
return primaryRedis;
}

View File

@@ -1,31 +1,194 @@
import type { Redis } from "ioredis";
import { logger } from "@/external/logtail/logtailUtils.js";
/** How long primary must be erroring before we switch to failover. */
const FAILOVER_DELAY_MS = 5_000;
// ── Config ──────────────────────────────────────────────────────────
/** How long primary must stay down before we switch to failover. */
const FAILOVER_THRESHOLD_MS = 60_000;
/** How long primary must be stable before we switch back from failover. */
const RECOVERY_DELAY_MS = 3_000;
/** How long primary must stay healthy before we switch back. */
const RECOVERY_THRESHOLD_MS = 5_000;
export type RedisFailoverState = {
/** The currently active Redis instance (what consumers use). */
/** Health-check polling interval. */
const POLL_INTERVAL_MS = 2_000;
/** Log a warning if blip count exceeds this in the trailing window. */
const BLIP_WARN_THRESHOLD = 10;
/** Trailing window for blip counting. */
const BLIP_WINDOW_MS = 60 * 60 * 1_000; // 1 hour
// ── State machine ───────────────────────────────────────────────────
type FailoverPhase = "NORMAL" | "DEGRADED" | "FAILOVER" | "RECOVERING";
type FailoverState = {
phase: FailoverPhase;
active: Redis;
/** Always the current region's instance. */
primary: Redis;
/** The other region's instance (null if only one region configured). */
failover: Redis | null;
/** Whether we're currently using the failover instance. */
isUsingFailover: boolean;
/** Region name of the failover instance. */
failoverRegion: string | null;
/** Timestamp when the current phase was entered. */
phaseEnteredAt: number;
};
let state: RedisFailoverState;
let primaryErrorSince: number | null = null;
let recoveryTimer: ReturnType<typeof setTimeout> | null = null;
let state: FailoverState;
let primaryHasBeenReady = false;
let pollTimer: ReturnType<typeof setInterval> | null = null;
/** Initialize failover state. Call once after creating both Redis instances. */
/** Tracks timestamps of recent transient blips (DEGRADED → NORMAL). */
const blipTimestamps: number[] = [];
// ── Callbacks ───────────────────────────────────────────────────────
type StateChangeCallback = () => void;
const onChangeCallbacks: StateChangeCallback[] = [];
/** Register a callback invoked whenever `active` changes. */
export const onActiveChange = (cb: StateChangeCallback): void => {
onChangeCallbacks.push(cb);
};
const notifyChange = (): void => {
for (const cb of onChangeCallbacks) {
try {
cb();
} catch (err) {
logger.error("[Redis failover] onActiveChange callback threw", {
error: err,
});
}
}
};
// ── Helpers ─────────────────────────────────────────────────────────
const isPrimaryReady = (): boolean => state.primary.status === "ready";
const isFailoverReady = (): boolean => state.failover?.status === "ready";
const setPhase = (phase: FailoverPhase): void => {
state.phase = phase;
state.phaseEnteredAt = Date.now();
};
const msInPhase = (): number => Date.now() - state.phaseEnteredAt;
const pruneBlips = (): void => {
const cutoff = Date.now() - BLIP_WINDOW_MS;
while (blipTimestamps.length > 0 && blipTimestamps[0] < cutoff) {
blipTimestamps.shift();
}
};
const recordBlip = ({ durationMs }: { durationMs: number }): void => {
blipTimestamps.push(Date.now());
pruneBlips();
logger.warn(
`[Redis failover] Primary blip #${blipTimestamps.length} (recovered in ${durationMs}ms)`,
{
type: "redis_failover_blip",
blipCount: blipTimestamps.length,
durationMs,
},
);
if (blipTimestamps.length >= BLIP_WARN_THRESHOLD) {
logger.error(
`[Redis failover] ${blipTimestamps.length} blips in the last hour — check Redis health`,
{
type: "redis_failover_blip_alert",
blipCount: blipTimestamps.length,
},
);
}
};
// ── Core poll tick ──────────────────────────────────────────────────
const tick = (): void => {
const ready = isPrimaryReady();
switch (state.phase) {
case "NORMAL": {
if (!ready && primaryHasBeenReady) {
setPhase("DEGRADED");
logger.warn("[Redis failover] Primary unhealthy — entering DEGRADED", {
type: "redis_failover_degraded",
primaryStatus: state.primary.status,
});
}
break;
}
case "DEGRADED": {
if (ready) {
// Blip — primary recovered before we had to failover
recordBlip({ durationMs: msInPhase() });
setPhase("NORMAL");
break;
}
if (msInPhase() >= FAILOVER_THRESHOLD_MS) {
if (!state.failover || !isFailoverReady()) {
logger.error(
"[Redis failover] Threshold reached but failover instance not ready",
{
type: "redis_failover_switch",
failoverStatus: state.failover?.status ?? "none",
},
);
break;
}
state.active = state.failover;
setPhase("FAILOVER");
notifyChange();
logger.error(
`[Redis failover] SWITCHED to failover region (${state.failoverRegion})`,
{
type: "redis_failover_switch",
failoverRegion: state.failoverRegion,
},
);
}
break;
}
case "FAILOVER": {
if (ready) {
setPhase("RECOVERING");
logger.info("[Redis failover] Primary back — entering RECOVERING", {
type: "redis_failover_recovering",
});
}
break;
}
case "RECOVERING": {
if (!ready) {
// Primary dropped again — go back to failover
setPhase("FAILOVER");
logger.warn(
"[Redis failover] Primary dropped during recovery — back to FAILOVER",
{ type: "redis_failover_recovery_failed" },
);
break;
}
if (msInPhase() >= RECOVERY_THRESHOLD_MS) {
state.active = state.primary;
setPhase("NORMAL");
notifyChange();
logger.info("[Redis failover] RECOVERED to primary region", {
type: "redis_failover_recovered",
});
}
break;
}
}
};
// ── Public API ──────────────────────────────────────────────────────
/** Initialize failover. Call once after creating both Redis instances. */
export const initFailover = ({
primary,
failover,
@@ -38,135 +201,83 @@ export const initFailover = ({
currentRegion: string;
}): void => {
state = {
phase: "NORMAL",
active: primary,
primary,
failover,
isUsingFailover: false,
failoverRegion,
phaseEnteredAt: Date.now(),
};
if (!failover) {
logger.info(
"[Redis failover] No failover region configured — failover disabled",
{
type: "redis_failover_init",
},
{ type: "redis_failover_init" },
);
return;
}
logger.info(
`[Redis failover] Enabled: primary=${currentRegion}, failover=${failoverRegion}`,
{
type: "redis_failover_init",
currentRegion,
failoverRegion,
},
{ type: "redis_failover_init", currentRegion, failoverRegion },
);
const onPrimaryDown = () => {
// Don't trigger failover during initial startup — only after
// the primary has successfully connected at least once.
if (!primaryHasBeenReady) return;
if (!primaryErrorSince) {
primaryErrorSince = Date.now();
// Schedule failover after the delay
setTimeout(() => {
if (primaryErrorSince && primary.status !== "ready") {
switchToFailover();
}
}, FAILOVER_DELAY_MS);
}
};
// Listen to all events that indicate the primary is unhealthy
primary.on("error", onPrimaryDown);
primary.on("close", onPrimaryDown);
primary.on("end", onPrimaryDown);
// Track when primary first connects so we don't failover during startup
primary.on("ready", () => {
primaryHasBeenReady = true;
primaryErrorSince = null;
if (!state.isUsingFailover) return;
// Primary recovered — wait for stability before switching back
if (!recoveryTimer) {
recoveryTimer = setTimeout(() => {
recoveryTimer = null;
if (primary.status === "ready") {
switchToPrimary();
}
}, RECOVERY_DELAY_MS);
}
});
};
const switchToFailover = (): void => {
if (!state.failover || state.isUsingFailover) return;
if (state.failover.status !== "ready") {
logger.error(
"[Redis failover] Cannot switch — failover instance is not ready",
{
type: "redis_failover_switch",
failoverStatus: state.failover.status,
},
);
return;
// Clear any existing state from a previous init
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
primaryHasBeenReady = false;
blipTimestamps.length = 0;
state.active = state.failover;
state.isUsingFailover = true;
logger.error(
`[Redis failover] SWITCHED to failover region (${state.failoverRegion})`,
{
type: "redis_failover_switch",
failoverRegion: state.failoverRegion,
},
);
};
const switchToPrimary = (): void => {
if (!state.isUsingFailover) return;
state.active = state.primary;
state.isUsingFailover = false;
primaryErrorSince = null;
logger.info("[Redis failover] RECOVERED to primary region", {
type: "redis_failover_recovered",
});
// Start the single polling loop
pollTimer = setInterval(tick, POLL_INTERVAL_MS);
};
/** Get the currently active Redis instance. */
export const getActiveRedis = (): Redis => state.active;
/** Get the current failover state (for debug/monitoring). */
/** Get current failover state (for debug/monitoring). */
export const getFailoverState = (): {
phase: FailoverPhase;
isUsingFailover: boolean;
failoverRegion: string | null;
primaryStatus: string;
failoverStatus: string | null;
primaryErrorSince: number | null;
} => ({
isUsingFailover: state.isUsingFailover,
failoverRegion: state.failoverRegion,
primaryStatus: state.primary.status,
failoverStatus: state.failover?.status ?? null,
primaryErrorSince,
});
msInPhase: number;
blipsLastHour: number;
} => {
pruneBlips();
return {
phase: state.phase,
isUsingFailover: state.phase === "FAILOVER" || state.phase === "RECOVERING",
failoverRegion: state.failoverRegion,
primaryStatus: state.primary.status,
failoverStatus: state.failover?.status ?? null,
msInPhase: msInPhase(),
blipsLastHour: blipTimestamps.length,
};
};
/**
* Force disconnect the primary instance (for testing).
* ioredis will NOT auto-reconnect after a manual disconnect() call.
* Use `reconnectPrimary()` to manually reconnect.
*/
/** Force disconnect the primary (for testing). */
export const disconnectPrimary = (): void => {
state.primary.disconnect();
};
/** Force reconnect the primary instance (for testing). */
/** Force reconnect the primary (for testing). */
export const reconnectPrimary = (): void => {
state.primary.connect();
};
/** Stop the polling loop (for testing/cleanup). */
export const stopFailoverPolling = (): void => {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
};

View File

@@ -82,19 +82,42 @@ const checkCurStripePrice = async ({
}
}
let stripePrepaidPriceV2: Stripe.Price | undefined;
if (!config.stripe_prepaid_price_v2_id) {
stripePrepaidPriceV2 = undefined;
} else {
stripePrepaidPriceV2 = await getStripePrice({
stripeClient: stripeCli,
stripePriceId: config.stripe_prepaid_price_v2_id,
});
}
const getStripeEmptyPrice = async () => {
let stripeEmptyPrice: Stripe.Price | undefined;
if (!config.stripe_empty_price_id) {
stripeEmptyPrice = undefined;
} else {
stripeEmptyPrice = await getStripePrice({
stripeClient: stripeCli,
stripePriceId: config.stripe_empty_price_id,
});
}
return stripeEmptyPrice;
};
const getStripePrepaidPriceV2 = async () => {
let stripePrepaidPriceV2: Stripe.Price | undefined;
if (!config.stripe_prepaid_price_v2_id) {
stripePrepaidPriceV2 = undefined;
} else {
stripePrepaidPriceV2 = await getStripePrice({
stripeClient: stripeCli,
stripePriceId: config.stripe_prepaid_price_v2_id,
});
}
return stripePrepaidPriceV2;
};
const [stripeEmptyPrice, stripePrepaidPriceV2] = await Promise.all([
getStripeEmptyPrice(),
getStripePrepaidPriceV2(),
]);
return {
stripePrice,
stripePrepaidPriceV2,
stripeEmptyPrice,
stripeProd,
};
};
@@ -121,7 +144,7 @@ export const createStripePriceIFNotExist = async ({
const billingType = getBillingType(price.config!);
const { stripePrice, stripePrepaidPriceV2, stripeProd } =
const { stripePrice, stripePrepaidPriceV2, stripeProd, stripeEmptyPrice } =
await checkCurStripePrice({
price,
stripeCli,
@@ -232,7 +255,7 @@ export const createStripePriceIFNotExist = async ({
useCheckout,
});
if (!config.stripe_empty_price_id) {
if (!stripeEmptyPrice) {
try {
logger.info(`Creating stripe empty price`);
// console.log(`Product: ${config.stripe_product_id || stripeProd?.id}`);

View File

@@ -104,6 +104,6 @@ export const updateOptionsFromStripeCheckoutSession = async ({
}
}
console.log("FEATURE OPTIONS:", newCustomerProduct.options);
}
};

View File

@@ -69,10 +69,6 @@ export const handleCheckoutSub = async ({
billingType: BillingType.UsageInArrear,
});
console.log("internal entity id:", attachParams.internalEntityId);
console.log("api version:", attachParams.apiVersion);
if (
arrearPrice &&
(attachParams.internalEntityId ||

View File

@@ -51,8 +51,15 @@ export const handleRemainingSets = async ({
}
if (remainingItems.length > 0) {
const sanitizedItems = remainingItems.map((item) => {
if (!("adjustable_quantity" in item)) return item;
const { adjustable_quantity: _adjustableQuantity, ...rest } = item;
return rest;
});
await stripeCli.subscriptions.update(checkoutSub!.id, {
items: remainingItems,
items: sanitizedItems,
});
}

View File

@@ -37,8 +37,7 @@ const extractCustomerIdFromBody = ({
method: string;
}): string | undefined => {
const isCreateCustomerPath =
(path.startsWith("/v1/customers") && method === "POST") ||
path.includes("customers.get_or_create");
(path.startsWith("/v1/customers") && method === "POST" && !path.includes("customers.get_or_create"));
return (isCreateCustomerPath ? body?.id : body?.customer_id) as
| string
| undefined;

View File

@@ -26,7 +26,12 @@ export const baseMiddleware = async (c: Context<HonoEnv>, next: Next) => {
const timestamp = Date.now();
const { data: body } = await tryCatch(c.req.json());
const { data: body } =
c.req.method !== "GET" && c.req.method !== "HEAD"
? await tryCatch(c.req.json())
: { data: undefined };
// const { data: body } = await tryCatch(c.req.json());
const childLogger = addRequestToLogs({
logger,

View File

@@ -51,7 +51,7 @@ if (process.env.NODE_ENV === "development") {
console.log(`Master ${process.pid} is running`);
console.log("Number of CPUs", numCPUs);
const numWorkers = 2;
const numWorkers = 3;
for (let i = 0; i < numWorkers; i++) {
cluster.fork();

View File

@@ -16,7 +16,6 @@ import { errorMiddleware } from "./honoMiddlewares/errorMiddleware.js";
import { traceEnrichMiddleware } from "./honoMiddlewares/traceMiddleware.js";
import type { HonoEnv } from "./honoUtils/HonoEnv.js";
import { handleHealthCheck } from "./honoUtils/handleHealthCheck.js";
import { debugRouter } from "./internal/debug/debugRouter.js";
import { cliRouter } from "./internal/dev/cli/cliRouter.js";
import { handleOAuthCallback } from "./internal/orgs/handlers/stripeHandlers/handleOAuthCallback.js";
import { apiRouter } from "./routers/apiRouter.js";
@@ -139,7 +138,9 @@ export const createHonoApp = () => {
// Public routes (no auth required)
app.route("", publicRouter);
// Debug routes (no auth, dev-only guard is inside the handler)
// Debug routes (auth handled internally)
// app.route("/debug", heapSnapshotRouter);
// app.route("/v1/debug", debugRouter);
// API Middleware

View File

@@ -3,7 +3,6 @@ import { logger } from "@/external/logtail/logtailUtils.js";
import { currentRegion } from "@/external/redis/initRedis.js";
import { JobName } from "@/queue/JobName.js";
import { addTaskToQueue } from "@/queue/queueUtils.js";
import { generateId } from "@/utils/genUtils.js";
interface CustomerBatchContext {
customerId: string;
@@ -20,17 +19,56 @@ interface CustomerBatch {
timer: NodeJS.Timeout | null;
}
export type QueueSyncPayload = {
jobName: string;
payload: {
customerId: string;
orgId: string;
env: AppEnv;
region: string;
timestamp: number;
cusEntIds: string[];
rolloverIds: string[];
};
messageGroupId?: string;
messageDeduplicationId: string;
};
/**
* Batching manager for syncing FullCustomer cache to PostgreSQL.
* Batches by customer, collects modified cusEntIds within a time window.
* Batches sync jobs per customer using a fixed tumbling window.
* Timer is set once when the batch is created — subsequent items just merge.
* Cross-instance dedup is handled via a stable SQS/BullMQ dedup ID
* bucketed at DEDUP_BUCKET_MS.
*/
class SyncBatchingManagerV2 {
export class SyncBatchingManagerV2 {
private customerBatches: Map<string, CustomerBatch> = new Map();
private readonly BATCH_WINDOW_MS =
process.env.NODE_ENV === "development" ? 1000 : 5000;
/** Fixed window: the batch flushes this long after the first item */
private readonly BATCH_WINDOW_MS: number = 1000; // 1 second batch window
private readonly MAX_BATCH_SIZE = 1000;
/** Cross-instance dedup bucket. Messages with the same content in the same bucket share a dedup ID. */
private readonly DEDUP_BUCKET_MS: number = 2500; // 2.5 seconds dedup bucket
/** Injectable queue function — overridable for tests */
private readonly _addTaskToQueue: (args: QueueSyncPayload) => Promise<void>;
constructor({
addTaskToQueueFn,
batchWindowMs,
dedupBucketMs,
}: {
addTaskToQueueFn?: (args: QueueSyncPayload) => Promise<void>;
batchWindowMs?: number;
dedupBucketMs?: number;
} = {}) {
this._addTaskToQueue =
addTaskToQueueFn ??
(addTaskToQueue as unknown as (args: QueueSyncPayload) => Promise<void>);
this.BATCH_WINDOW_MS = batchWindowMs ?? 1000;
this.DEDUP_BUCKET_MS = dedupBucketMs ?? 2500;
}
addSyncItem({
customerId,
orgId,
@@ -52,11 +90,14 @@ class SyncBatchingManagerV2 {
if (!batch) {
batch = this.createBatch({ customerId, orgId, env, region });
this.customerBatches.set(batchKey, batch);
// Fixed window: schedule ONCE when the batch is created.
// Subsequent items just merge — the timer is not reset.
this.scheduleCustomerBatch({ batchKey });
}
this.mergeCusEntIds({ batch, cusEntIds });
this.mergeRolloverIds({ batch, rolloverIds: rolloverIds ?? [] });
batch.context.timestamp = Date.now();
if (region) {
batch.context.region = region;
}
@@ -65,10 +106,7 @@ class SyncBatchingManagerV2 {
batch.context.cusEntIds.size + batch.context.rolloverIds.size;
if (totalSize >= this.MAX_BATCH_SIZE) {
this.executeCustomerBatch({ batchKey });
return;
}
this.scheduleCustomerBatch({ batchKey });
}
getStats(): {
@@ -161,8 +199,6 @@ class SyncBatchingManagerV2 {
const batch = this.customerBatches.get(batchKey);
if (!batch) return;
this.clearBatchTimer({ batch });
batch.timer = setTimeout(() => {
this.executeCustomerBatch({ batchKey });
}, this.BATCH_WINDOW_MS);
@@ -196,60 +232,17 @@ class SyncBatchingManagerV2 {
}
}
private async queueSyncJob({
context,
}: {
context: CustomerBatchContext;
}): Promise<void> {
const cusEntIds = Array.from(context.cusEntIds).sort();
const rolloverIds = Array.from(context.rolloverIds).sort();
const timestamp = Date.now();
const messageDeduplicationId = this.getMessageDeduplicationId({
context,
cusEntIds,
rolloverIds,
timestamp,
});
try {
await addTaskToQueue({
jobName: JobName.SyncBalanceBatchV3,
payload: {
customerId: context.customerId,
orgId: context.orgId,
env: context.env,
region: context.region,
timestamp,
cusEntIds,
rolloverIds,
},
messageGroupId: generateId("msg"),
messageDeduplicationId,
generateDeduplicationId: false,
});
logger.info(
`[SyncV3] Queued sync for ${context.customerId}, ${cusEntIds.length} entitlements, ${rolloverIds.length} rollovers`,
);
} catch (error) {
logger.error(
`[SyncV3] Failed to queue sync for ${context.customerId}: ${error}`,
);
}
}
private getMessageDeduplicationId({
/** Stable dedup ID from batch content + 5s time bucket */
private buildDeduplicationId({
context,
cusEntIds,
rolloverIds,
timestamp,
}: {
context: CustomerBatchContext;
cusEntIds: string[];
rolloverIds: string[];
timestamp: number;
}): string {
const dedupBucket = Math.floor(timestamp / this.BATCH_WINDOW_MS);
const dedupBucket = Math.floor(Date.now() / this.DEDUP_BUCKET_MS);
const dedupKey = JSON.stringify({
jobName: JobName.SyncBalanceBatchV3,
orgId: context.orgId,
@@ -262,6 +255,44 @@ class SyncBatchingManagerV2 {
return Bun.hash(dedupKey).toString();
}
private async queueSyncJob({
context,
}: {
context: CustomerBatchContext;
}): Promise<void> {
const cusEntIds = Array.from(context.cusEntIds).sort();
const rolloverIds = Array.from(context.rolloverIds).sort();
const messageDeduplicationId = this.buildDeduplicationId({
context,
cusEntIds,
rolloverIds,
});
try {
await this._addTaskToQueue({
jobName: JobName.SyncBalanceBatchV3,
payload: {
customerId: context.customerId,
orgId: context.orgId,
env: context.env,
region: context.region,
timestamp: Date.now(),
cusEntIds,
rolloverIds,
},
messageDeduplicationId,
});
logger.info(
`[SyncV3] Queued sync for ${context.customerId}, ${cusEntIds.length} entitlements, ${rolloverIds.length} rollovers`,
);
} catch (error) {
logger.error(
`[SyncV3] Failed to queue sync for ${context.customerId}: ${error}`,
);
}
}
}
export const globalSyncBatchingManagerV2 = new SyncBatchingManagerV2();

View File

@@ -37,6 +37,7 @@ export const updateCustomerEntitlements = async ({
customerId,
cusEntId: customerEntitlement.id,
updates,
incrementCacheVersion: true,
});
continue;
}

View File

@@ -1,4 +1,8 @@
import type { AutumnBillingPlan, BillingContext } from "@autumn/shared";
import {
type AutumnBillingPlan,
type BillingContext,
formatMs,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { getTrialStateTransition } from "@/internal/billing/v2/utils/billingContext/getTrialStateTransition";
import { addToExtraLogs } from "@/utils/logging/addToExtraLogs";
@@ -56,9 +60,10 @@ export const logAutumnBillingPlan = ({
.join(", ") || "none",
lineItems:
plan.lineItems?.map(
(item) => `${item.description}: ${item.amountAfterDiscounts}`,
) ?? "none",
plan.lineItems?.map((item) => ({
item: `${item.description}: ${item.amountAfterDiscounts}`,
effectivePeriod: `${formatMs(item.context.effectivePeriod?.start)} - ${formatMs(item.context.effectivePeriod?.end)}`,
})) ?? "none",
},
},
});

View File

@@ -225,3 +225,25 @@ debugRouter.post("/pg-health", async (c) => {
return c.json({ error: "Unknown action" }, 400);
});
/** Write a V8 heap snapshot to disk. Requires secret key auth + dev-only. */
// debugRouter.get("/heap-snapshot", async (c) => {
// if (process.env.NODE_ENV === "production") {
// return c.json({ error: "Not available in production" }, 403);
// }
// const ctx = c.get("ctx");
// if (!ALLOWED_ORG_IDS.has(ctx.org?.id)) {
// return c.json({ error: "Forbidden" }, 403);
// }
// const snapshotDir = new URL("../../../perf/snapshots/", import.meta.url)
// .pathname;
// const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
// const filename = `heap-${timestamp}-pid${process.pid}.heapsnapshot`;
// const filepath = `${snapshotDir}${filename}`;
// writeHeapSnapshot(filepath);
// return c.json({ ok: true, file: filename, path: filepath });
// });

View File

@@ -47,7 +47,7 @@ const updateLinkedCusEnt = async ({
};
delete newEntities[replaceableId];
} else {
const balance = linkedCusEnt.entitlement.allowance!;
const balance = linkedCusEnt.entitlement.allowance ?? 0; // cannot be null, must be 0 in unlimited case.
newEntities[entity.id] = {
id: entity.id,
balance,

View File

@@ -25,6 +25,14 @@ export const getRateLimitType = (c: Context<HonoEnv>) => {
method: "POST",
url: "/v1/track",
},
{
method: "POST",
url: "/v1/balances.track",
},
{
method: "POST",
url: "/v1/balances.finalize",
},
];
// Patterns for check endpoints (including dynamic customer_id)
@@ -37,6 +45,10 @@ export const getRateLimitType = (c: Context<HonoEnv>) => {
method: "POST",
url: "/v1/entitled",
},
{
method: "POST",
url: "/v1/balances.check",
},
];
const getCustomerPatterns = [
@@ -52,6 +64,14 @@ export const getRateLimitType = (c: Context<HonoEnv>) => {
method: "POST",
url: "/v1/customers",
},
{
method: "POST",
url: "/v1/customers.get_or_create",
},
{
method: "POST",
url: "/v1/entities.get",
},
];
const eventsPatterns = [
@@ -67,6 +87,14 @@ export const getRateLimitType = (c: Context<HonoEnv>) => {
method: "POST",
url: "/v1/query",
},
{
method: "POST",
url: "/v1/events.list",
},
{
method: "POST",
url: "/v1/events.aggregate",
},
];
const attachPatterns = [
@@ -74,6 +102,18 @@ export const getRateLimitType = (c: Context<HonoEnv>) => {
method: "POST",
url: "/v1/attach",
},
{
method: "POST",
url: "/v1/billing.attach",
},
{
method: "POST",
url: "/v1/billing.multi_attach",
},
{
method: "POST",
url: "/v1/billing.update",
},
];
const listProductsPatterns = [
@@ -89,6 +129,10 @@ export const getRateLimitType = (c: Context<HonoEnv>) => {
method: "GET",
url: "/v1/plans",
},
{
method: "POST",
url: "/v1/plans.list",
},
];
const patternMap: {

View File

@@ -27,6 +27,7 @@ const MAX_MESSAGES_BEFORE_RECYCLE = 50_000;
// Idle self-kill — exit if worker processes 0 messages for this many consecutive intervals
const IDLE_SELF_KILL_THRESHOLD = 5; // ~5 min of 0 messages (5 * 60s)
const shouldIdleSelfKill = process.env.NODE_ENV !== "development";
// Per-message processing timeout — must be under VisibilityTimeout (30s)
const MESSAGE_TIMEOUT_MS = 25_000;
@@ -134,6 +135,7 @@ const startPollingLoop = async ({
consecutiveZeroMessageIntervals++;
if (
shouldIdleSelfKill &&
consecutiveZeroMessageIntervals >= IDLE_SELF_KILL_THRESHOLD &&
totalMessagesProcessed > 0 &&
activeMigrationJobs === 0

View File

@@ -130,9 +130,11 @@ export const addTaskToQueue = async <T extends keyof Payloads>({
if (process.env.QUEUE_URL) {
const { queue } = await import("./bullmq/initBullMq.js");
// BullMQ implementation (ignores messageGroupId)
// BullMQ dedup: if a stable dedup ID is provided, use it as the jobId.
// BullMQ ignores jobs whose jobId already exists in the queue (not yet completed).
await queue.add(jobName as string, payload, {
delay: delayMs,
...(messageDeduplicationId && { jobId: messageDeduplicationId }),
});
return;
}

View File

@@ -1,12 +1,23 @@
/**
* Periodic memory usage logger for diagnosing memory leaks.
*
* Logs heap usage, RSS, external memory, and array buffers every interval.
* Logs heap usage, RSS, external memory, array buffers, and event loop lag every interval.
* Uses Axiom logger so metrics are queryable via type: "memory_log".
*/
import { monitorEventLoopDelay } from "node:perf_hooks";
import { logger } from "../external/logtail/logtailUtils.js";
// Event loop lag histogram — samples at 100ms resolution at the C++ level.
// No JS callbacks involved, negligible overhead.
const lagHistogram = monitorEventLoopDelay({ resolution: 100 });
lagHistogram.enable();
/** Get the current mean event loop lag in milliseconds. */
export function getEventLoopLagMs(): number {
return Math.round((lagHistogram.mean / 1e6) * 10) / 10;
}
const DEFAULT_INTERVAL_MS = 60_000; // 1 minute
let intervalHandle: ReturnType<typeof setInterval> | null = null;
@@ -18,18 +29,28 @@ function toMB(bytes: number): number {
function logMemoryUsage(label: string) {
const mem = process.memoryUsage();
logger.info("memory_log", {
type: "memory_log",
data: {
label,
pid: process.pid,
rssMB: toMB(mem.rss),
heapUsedMB: toMB(mem.heapUsed),
heapTotalMB: toMB(mem.heapTotal),
externalMB: toMB(mem.external),
arrayBuffersMB: toMB(mem.arrayBuffers),
const lagMeanMs = Math.round((lagHistogram.mean / 1e6) * 10) / 10;
const lagP99Ms = Math.round((lagHistogram.percentile(99) / 1e6) * 10) / 10;
lagHistogram.reset();
logger.info(
`memory_log, rss: ${toMB(mem.rss)}MB, heapUsed: ${toMB(mem.heapUsed)}MB, eventLoopLagP99: ${lagP99Ms}ms`,
{
type: "memory_log",
data: {
label,
pid: process.pid,
rssMB: toMB(mem.rss),
heapUsedMB: toMB(mem.heapUsed),
heapTotalMB: toMB(mem.heapTotal),
externalMB: toMB(mem.external),
arrayBuffersMB: toMB(mem.arrayBuffers),
nativeGapMB: toMB(mem.rss - mem.heapTotal - mem.external),
eventLoopLagMeanMs: lagMeanMs,
eventLoopLagP99Ms: lagP99Ms,
},
},
});
);
}
/**

View File

@@ -1,9 +1,4 @@
import { expect, test } from "bun:test";
import type { ApiCustomerV3 } from "@autumn/shared";
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
import { expectCustomerProducts } from "@tests/integration/billing/utils/expectCustomerProductCorrect";
import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect";
import { TestFeature } from "@tests/setup/v2Features";
import { completeStripeCheckoutFormV2 as completeStripeCheckoutForm } from "@tests/utils/browserPool/completeStripeCheckoutFormV2";
import { items } from "@tests/utils/fixtures/items";
@@ -12,103 +7,50 @@ import { timeout } from "@tests/utils/genUtils";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
test.concurrent(`${chalk.yellowBright("temp: multi-attach checkout pro annual + prepaid messages")}`, async () => {
const customerId = "temp-multi-attach-pro-annual-prepaid";
const prepaidQuantity = 300;
const prepaidQuantity2 = 600;
test.concurrent(`${chalk.yellowBright("temp: legacy checkout annual base + adjustable monthly prepaid")}`, async () => {
const customerId = "temp-legacy-checkout-annual-adjustable-prepaid";
const includedCallMinutes = 100;
const checkoutQuantityInUnits = 300;
const annualMessagesItem = items.monthlyWords({ includedUsage: 200 });
const prepaidMessagesItem = items.prepaidMessages({
includedUsage: 0,
const monthlyPrepaidCallMinutes = items.prepaid({
featureId: TestFeature.Messages,
includedUsage: includedCallMinutes,
billingUnits: 100,
price: 10,
price: 13,
});
const proAnnual = products.proAnnual({
id: "temp-pro-annual",
items: [annualMessagesItem],
const smallBusiness = products.proAnnual({
id: "small-business",
items: [monthlyPrepaidCallMinutes],
});
const monthlyPrepaidMessages = products.base({
id: "temp-monthly-prepaid-messages",
isAddOn: true,
items: [prepaidMessagesItem],
});
const { autumnV1, ctx } = await initScenario({
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ testClock: true }),
s.products({ list: [proAnnual, monthlyPrepaidMessages] }),
s.products({ list: [smallBusiness] }),
],
actions: [],
});
const multiAttachParams = {
const result = await autumnV1.attach({
customer_id: customerId,
plans: [
{ plan_id: proAnnual.id },
{
plan_id: monthlyPrepaidMessages.id,
feature_quantities: [
{
feature_id: TestFeature.Messages,
quantity: prepaidQuantity,
},
],
},
{
plan_id: monthlyPrepaidMessages.id,
subscription_id: "temp-subscription-id-2",
feature_quantities: [
{
feature_id: TestFeature.Messages,
quantity: prepaidQuantity2,
},
],
},
],
};
product_id: smallBusiness.id,
// options: [
// {
// feature_id: TestFeature.Messages,
// adjustable: true,
// },
// ],
});
const result = await autumnV1.billing.multiAttach(multiAttachParams);
expect(result.checkout_url).toBeDefined();
expect(result.checkout_url).toContain("checkout.stripe.com");
expect(result.payment_url).toBeDefined();
expect(result.payment_url).toContain("checkout.stripe.com");
await completeStripeCheckoutForm({
url: result.checkout_url,
// overrideQuantity: checkoutQuantityInUnits / 100,
});
await completeStripeCheckoutForm({ url: result.payment_url });
await timeout(12000);
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerProducts({
customer,
active: [proAnnual.id, monthlyPrepaidMessages.id],
});
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
balance: prepaidQuantity,
usage: 0,
});
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Words,
balance: 200,
usage: 0,
});
await expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: 230,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});

View File

@@ -141,9 +141,9 @@ describe(`${chalk.yellowBright("track-breakdown-sync: track→attach race condit
overage_allowed: true,
});
// // CRITICAL: Wait for Redis → Postgres sync before attaching new product
// // Without this, attach would rebuild cache from stale Postgres data
// await timeout(2000);
// CRITICAL: Wait for Redis → Postgres sync before attaching new product
// Without this, attach would rebuild cache from stale Postgres data
await timeout(4000);
});
test("attach lifetime prepaid product (after sync)", async () => {
@@ -191,7 +191,7 @@ describe(`${chalk.yellowBright("track-breakdown-sync: track→attach race condit
purchased_balance: 0,
});
await timeout(2000);
await timeout(4000);
});
test("track 150: should deduct from lifetime prepaid, NOT add to overage", async () => {
@@ -301,7 +301,7 @@ describe(`${chalk.yellowBright("track-breakdown-sync: track→attach race condit
});
test("verify DB sync with skip_cache=true", async () => {
await timeout(2000);
await timeout(4000);
const customer = await autumnV2.customers.get<ApiCustomer>(customerId, {
skip_cache: "true",

View File

@@ -0,0 +1,137 @@
import { expect, test } from "bun:test";
import type { ApiCustomerV3 } from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features.js";
import { items } from "@tests/utils/fixtures/items.js";
import { products } from "@tests/utils/fixtures/products.js";
import { timeout } from "@tests/utils/genUtils.js";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
import chalk from "chalk";
import { Decimal } from "decimal.js";
// ─────────────────────────────────────────────────────────────────────────────
// Sustained-rate track test.
//
// Instead of blasting everything at once (like track6), this sends tracks
// at a steady rate over multiple seconds — exercising the sync batching
// window and cross-window dedup behavior. Verifies that the final cached
// + DB balances are correct.
//
// 100 tracks/s × 10s = 1000 total, each with a random decimal value.
// ─────────────────────────────────────────────────────────────────────────────
const INCLUDED_USAGE = 50_000;
const RATE_PER_SECOND = 100;
const DURATION_SECONDS = 10;
const TOTAL_TRACKS = RATE_PER_SECOND * DURATION_SECONDS;
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
test(
`${chalk.yellowBright(`concurrentTrack10: sustained-rate track (${RATE_PER_SECOND}/s for ${DURATION_SECONDS}s) — cache + DB correct`)}`,
async () => {
const messagesItem = items.monthlyMessages({
includedUsage: INCLUDED_USAGE,
});
const freeProd = products.base({
id: "free",
items: [messagesItem],
});
const { customerId, autumnV1 } = await initScenario({
customerId: "concurrentTrack10",
setup: [
s.customer({ testClock: false }),
s.products({ list: [freeProd] }),
],
actions: [s.attach({ productId: freeProd.id })],
});
// Verify initial balance
const customerBefore =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
expect(customerBefore.features[TestFeature.Messages].balance).toBe(
INCLUDED_USAGE,
);
// Fire tracks at a steady rate: RATE_PER_SECOND per second for DURATION_SECONDS
let expectedUsage = new Decimal(0);
const allPromises: Promise<unknown>[] = [];
const startTime = Date.now();
for (let sec = 0; sec < DURATION_SECONDS; sec++) {
for (let i = 0; i < RATE_PER_SECOND; i++) {
const value = new Decimal(Math.random() * 2 + 0.1)
.toDecimalPlaces(4)
.toNumber();
expectedUsage = expectedUsage.plus(value);
allPromises.push(
autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value,
skip_event: true,
}),
);
}
if (sec < DURATION_SECONDS - 1) {
await wait(1000);
}
}
await Promise.all(allPromises);
console.log(
`[concurrentTrack10] Sent ${TOTAL_TRACKS} tracks in ${Date.now() - startTime}ms`,
);
// Verify cached balance
const customerCached =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
const expectedBalance = Decimal.max(
0,
new Decimal(INCLUDED_USAGE).minus(expectedUsage),
)
.toDP(5)
.toNumber();
const cappedUsage = Decimal.min(expectedUsage, INCLUDED_USAGE)
.toDP(5)
.toNumber();
expect(
new Decimal(customerCached.features[TestFeature.Messages].balance ?? 0)
.toDP(5)
.toNumber(),
).toEqual(expectedBalance);
expect(
new Decimal(customerCached.features[TestFeature.Messages].usage ?? 0)
.toDP(5)
.toNumber(),
).toEqual(cappedUsage);
// Wait for sync to flush to Postgres
await timeout(8000);
// Verify DB balance matches
const customerDb = await autumnV1.customers.get<ApiCustomerV3>(customerId, {
skip_cache: "true",
});
expect(
new Decimal(customerDb.features[TestFeature.Messages].balance ?? 0)
.toDP(5)
.toNumber(),
).toEqual(expectedBalance);
expect(
new Decimal(customerDb.features[TestFeature.Messages].usage ?? 0)
.toDP(5)
.toNumber(),
).toEqual(cappedUsage);
},
{ timeout: 60_000 },
);

View File

@@ -1,215 +0,0 @@
import { expect, test } from "bun:test";
import chalk from "chalk";
const BASE_URL = process.env.AUTUMN_TEST_BASE_URL || "http://localhost:8080";
const SECRET_KEY = process.env.UNIT_TEST_AUTUMN_SECRET_KEY || "";
const headers = {
"Content-Type": "application/json",
Authorization: `Bearer ${SECRET_KEY}`,
};
const CUSTOMER_ID = "redis-failover-test-customer";
const FEATURE_ID = "messages";
// Failover timing (must match redisFailover.ts constants)
const FAILOVER_DELAY_MS = 5_000;
const RECOVERY_DELAY_MS = 3_000;
type FailoverStatus = {
ok: boolean;
isUsingFailover: boolean;
failoverRegion: string | null;
primaryStatus: string;
failoverStatus: string | null;
primaryErrorSince: number | null;
durationMs?: number;
error?: string;
};
const redisAction = async ({
action,
}: {
action: "status" | "kill-primary" | "recover-primary" | "ping";
}): Promise<FailoverStatus> => {
const res = await fetch(`${BASE_URL}/v1/debug/redis-failover`, {
method: "POST",
headers,
body: JSON.stringify({ action }),
});
return res.json();
};
const timedFetch = async ({
label,
url,
method = "POST",
body,
}: {
label: string;
url: string;
method?: string;
body?: Record<string, unknown>;
}): Promise<{
label: string;
status: number;
durationMs: number;
ok: boolean;
}> => {
const start = Date.now();
const res = await fetch(`${BASE_URL}${url}`, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
});
const durationMs = Date.now() - start;
return { label, status: res.status, durationMs, ok: res.ok };
};
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
test(`${chalk.yellowBright("redis failover: full lifecycle")}`, async () => {
// ---- Setup ----
console.log("\n--- Setup ---");
await fetch(`${BASE_URL}/v1/customers/${CUSTOMER_ID}`, {
method: "DELETE",
headers,
}).catch(() => {});
const createRes = await fetch(`${BASE_URL}/v1/customers`, {
method: "POST",
headers,
body: JSON.stringify({
id: CUSTOMER_ID,
name: "Redis Failover Test",
email: `${CUSTOMER_ID}@example.com`,
internal_options: { disable_defaults: true },
}),
});
console.log(` Customer create: ${createRes.status}`);
// ---- 1. Verify initial state: primary is active ----
console.log("\n--- Step 1: Verify primary is active ---");
const initialStatus = await redisAction({ action: "status" });
console.log(` Response: ${JSON.stringify(initialStatus)}`);
console.log(` Primary: ${initialStatus.primaryStatus}`);
console.log(` Failover: ${initialStatus.failoverStatus}`);
console.log(` Using failover: ${initialStatus.isUsingFailover}`);
expect(initialStatus.isUsingFailover).toBe(false);
expect(initialStatus.primaryStatus).toBe("ready");
// Verify endpoints work with primary
const preCheck = await timedFetch({
label: "pre-failover check",
url: "/v1/balances.check",
body: { customer_id: CUSTOMER_ID, feature_id: FEATURE_ID },
});
console.log(` Check: ${preCheck.durationMs}ms (${preCheck.status})`);
expect(preCheck.ok).toBe(true);
// ---- 2. Kill primary Redis ----
console.log("\n--- Step 2: Kill primary Redis ---");
await redisAction({ action: "kill-primary" });
console.log(` Primary disconnected`);
// ---- 3. Wait for failover to trigger ----
console.log(
`\n--- Step 3: Waiting ${FAILOVER_DELAY_MS + 2000}ms for failover ---`,
);
await wait(FAILOVER_DELAY_MS + 2000);
const failoverStatus = await redisAction({ action: "status" });
console.log(` Primary: ${failoverStatus.primaryStatus}`);
console.log(` Failover: ${failoverStatus.failoverStatus}`);
console.log(` Using failover: ${failoverStatus.isUsingFailover}`);
if (failoverStatus.failoverStatus) {
// Only assert failover if a failover region is configured
expect(failoverStatus.isUsingFailover).toBe(true);
// Verify endpoints work on failover
console.log("\n--- Step 4: Test endpoints on failover ---");
const [getCustomer, check, track] = await Promise.all([
timedFetch({
label: "GET /customers/:id",
url: `/v1/customers/${CUSTOMER_ID}`,
method: "GET",
}),
timedFetch({
label: "POST /check",
url: "/v1/balances.check",
body: { customer_id: CUSTOMER_ID, feature_id: FEATURE_ID },
}),
timedFetch({
label: "POST /track",
url: "/v1/balances.track",
body: {
customer_id: CUSTOMER_ID,
feature_id: FEATURE_ID,
value: 1,
},
}),
]);
for (const r of [getCustomer, check, track]) {
console.log(` ${r.label}: ${r.durationMs}ms (${r.status})`);
}
// Endpoints should still work (via failover Redis or Postgres fallback)
expect(check.ok).toBe(true);
expect(track.ok).toBe(true);
} else {
console.log(
" No failover region configured — skipping failover assertions",
);
}
// ---- 5. Recover primary ----
console.log("\n--- Step 5: Recover primary ---");
await redisAction({ action: "recover-primary" });
console.log(
` Reconnect triggered, waiting ${RECOVERY_DELAY_MS + 2000}ms for recovery...`,
);
await wait(RECOVERY_DELAY_MS + 2000);
const recoveredStatus = await redisAction({ action: "status" });
console.log(` Primary: ${recoveredStatus.primaryStatus}`);
console.log(` Using failover: ${recoveredStatus.isUsingFailover}`);
expect(recoveredStatus.primaryStatus).toBe("ready");
expect(recoveredStatus.isUsingFailover).toBe(false);
// Verify endpoints work after recovery
console.log("\n--- Step 6: Test endpoints after recovery ---");
const [postGetCus, postCheck, postTrack] = await Promise.all([
timedFetch({
label: "GET /customers/:id",
url: `/v1/customers/${CUSTOMER_ID}`,
method: "GET",
}),
timedFetch({
label: "POST /check",
url: "/v1/balances.check",
body: { customer_id: CUSTOMER_ID, feature_id: FEATURE_ID },
}),
timedFetch({
label: "POST /track",
url: "/v1/balances.track",
body: {
customer_id: CUSTOMER_ID,
feature_id: FEATURE_ID,
value: 1,
},
}),
]);
for (const r of [postGetCus, postCheck, postTrack]) {
console.log(` ${r.label}: ${r.durationMs}ms (${r.status})`);
}
expect(postGetCus.ok).toBe(true);
expect(postCheck.ok).toBe(true);
expect(postTrack.ok).toBe(true);
console.log("\nRedis failover lifecycle complete.");
}, 60000);

View File

@@ -0,0 +1,315 @@
import { expect, test } from "bun:test";
import { AppEnv } from "@autumn/shared";
import chalk from "chalk";
import {
type QueueSyncPayload,
SyncBatchingManagerV2,
} from "@/internal/balances/utils/sync/SyncBatchingManagerV2.js";
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const createMockQueue = () => {
const calls: QueueSyncPayload[] = [];
const fn = async (args: QueueSyncPayload) => {
calls.push(structuredClone(args));
};
return { fn, calls };
};
const addItems = ({
manager,
count,
customerId = "cust-1",
cusEntIds = ["ce-1"],
rolloverIds,
}: {
manager: SyncBatchingManagerV2;
count: number;
customerId?: string;
cusEntIds?: string[];
rolloverIds?: string[];
}) => {
for (let i = 0; i < count; i++) {
manager.addSyncItem({
customerId,
orgId: "org-1",
env: AppEnv.Sandbox,
cusEntIds,
rolloverIds,
region: "us-east-1",
});
}
};
// ═══════════════════════════════════════════════════════════════════
// 1. Fixed window: rapid items within 1 window → 1 flush
// ═══════════════════════════════════════════════════════════════════
test(
`${chalk.yellowBright("sync-batch-1: rapid addSyncItem calls within one window → 1 flush")}`,
async () => {
const { fn, calls } = createMockQueue();
const manager = new SyncBatchingManagerV2({
addTaskToQueueFn: fn,
batchWindowMs: 100,
});
// Fire 500 sync items rapidly (same customer, same cusEntIds)
addItems({ manager, count: 500 });
// Nothing queued yet — timer hasn't fired
expect(calls.length).toBe(0);
// Wait for window to fire
await wait(200);
expect(calls.length).toBe(1);
expect(calls[0].payload.cusEntIds).toEqual(["ce-1"]);
expect(calls[0].payload.customerId).toBe("cust-1");
},
{ timeout: 5_000 },
);
// ═══════════════════════════════════════════════════════════════════
// 2. Fixed window fires on schedule even during continuous load
// ═══════════════════════════════════════════════════════════════════
test(
`${chalk.yellowBright("sync-batch-2: fixed window fires on schedule during continuous load")}`,
async () => {
const { fn, calls } = createMockQueue();
const manager = new SyncBatchingManagerV2({
addTaskToQueueFn: fn,
batchWindowMs: 100,
});
// Add items continuously every 20ms for 500ms
const interval = setInterval(() => {
addItems({ manager, count: 1 });
}, 20);
await wait(600);
clearInterval(interval);
// Flush any leftovers
await manager.flush();
// Fixed window of 100ms over 500ms → should produce ~5 flushes
// (NOT 1, which would indicate debounce behavior)
expect(calls.length).toBeGreaterThanOrEqual(3);
expect(calls.length).toBeLessThanOrEqual(8);
},
{ timeout: 5_000 },
);
// ═══════════════════════════════════════════════════════════════════
// 3. Different customers get independent batches
// ═══════════════════════════════════════════════════════════════════
test(
`${chalk.yellowBright("sync-batch-3: different customers produce separate flushes")}`,
async () => {
const { fn, calls } = createMockQueue();
const manager = new SyncBatchingManagerV2({
addTaskToQueueFn: fn,
batchWindowMs: 50,
});
addItems({ manager, count: 10, customerId: "cust-A" });
addItems({ manager, count: 10, customerId: "cust-B" });
const stats = manager.getStats();
expect(stats.totalCustomers).toBe(2);
await wait(150);
expect(calls.length).toBe(2);
const customerIds = calls.map((c) => c.payload.customerId).sort();
expect(customerIds).toEqual(["cust-A", "cust-B"]);
},
{ timeout: 5_000 },
);
// ═══════════════════════════════════════════════════════════════════
// 4. cusEntIds from multiple adds merge into one flush
// ═══════════════════════════════════════════════════════════════════
test(
`${chalk.yellowBright("sync-batch-4: cusEntIds merge across multiple addSyncItem calls")}`,
async () => {
const { fn, calls } = createMockQueue();
const manager = new SyncBatchingManagerV2({
addTaskToQueueFn: fn,
batchWindowMs: 100,
});
manager.addSyncItem({
customerId: "cust-1",
orgId: "org-1",
env: AppEnv.Sandbox,
cusEntIds: ["ce-1"],
region: "us-east-1",
});
manager.addSyncItem({
customerId: "cust-1",
orgId: "org-1",
env: AppEnv.Sandbox,
cusEntIds: ["ce-2"],
region: "us-east-1",
});
manager.addSyncItem({
customerId: "cust-1",
orgId: "org-1",
env: AppEnv.Sandbox,
cusEntIds: ["ce-1", "ce-3"],
rolloverIds: ["r-1"],
region: "us-east-1",
});
await wait(200);
expect(calls.length).toBe(1);
expect(calls[0].payload.cusEntIds.sort()).toEqual(["ce-1", "ce-2", "ce-3"]);
expect(calls[0].payload.rolloverIds).toEqual(["r-1"]);
},
{ timeout: 5_000 },
);
// ═══════════════════════════════════════════════════════════════════
// 5. Same content in same dedup bucket → same dedup ID
// ═══════════════════════════════════════════════════════════════════
test(
`${chalk.yellowBright("sync-batch-5: same cusEntIds produce stable dedup IDs within a bucket")}`,
async () => {
const { fn, calls } = createMockQueue();
const manager = new SyncBatchingManagerV2({
addTaskToQueueFn: fn,
batchWindowMs: 30,
dedupBucketMs: 60_000, // Large bucket so all calls land in the same bucket
});
// First batch
addItems({ manager, count: 5 });
await wait(80);
// Second batch (new window, same cusEntIds)
addItems({ manager, count: 5 });
await wait(80);
expect(calls.length).toBe(2);
expect(calls[0].messageDeduplicationId).toBe(
calls[1].messageDeduplicationId,
);
},
{ timeout: 5_000 },
);
// ═══════════════════════════════════════════════════════════════════
// 6. Different cusEntIds → different dedup ID
// ═══════════════════════════════════════════════════════════════════
test(
`${chalk.yellowBright("sync-batch-6: different cusEntIds produce different dedup IDs")}`,
async () => {
const { fn, calls } = createMockQueue();
const manager = new SyncBatchingManagerV2({
addTaskToQueueFn: fn,
batchWindowMs: 30,
dedupBucketMs: 60_000,
});
addItems({ manager, count: 1, cusEntIds: ["ce-1"] });
await wait(80);
addItems({ manager, count: 1, cusEntIds: ["ce-2"] });
await wait(80);
expect(calls.length).toBe(2);
expect(calls[0].messageDeduplicationId).not.toBe(
calls[1].messageDeduplicationId,
);
},
{ timeout: 5_000 },
);
// ═══════════════════════════════════════════════════════════════════
// 7. MAX_BATCH_SIZE triggers immediate flush
// ═══════════════════════════════════════════════════════════════════
test(
`${chalk.yellowBright("sync-batch-7: exceeding MAX_BATCH_SIZE triggers immediate flush")}`,
async () => {
const { fn, calls } = createMockQueue();
const manager = new SyncBatchingManagerV2({
addTaskToQueueFn: fn,
batchWindowMs: 5000, // Won't fire naturally
});
const cusEntIds = Array.from({ length: 1001 }, (_, i) => `ce-${i}`);
manager.addSyncItem({
customerId: "cust-1",
orgId: "org-1",
env: AppEnv.Sandbox,
cusEntIds,
region: "us-east-1",
});
await wait(50);
expect(calls.length).toBe(1);
expect(calls[0].payload.cusEntIds.length).toBe(1001);
},
{ timeout: 5_000 },
);
// ═══════════════════════════════════════════════════════════════════
// 8. flush() drains all pending batches
// ═══════════════════════════════════════════════════════════════════
test(
`${chalk.yellowBright("sync-batch-8: flush() drains all pending batches")}`,
async () => {
const { fn, calls } = createMockQueue();
const manager = new SyncBatchingManagerV2({
addTaskToQueueFn: fn,
batchWindowMs: 10_000, // Will never fire naturally
});
addItems({ manager, count: 3, customerId: "cust-A" });
addItems({ manager, count: 3, customerId: "cust-B" });
expect(calls.length).toBe(0);
await manager.flush();
expect(calls.length).toBe(2);
expect(manager.getStats().totalCustomers).toBe(0);
},
{ timeout: 5_000 },
);
// ═══════════════════════════════════════════════════════════════════
// 9. 10k burst → bounded number of flushes
// ═══════════════════════════════════════════════════════════════════
test(
`${chalk.yellowBright("sync-batch-9: 10k rapid items produce bounded flushes")}`,
async () => {
const { fn, calls } = createMockQueue();
const manager = new SyncBatchingManagerV2({
addTaskToQueueFn: fn,
batchWindowMs: 100,
});
// All 10k items arrive synchronously — faster than the timer can fire
addItems({ manager, count: 10_000 });
await wait(300);
// Fixed window: all items land in a single batch since they arrive
// before the first timer fires. Should be exactly 1 flush.
expect(calls.length).toBe(1);
},
{ timeout: 5_000 },
);

View File

@@ -484,83 +484,76 @@ test.concurrent(`${chalk.yellowBright("track-misc6: v2.1 does not auto-create cu
// The lock ensures only 1 succeeds — others are rejected.
// ═══════════════════════════════════════════════════════════════════
test(
`${chalk.yellowBright("track-misc10: paid-allocated concurrent track serialized by distributed lock")}`,
async () => {
const allocatedUsersItem = items.allocatedUsers({ includedUsage: 0 });
const priceItem = items.monthlyPrice({ price: 20 });
const pro = products.base({
id: "pro",
items: [allocatedUsersItem, priceItem],
});
test(`${chalk.yellowBright("track-misc10: paid-allocated concurrent track serialized by distributed lock")}`, async () => {
const allocatedUsersItem = items.allocatedUsers({ includedUsage: 0 });
const priceItem = items.monthlyPrice({ price: 20 });
const pro = products.base({
id: "pro",
items: [allocatedUsersItem, priceItem],
});
const { customerId, autumnV2 } = await initScenario({
customerId: "track-misc10",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [s.attach({ productId: pro.id })],
});
const { customerId, autumnV2 } = await initScenario({
customerId: "track-misc10",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [s.attach({ productId: pro.id })],
});
// Verify initial balance
const customerBefore =
await autumnV2.customers.get<ApiCustomer>(customerId);
expect(customerBefore.balances[TestFeature.Users].current_balance).toBe(0);
// Verify initial balance
const customerBefore = await autumnV2.customers.get<ApiCustomer>(customerId);
expect(customerBefore.balances[TestFeature.Users].current_balance).toBe(0);
// Send 5 concurrent requests — lock should serialize, only 1 succeeds
const promises = Array(5)
.fill(null)
.map(() =>
autumnV2.track({
customer_id: customerId,
feature_id: TestFeature.Users,
value: 2,
}),
);
const results = await Promise.allSettled(promises);
const successCount = results.filter((r) => r.status === "fulfilled").length;
expect(successCount).toEqual(1);
// Verify balance is mathematically correct
await timeout(2000);
const customerAfter = await autumnV2.customers.get<ApiCustomer>(customerId);
const balance = customerAfter.balances[TestFeature.Users];
const expectedUsage = successCount * 2;
expect(balance.usage).toBe(expectedUsage);
expect(balance.granted_balance).toBe(0);
expect(customerAfter.invoices?.length).toBe(2);
// Balance equation: granted + purchased - usage = current
const expectedCurrentBalance =
balance.granted_balance + balance.purchased_balance - balance.usage;
expect(balance.current_balance).toBe(expectedCurrentBalance);
// Sequential track after concurrent burst should work
await autumnV2.track({
customer_id: customerId,
feature_id: TestFeature.Users,
value: 1,
});
const customerFinal = await autumnV2.customers.get<ApiCustomer>(customerId);
expect(customerFinal.balances[TestFeature.Users].usage).toBe(
expectedUsage + 1,
// Send 5 concurrent requests — lock should serialize, only 1 succeeds
const promises = Array(5)
.fill(null)
.map(() =>
autumnV2.track({
customer_id: customerId,
feature_id: TestFeature.Users,
value: 2,
}),
);
// Verify DB consistency
await timeout(3000);
const dbCustomer = await autumnV2.customers.get<ApiCustomer>(customerId, {
skip_cache: "true",
});
expect(dbCustomer.balances[TestFeature.Users].usage).toBe(
expectedUsage + 1,
);
expect(dbCustomer.invoices?.length).toBe(3);
},
{ timeout: 60_000 },
);
const results = await Promise.allSettled(promises);
const successCount = results.filter((r) => r.status === "fulfilled").length;
expect(successCount).toEqual(1);
// Verify balance is mathematically correct
await timeout(2000);
const customerAfter = await autumnV2.customers.get<ApiCustomer>(customerId);
const balance = customerAfter.balances[TestFeature.Users];
const expectedUsage = successCount * 2;
expect(balance.usage).toBe(expectedUsage);
expect(balance.granted_balance).toBe(0);
expect(customerAfter.invoices?.length).toBe(2);
// Balance equation: granted + purchased - usage = current
const expectedCurrentBalance =
balance.granted_balance + balance.purchased_balance - balance.usage;
expect(balance.current_balance).toBe(expectedCurrentBalance);
// Sequential track after concurrent burst should work
await autumnV2.track({
customer_id: customerId,
feature_id: TestFeature.Users,
value: 1,
});
const customerFinal = await autumnV2.customers.get<ApiCustomer>(customerId);
expect(customerFinal.balances[TestFeature.Users].usage).toBe(
expectedUsage + 1,
);
// Verify DB consistency
await timeout(3000);
const dbCustomer = await autumnV2.customers.get<ApiCustomer>(customerId, {
skip_cache: "true",
});
expect(dbCustomer.balances[TestFeature.Users].usage).toBe(expectedUsage + 1);
expect(dbCustomer.invoices?.length).toBe(3);
});

View File

@@ -4,6 +4,7 @@ import {
type ApiEntityV0,
ApiVersion,
formatMs,
ms,
} from "@autumn/shared";
import { AutumnInt } from "@/external/autumn/autumnCli";
@@ -37,7 +38,7 @@ export const expectCustomerFeatureCorrect = ({
balance,
usage,
resetsAt,
toleranceMs = TEN_MINUTES_MS,
toleranceMs = TEN_MINUTES_MS + ms.hours(1),
}: {
customerId?: string;
customer?: ApiCustomerV3 | ApiEntityV0;

View File

@@ -1,16 +1,18 @@
import { expect } from "bun:test";
import type {
ApiCustomerV3,
ApiCustomerV5,
ApiEntityV0,
ApiEntityV2,
import {
type ApiCustomerV3,
type ApiCustomerV5,
type ApiEntityV0,
type ApiEntityV2,
ApiVersion,
formatMs,
ms,
} from "@autumn/shared";
import { ApiVersion, formatMs } from "@autumn/shared";
import { AutumnInt } from "@/external/autumn/autumnCli";
import {
expectSubscriptionTrialing,
expectSubscriptionNotTrialing,
expectSubscriptionPeriodAlignedWithTrialEnd,
expectSubscriptionTrialing,
} from "./expect-customer-products/expectSubscriptionTrialing";
const defaultAutumn = new AutumnInt({ version: ApiVersion.V1_2 });
@@ -40,7 +42,7 @@ export const expectProductTrialing = async ({
customer: providedCustomer,
productId,
trialEndsAt: expectedTrialEndsAt,
toleranceMs = TEN_MINUTES_MS,
toleranceMs = TEN_MINUTES_MS + ms.hours(1),
}: {
customerId?: string;
customer?: CustomerOrEntity;
@@ -262,9 +264,7 @@ export const getTrialEndsAt = async ({
// Route to V5
if (isV5Customer(customer)) {
const sub = customer.subscriptions.find(
(s) => s.plan_id === productId,
);
const sub = customer.subscriptions.find((s) => s.plan_id === productId);
return sub?.trial_ends_at ?? null;
}

View File

@@ -45,6 +45,6 @@
"drizzle-orm/*": ["../node_modules/drizzle-orm/*"]
}
},
"include": ["src", "tests", "scripts", "experiments"],
"include": ["src", "tests", "scripts", "experiments", "perf"],
"exclude": ["node_modules", "dist", "tests/archives"]
}

View File

@@ -65,7 +65,11 @@ export const isCustomerProductPaidRecurring = (
) => {
if (!customerProduct) return false;
const prices = cusProductToPrices({ cusProduct: customerProduct });
return !isFreeProduct({ prices }) && !isOneOffProduct({ prices });
const freeProduct = isFreeProduct({ prices });
const oneOffProduct = isOneOffProduct({ prices });
return !freeProduct && !oneOffProduct;
};
// ============================================================================

View File

@@ -1,5 +1,5 @@
import { BillingInterval } from "@models/productModels/intervals/billingInterval.js";
import { type FullProduct, nullish } from "../../../index.js";
import { type FullProduct, notNullish, nullish } from "../../../index.js";
import type { FixedPriceConfig } from "../../../models/productModels/priceModels/priceConfig/fixedPriceConfig.js";
import type { UsagePriceConfig } from "../../../models/productModels/priceModels/priceConfig/usagePriceConfig.js";
import { PriceType } from "../../../models/productModels/priceModels/priceEnums.js";
@@ -29,15 +29,15 @@ export const isFreeProduct = ({ prices }: { prices: Price[] }) => {
let totalPrice = 0;
for (const price of prices) {
if ("usage_tiers" in price.config) {
if ("usage_tiers" in price.config && notNullish(price.config.usage_tiers)) {
const tiers = price.config.usage_tiers;
if (nullish(tiers) || tiers.length === 0) continue;
totalPrice += tiers.reduce(
(acc, tier) => acc + tier.amount + (tier.flat_amount ?? 0),
0,
);
} else {
totalPrice += price.config.amount;
} else if ("amount" in price.config && notNullish(price.config.amount)) {
totalPrice += price.config.amount ?? 0;
}
}
return totalPrice === 0;

View File

@@ -81,7 +81,6 @@
"streamdown": "^1.6.10",
"stripe": "catalog:",
"svix-react": "^1.13.3",
"swr": "^2.3.3",
"tailwind-merge": "^3.0.2",
"tailwind-scrollbar-hide": "^4.0.0",
"tailwindcss": "^4.0.13",

View File

@@ -10,7 +10,7 @@ import { IconButton } from "@/components/v2/buttons/IconButton";
import { PortalContainerContext } from "@/contexts/PortalContainerContext";
import { useAutumnFlags } from "@/hooks/common/useAutumnFlags";
import { useGlobalErrorHandler } from "@/hooks/common/useGlobalErrorHandler";
import { useOrg } from "@/hooks/common/useOrg";
import { getLastSwitchedOrgId, useOrg } from "@/hooks/common/useOrg";
import { useDevQuery } from "@/hooks/queries/useDevQuery";
import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery";
import { useRewardsQuery } from "@/hooks/queries/useRewardsQuery";
@@ -52,16 +52,15 @@ export function MainLayout() {
// Redirect to sandbox if not deployed
useEffect(() => {
if (!orgLoading && org && !org.deployed) {
if (!orgLoading && org && !org.deployed && env !== AppEnv.Sandbox) {
const lastSwitchedId = getLastSwitchedOrgId();
if (lastSwitchedId && org.id !== lastSwitchedId) return;
const pathname = window.location.pathname;
if (!pathname.startsWith("/sandbox")) {
const search = window.location.search;
navigate(`/sandbox${pathname}${search}`);
}
const search = window.location.search;
navigate(`/sandbox${pathname}${search}`);
}
}, [org, orgLoading, navigate]);
}, [orgLoading, org, env, navigate]);
// Show loading screen while data is loading
if (isPending || orgLoading) {
return (
<AutumnProvider

View File

@@ -1,6 +1,7 @@
import type { CheckoutResponseV0, ProductV2 } from "@autumn/shared";
import { useQuery } from "@tanstack/react-query";
import { useEffect, useMemo, useState } from "react";
import { useQueryKeyFactory } from "@/hooks/common/useQueryKeyFactory";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { useAttachBodyBuilder } from "./use-attach-body-builder";
@@ -18,6 +19,7 @@ interface AttachPreviewParams {
export function useAttachPreview(params: AttachPreviewParams = {}) {
const axiosInstance = useAxiosInstance();
const buildKey = useQueryKeyFactory();
// Build attach body using shared hook with explicit params
const { attachBody } = useAttachBodyBuilder({
@@ -51,7 +53,7 @@ export function useAttachPreview(params: AttachPreviewParams = {}) {
const isDebouncing = queryKeyDeps !== debouncedQueryKey;
const query = useQuery({
queryKey: ["attach-checkout", debouncedQueryKey],
queryKey: buildKey(["attach-checkout", debouncedQueryKey]),
queryFn: async () => {
if (!attachBody || !params.customerId) {
return null;

View File

@@ -2,6 +2,7 @@ import type { AttachParamsV0, AttachPreviewResponse } from "@autumn/shared";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { useEffect, useMemo, useState } from "react";
import { useQueryKeyFactory } from "@/hooks/common/useQueryKeyFactory";
import { useAxiosInstance } from "@/services/useAxiosInstance";
const ATTACH_PREVIEW_EXPAND = [
@@ -19,6 +20,7 @@ export function useAttachPreview({
enabled,
}: UseAttachPreviewParams) {
const axiosInstance = useAxiosInstance();
const buildKey = useQueryKeyFactory();
const shouldEnable = enabled !== undefined ? enabled : !!requestBody;
@@ -39,7 +41,7 @@ export function useAttachPreview({
const isDebouncing = queryKeyDeps !== debouncedQueryKey;
const query = useQuery({
queryKey: ["attach-preview-v2", debouncedQueryKey],
queryKey: buildKey(["attach-preview-v2", debouncedQueryKey]),
queryFn: async () => {
if (!requestBody) {
return null;

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