Merge branch 'dev' into feature/usage-alerts-existing-threshold-migration-and-test
@@ -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.
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 220 KiB |
|
After Width: | Height: | Size: 54 KiB |
BIN
apps/docs/mintlify/images/vercel-marketplace/step-3-base-url.png
Normal file
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 335 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 124 KiB |
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
513
packages/atmn-tests/.claude/skills/autumn-billing-page/SKILL.md
Normal 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
|
||||
261
packages/atmn-tests/.claude/skills/autumn-gating/SKILL.md
Normal 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
|
||||
@@ -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
|
||||
339
packages/atmn-tests/.claude/skills/autumn-setup/SKILL.md
Normal 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
|
||||
@@ -0,0 +1 @@
|
||||
../../CLAUDE.md
|
||||
34
packages/atmn-tests/.gitignore
vendored
Normal 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
@@ -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;
|
||||
}
|
||||
111
packages/atmn-tests/CLAUDE.md
Normal 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`.
|
||||
15
packages/atmn-tests/README.md
Normal 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.
|
||||
24
packages/atmn-tests/autumn.config.ts
Normal 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',
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
26
packages/atmn-tests/bun.lock
Normal 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=="],
|
||||
}
|
||||
}
|
||||
1
packages/atmn-tests/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
console.log("Hello via Bun!");
|
||||
13
packages/atmn-tests/package.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "atmn-tests",
|
||||
"module": "index.ts",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"atmn": "workspace:*",
|
||||
"@types/bun": "latest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
29
packages/atmn-tests/tsconfig.json
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -31,4 +31,5 @@ async function build() {
|
||||
console.timeEnd(`Generating type declarations`);
|
||||
}
|
||||
|
||||
build();
|
||||
await build();
|
||||
process.exit(0);
|
||||
|
||||
@@ -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",
|
||||
|
||||
261
packages/atmn/src/commands/preview/displayUtils.ts
Normal 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}`;
|
||||
};
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
formatInterval,
|
||||
getFeatureName,
|
||||
numberWithCommas,
|
||||
} from "@autumn/shared";
|
||||
} from "./displayUtils.js";
|
||||
import {
|
||||
type FeatureLike,
|
||||
featureToDisplayFeature,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
|
||||
97
packages/atmn/src/views/react/components/MultiSelect.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
packages/atmn/src/views/react/components/ProgressBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -15,8 +15,6 @@
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@server/*": ["../server/src/*"],
|
||||
"@shared/*": ["../shared/*"]
|
||||
|
||||
@@ -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
@@ -0,0 +1 @@
|
||||
.customers.json
|
||||
111
server/perf/load-test/README.md
Normal 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
|
||||
```
|
||||
73
server/perf/load-test/artillery.yml
Normal 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 }}"
|
||||
71
server/perf/load-test/memoryLeak.yml
Normal 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 }}"
|
||||
78
server/perf/load-test/processor.mjs
Normal 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();
|
||||
}
|
||||
150
server/perf/load-test/runLeakTest.ts
Normal 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);
|
||||
});
|
||||
175
server/perf/load-test/setup.ts
Normal 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);
|
||||
});
|
||||
1459
server/perf/load-test/summarizeLeakSnapshots.ts
Normal file
1
server/perf/snapshots/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
*.heapsnapshot
|
||||
@@ -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;
|
||||
|
||||
52
server/src/external/connect/clientCache/cacheKeyUtils.ts
vendored
Normal 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}`;
|
||||
};
|
||||
25
server/src/external/connect/clientCache/stripeClientCache.ts
vendored
Normal 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;
|
||||
};
|
||||
27
server/src/external/connect/createStripeCli.ts
vendored
@@ -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,
|
||||
}),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
67
server/src/external/connect/initStripeCli.ts
vendored
@@ -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,
|
||||
}),
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
57
server/src/external/redis/initRedis.ts
vendored
@@ -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;
|
||||
}
|
||||
|
||||
323
server/src/external/redis/redisFailover.ts
vendored
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -104,6 +104,6 @@ export const updateOptionsFromStripeCheckoutSession = async ({
|
||||
}
|
||||
}
|
||||
|
||||
console.log("FEATURE OPTIONS:", newCustomerProduct.options);
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -37,6 +37,7 @@ export const updateCustomerEntitlements = async ({
|
||||
customerId,
|
||||
cusEntId: customerEntitlement.id,
|
||||
updates,
|
||||
incrementCacheVersion: true,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
// });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
@@ -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);
|
||||
315
server/tests/infra/syncBatching.test.ts
Normal 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 },
|
||||
);
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||