diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 029f8b405..9849ec4cf 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -26,7 +26,7 @@ env:
# staging repo (autumn-staging) -> us-east-1
# Branches allowed to deploy to staging via workflow_dispatch with tag=deploy-staging.
# Add short-lived PR branches here when you need staging without merging to dev.
- STAGING_DEPLOY_BRANCH_ALLOWLIST: fix-health-check-redis-disabled-detection feat/track-rate-limit-redis feat/events-hourly-rollup uw-1-storage
+ STAGING_DEPLOY_BRANCH_ALLOWLIST: fix-health-check-redis-disabled-detection feat/track-rate-limit-redis feat/events-hourly-rollup fix/analytics-tz-bucket-offset uw-1-storage
jobs:
checks:
diff --git a/.github/workflows/server-unit-tests.yml b/.github/workflows/server-unit-tests.yml
index 8cb93eb2c..8560a60e0 100644
--- a/.github/workflows/server-unit-tests.yml
+++ b/.github/workflows/server-unit-tests.yml
@@ -39,11 +39,11 @@ jobs:
- name: Set up Bun
uses: oven-sh/setup-bun@v2
with:
- bun-version: 1.3.10
+ bun-version: 1.3.14
- name: Install dependencies
run: bun install
- name: Run unit tests
- run: bun test tests/unit
+ run: bun test --isolate tests/unit
working-directory: server
diff --git a/ai b/ai
index 794c164ae..0e52f71fb 160000
--- a/ai
+++ b/ai
@@ -1 +1 @@
-Subproject commit 794c164aed6e15cde9dc859b2e256c72a02af05d
+Subproject commit 0e52f71fbd69e7a4a58b63863a8c4929bfd9ebf8
diff --git a/apps/docs/mintlify/api-reference/platform/getRevenueCatKeys.mdx b/apps/docs/mintlify/api-reference/platform/getRevenueCatKeys.mdx
new file mode 100644
index 000000000..308c4bec7
--- /dev/null
+++ b/apps/docs/mintlify/api-reference/platform/getRevenueCatKeys.mdx
@@ -0,0 +1,79 @@
+---
+title: "Get Revenue Cat Keys"
+openapi: "openapi POST /v1/platform.get_revenuecat_keys"
+---
+
+import { DynamicParamField } from "/snippets/dynamic-param-field.jsx";
+import { DynamicResponseField } from "/snippets/dynamic-response-field.jsx";
+import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx";
+
+### Body Parameters
+
+
+
+
+ "test" and "sandbox" both target the sandbox environment
+
+
+
+### Response
+
+
+
+
+
+
+ RevenueCat store type, e.g. test_store / app_store / play_store
+
+
+
+
+
+
+
+
+
+ The public SDK API key value
+
+
+
+ e.g. "production" / "sandbox"
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Freshly-refreshed RevenueCat OAuth access token for the org (null for api-key orgs). The refresh token is never exposed — call this endpoint again for a new access token.
+
+
+
+
+```json 200
+{
+ "apps": [
+ {
+ "app_id": "app1a2b3c4d",
+ "app_type": "test_store",
+ "name": "Acme (Test Store)",
+ "api_keys": [
+ {
+ "id": "apikey12345",
+ "key": "test_aBcDeFgHiJkLmNoPqRsTuVwXyZ",
+ "environment": "production",
+ "app_id": "app1a2b3c4"
+ }
+ ]
+ }
+ ],
+ "oauth_access_token": "atk_aBcDeFgHiJkLmNoPqRsTuVwXyZ"
+}
+```
+
diff --git a/apps/docs/mintlify/api-reference/platform/linkRevenueCat.mdx b/apps/docs/mintlify/api-reference/platform/linkRevenueCat.mdx
new file mode 100644
index 000000000..4c891cc98
--- /dev/null
+++ b/apps/docs/mintlify/api-reference/platform/linkRevenueCat.mdx
@@ -0,0 +1,32 @@
+---
+title: "Link Revenue Cat"
+openapi: "openapi POST /v1/platform.link_revenuecat"
+---
+
+import { DynamicParamField } from "/snippets/dynamic-param-field.jsx";
+import { DynamicResponseField } from "/snippets/dynamic-response-field.jsx";
+import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx";
+
+### Body Parameters
+
+
+
+
+
+
+
+
+
+
+### Response
+
+
+
+
+
+```json 200
+{
+ "oauth_url": "https://api.revenuecat.com/oauth2/authorize?client_id=...&redirect_uri=...&response_type=code&scope=project.read+project.write"
+}
+```
+
diff --git a/apps/docs/mintlify/api-reference/platform/syncRevenueCat.mdx b/apps/docs/mintlify/api-reference/platform/syncRevenueCat.mdx
new file mode 100644
index 000000000..2f3016390
--- /dev/null
+++ b/apps/docs/mintlify/api-reference/platform/syncRevenueCat.mdx
@@ -0,0 +1,77 @@
+---
+title: "Sync Revenue Cat"
+openapi: "openapi POST /v1/platform.sync_revenuecat"
+---
+
+import { DynamicParamField } from "/snippets/dynamic-param-field.jsx";
+import { DynamicResponseField } from "/snippets/dynamic-response-field.jsx";
+import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx";
+
+### Body Parameters
+
+
+
+
+ "test" and "sandbox" both target the sandbox environment
+
+
+
+ Plans to push. Omit to sync every plan in the org/env.
+
+
+
+### Response
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```json 200
+{
+ "results": [
+ {
+ "plan_id": "pro",
+ "status": "synced",
+ "store_identifier": "autumn.sandbox.org_123.pro",
+ "apps": [
+ {
+ "app_id": "app_test",
+ "app_type": "test_store",
+ "product": "created",
+ "store_push": "skipped",
+ "price": "set"
+ }
+ ]
+ }
+ ]
+}
+```
+
diff --git a/apps/docs/mintlify/api/openapi.yml b/apps/docs/mintlify/api/openapi.yml
index 82f7e8f16..683707a8b 100644
--- a/apps/docs/mintlify/api/openapi.yml
+++ b/apps/docs/mintlify/api/openapi.yml
@@ -19744,6 +19744,384 @@ paths:
code="REWARD10",
customer_id="cus_456",
)
+ /v1/platform.link_revenuecat:
+ post:
+ operationId: linkRevenueCat
+ description: Generate a RevenueCat OAuth URL for linking a project to an organization.
+ tags:
+ - platform
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ organization_slug:
+ type: string
+ minLength: 1
+ env:
+ enum:
+ - test
+ - live
+ type: string
+ project_name:
+ type: string
+ minLength: 1
+ maxLength: 255
+ redirect_url:
+ type: string
+ format: uri
+ required:
+ - organization_slug
+ - env
+ - project_name
+ - redirect_url
+ title: LinkRevenueCatParams
+ examples:
+ - &a77
+ organization_slug: acme
+ env: test
+ project_name: acme-mobile
+ redirect_url: https://dashboard.useautumn.com/dev?tab=revenuecat
+ example: *a77
+ responses:
+ "200":
+ description: OK
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ oauth_url:
+ type: string
+ required:
+ - oauth_url
+ title: LinkRevenueCatResponse
+ examples:
+ - &a78
+ oauth_url: https://api.revenuecat.com/oauth2/authorize?client_id=...&redirect_uri=...&response_type=code&scope=project.read+project.write
+ example: *a78
+ x-speakeasy-name-override: linkRevenueCat
+ parameters:
+ - *a5
+ x-codeSamples:
+ - lang: typescript
+ label: Typescript (SDK)
+ source: |-
+ import { Autumn } from 'autumn-js'
+
+ const autumn = new Autumn()
+
+ const result = await autumn.platform.linkRevenueCat({
+ organizationSlug: "acme",
+ env: "test",
+ projectName: "acme-mobile",
+ redirectUrl: "https://dashboard.useautumn.com/dev?tab=revenuecat",
+ });
+ - lang: python
+ label: Python (SDK)
+ source: >-
+ from autumn_sdk import Autumn
+
+
+ autumn = Autumn(secret_key="am_sk_test...")
+
+
+ res = autumn.platform.link_revenue_cat(
+ organization_slug="acme",
+ env="test",
+ project_name="acme-mobile",
+ redirect_url="https://dashboard.useautumn.com/dev?tab=revenuecat",
+ )
+ /v1/platform.sync_revenuecat:
+ post:
+ operationId: syncRevenueCat
+ description: Push an organization's plans into RevenueCat as products (creating
+ or renaming them across the project's apps) and set test-store prices
+ from each plan's price. Requires the org to have linked RevenueCat via
+ OAuth.
+ tags:
+ - platform
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ organization_slug:
+ type: string
+ minLength: 1
+ env:
+ enum:
+ - test
+ - sandbox
+ - live
+ type: string
+ description: '"test" and "sandbox" both target the sandbox environment'
+ product_ids:
+ type: array
+ items:
+ type: string
+ description: Plans to push. Omit to sync every plan in the org/env.
+ required:
+ - organization_slug
+ - env
+ title: SyncRevenueCatParams
+ examples:
+ - &a79
+ organization_slug: acme
+ env: test
+ product_ids:
+ - pro
+ - premium
+ example: *a79
+ responses:
+ "200":
+ description: OK
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ results:
+ type: array
+ items:
+ type: object
+ properties:
+ plan_id:
+ type: string
+ status:
+ enum:
+ - synced
+ - skipped
+ - error
+ type: string
+ store_identifier:
+ type: string
+ apps:
+ type: array
+ items:
+ type: object
+ properties:
+ app_id:
+ type: string
+ app_type:
+ type: string
+ product:
+ enum:
+ - created
+ - updated
+ - exists
+ type: string
+ store_push:
+ enum:
+ - pushed
+ - failed
+ - skipped
+ type: string
+ price:
+ enum:
+ - set
+ - skipped
+ - failed
+ type: string
+ message:
+ type: string
+ required:
+ - app_id
+ - app_type
+ - product
+ message:
+ type: string
+ required:
+ - plan_id
+ - status
+ required:
+ - results
+ title: SyncRevenueCatResponse
+ examples:
+ - &a80
+ results:
+ - plan_id: pro
+ status: synced
+ store_identifier: autumn.sandbox.org_123.pro
+ apps:
+ - app_id: app_test
+ app_type: test_store
+ product: created
+ store_push: skipped
+ price: set
+ example: *a80
+ x-speakeasy-name-override: syncRevenueCat
+ parameters:
+ - *a5
+ x-codeSamples:
+ - lang: typescript
+ label: Typescript (SDK)
+ source: |-
+ import { Autumn } from 'autumn-js'
+
+ const autumn = new Autumn()
+
+ const result = await autumn.platform.syncRevenueCat({
+ organizationSlug: "acme",
+ env: "test",
+ productIds: [
+ "pro",
+ "premium",
+ ],
+ });
+ - lang: python
+ label: Python (SDK)
+ source: |-
+ from autumn_sdk import Autumn
+
+ autumn = Autumn(secret_key="am_sk_test...")
+
+ res = autumn.platform.sync_revenue_cat(
+ organization_slug="acme",
+ env="test",
+ product_ids=[
+ "pro",
+ "premium",
+ ],
+ )
+ /v1/platform.get_revenuecat_keys:
+ post:
+ operationId: getRevenueCatKeys
+ description: Retrieve a managed organization's RevenueCat public (SDK) API keys,
+ grouped by app — for the test store, App Store, and Google Play Store.
+ Use these to configure the RevenueCat SDK in the org's mobile app.
+ tags:
+ - platform
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ organization_slug:
+ type: string
+ minLength: 1
+ env:
+ enum:
+ - test
+ - sandbox
+ - live
+ type: string
+ description: '"test" and "sandbox" both target the sandbox environment'
+ required:
+ - organization_slug
+ - env
+ title: GetRevenueCatKeysParams
+ examples:
+ - &a81
+ organization_slug: acme
+ env: test
+ example: *a81
+ responses:
+ "200":
+ description: OK
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ apps:
+ type: array
+ items:
+ type: object
+ properties:
+ app_id:
+ type: string
+ app_type:
+ type: string
+ description: RevenueCat store type, e.g. test_store / app_store / play_store
+ name:
+ type: string
+ api_keys:
+ type: array
+ items:
+ type: object
+ properties:
+ id:
+ type: string
+ key:
+ type: string
+ description: The public SDK API key value
+ environment:
+ anyOf:
+ - type: string
+ - type: "null"
+ description: e.g. "production" / "sandbox"
+ app_id:
+ anyOf:
+ - type: string
+ - type: "null"
+ created_at:
+ type: number
+ required:
+ - id
+ - key
+ additionalProperties: {}
+ required:
+ - app_id
+ - app_type
+ - name
+ - api_keys
+ oauth_access_token:
+ anyOf:
+ - type: string
+ - type: "null"
+ description: Freshly-refreshed RevenueCat OAuth access token for the org (null
+ for api-key orgs). The refresh token is never exposed —
+ call this endpoint again for a new access token.
+ required:
+ - apps
+ - oauth_access_token
+ title: GetRevenueCatKeysResponse
+ examples:
+ - &a82
+ apps:
+ - app_id: app1a2b3c4d
+ app_type: test_store
+ name: Acme (Test Store)
+ api_keys:
+ - id: apikey12345
+ key: test_aBcDeFgHiJkLmNoPqRsTuVwXyZ
+ environment: production
+ app_id: app1a2b3c4
+ oauth_access_token: atk_aBcDeFgHiJkLmNoPqRsTuVwXyZ
+ example: *a82
+ x-speakeasy-name-override: getRevenueCatKeys
+ parameters:
+ - *a5
+ x-codeSamples:
+ - lang: typescript
+ label: Typescript (SDK)
+ source: |-
+ import { Autumn } from 'autumn-js'
+
+ const autumn = new Autumn()
+
+ const result = await autumn.platform.getRevenueCatKeys({
+ organizationSlug: "acme",
+ env: "test",
+ });
+ - lang: python
+ label: Python (SDK)
+ source: |-
+ from autumn_sdk import Autumn
+
+ autumn = Autumn(secret_key="am_sk_test...")
+
+ res = autumn.platform.get_revenue_cat_keys(
+ organization_slug="acme",
+ env="test",
+ )
security:
- secretKey: []
x-speakeasy-globals:
diff --git a/apps/docs/mintlify/changelog/changelog.mdx b/apps/docs/mintlify/changelog/changelog.mdx
index 302d6381d..ff0bdc9e0 100644
--- a/apps/docs/mintlify/changelog/changelog.mdx
+++ b/apps/docs/mintlify/changelog/changelog.mdx
@@ -4,6 +4,54 @@ mode: "center"
description: "Some new things we've shipped at Autumn HQ"
---
+
+ ## Autumn MCP server
+
+ Autumn now ships an official **Model Context Protocol (MCP) server**, so AI assistants like Claude Desktop and Cursor can read and act on your Autumn data directly. The server is generated against the public API and respects your existing API key scopes (`customers:read`, `plans:read`, `billing:read`, `billing:write`) — no new permission model to learn.
+
+ Install it as a Claude Desktop extension or wire it into Cursor with a single deeplink. Use it to look up customers, inspect plans, preview attaches, and run billing updates from your assistant.
+
+ ## Org-level usage alerts
+
+ Usage alerts can now be configured **once at the org level** and applied across every customer, instead of being attached plan by plan. Set a threshold and channel from settings, and Autumn fires alerts whenever any customer crosses the bar. The [`balances.usage_alert.triggered` webhook](/api-reference/webhooks/balancesUsageAlertTriggered) fires for both per-plan and org-level alerts.
+
+ ## Vercel Marketplace: invoice mode and resource logs
+
+ The Vercel integration now supports **invoice-mode billing** end to end and surfaces **per-resource logs** in the dashboard, so you can debug a Vercel customer's provisioning, auto top-ups, and invoice flow without leaving Autumn. The frontend was rebuilt around the new resource model, with fallbacks for identity edge cases.
+
+ See the [Vercel Marketplace guide](/documentation/external-providers/vercel-marketplace).
+
+
+
+ - Official Autumn MCP server for Claude Desktop, Cursor, and other MCP clients — [#1715](https://github.com/useautumn/autumn/pull/1715), [#1740](https://github.com/useautumn/autumn/pull/1740), [#1741](https://github.com/useautumn/autumn/pull/1741)
+ - Org-level usage alerts with shared thresholds across customers — [#1707](https://github.com/useautumn/autumn/pull/1707)
+ - Vercel Marketplace invoice mode, resource logs, and refreshed frontend — [#1711](https://github.com/useautumn/autumn/pull/1711)
+ - Preflight tax-address check before charging customers in taxable regions — [#1699](https://github.com/useautumn/autumn/pull/1699)
+ - Automatically void uncollectible Stripe invoices instead of leaving them open — [#1732](https://github.com/useautumn/autumn/pull/1732)
+ - New plan-version filters for [custom-plan migrations](/documentation/customers/custom-plans) — [#1718](https://github.com/useautumn/autumn/pull/1718)
+ - Faster customer detail loads and smoother large entity lists — [#1712](https://github.com/useautumn/autumn/pull/1712)
+ - Customer list filter by entity ID — [#1688](https://github.com/useautumn/autumn/pull/1688)
+ - Refreshed dashboard theme and appearance controls — [#1679](https://github.com/useautumn/autumn/pull/1679)
+ - Optimised product counts in the customer table — [#1690](https://github.com/useautumn/autumn/pull/1690)
+ - Mobile fixes for the customer list view — [#1689](https://github.com/useautumn/autumn/pull/1689)
+
+
+ - Stripe checkout no longer drops carry-over balances on plan changes — [#1708](https://github.com/useautumn/autumn/pull/1708)
+ - Trial grants are now excluded from reward eligibility — [#1730](https://github.com/useautumn/autumn/pull/1730)
+ - RevenueCat sync no longer fails with "entities not found" — [#1729](https://github.com/useautumn/autumn/pull/1729)
+ - Tax IDs now render correctly in the attach preview — [#1728](https://github.com/useautumn/autumn/pull/1728)
+ - Invalid email addresses are now rejected with a clearer error — [#1722](https://github.com/useautumn/autumn/pull/1722)
+ - Sandbox usage alerts now respect their configured threshold — [#1706](https://github.com/useautumn/autumn/pull/1706)
+ - Proration toggle now shows even when the customer has a past trial — [#1704](https://github.com/useautumn/autumn/pull/1704)
+ - One-off prepaid items upgrade correctly without double carry-over — [#1676](https://github.com/useautumn/autumn/pull/1676), [#1695](https://github.com/useautumn/autumn/pull/1695)
+ - Subscription metadata is preserved through checkout — [#1719](https://github.com/useautumn/autumn/pull/1719)
+ - Price dropdown no longer loses its selected value when reopened — [#1720](https://github.com/useautumn/autumn/pull/1720)
+ - Vercel auto top-ups no longer fail to process the resulting invoice — multiple fixes ([#1725](https://github.com/useautumn/autumn/pull/1725))
+
+
+
+
+
## Batch track and async track
diff --git a/apps/leaf/README.md b/apps/leaf/README.md
index ed301a3b3..5db168b3c 100644
--- a/apps/leaf/README.md
+++ b/apps/leaf/README.md
@@ -1,6 +1,6 @@
# Autumn Leaf
-Autumn's AI service: the Slack chat bot plus the hosted MCP routes (`src/mcp/http.ts`).
+Autumn's AI service: the Slack chat bot plus the hosted MCP routes (`src/mcp/mcpRouter.ts`).
Local Slack testing uses a normal Slack app in a development workspace. Keep the app undistributed while testing.
@@ -15,12 +15,20 @@ bun run chat:tunnel
2. Start Autumn with the same public URL:
```sh
-CHAT_URL=https://c.autumn.ngrok.app SLACK_BOT_URL=https://c.autumn.ngrok.app bun d
+NGROK_URL=https://c.autumn.ngrok.app bun d
```
+`bun d` derives `CHAT_URL`, `SLACK_BOT_URL`, and `SLACK_REDIRECT_URI` from
+`NGROK_URL`, so the Slack OAuth redirect becomes
+`https://c.autumn.ngrok.app/slack/oauth/callback`. This exact URL must be in
+the Slack app's OAuth redirect URLs.
+
The chat SDK stores its own subscriptions, locks, and queues in Postgres. By
default it uses the same `DATABASE_URL` host with the database name changed to
-`chat`; set `CHAT_STATE_DATABASE_URL` to override this.
+`chat`; set `CHAT_STATE_DATABASE_URL` to override this. `bun dev:services up`
+creates the local `chat` database. The `@chat-adapter/state-pg` package creates
+its state tables automatically on connect, so there is no separate migration
+command for the chat state database.
3. Create a Slack app at https://api.slack.com/apps using `slack-manifest.example.json`.
diff --git a/apps/leaf/package.json b/apps/leaf/package.json
index b1cd01d9d..060ccf12c 100644
--- a/apps/leaf/package.json
+++ b/apps/leaf/package.json
@@ -10,6 +10,8 @@
"ts": "tsc --noEmit"
},
"dependencies": {
+ "@autumn/auth": "workspace:*",
+ "@autumn/logging": "workspace:*",
"@autumn/mcp": "workspace:*",
"@autumn/shared": "workspace:*",
"@chat-adapter/slack": "^4.29.0",
diff --git a/apps/leaf/src/agent/agent.ts b/apps/leaf/src/agent/agent.ts
index 5143b1a58..2cb15ba80 100644
--- a/apps/leaf/src/agent/agent.ts
+++ b/apps/leaf/src/agent/agent.ts
@@ -1,13 +1,12 @@
+import type { AutumnLogger } from "@autumn/logging";
import { AppEnv } from "@autumn/shared";
import { Agent } from "@mastra/core/agent";
import { z } from "zod";
-import {
- createAutumnMcpClient,
- getAutumnMcpTools,
-} from "./mcp.js";
-import { createFirecrawlTools } from "./firecrawl.js";
import { env as chatEnv } from "../lib/env.js";
+import { logger as rootLogger } from "../lib/logger.js";
import type { ChatContextMessage } from "../types.js";
+import { createFirecrawlTools } from "./firecrawl.js";
+import { createAutumnMcpClient, getAutumnMcpTools } from "./mcp.js";
const docs = [
"autumn://docs/tool-composition",
@@ -37,15 +36,25 @@ const recentMessageContext = (messages: ChatContextMessage[] = []) =>
}));
export const selectChatEnv = async ({
+ logger = rootLogger,
message,
recentMessages,
select,
}: {
+ logger?: AutumnLogger;
message: string;
recentMessages?: ChatContextMessage[];
select?: () => Promise | unknown;
}) => {
- if (select) return envSelectionSchema.parse(await select()).env;
+ if (select) {
+ const env = envSelectionSchema.parse(await select()).env;
+ logger.debug("Selected chat environment from override", {
+ event: "leaf.chat_env_selected",
+ context: { env },
+ data: { source: "override" },
+ });
+ return env;
+ }
const agent = new Agent({
id: "autumn-chat-env",
@@ -61,9 +70,12 @@ export const selectChatEnv = async ({
instructions:
"Return live unless the latest user request clearly asks to use sandbox or test mode.",
},
- context: [
- ...recentMessageContext(recentMessages),
- ],
+ context: [...recentMessageContext(recentMessages)],
+ });
+ logger.debug("Selected chat environment from model", {
+ event: "leaf.chat_env_selected",
+ context: { env: output.object.env },
+ data: { source: "model" },
});
return output.object.env;
};
@@ -84,8 +96,9 @@ const readDocs = async (mcp: ReturnType) => {
};
export const runChatAgent = async ({
- apiKey,
+ token,
env,
+ logger = rootLogger,
message,
threadId,
resourceId,
@@ -93,8 +106,9 @@ export const runChatAgent = async ({
provider,
recentMessages,
}: {
- apiKey: string;
+ token: string;
env: AppEnv;
+ logger?: AutumnLogger;
message: string;
onAction?: (message: string) => Promise | void;
threadId: string;
@@ -102,7 +116,11 @@ export const runChatAgent = async ({
provider: string;
recentMessages?: ChatContextMessage[];
}) => {
- const mcp = createAutumnMcpClient(apiKey, { requireApproval: true });
+ const mcp = createAutumnMcpClient({
+ token,
+ appEnv: env,
+ options: { requireApproval: true },
+ });
let previewApproval:
| {
toolName: string;
@@ -111,13 +129,28 @@ export const runChatAgent = async ({
}
| undefined;
try {
+ logger.info("Starting chat agent", {
+ event: "leaf.agent_started",
+ context: {
+ env,
+ org_id: resourceId,
+ provider,
+ },
+ data: {
+ thread_id: threadId,
+ },
+ });
await onAction?.("Loading Autumn tools and guidance");
const [tools, docsText] = await Promise.all([
- getAutumnMcpTools(mcp, {
- applyApprovalPolicy: true,
- onToolCall: onAction,
- onPreview: (approval) => {
- previewApproval = approval;
+ getAutumnMcpTools({
+ mcp,
+ options: {
+ applyApprovalPolicy: true,
+ logger,
+ onToolCall: onAction,
+ onPreview: (approval) => {
+ previewApproval = approval;
+ },
},
}),
readDocs(mcp),
@@ -150,8 +183,19 @@ export const runChatAgent = async ({
...recentMessageContext(recentMessages),
],
});
+ logger.info("Completed chat agent", {
+ event: "leaf.agent_completed",
+ context: { env },
+ data: {
+ finish_reason: output.finishReason,
+ run_id: output.runId,
+ },
+ });
return { ...output, env, previewApproval };
} finally {
await mcp.disconnect();
+ logger.debug("Disconnected Autumn MCP client", {
+ event: "leaf.mcp_client_disconnected",
+ });
}
};
diff --git a/apps/leaf/src/agent/mcp.ts b/apps/leaf/src/agent/mcp.ts
index 44b2dc2ec..5bbbdeb12 100644
--- a/apps/leaf/src/agent/mcp.ts
+++ b/apps/leaf/src/agent/mcp.ts
@@ -1,6 +1,10 @@
+import { isSecretKeyPrefix } from "@autumn/auth";
+import type { AutumnLogger } from "@autumn/logging";
+import type { AppEnv } from "@autumn/shared";
import { MCPClient } from "@mastra/mcp";
-import { getWriteToolForPreview, toolLabel } from "./toolPolicy.js";
import { env } from "../lib/env.js";
+import { logger as rootLogger } from "../lib/logger.js";
+import { getWriteToolForPreview, toolLabel } from "./toolPolicy.js";
type AutumnTool = {
execute?: (
@@ -14,6 +18,7 @@ type AutumnTool = {
type ToolOptions = {
applyApprovalPolicy?: boolean;
+ logger?: AutumnLogger;
onToolCall?: (message: string) => Promise | void;
onPreview?: (approval: {
toolName: string;
@@ -23,29 +28,41 @@ type ToolOptions = {
};
const withAuthFetch =
- (apiKey: string) => (input: RequestInfo | URL, init?: RequestInit) => {
+ ({ appEnv, token }: { appEnv: AppEnv; token: string }) =>
+ (input: RequestInfo | URL, init?: RequestInit) => {
const headers = new Headers(init?.headers);
- headers.set("Authorization", `Bearer ${apiKey}`);
- headers.set("secret-key", apiKey);
+ headers.set("Authorization", `Bearer ${token}`);
+ headers.set("x-autumn-environment", appEnv);
+ if (isSecretKeyPrefix({ token })) {
+ headers.set("secret-key", token);
+ }
return fetch(input, { ...init, headers });
};
-export const createAutumnMcpClient = (
- apiKey: string,
- options: { requireApproval?: boolean } = {},
-) => {
- const fetchWithAuth = withAuthFetch(apiKey);
+export const createAutumnMcpClient = ({
+ token,
+ appEnv,
+ options = {},
+}: {
+ token: string;
+ appEnv: AppEnv;
+ options?: { requireApproval?: boolean };
+}) => {
+ const fetchWithAuth = withAuthFetch({ appEnv, token });
+ const headers: Record = {
+ Authorization: `Bearer ${token}`,
+ "x-autumn-environment": appEnv,
+ };
+ if (isSecretKeyPrefix({ token })) {
+ headers["secret-key"] = token;
+ }
+
return new MCPClient({
- id: `autumn-${apiKey.slice(0, 14)}`,
+ id: `autumn-${token.slice(0, 14)}`,
servers: {
autumn: {
url: new URL("/mcp", env.MCP_SERVER_URL),
- requestInit: {
- headers: {
- Authorization: `Bearer ${apiKey}`,
- "secret-key": apiKey,
- },
- },
+ requestInit: { headers },
eventSourceInit: { fetch: fetchWithAuth },
fetch: fetchWithAuth,
requireToolApproval: options.requireApproval
@@ -56,7 +73,13 @@ export const createAutumnMcpClient = (
});
};
-const formatToolAction = (toolName: string, args: Record) => {
+const formatToolAction = ({
+ toolName,
+ args,
+}: {
+ toolName: string;
+ args: Record;
+}) => {
const request =
args.request && typeof args.request === "object"
? (args.request as Record)
@@ -73,16 +96,32 @@ const formatToolAction = (toolName: string, args: Record) => {
return `${toolLabel(toolName)}${details.length ? ` (${details.join(", ")})` : ""}`;
};
-export const getAutumnMcpTools = async (
- mcp: MCPClient,
- options: ToolOptions = {},
-) => {
+export const getAutumnMcpTools = async ({
+ mcp,
+ options = {},
+}: {
+ mcp: MCPClient;
+ options?: ToolOptions;
+}) => {
+ const logger = options.logger ?? rootLogger;
const { toolsets, errors } = await mcp.listToolsetsWithErrors();
if (Object.keys(errors).length) {
- throw new Error(`Could not load Autumn MCP tools: ${JSON.stringify(errors)}`);
+ logger.error("Could not load Autumn MCP tools", {
+ event: "leaf.mcp_tools_load_failed",
+ data: { errors },
+ });
+ throw new Error(
+ `Could not load Autumn MCP tools: ${JSON.stringify(errors)}`,
+ );
}
const tools = (toolsets.autumn ?? {}) as Record;
+ logger.info("Loaded Autumn MCP tools", {
+ event: "leaf.mcp_tools_loaded",
+ data: {
+ tool_count: Object.keys(tools).length,
+ },
+ });
for (const [toolName, tool] of Object.entries(tools)) {
if (options.applyApprovalPolicy) {
tool.requireApproval = tool.mcp?.annotations?.destructiveHint === true;
@@ -91,10 +130,21 @@ export const getAutumnMcpTools = async (
if (tool.execute && (options.onToolCall || options.onPreview)) {
const execute = tool.execute.bind(tool);
tool.execute = async (args, ...rest) => {
- await options.onToolCall?.(formatToolAction(toolName, args));
+ logger.info("Calling Autumn MCP tool", {
+ event: "leaf.mcp_tool_called",
+ tool: toolName,
+ });
+ await options.onToolCall?.(formatToolAction({ toolName, args }));
const result = await execute(args, ...rest);
const writeTool = getWriteToolForPreview(toolName);
if (writeTool) {
+ logger.info("Captured Autumn MCP preview", {
+ event: "leaf.mcp_preview_captured",
+ tool: writeTool,
+ data: {
+ preview_tool: toolName,
+ },
+ });
options.onPreview?.({
toolName: writeTool,
toolArgs: args,
@@ -109,17 +159,19 @@ export const getAutumnMcpTools = async (
};
export const executeAutumnMcpTool = async ({
- apiKey,
+ env,
+ token,
toolName,
args,
}: {
- apiKey: string;
+ env: AppEnv;
+ token: string;
toolName: string;
args: Record;
}) => {
- const mcp = createAutumnMcpClient(apiKey);
+ const mcp = createAutumnMcpClient({ token, appEnv: env });
try {
- const tools = await getAutumnMcpTools(mcp);
+ const tools = await getAutumnMcpTools({ mcp });
const tool = tools[toolName.replace(/^autumn_/, "")];
if (!tool?.execute) throw new Error(`Unknown Autumn MCP tool: ${toolName}`);
return await tool.execute(args);
diff --git a/apps/leaf/src/agent/messages.ts b/apps/leaf/src/agent/messages.ts
index 395a410f1..6099c2b0d 100644
--- a/apps/leaf/src/agent/messages.ts
+++ b/apps/leaf/src/agent/messages.ts
@@ -1,6 +1,7 @@
-import { runChatAgent, selectChatEnv } from "./agent.js";
-import { getInstallationKey } from "../providers/slack/installations.js";
+import { getInstallationOAuthAccessToken } from "../internal/installations/actions/getInstallationOAuthAccessToken.js";
+import { logger as rootLogger } from "../lib/logger.js";
import { agentOutputSchema, type BotMessage } from "../types.js";
+import { runChatAgent, selectChatEnv } from "./agent.js";
const withTimeout = (promise: Promise, ms: number) =>
new Promise((resolve, reject) => {
@@ -13,6 +14,7 @@ const withTimeout = (promise: Promise, ms: number) =>
export const runMessage = async ({
installation,
+ logger = rootLogger,
onAction,
recentMessages,
text,
@@ -23,11 +25,25 @@ export const runMessage = async ({
const env = await selectChatEnv({
message: text,
recentMessages,
+ logger,
+ });
+ logger.info("Selected chat environment", {
+ event: "leaf.chat_env_selected",
+ context: {
+ env,
+ org_id: installation.org_id,
+ provider: installation.provider,
+ },
+ });
+ const token = await getInstallationOAuthAccessToken({
+ installation,
+ env,
});
return agentOutputSchema.parse(
await runChatAgent({
- apiKey: getInstallationKey(installation, env),
+ token,
env,
+ logger,
message: text,
onAction,
threadId,
diff --git a/apps/leaf/src/approvals/flow.ts b/apps/leaf/src/approvals/flow.ts
index 3486125cd..f79e6930d 100644
--- a/apps/leaf/src/approvals/flow.ts
+++ b/apps/leaf/src/approvals/flow.ts
@@ -1,5 +1,16 @@
+import type { AutumnLogger } from "@autumn/logging";
import type { ChatApproval, ChatInstallation } from "@autumn/shared";
import type { ActionEvent } from "chat";
+import { toolLabel } from "../agent/toolPolicy.js";
+import { logger as rootLogger } from "../lib/logger.js";
+import type { AgentOutput } from "../types.js";
+import { approvalCard, approvalStatusCard } from "../ui/blocks.js";
+import {
+ finishLoading,
+ type LoadingState,
+ type ReplyTarget,
+} from "../ui/progress.js";
+import { approvalRequestFromOutput } from "./request.js";
import {
approveAndRun,
cancelApproval,
@@ -7,21 +18,13 @@ import {
getApproval,
isErrorResult,
} from "./store.js";
-import { approvalRequestFromOutput } from "./request.js";
-import { approvalCard, approvalStatusCard } from "../ui/blocks.js";
-import {
- finishLoading,
- type LoadingState,
- type ReplyTarget,
-} from "../ui/progress.js";
-import { toolLabel } from "../agent/toolPolicy.js";
-import type { AgentOutput } from "../types.js";
export const postApprovalRequest = async ({
channelId,
installation,
loading,
logAction,
+ logger = rootLogger,
output,
providerUserId,
target,
@@ -30,6 +33,7 @@ export const postApprovalRequest = async ({
installation: ChatInstallation;
loading: LoadingState;
logAction: (message: string) => Promise | void;
+ logger?: AutumnLogger;
output: AgentOutput;
providerUserId: string;
target: ReplyTarget;
@@ -47,6 +51,15 @@ export const postApprovalRequest = async ({
});
await logAction(`Waiting for approval: ${toolLabel(approval.toolName)}`);
+ logger.info("Created approval request", {
+ event: "leaf.approval_created",
+ context: {
+ env: approval.env,
+ org_id: installation.org_id,
+ },
+ approval_id: approvalId,
+ tool: approval.toolName,
+ });
await finishLoading(target, loading, "Preview ready.");
await target.post(
approvalCard({
@@ -92,10 +105,22 @@ export const handleApprovalAction = async (event: ActionEvent) => {
if (!event.value) return;
try {
+ rootLogger.info("Received approval action", {
+ event: "leaf.approval_action_received",
+ approval_id: event.value,
+ action: event.actionId,
+ data: {
+ provider_user_id: event.user.userId,
+ },
+ });
const details = await approvalDetails(event.value);
if (event.actionId === "cancel_billing_action") {
const cancelled = await cancelApproval(event.value, event.user.userId);
if (!cancelled) {
+ rootLogger.warn("Approval cancellation ignored", {
+ event: "leaf.approval_cancel_ignored",
+ approval_id: event.value,
+ });
const current = await getApproval(event.value);
await editActionMessage(
event,
@@ -110,6 +135,11 @@ export const handleApprovalAction = async (event: ActionEvent) => {
event,
approvalStatusCard({ status: "cancelled", ...details }),
);
+ rootLogger.info("Cancelled approval", {
+ event: "leaf.approval_cancelled",
+ approval_id: event.value,
+ tool: details.toolName,
+ });
return;
}
@@ -118,6 +148,12 @@ export const handleApprovalAction = async (event: ActionEvent) => {
approvalStatusCard({ status: "running", ...details }),
);
const result = await approveAndRun(event.value, event.user.userId);
+ rootLogger.info("Completed approval action", {
+ event: "leaf.approval_completed",
+ approval_id: event.value,
+ status: isErrorResult(result) ? "failed" : "approved",
+ tool: details.toolName,
+ });
await editActionMessage(
event,
approvalStatusCard({
@@ -127,7 +163,11 @@ export const handleApprovalAction = async (event: ActionEvent) => {
}),
);
} catch (error) {
- console.error("[chat] Approval action failed", error);
+ rootLogger.error("[chat] Approval action failed", error, {
+ event: "leaf.approval_failed",
+ approval_id: event.value,
+ action: event.actionId,
+ });
const current = await getApproval(event.value);
await editActionMessage(
event,
diff --git a/apps/leaf/src/approvals/store.ts b/apps/leaf/src/approvals/store.ts
index 82f2e7847..e92741e97 100644
--- a/apps/leaf/src/approvals/store.ts
+++ b/apps/leaf/src/approvals/store.ts
@@ -1,15 +1,15 @@
import crypto from "node:crypto";
import {
- AppEnv,
+ type AppEnv,
type ChatProvider,
chatApprovals,
chatInstallations,
} from "@autumn/shared";
import { addMinutes, isPast } from "date-fns";
import { and, eq, gt } from "drizzle-orm";
-import { decrypt } from "../lib/crypto.js";
-import { db } from "../lib/db.js";
import { executeAutumnMcpTool } from "../agent/mcp.js";
+import { getInstallationOAuthAccessToken } from "../internal/installations/actions/getInstallationOAuthAccessToken.js";
+import { db } from "../lib/db.js";
export const normalizeToolName = (toolName: string) =>
toolName.replace(/^autumn_/, "");
@@ -120,14 +120,14 @@ export const approveAndRun = async (id: string, providerUserId: string) => {
});
if (!installation) throw new Error("Chat installation not found");
- const encryptedKey =
- claimed.env === AppEnv.Live
- ? installation.live_api_key
- : installation.sandbox_api_key;
- if (!encryptedKey) throw new Error(`Missing ${claimed.env} API key`);
+ const token = await getInstallationOAuthAccessToken({
+ installation,
+ env: claimed.env,
+ });
const result = await executeAutumnMcpTool({
- apiKey: decrypt(encryptedKey),
+ token,
+ env: claimed.env,
toolName: claimed.tool_name,
args: claimed.tool_args,
});
diff --git a/apps/leaf/src/bot.ts b/apps/leaf/src/bot.ts
index 1e9db935c..800b9a43d 100644
--- a/apps/leaf/src/bot.ts
+++ b/apps/leaf/src/bot.ts
@@ -2,12 +2,19 @@ import { createSlackAdapter } from "@chat-adapter/slack";
import { createPostgresState } from "@chat-adapter/state-pg";
import type { Message, Thread } from "chat";
import { Chat } from "chat";
+import { runMessage } from "./agent/messages.js";
import { handleApprovalAction, postApprovalRequest } from "./approvals/flow.js";
-import { getSlackWorkspaceId } from "./providers/slack/context.js";
import { decrypt } from "./lib/crypto.js";
import { env } from "./lib/env.js";
+import {
+ addLeafContext,
+ createLeafSessionContext,
+ logger as rootLogger,
+} from "./lib/logger.js";
+import { getSlackWorkspaceId } from "./providers/slack/context.js";
import { findInstallation } from "./providers/slack/installations.js";
-import { runMessage } from "./agent/messages.js";
+import { getRecentMessages } from "./providers/slack/threadContext.js";
+import type { ChatContextMessage } from "./types.js";
import {
createActionLogger,
finishLoading,
@@ -15,8 +22,6 @@ import {
type ReplyTarget,
startLoading,
} from "./ui/progress.js";
-import { getRecentMessages } from "./providers/slack/threadContext.js";
-import type { ChatContextMessage } from "./types.js";
export const chatAdapterNames = ["slack"];
@@ -66,15 +71,46 @@ const runAndReply = async ({
threadId: string;
}) => {
let loading: LoadingState = null;
+ let logger = rootLogger;
try {
const workspaceId = getSlackWorkspaceId(raw);
+ const session = createLeafSessionContext({
+ channelId,
+ provider: "slack",
+ providerUserId,
+ threadId,
+ workspaceId,
+ });
+ logger = addLeafContext(rootLogger, {
+ ...session.context,
+ agent_run_id: session.agentRunId,
+ });
+ logger.info("Received Slack message", {
+ event: "leaf.slack_message_received",
+ data: {
+ text_length: text.length,
+ },
+ });
const installation = await findInstallation("slack", workspaceId);
- if (!installation || !text.trim()) return;
+ if (!installation) {
+ logger.warn("Slack installation not found", {
+ event: "leaf.slack_installation_missing",
+ });
+ return;
+ }
+ if (!text.trim()) {
+ logger.info("Skipping empty Slack message", {
+ event: "leaf.slack_message_skipped",
+ data: { reason: "empty" },
+ });
+ return;
+ }
loading = await startLoading(target);
const logAction = createActionLogger(loading);
const output = await runMessage({
installation,
+ logger,
onAction: logAction,
recentMessages,
text,
@@ -86,6 +122,7 @@ const runAndReply = async ({
installation,
loading,
logAction,
+ logger,
output,
providerUserId,
target,
@@ -94,8 +131,16 @@ const runAndReply = async ({
await finishLoading(target, loading, "Done.");
await target.post({ markdown: output.text || "Done." });
+ logger.info("Posted Slack response", {
+ event: "leaf.slack_response_posted",
+ data: {
+ has_text: Boolean(output.text),
+ },
+ });
} catch (error) {
- console.error("[chat] Message failed", error);
+ logger.error("[chat] Message failed", error, {
+ event: "leaf.slack_message_failed",
+ });
await finishLoading(target, loading, "Request failed.");
await target.post({
markdown: "I could not complete that request. Please try again.",
diff --git a/apps/leaf/src/internal/installations/actions/getInstallationOAuthAccessToken.ts b/apps/leaf/src/internal/installations/actions/getInstallationOAuthAccessToken.ts
new file mode 100644
index 000000000..cfc42cc7e
--- /dev/null
+++ b/apps/leaf/src/internal/installations/actions/getInstallationOAuthAccessToken.ts
@@ -0,0 +1,83 @@
+import type { AppEnv, ChatInstallation } from "@autumn/shared";
+import { decrypt, encrypt } from "../../../lib/crypto.js";
+import { db } from "../../../lib/db.js";
+import { env as leafEnv } from "../../../lib/env.js";
+import {
+ getChatOAuthCredentialByInstallationEnv,
+ updateChatOAuthCredentialTokens,
+} from "../repos/chatOAuthCredentialsRepo.js";
+import {
+ parseOAuthScopeString,
+ parseOAuthTokenResponse,
+} from "../utils/oauthTokenResponse.js";
+
+const TOKEN_EXPIRY_SKEW_MS = 60_000;
+
+const getTokenEndpoint = () =>
+ new URL("/api/auth/oauth2/token", leafEnv.BETTER_AUTH_URL).href;
+
+const getDefaultExpiresAt = () => Date.now() + 60 * 60 * 1000;
+
+export const getInstallationOAuthAccessToken = async ({
+ installation,
+ env,
+}: {
+ installation: ChatInstallation;
+ env: AppEnv;
+}) => {
+ const credential = await getChatOAuthCredentialByInstallationEnv({
+ db,
+ chatInstallationId: installation.id,
+ env,
+ });
+
+ if (!credential) {
+ throw new Error(
+ `Missing ${env} Autumn OAuth credentials for Slack install`,
+ );
+ }
+
+ if (credential.access_token_expires_at - TOKEN_EXPIRY_SKEW_MS > Date.now()) {
+ return decrypt(credential.access_token);
+ }
+
+ const refreshToken = decrypt(credential.refresh_token);
+ const body = new URLSearchParams({
+ grant_type: "refresh_token",
+ refresh_token: refreshToken,
+ client_id: credential.oauth_client_id,
+ });
+
+ const response = await fetch(getTokenEndpoint(), {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/x-www-form-urlencoded",
+ },
+ body,
+ });
+
+ if (!response.ok) {
+ throw new Error(
+ `Could not refresh ${env} Autumn OAuth token for Slack install`,
+ );
+ }
+
+ const parsed = parseOAuthTokenResponse({ body: await response.json() });
+ const accessTokenExpiresAt = parsed.expires_in
+ ? Date.now() + parsed.expires_in * 1000
+ : getDefaultExpiresAt();
+ const nextRefreshToken = parsed.refresh_token ?? refreshToken;
+ const scopes = parseOAuthScopeString({ scope: parsed.scope });
+
+ await updateChatOAuthCredentialTokens({
+ db,
+ id: credential.id,
+ accessToken: encrypt(parsed.access_token),
+ refreshToken: encrypt(nextRefreshToken),
+ accessTokenExpiresAt,
+ scopes: scopes.length > 0 ? scopes : credential.scopes,
+ updatedAt: Date.now(),
+ });
+
+ return parsed.access_token;
+};
diff --git a/apps/leaf/src/internal/installations/actions/replaceInstallationOAuthCredentials.ts b/apps/leaf/src/internal/installations/actions/replaceInstallationOAuthCredentials.ts
new file mode 100644
index 000000000..708476aae
--- /dev/null
+++ b/apps/leaf/src/internal/installations/actions/replaceInstallationOAuthCredentials.ts
@@ -0,0 +1,218 @@
+import crypto from "node:crypto";
+import { prefixOAuthToken } from "@autumn/auth";
+import {
+ AppEnv,
+ type ChatInstallation,
+ chatOAuthCredentials,
+ oauthAccessToken,
+ oauthClient,
+ oauthConsent,
+ oauthRefreshToken,
+} from "@autumn/shared";
+import { ALL_SCOPES } from "@autumn/shared/utils/scopeDefinitions";
+import { and, eq } from "drizzle-orm";
+import { encrypt } from "../../../lib/crypto.js";
+import type { db } from "../../../lib/db.js";
+import { AUTUMN_SLACK_OAUTH_CLIENT_ID } from "./upsertInstallationOAuthCredential.js";
+
+type ChatTransaction = Parameters[0]>[0];
+
+const ACCESS_TOKEN_TTL_MS = 60 * 60 * 1000;
+const REFRESH_TOKEN_TTL_MS = 365 * 24 * 60 * 60 * 1000;
+
+const tokenHash = ({ token }: { token: string }) => {
+ const hash = crypto.createHash("sha256").update(token).digest();
+ return hash
+ .toString("base64")
+ .replace(/\+/g, "-")
+ .replace(/\//g, "_")
+ .replace(/=/g, "");
+};
+
+const generateToken = () => crypto.randomBytes(48).toString("base64url");
+
+const ensureSlackMcpOAuthClient = async ({ tx }: { tx: ChatTransaction }) => {
+ const now = new Date();
+
+ await tx
+ .insert(oauthClient)
+ .values({
+ id: `oauth_client_${crypto.randomUUID().replace(/-/g, "")}`,
+ clientId: AUTUMN_SLACK_OAUTH_CLIENT_ID,
+ name: "Slack",
+ redirectUris: ["slack://autumn-chat"],
+ scopes: [...ALL_SCOPES],
+ tokenEndpointAuthMethod: "none",
+ grantTypes: ["authorization_code", "refresh_token"],
+ responseTypes: ["code"],
+ public: true,
+ type: "native",
+ metadata: {
+ kind: "mcp_client",
+ mcpClientType: "slack",
+ },
+ createdAt: now,
+ updatedAt: now,
+ })
+ .onConflictDoUpdate({
+ target: oauthClient.clientId,
+ set: {
+ name: "Slack",
+ scopes: [...ALL_SCOPES],
+ tokenEndpointAuthMethod: "none",
+ grantTypes: ["authorization_code", "refresh_token"],
+ responseTypes: ["code"],
+ public: true,
+ type: "native",
+ metadata: {
+ kind: "mcp_client",
+ mcpClientType: "slack",
+ },
+ updatedAt: now,
+ },
+ });
+};
+
+const upsertOAuthConsent = async ({
+ tx,
+ env,
+ orgId,
+ userId,
+}: {
+ tx: ChatTransaction;
+ env: AppEnv;
+ orgId: string;
+ userId: string;
+}) => {
+ const now = new Date();
+ const [existingConsent] = await tx
+ .select({ id: oauthConsent.id })
+ .from(oauthConsent)
+ .where(
+ and(
+ eq(oauthConsent.clientId, AUTUMN_SLACK_OAUTH_CLIENT_ID),
+ eq(oauthConsent.userId, userId),
+ eq(oauthConsent.referenceId, orgId),
+ eq(oauthConsent.env, env),
+ ),
+ )
+ .limit(1);
+
+ if (existingConsent) {
+ await tx
+ .update(oauthConsent)
+ .set({
+ scopes: [...ALL_SCOPES],
+ updatedAt: now,
+ })
+ .where(eq(oauthConsent.id, existingConsent.id));
+ return existingConsent.id;
+ }
+
+ const consentId = `oauth_consent_${crypto.randomUUID().replace(/-/g, "")}`;
+ await tx.insert(oauthConsent).values({
+ id: consentId,
+ clientId: AUTUMN_SLACK_OAUTH_CLIENT_ID,
+ userId,
+ referenceId: orgId,
+ scopes: [...ALL_SCOPES],
+ env,
+ redirectUri: "slack://autumn-chat",
+ createdAt: now,
+ updatedAt: now,
+ });
+
+ return consentId;
+};
+
+const createCredentialForEnv = async ({
+ tx,
+ installation,
+ env,
+ userId,
+}: {
+ tx: ChatTransaction;
+ installation: ChatInstallation;
+ env: AppEnv;
+ userId: string;
+}) => {
+ const now = Date.now();
+ const nowDate = new Date(now);
+ const rawAccessToken = generateToken();
+ const rawRefreshToken = generateToken();
+ const accessTokenExpiresAt = now + ACCESS_TOKEN_TTL_MS;
+ const refreshTokenExpiresAt = now + REFRESH_TOKEN_TTL_MS;
+ const refreshTokenId = `oauth_refresh_${crypto.randomUUID().replace(/-/g, "")}`;
+ const accessTokenId = `oauth_access_${crypto.randomUUID().replace(/-/g, "")}`;
+ const consentId = await upsertOAuthConsent({
+ tx,
+ env,
+ orgId: installation.org_id,
+ userId,
+ });
+
+ await tx.insert(oauthRefreshToken).values({
+ id: refreshTokenId,
+ token: tokenHash({ token: rawRefreshToken }),
+ clientId: AUTUMN_SLACK_OAUTH_CLIENT_ID,
+ userId,
+ referenceId: installation.org_id,
+ expiresAt: new Date(refreshTokenExpiresAt),
+ createdAt: nowDate,
+ authTime: nowDate,
+ scopes: [...ALL_SCOPES],
+ });
+ await tx.insert(oauthAccessToken).values({
+ id: accessTokenId,
+ token: tokenHash({ token: rawAccessToken }),
+ clientId: AUTUMN_SLACK_OAUTH_CLIENT_ID,
+ userId,
+ referenceId: installation.org_id,
+ refreshId: refreshTokenId,
+ expiresAt: new Date(accessTokenExpiresAt),
+ createdAt: nowDate,
+ scopes: [...ALL_SCOPES],
+ });
+ await tx.insert(chatOAuthCredentials).values({
+ id: `chat_oauth_${crypto.randomUUID().replace(/-/g, "")}`,
+ chat_installation_id: installation.id,
+ org_id: installation.org_id,
+ env,
+ oauth_client_id: AUTUMN_SLACK_OAUTH_CLIENT_ID,
+ oauth_consent_id: consentId,
+ access_token: encrypt(prefixOAuthToken({ token: rawAccessToken })),
+ refresh_token: encrypt(rawRefreshToken),
+ access_token_expires_at: accessTokenExpiresAt,
+ scopes: [...ALL_SCOPES],
+ created_at: now,
+ updated_at: now,
+ });
+};
+
+export const replaceInstallationOAuthCredentials = async ({
+ tx,
+ installation,
+ userId,
+}: {
+ tx: ChatTransaction;
+ installation: ChatInstallation;
+ userId: string;
+}) => {
+ if (!userId) {
+ throw new Error("Missing user id for Slack MCP OAuth credentials");
+ }
+
+ await ensureSlackMcpOAuthClient({ tx });
+ await createCredentialForEnv({
+ tx,
+ installation,
+ env: AppEnv.Sandbox,
+ userId,
+ });
+ await createCredentialForEnv({
+ tx,
+ installation,
+ env: AppEnv.Live,
+ userId,
+ });
+};
diff --git a/apps/leaf/src/internal/installations/actions/upsertInstallationOAuthCredential.ts b/apps/leaf/src/internal/installations/actions/upsertInstallationOAuthCredential.ts
new file mode 100644
index 000000000..164576e7f
--- /dev/null
+++ b/apps/leaf/src/internal/installations/actions/upsertInstallationOAuthCredential.ts
@@ -0,0 +1,47 @@
+import crypto from "node:crypto";
+import type { AppEnv, ChatInstallation } from "@autumn/shared";
+import { encrypt } from "../../../lib/crypto.js";
+import { db } from "../../../lib/db.js";
+import { upsertChatOAuthCredential } from "../repos/chatOAuthCredentialsRepo.js";
+
+export const AUTUMN_SLACK_OAUTH_CLIENT_ID = "autumn_mcp_slack";
+
+export const upsertInstallationOAuthCredential = async ({
+ installation,
+ env,
+ accessToken,
+ refreshToken,
+ accessTokenExpiresAt,
+ scopes,
+ oauthClientId = AUTUMN_SLACK_OAUTH_CLIENT_ID,
+ oauthConsentId,
+}: {
+ installation: ChatInstallation;
+ env: AppEnv;
+ accessToken: string;
+ refreshToken: string;
+ accessTokenExpiresAt: number;
+ scopes: string[];
+ oauthClientId?: string;
+ oauthConsentId?: string | null;
+}) => {
+ const now = Date.now();
+
+ return upsertChatOAuthCredential({
+ db,
+ credential: {
+ id: `chat_oauth_${crypto.randomUUID().replace(/-/g, "")}`,
+ chat_installation_id: installation.id,
+ org_id: installation.org_id,
+ env,
+ oauth_client_id: oauthClientId,
+ oauth_consent_id: oauthConsentId ?? null,
+ access_token: encrypt(accessToken),
+ refresh_token: encrypt(refreshToken),
+ access_token_expires_at: accessTokenExpiresAt,
+ scopes,
+ created_at: now,
+ updated_at: now,
+ },
+ });
+};
diff --git a/apps/leaf/src/internal/installations/repos/chatOAuthCredentialsRepo.ts b/apps/leaf/src/internal/installations/repos/chatOAuthCredentialsRepo.ts
new file mode 100644
index 000000000..5ac48096b
--- /dev/null
+++ b/apps/leaf/src/internal/installations/repos/chatOAuthCredentialsRepo.ts
@@ -0,0 +1,89 @@
+import {
+ type AppEnv,
+ type ChatOAuthCredential,
+ chatOAuthCredentials,
+} from "@autumn/shared";
+import { and, eq } from "drizzle-orm";
+import type { ChatDb } from "../../../lib/db.js";
+
+export type ChatOAuthCredentialInsert =
+ typeof chatOAuthCredentials.$inferInsert;
+
+export const getChatOAuthCredentialByInstallationEnv = async ({
+ db,
+ chatInstallationId,
+ env,
+}: {
+ db: ChatDb;
+ chatInstallationId: string;
+ env: AppEnv;
+}) =>
+ db.query.chatOAuthCredentials.findFirst({
+ where: and(
+ eq(chatOAuthCredentials.chat_installation_id, chatInstallationId),
+ eq(chatOAuthCredentials.env, env),
+ ),
+ });
+
+export const upsertChatOAuthCredential = async ({
+ db,
+ credential,
+}: {
+ db: ChatDb;
+ credential: ChatOAuthCredentialInsert;
+}) => {
+ const [row] = await db
+ .insert(chatOAuthCredentials)
+ .values(credential)
+ .onConflictDoUpdate({
+ target: [
+ chatOAuthCredentials.chat_installation_id,
+ chatOAuthCredentials.env,
+ ],
+ set: {
+ org_id: credential.org_id,
+ oauth_client_id: credential.oauth_client_id,
+ oauth_consent_id: credential.oauth_consent_id,
+ access_token: credential.access_token,
+ refresh_token: credential.refresh_token,
+ access_token_expires_at: credential.access_token_expires_at,
+ scopes: credential.scopes,
+ updated_at: credential.updated_at,
+ },
+ })
+ .returning();
+
+ return row as ChatOAuthCredential;
+};
+
+export const updateChatOAuthCredentialTokens = async ({
+ db,
+ id,
+ accessToken,
+ refreshToken,
+ accessTokenExpiresAt,
+ scopes,
+ updatedAt,
+}: {
+ db: ChatDb;
+ id: string;
+ accessToken: string;
+ refreshToken: string;
+ accessTokenExpiresAt: number;
+ scopes: string[];
+ updatedAt: number;
+}) => {
+ const [row] = await db
+ .update(chatOAuthCredentials)
+ .set({
+ access_token: accessToken,
+ refresh_token: refreshToken,
+ access_token_expires_at: accessTokenExpiresAt,
+ scopes,
+ updated_at: updatedAt,
+ })
+ .where(eq(chatOAuthCredentials.id, id))
+ .returning();
+
+ return row as ChatOAuthCredential | undefined;
+};
diff --git a/apps/leaf/src/internal/installations/utils/oauthTokenResponse.ts b/apps/leaf/src/internal/installations/utils/oauthTokenResponse.ts
new file mode 100644
index 000000000..991371908
--- /dev/null
+++ b/apps/leaf/src/internal/installations/utils/oauthTokenResponse.ts
@@ -0,0 +1,22 @@
+import { z } from "zod";
+
+const oauthTokenPayloadSchema = z.object({
+ access_token: z.string().min(1),
+ refresh_token: z.string().min(1).optional(),
+ expires_in: z.number().optional(),
+ scope: z.string().optional(),
+});
+
+const oauthTokenResponseSchema = z.preprocess((value) => {
+ if (value && typeof value === "object" && "response" in value) {
+ return (value as { response?: unknown }).response;
+ }
+
+ return value;
+}, oauthTokenPayloadSchema);
+
+export const parseOAuthTokenResponse = ({ body }: { body: unknown }) =>
+ oauthTokenResponseSchema.parse(body);
+
+export const parseOAuthScopeString = ({ scope }: { scope?: string }) =>
+ scope?.split(/\s+/).filter(Boolean) ?? [];
diff --git a/apps/leaf/src/lib/logger.ts b/apps/leaf/src/lib/logger.ts
new file mode 100644
index 000000000..f831e6d16
--- /dev/null
+++ b/apps/leaf/src/lib/logger.ts
@@ -0,0 +1,60 @@
+import {
+ type AutumnLogger,
+ createAppLogger,
+ createSessionId,
+ createTraceId,
+} from "@autumn/logging";
+
+export const logger = createAppLogger({
+ service: "leaf",
+ dataset: process.env.LEAF_LOG_DATASET ?? "leaf",
+ preset: "default",
+});
+
+export const createLeafSessionContext = ({
+ channelId,
+ provider,
+ providerUserId,
+ threadId,
+ workspaceId,
+}: {
+ channelId: string;
+ provider: string;
+ providerUserId: string;
+ threadId: string;
+ workspaceId: string;
+}) => {
+ const traceId = createTraceId();
+ const sessionId = createSessionId({
+ parts: {
+ channelId,
+ provider,
+ threadId,
+ workspaceId,
+ },
+ });
+ return {
+ agentRunId: createTraceId(),
+ sessionId,
+ traceId,
+ context: {
+ provider,
+ provider_user_id: providerUserId,
+ session_id: sessionId,
+ trace_id: traceId,
+ slack_channel_id: channelId,
+ slack_thread_id: threadId,
+ slack_workspace_id: workspaceId,
+ },
+ };
+};
+
+export const addLeafContext = (
+ baseLogger: AutumnLogger,
+ context: Record,
+): AutumnLogger =>
+ baseLogger.child({
+ context: {
+ context,
+ },
+ });
diff --git a/apps/leaf/src/main.ts b/apps/leaf/src/main.ts
index 452cfacaa..fb00cd9e2 100644
--- a/apps/leaf/src/main.ts
+++ b/apps/leaf/src/main.ts
@@ -1,10 +1,10 @@
-import { createConsoleLogger } from "@autumn/mcp";
import type { HttpBindings } from "@hono/node-server";
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { chatAdapterNames } from "./bot.js";
import { env } from "./lib/env.js";
-import { registerMcpRoutes } from "./mcp/http.js";
+import { logger } from "./lib/logger.js";
+import { createMcpRouter } from "./mcp/mcpRouter.js";
import { slackRoutes } from "./providers/slack/routes.js";
const app = new Hono<{ Bindings: HttpBindings }>();
@@ -18,12 +18,16 @@ app.use("*", async (c, next) => {
app.get("/health", (c) => c.json({ ok: true }));
-registerMcpRoutes(app, {
- "oauth-enabled": true,
- "oauth-environment": env.MCP_OAUTH_ENVIRONMENT,
- "server-url": env.BETTER_AUTH_URL,
- logger: createConsoleLogger("info"),
-});
+app.route(
+ "",
+ createMcpRouter({
+ "oauth-enabled": true,
+ "oauth-environment": env.MCP_OAUTH_ENVIRONMENT,
+ "server-url": env.BETTER_AUTH_URL,
+ logger,
+ resourceUrl: new URL("/mcp", env.MCP_SERVER_URL).href,
+ }),
+);
app.route("/slack", slackRoutes);
@@ -34,9 +38,12 @@ serve(
port: env.PORT,
},
({ address, port }) => {
- console.log("Chat listening", {
- host: `${address}:${port}`,
- adapters: chatAdapterNames,
+ logger.info("Chat listening", {
+ event: "leaf.server_started",
+ data: {
+ host: `${address}:${port}`,
+ adapters: chatAdapterNames,
+ },
});
},
);
diff --git a/apps/leaf/src/mcp/auth/protectedResourceMetadata.ts b/apps/leaf/src/mcp/auth/protectedResourceMetadata.ts
new file mode 100644
index 000000000..6c2dd5066
--- /dev/null
+++ b/apps/leaf/src/mcp/auth/protectedResourceMetadata.ts
@@ -0,0 +1,34 @@
+import {
+ getOAuthIssuerUrl,
+} from "@autumn/auth/oauth";
+import {
+ DEFAULT_AUTUMN_API_URL,
+ MCP_OAUTH_SCOPES,
+} from "@autumn/mcp";
+
+export class OAuthHttpError extends Error {
+ constructor(
+ readonly status: number,
+ message: string,
+ readonly error = "invalid_token",
+ readonly wwwAuthenticate?: string,
+ ) {
+ super(message);
+ }
+}
+
+export const getProtectedResourceMetadata = ({
+ resourceUrl,
+ serverURL,
+}: {
+ resourceUrl: string;
+ serverURL?: string;
+}) => ({
+ resource: resourceUrl,
+ authorization_servers: [
+ getOAuthIssuerUrl({ baseUrl: serverURL ?? DEFAULT_AUTUMN_API_URL }),
+ ],
+ scopes_supported: [...MCP_OAUTH_SCOPES],
+ bearer_methods_supported: ["header"],
+ resource_name: "Autumn MCP",
+});
diff --git a/apps/leaf/src/mcp/auth/resolveRequestAuth.ts b/apps/leaf/src/mcp/auth/resolveRequestAuth.ts
new file mode 100644
index 000000000..5dfe335f0
--- /dev/null
+++ b/apps/leaf/src/mcp/auth/resolveRequestAuth.ts
@@ -0,0 +1,184 @@
+import { createHash } from "node:crypto";
+import { getBearerToken, isOAuthToken, isSecretKeyPrefix } from "@autumn/auth";
+import {
+ getProtectedResourceMetadataUrl,
+ getWwwAuthenticateHeader,
+} from "@autumn/auth/oauth";
+import {
+ type AutumnMcpAuth,
+ DEFAULT_API_VERSION,
+ environmentSchema,
+ MCP_OAUTH_SCOPES,
+ type MCPServerFlags,
+ type OAuthEnvironment,
+} from "@autumn/mcp";
+import * as z from "zod/v4";
+import { OAuthHttpError } from "./protectedResourceMetadata.js";
+
+type AuthLogger = {
+ warning: (message: string, data?: Record) => void;
+};
+
+export interface MCPOAuthFlags extends MCPServerFlags {
+ readonly "oauth-enabled"?: boolean | undefined;
+ readonly "oauth-environment"?: OAuthEnvironment | undefined;
+}
+
+const xApiVersionSchema = z.string().default(DEFAULT_API_VERSION);
+const secretKeySchema = z.string().min(1).optional();
+const failOpenSchema = z
+ .union([
+ z.boolean(),
+ z.enum(["true", "false"]).transform((v) => v === "true"),
+ ])
+ .default(true);
+
+const parseRequestOption = ({
+ value,
+ schema,
+ message,
+}: {
+ value: unknown;
+ schema: z.ZodType;
+ message: string;
+}): T => {
+ const parsed = schema.safeParse(value);
+ if (parsed.success) return parsed.data;
+
+ throw new OAuthHttpError(400, message, "invalid_request");
+};
+
+const getEnvironment = ({
+ headers,
+ flags,
+}: {
+ headers: Headers;
+ flags: MCPOAuthFlags;
+}): OAuthEnvironment =>
+ parseRequestOption({
+ value:
+ headers.get("x-autumn-environment") ??
+ flags["oauth-environment"] ??
+ "sandbox",
+ schema: environmentSchema,
+ message: "Invalid x-autumn-environment",
+ });
+
+const getStaticApiKey = ({
+ headers,
+ flags,
+}: {
+ headers: Headers;
+ flags: MCPOAuthFlags;
+}): string | undefined => {
+ const secretKey = headers.get("secret-key");
+ if (secretKey && isSecretKeyPrefix({ token: secretKey })) return secretKey;
+
+ const bearer = getBearerToken({ headers });
+ if (bearer && isSecretKeyPrefix({ token: bearer })) return bearer;
+
+ const fallbackSecretKey = flags["secret-key"];
+ if (
+ !flags["oauth-enabled"] &&
+ fallbackSecretKey &&
+ isSecretKeyPrefix({ token: fallbackSecretKey })
+ ) {
+ return fallbackSecretKey;
+ }
+
+ return undefined;
+};
+
+const principalFromSecret = ({
+ kind,
+ value,
+}: {
+ kind: string;
+ value: string;
+}) => {
+ const digest = createHash("sha256").update(value).digest("hex").slice(0, 32);
+ return `${kind}:${digest}`;
+};
+
+export const buildAuthForRequest = async ({
+ headers,
+ flags,
+ logger,
+ resourceUrl,
+}: {
+ headers: Headers;
+ flags: MCPOAuthFlags;
+ logger: AuthLogger;
+ resourceUrl: string;
+}): Promise => {
+ const env = getEnvironment({ headers, flags });
+ const xApiVersion = parseRequestOption({
+ value: headers.get("x-api-version") ?? flags["x-api-version"],
+ schema: xApiVersionSchema,
+ message: "Invalid x-api-version",
+ });
+ const failOpen = parseRequestOption({
+ value: headers.get("fail-open") ?? flags["fail-open"],
+ schema: failOpenSchema,
+ message: "Invalid fail-open",
+ });
+ const apiKey = parseRequestOption({
+ value: getStaticApiKey({ headers, flags }),
+ schema: secretKeySchema,
+ message: "Invalid secret-key",
+ });
+
+ if (apiKey) {
+ return {
+ apiKey,
+ authMethod: "secret-key",
+ env,
+ resource: resourceUrl,
+ principalId: principalFromSecret({ kind: "secret-key", value: apiKey }),
+ scopes: [...MCP_OAUTH_SCOPES],
+ serverURL: flags["server-url"],
+ xApiVersion,
+ failOpen,
+ };
+ }
+
+ const bearer = getBearerToken({ headers });
+ if (bearer && isOAuthToken({ token: bearer })) {
+ return {
+ apiKey: bearer,
+ authMethod: "oauth",
+ env,
+ resource: resourceUrl,
+ principalId: "oauth:unverified",
+ scopes: [...MCP_OAUTH_SCOPES],
+ serverURL: flags["server-url"],
+ xApiVersion,
+ failOpen,
+ };
+ }
+
+ if (bearer) {
+ throw new OAuthHttpError(
+ 401,
+ "Invalid OAuth token prefix",
+ "invalid_token",
+ );
+ }
+
+ if (flags["oauth-enabled"]) {
+ throw new OAuthHttpError(
+ 401,
+ "Missing Autumn API key bearer token",
+ "invalid_token",
+ getWwwAuthenticateHeader({
+ resourceMetadataUrl: getProtectedResourceMetadataUrl({
+ resourceUrl,
+ }),
+ error: "invalid_token",
+ }),
+ );
+ }
+
+ logger.warning("Missing secret-key for MCP request");
+ throw new OAuthHttpError(401, "Missing secret-key", "invalid_token");
+};
diff --git a/apps/leaf/src/mcp/constants.ts b/apps/leaf/src/mcp/constants.ts
new file mode 100644
index 000000000..1615e9bc7
--- /dev/null
+++ b/apps/leaf/src/mcp/constants.ts
@@ -0,0 +1,3 @@
+export const MCP_PATH = "/mcp" as const;
+export const PROTECTED_RESOURCE_METADATA_PATH =
+ "/.well-known/oauth-protected-resource/mcp";
diff --git a/apps/leaf/src/mcp/handlers/handleMcp.ts b/apps/leaf/src/mcp/handlers/handleMcp.ts
new file mode 100644
index 000000000..6b9e4a3fe
--- /dev/null
+++ b/apps/leaf/src/mcp/handlers/handleMcp.ts
@@ -0,0 +1,71 @@
+import { randomUUID } from "node:crypto";
+import {
+ type createAutumnOperationsMCPServer,
+} from "@autumn/mcp";
+import { RESPONSE_ALREADY_SENT } from "@hono/node-server/utils/response";
+import {
+ buildAuthForRequest,
+} from "../auth/resolveRequestAuth.js";
+import { OAuthHttpError } from "../auth/protectedResourceMetadata.js";
+import type { LeafMcpContext, McpRouteOptions } from "../types.js";
+
+type McpServer = ReturnType;
+type McpAuth = Awaited>;
+
+const setIncomingAuth = ({
+ c,
+ auth,
+}: {
+ c: LeafMcpContext;
+ auth: McpAuth;
+}) => {
+ (c.env.incoming as typeof c.env.incoming & { auth?: McpAuth }).auth = auth;
+};
+
+const oauthErrorResponse = (c: LeafMcpContext, error: OAuthHttpError) => {
+ if (error.wwwAuthenticate) {
+ c.header("WWW-Authenticate", error.wwwAuthenticate);
+ }
+
+ return c.json(
+ { error: error.error, error_description: error.message },
+ { status: error.status as 400 | 401 | 403 },
+ );
+};
+
+export const createHandleMcp =
+ ({
+ options,
+ path,
+ server,
+ }: {
+ options: McpRouteOptions;
+ path: string;
+ server: McpServer;
+ }) =>
+ async (c: LeafMcpContext) => {
+ let auth: McpAuth;
+ try {
+ auth = await buildAuthForRequest({
+ headers: c.req.raw.headers,
+ flags: options,
+ logger: options.logger,
+ resourceUrl: options.resourceUrl,
+ });
+ } catch (error) {
+ if (error instanceof OAuthHttpError) {
+ return oauthErrorResponse(c, error);
+ }
+ throw error;
+ }
+
+ setIncomingAuth({ c, auth });
+ await server.startHTTP({
+ url: new URL(c.req.url),
+ httpPath: path,
+ req: c.env.incoming,
+ res: c.env.outgoing,
+ options: { sessionIdGenerator: randomUUID },
+ });
+ return RESPONSE_ALREADY_SENT;
+ };
diff --git a/apps/leaf/src/mcp/handlers/handleProtectedResourceMetadata.ts b/apps/leaf/src/mcp/handlers/handleProtectedResourceMetadata.ts
new file mode 100644
index 000000000..4bd455c2f
--- /dev/null
+++ b/apps/leaf/src/mcp/handlers/handleProtectedResourceMetadata.ts
@@ -0,0 +1,12 @@
+import { getProtectedResourceMetadata } from "../auth/protectedResourceMetadata.js";
+import type { LeafMcpContext, McpRouteOptions } from "../types.js";
+
+export const createHandleProtectedResourceMetadata =
+ ({ options }: { options: McpRouteOptions }) =>
+ (c: LeafMcpContext) =>
+ c.json(
+ getProtectedResourceMetadata({
+ resourceUrl: options.resourceUrl,
+ serverURL: options["server-url"],
+ }),
+ );
diff --git a/apps/leaf/src/mcp/http.ts b/apps/leaf/src/mcp/http.ts
deleted file mode 100644
index 23fec47ea..000000000
--- a/apps/leaf/src/mcp/http.ts
+++ /dev/null
@@ -1,87 +0,0 @@
-import {
- buildAuthForRequest,
- type ConsoleLogger,
- createAskAutumnMCPServer,
- createAutumnOperationsMCPServer,
- getAuthorizationServerMetadata,
- getProtectedResourceMetadata,
- type MCPServerFlags,
- type OAuthEnvironment,
- OAuthHttpError,
-} from "@autumn/mcp";
-import type { HttpBindings } from "@hono/node-server";
-import { RESPONSE_ALREADY_SENT } from "@hono/node-server/utils/response";
-import type { Context, Hono } from "hono";
-
-export interface McpRouteOptions extends MCPServerFlags {
- readonly "oauth-enabled": boolean;
- readonly "oauth-environment": OAuthEnvironment;
- readonly logger: ConsoleLogger;
-}
-
-type AppContext = Context<{ Bindings: HttpBindings }>;
-type McpPath = "/mcp" | "/internal/mcp";
-type McpApp = Hono<{ Bindings: HttpBindings }>;
-
-export function registerMcpRoutes(app: McpApp, options: McpRouteOptions) {
- app.get("/.well-known/oauth-protected-resource/mcp", (c) =>
- c.json(getProtectedResourceMetadata(c.req.raw.headers, options, "/mcp")),
- );
-
- app.get("/.well-known/oauth-protected-resource/internal/mcp", (c) =>
- c.json(
- getProtectedResourceMetadata(c.req.raw.headers, options, "/internal/mcp"),
- ),
- );
-
- app.get("/.well-known/oauth-authorization-server", (c) =>
- c.json(getAuthorizationServerMetadata(options)),
- );
-
- const handleMcp = async (
- c: AppContext,
- path: McpPath,
- server: ReturnType,
- ) => {
- let auth: Awaited>;
- try {
- auth = await buildAuthForRequest(
- c.req.raw.headers,
- options,
- options.logger,
- path,
- );
- } catch (error) {
- if (error instanceof OAuthHttpError) {
- if (error.wwwAuthenticate) {
- c.header("WWW-Authenticate", error.wwwAuthenticate);
- }
- return c.json(
- { error: error.error, error_description: error.message },
- { status: error.status as 401 | 403 },
- );
- }
- throw error;
- }
-
- (c.env.incoming as typeof c.env.incoming & { auth?: typeof auth }).auth =
- auth;
- await server.startHTTP({
- url: new URL(c.req.url),
- httpPath: path,
- req: c.env.incoming,
- res: c.env.outgoing,
- options: { serverless: true },
- });
- return RESPONSE_ALREADY_SENT;
- };
-
- app.all("/mcp", (c) =>
- handleMcp(c, "/mcp", createAutumnOperationsMCPServer()),
- );
- app.all("/internal/mcp", (c) =>
- handleMcp(c, "/internal/mcp", createAskAutumnMCPServer()),
- );
-
- return app;
-}
diff --git a/apps/leaf/src/mcp/mcpRouter.ts b/apps/leaf/src/mcp/mcpRouter.ts
new file mode 100644
index 000000000..c8c2f5888
--- /dev/null
+++ b/apps/leaf/src/mcp/mcpRouter.ts
@@ -0,0 +1,29 @@
+import { createAutumnOperationsMCPServer } from "@autumn/mcp";
+import type { HttpBindings } from "@hono/node-server";
+import { Hono } from "hono";
+import { MCP_PATH, PROTECTED_RESOURCE_METADATA_PATH } from "./constants.js";
+import { createHandleMcp } from "./handlers/handleMcp.js";
+import { createHandleProtectedResourceMetadata } from "./handlers/handleProtectedResourceMetadata.js";
+import type { McpRouteOptions } from "./types.js";
+
+export const createMcpRouter = (options: McpRouteOptions) => {
+ const router = new Hono<{ Bindings: HttpBindings }>();
+ const mcpServer = createAutumnOperationsMCPServer();
+
+ router.get(
+ PROTECTED_RESOURCE_METADATA_PATH,
+ createHandleProtectedResourceMetadata({
+ options,
+ }),
+ );
+ router.all(
+ MCP_PATH,
+ createHandleMcp({
+ options,
+ path: MCP_PATH,
+ server: mcpServer,
+ }),
+ );
+
+ return router;
+};
diff --git a/apps/leaf/src/mcp/types.ts b/apps/leaf/src/mcp/types.ts
new file mode 100644
index 000000000..71b420b98
--- /dev/null
+++ b/apps/leaf/src/mcp/types.ts
@@ -0,0 +1,15 @@
+import type { AutumnLogger } from "@autumn/logging";
+import type { MCPServerFlags, OAuthEnvironment } from "@autumn/mcp";
+import type { HttpBindings } from "@hono/node-server";
+import type { Context, Hono } from "hono";
+
+export interface McpRouteOptions extends MCPServerFlags {
+ readonly "oauth-enabled": boolean;
+ readonly "oauth-environment": OAuthEnvironment;
+ readonly logger: AutumnLogger;
+ readonly resourceUrl: string;
+}
+
+export type LeafMcpContext = Context<{ Bindings: HttpBindings }>;
+export type LeafMcpRouter = Hono<{ Bindings: HttpBindings }>;
+export type { MCPOAuthFlags } from "./auth/resolveRequestAuth.js";
diff --git a/apps/leaf/src/providers/slack/installations.ts b/apps/leaf/src/providers/slack/installations.ts
index 75441d9c1..d5bec4931 100644
--- a/apps/leaf/src/providers/slack/installations.ts
+++ b/apps/leaf/src/providers/slack/installations.ts
@@ -5,29 +5,16 @@ import {
type ChatInstallation,
type ChatProvider,
chatInstallations,
- Scopes,
} from "@autumn/shared";
import type { ChatInstallState } from "@autumn/shared/utils/chatState";
import { and, eq, or } from "drizzle-orm";
+import { replaceInstallationOAuthCredentials } from "../../internal/installations/actions/replaceInstallationOAuthCredentials.js";
import { decrypt, encrypt } from "../../lib/crypto.js";
import { db } from "../../lib/db.js";
import { env } from "../../lib/env.js";
type ChatTransaction = Parameters[0]>[0];
-const apiKeyScopes = [
- Scopes.Customers.Read,
- Scopes.Customers.Write,
- Scopes.Plans.Read,
- Scopes.Plans.Write,
- Scopes.Billing.Read,
- Scopes.Billing.Write,
- Scopes.Balances.Write,
-];
-
-const apiKeyPrefix = (env: AppEnv) =>
- env === AppEnv.Live ? "am_sk_live" : "am_sk_test";
-
export const getStateSecret = () => env.CHAT_STATE_SECRET;
export const findInstallation = (provider: ChatProvider, workspaceId: string) =>
@@ -50,33 +37,6 @@ export const getInstallationKey = (
return decrypt(key);
};
-const buildApiKey = ({
- orgId,
- userId,
- env,
- provider,
-}: {
- orgId: string;
- userId: string;
- env: AppEnv;
- provider: ChatProvider;
-}) => {
- const secret = `${apiKeyPrefix(env)}_${crypto.randomBytes(32).toString("base64url")}`;
- const key = {
- id: `key_${crypto.randomUUID().replace(/-/g, "")}`,
- org_id: orgId,
- user_id: userId,
- name: `Chat MCP (${provider})`,
- prefix: secret.substring(0, 14),
- created_at: Date.now(),
- env,
- hashed_key: crypto.createHash("sha256").update(secret).digest("hex"),
- meta: { created_via: "chat", provider },
- scopes: apiKeyScopes,
- };
- return { key, secret };
-};
-
const deleteInstallationApiKeys = async (
tx: ChatTransaction,
installation: ChatInstallation,
@@ -111,19 +71,6 @@ export const replaceInstallation = async ({
scopes: string[];
installedByProviderUserId?: string;
}) => {
- const sandbox = buildApiKey({
- orgId: state.orgId,
- userId: state.userId,
- env: AppEnv.Sandbox,
- provider,
- });
- const live = buildApiKey({
- orgId: state.orgId,
- userId: state.userId,
- env: AppEnv.Live,
- provider,
- });
-
const sameOrg = and(
eq(chatInstallations.org_id, state.orgId),
eq(chatInstallations.provider, provider),
@@ -134,8 +81,6 @@ export const replaceInstallation = async ({
);
await db.transaction(async (tx) => {
- await tx.insert(apiKeys).values([sandbox.key, live.key]);
-
const existingInstallations = await tx.query.chatInstallations.findMany({
where: or(sameOrg, sameWorkspace),
});
@@ -144,24 +89,29 @@ export const replaceInstallation = async ({
}
await tx.delete(chatInstallations).where(or(sameOrg, sameWorkspace));
- await tx.insert(chatInstallations).values({
- id: `chat_inst_${crypto.randomUUID().replace(/-/g, "")}`,
- org_id: state.orgId,
- provider,
- workspace_id: workspaceId,
- workspace_name: workspaceName,
- bot_user_id: botUserId,
- bot_access_token: encrypt(botAccessToken),
- scopes,
- default_env: state.env,
- sandbox_api_key_id: sandbox.key.id,
- sandbox_api_key: encrypt(sandbox.secret),
- live_api_key_id: live.key.id,
- live_api_key: encrypt(live.secret),
- installed_by_user_id: state.userId,
- installed_by_provider_user_id: installedByProviderUserId,
- created_at: Date.now(),
- updated_at: Date.now(),
+ const [installation] = await tx
+ .insert(chatInstallations)
+ .values({
+ id: `chat_inst_${crypto.randomUUID().replace(/-/g, "")}`,
+ org_id: state.orgId,
+ provider,
+ workspace_id: workspaceId,
+ workspace_name: workspaceName,
+ bot_user_id: botUserId,
+ bot_access_token: encrypt(botAccessToken),
+ scopes,
+ default_env: state.env,
+ installed_by_user_id: state.userId,
+ installed_by_provider_user_id: installedByProviderUserId,
+ created_at: Date.now(),
+ updated_at: Date.now(),
+ })
+ .returning();
+
+ await replaceInstallationOAuthCredentials({
+ tx,
+ installation,
+ userId: state.userId,
});
});
};
diff --git a/apps/leaf/src/providers/slack/routes.ts b/apps/leaf/src/providers/slack/routes.ts
index 748c5872b..f6e393036 100644
--- a/apps/leaf/src/providers/slack/routes.ts
+++ b/apps/leaf/src/providers/slack/routes.ts
@@ -2,6 +2,7 @@ import { verifyChatInstallState } from "@autumn/shared/utils/chatState";
import { Hono } from "hono";
import { z } from "zod";
import { bot } from "../../bot.js";
+import { logger } from "../../lib/logger.js";
import { getStateSecret, replaceInstallation } from "./installations.js";
import { exchangeSlackCode, slackErrorUrl, slackSuccessUrl } from "./oauth.js";
@@ -36,25 +37,38 @@ slackRoutes.get("/oauth/callback", async (c) => {
.filter(Boolean),
installedByProviderUserId: oauth.authed_user?.id,
});
- console.info("[chat:slack] Installed", {
- orgId: parsedState.orgId,
- workspaceId: oauth.team.id,
- workspaceName: oauth.team.name,
+ logger.info("[chat:slack] Installed", {
+ event: "leaf.slack_installed",
+ context: {
+ org_id: parsedState.orgId,
+ slack_workspace_id: oauth.team.id,
+ },
+ data: {
+ workspace_name: oauth.team.name,
+ },
});
return c.redirect(slackSuccessUrl());
} catch (error) {
- console.error("[chat:slack] OAuth callback failed", error);
+ logger.error("[chat:slack] OAuth callback failed", error, {
+ event: "leaf.slack_oauth_failed",
+ });
return c.redirect(slackErrorUrl("Slack install failed"));
}
});
slackRoutes.post("/events", (c) => {
+ logger.debug("Received Slack events request", {
+ event: "leaf.slack_events_request_received",
+ });
if (!bot.webhooks.slack) return c.text("Slack is not configured", 503);
return bot.webhooks.slack(c.req.raw);
});
slackRoutes.post("/interactions", (c) => {
+ logger.debug("Received Slack interactions request", {
+ event: "leaf.slack_interactions_request_received",
+ });
if (!bot.webhooks.slack) return c.text("Slack is not configured", 503);
return bot.webhooks.slack(c.req.raw);
});
diff --git a/apps/leaf/src/types.ts b/apps/leaf/src/types.ts
index 92c3f937f..7a0dc1208 100644
--- a/apps/leaf/src/types.ts
+++ b/apps/leaf/src/types.ts
@@ -1,4 +1,5 @@
import { AppEnv, type ChatInstallation } from "@autumn/shared";
+import type { AutumnLogger } from "@autumn/logging";
import { z } from "zod";
export const agentOutputSchema = z.preprocess(
@@ -62,6 +63,7 @@ export type SignatureArgs = {
export type BotMessage = {
installation: ChatInstallation;
+ logger?: AutumnLogger;
onAction?: (message: string) => Promise | void;
recentMessages?: ChatContextMessage[];
text: string;
diff --git a/apps/leaf/tests/unit/lib/logger.test.ts b/apps/leaf/tests/unit/lib/logger.test.ts
new file mode 100644
index 000000000..2e5bccf0d
--- /dev/null
+++ b/apps/leaf/tests/unit/lib/logger.test.ts
@@ -0,0 +1,32 @@
+import { describe, expect, test } from "bun:test";
+import { createLeafSessionContext } from "../../../src/lib/logger.js";
+
+describe("Leaf logger context", () => {
+ test("creates stable session ids and distinct trace ids", () => {
+ const first = createLeafSessionContext({
+ channelId: "C1",
+ provider: "slack",
+ providerUserId: "U1",
+ threadId: "T1",
+ workspaceId: "W1",
+ });
+ const second = createLeafSessionContext({
+ channelId: "C1",
+ provider: "slack",
+ providerUserId: "U2",
+ threadId: "T1",
+ workspaceId: "W1",
+ });
+
+ expect(first.sessionId).toBe(second.sessionId);
+ expect(first.traceId).not.toBe(second.traceId);
+ expect(first.context).toMatchObject({
+ provider: "slack",
+ session_id: first.sessionId,
+ trace_id: first.traceId,
+ slack_channel_id: "C1",
+ slack_thread_id: "T1",
+ slack_workspace_id: "W1",
+ });
+ });
+});
diff --git a/apps/leaf/tests/unit/mcp/oauth.test.ts b/apps/leaf/tests/unit/mcp/oauth.test.ts
new file mode 100644
index 000000000..98fa81147
--- /dev/null
+++ b/apps/leaf/tests/unit/mcp/oauth.test.ts
@@ -0,0 +1,167 @@
+import { describe, expect, test } from "bun:test";
+import { MCP_OAUTH_SCOPES } from "@autumn/mcp";
+import { Scopes } from "@autumn/shared/scopeDefinitions";
+import {
+ buildAuthForRequest,
+ type MCPOAuthFlags,
+} from "../../../src/mcp/auth/resolveRequestAuth.js";
+import {
+ getProtectedResourceMetadata,
+ type OAuthHttpError,
+} from "../../../src/mcp/auth/protectedResourceMetadata.js";
+
+const flags = {
+ "oauth-enabled": true,
+ "oauth-environment": "sandbox",
+ "server-url": "http://localhost:8080",
+} satisfies Partial;
+
+const logger = {
+ warning: () => {},
+} as never;
+
+const resourceUrl = "http://localhost:2718/mcp";
+const internalResourceUrl = "http://localhost:2718/internal/mcp";
+
+describe("MCP OAuth auth resolution", () => {
+ test("requests scopes required by public write tools", () => {
+ expect(MCP_OAUTH_SCOPES).toEqual(
+ expect.arrayContaining([
+ Scopes.Customers.Write,
+ Scopes.Plans.Write,
+ Scopes.Billing.Write,
+ Scopes.Balances.Write,
+ ]),
+ );
+ });
+
+ test("returns a WWW-Authenticate challenge without a bearer token", async () => {
+ await expect(
+ buildAuthForRequest({
+ headers: new Headers(),
+ flags: flags as MCPOAuthFlags,
+ logger,
+ resourceUrl,
+ }),
+ ).rejects.toMatchObject({
+ status: 401,
+ error: "invalid_token",
+ wwwAuthenticate:
+ 'Bearer resource_metadata="http://localhost:2718/.well-known/oauth-protected-resource/mcp", error="invalid_token"',
+ } satisfies Partial);
+ });
+
+ test("returns an internal MCP resource challenge", async () => {
+ await expect(
+ buildAuthForRequest({
+ headers: new Headers(),
+ flags: flags as MCPOAuthFlags,
+ logger,
+ resourceUrl: internalResourceUrl,
+ }),
+ ).rejects.toMatchObject({
+ status: 401,
+ error: "invalid_token",
+ wwwAuthenticate:
+ 'Bearer resource_metadata="http://localhost:2718/.well-known/oauth-protected-resource/internal/mcp", error="invalid_token"',
+ } satisfies Partial);
+ });
+
+ test("passes OAuth bearer tokens through without local verification", async () => {
+ const originalFetch = globalThis.fetch;
+ let fetchCalled = false;
+ const mockFetch = (async () => {
+ fetchCalled = true;
+ return Response.json({});
+ }) as unknown as typeof fetch;
+ globalThis.fetch = mockFetch;
+
+ try {
+ const auth = await buildAuthForRequest({
+ headers: new Headers({
+ authorization: "Bearer oauth_token",
+ }),
+ flags: flags as MCPOAuthFlags,
+ logger,
+ resourceUrl,
+ });
+
+ expect(auth).toMatchObject({
+ apiKey: "oauth_token",
+ authMethod: "oauth",
+ env: "sandbox",
+ principalId: "oauth:unverified",
+ resource: "http://localhost:2718/mcp",
+ serverURL: "http://localhost:8080",
+ });
+ expect(fetchCalled).toBe(false);
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
+ });
+
+ test("accepts a static secret-key when OAuth is enabled", async () => {
+ const auth = await buildAuthForRequest({
+ headers: new Headers({
+ "secret-key": "am_sk_test_chat",
+ }),
+ flags: flags as MCPOAuthFlags,
+ logger,
+ resourceUrl,
+ });
+
+ expect(auth.apiKey).toBe("am_sk_test_chat");
+ expect(auth.principalId).toStartWith("secret-key:");
+ expect(auth.resource).toBe("http://localhost:2718/mcp");
+ });
+
+ test("accepts an Autumn API key bearer token when OAuth is enabled", async () => {
+ const auth = await buildAuthForRequest({
+ headers: new Headers({
+ authorization: "Bearer am_sk_test_chat",
+ }),
+ flags: flags as MCPOAuthFlags,
+ logger,
+ resourceUrl,
+ });
+
+ expect(auth.apiKey).toBe("am_sk_test_chat");
+ expect(auth.principalId).toStartWith("secret-key:");
+ });
+
+ test("uses route-specific resource URLs", async () => {
+ const auth = await buildAuthForRequest({
+ headers: new Headers({
+ authorization: "Bearer am_sk_test_chat",
+ }),
+ flags: flags as MCPOAuthFlags,
+ logger,
+ resourceUrl: internalResourceUrl,
+ });
+
+ expect(auth.resource).toBe("http://localhost:2718/internal/mcp");
+ expect(
+ getProtectedResourceMetadata({
+ resourceUrl: internalResourceUrl,
+ serverURL: flags["server-url"],
+ }).resource,
+ ).toBe("http://localhost:2718/internal/mcp");
+ });
+
+ test("missing static secret-key returns the auth error path", async () => {
+ await expect(
+ buildAuthForRequest({
+ headers: new Headers(),
+ flags: {
+ ...flags,
+ "oauth-enabled": false,
+ } as MCPOAuthFlags,
+ logger,
+ resourceUrl,
+ }),
+ ).rejects.toMatchObject({
+ status: 401,
+ error: "invalid_token",
+ } satisfies Partial);
+ });
+});
diff --git a/apps/leaf/tsconfig.json b/apps/leaf/tsconfig.json
index 9bd1abedd..39cf3a979 100644
--- a/apps/leaf/tsconfig.json
+++ b/apps/leaf/tsconfig.json
@@ -12,6 +12,7 @@
"paths": {
"@autumn/shared": ["../../shared/index.ts"],
"@autumn/shared/*": ["../../shared/*"],
+ "@autumn/logging": ["../../packages/logging/src/index.ts"],
"@autumn/mcp/*": ["../../packages/mcp/*"],
"@api/*": ["../../shared/api/*"],
"@models/*": ["../../shared/models/*"],
diff --git a/bun.lock b/bun.lock
index 536fed665..6c9c7419a 100644
--- a/bun.lock
+++ b/bun.lock
@@ -88,6 +88,8 @@
"name": "@autumn/leaf",
"version": "0.0.1",
"dependencies": {
+ "@autumn/auth": "workspace:*",
+ "@autumn/logging": "workspace:*",
"@autumn/mcp": "workspace:*",
"@autumn/shared": "workspace:*",
"@chat-adapter/slack": "^4.29.0",
@@ -241,6 +243,15 @@
"typescript": "^5",
},
},
+ "packages/auth": {
+ "name": "@autumn/auth",
+ "version": "0.0.1",
+ "devDependencies": {
+ "@types/bun": "^1.2.13",
+ "@types/node": "^18.19.3",
+ "typescript": "~5.8.3",
+ },
+ },
"packages/autumn-js": {
"name": "autumn-js",
"version": "1.2.17",
@@ -284,10 +295,25 @@
"name": "@autumn/ksuid",
"version": "1.0.0",
},
+ "packages/logging": {
+ "name": "@autumn/logging",
+ "version": "0.0.1",
+ "dependencies": {
+ "@axiomhq/pino": "^1.3.1",
+ "pino": "^9.6.0",
+ },
+ "devDependencies": {
+ "@types/bun": "^1.2.13",
+ "@types/node": "^18.19.3",
+ "typescript": "~5.8.3",
+ },
+ },
"packages/mcp": {
"name": "@autumn/mcp",
"version": "0.0.1",
"dependencies": {
+ "@autumn/auth": "workspace:*",
+ "@autumn/logging": "workspace:*",
"@autumn/shared": "workspace:*",
"@axiomhq/js": "^1.6.1",
"@mastra/core": "^1.36.0",
@@ -381,6 +407,7 @@
"dependencies": {
"@ai-sdk/anthropic": "^3.0.9",
"@anthropic-ai/sdk": "^0.32.1",
+ "@autumn/auth": "workspace:*",
"@autumn/ksuid": "workspace:*",
"@autumn/shared": "workspace:*",
"@autumn/stripe-sync": "workspace:*",
@@ -716,12 +743,16 @@
"@asyncapi/specs": ["@asyncapi/specs@6.8.1", "", { "dependencies": { "@types/json-schema": "^7.0.11" } }, "sha512-czHoAk3PeXTLR+X8IUaD+IpT+g+zUvkcgMDJVothBsan+oHN3jfcFcFUNdOPAAFoUCQN1hXF1dWuphWy05THlA=="],
+ "@autumn/auth": ["@autumn/auth@workspace:packages/auth"],
+
"@autumn/docs": ["@autumn/docs@workspace:apps/docs"],
"@autumn/ksuid": ["@autumn/ksuid@workspace:packages/ksuid"],
"@autumn/leaf": ["@autumn/leaf@workspace:apps/leaf"],
+ "@autumn/logging": ["@autumn/logging@workspace:packages/logging"],
+
"@autumn/mcp": ["@autumn/mcp@workspace:packages/mcp"],
"@autumn/openapi": ["@autumn/openapi@workspace:packages/openapi"],
@@ -5708,7 +5739,7 @@
"typed-query-selector": ["typed-query-selector@2.12.2", "", {}, "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ=="],
- "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
+ "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
"typescript-eslint": ["typescript-eslint@8.59.4", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.59.4", "@typescript-eslint/parser": "8.59.4", "@typescript-eslint/typescript-estree": "8.59.4", "@typescript-eslint/utils": "8.59.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Rw6+44QNFaXtgHSjPy+Kw8hrJniMYzR85E9yLmOLcfZ91/rz+JXQbDTCmc6ccxMPY6K6PgAq26f0JCBfR7LIPQ=="],
@@ -5998,14 +6029,20 @@
"@asyncapi/parser/node-fetch": ["node-fetch@2.6.7", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ=="],
+ "@autumn/auth/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
+
"@autumn/leaf/@types/node": ["@types/node@25.8.0", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ=="],
+ "@autumn/leaf/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
+
+ "@autumn/logging/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
+
"@autumn/mcp/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
- "@autumn/mcp/typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
-
"@autumn/openapi/dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="],
+ "@autumn/scripts/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
+
"@autumn/server/@types/node": ["@types/node@25.8.0", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ=="],
"@autumn/server/@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20260511.1", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260511.1", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260511.1", "@typescript/native-preview-linux-arm": "7.0.0-dev.20260511.1", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260511.1", "@typescript/native-preview-linux-x64": "7.0.0-dev.20260511.1", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260511.1", "@typescript/native-preview-win32-x64": "7.0.0-dev.20260511.1" }, "bin": { "tsgo": "bin/tsgo.js" } }, "sha512-cUyY4Sr6065280lB6hCwTMCBMTxlEIGjSLzHym28yikA5sFiEsAzlwiU0i+XkTUIqr5K5M/SzSJiioDN+vpjtA=="],
@@ -6014,8 +6051,12 @@
"@autumn/server/ink": ["ink@6.8.0", "", { "dependencies": { "@alcalzone/ansi-tokenize": "^0.2.4", "ansi-escapes": "^7.3.0", "ansi-styles": "^6.2.1", "auto-bind": "^5.0.1", "chalk": "^5.6.0", "cli-boxes": "^3.0.0", "cli-cursor": "^4.0.0", "cli-truncate": "^5.1.1", "code-excerpt": "^4.0.0", "es-toolkit": "^1.39.10", "indent-string": "^5.0.0", "is-in-ci": "^2.0.0", "patch-console": "^2.0.0", "react-reconciler": "^0.33.0", "scheduler": "^0.27.0", "signal-exit": "^3.0.7", "slice-ansi": "^8.0.0", "stack-utils": "^2.0.6", "string-width": "^8.1.1", "terminal-size": "^4.0.1", "type-fest": "^5.4.1", "widest-line": "^6.0.0", "wrap-ansi": "^9.0.0", "ws": "^8.18.0", "yoga-layout": "~3.2.1" }, "peerDependencies": { "@types/react": ">=19.0.0", "react": ">=19.0.0", "react-devtools-core": ">=6.1.2" }, "optionalPeers": ["@types/react", "react-devtools-core"] }, "sha512-sbl1RdLOgkO9isK42WCZlJCFN9hb++sX9dsklOvfd1YQ3bQ2AiFu12Q6tFlr0HvEUvzraJntQCCpfEoUe9DSzA=="],
+ "@autumn/server/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
+
"@autumn/shared/@date-fns/utc": ["@date-fns/utc@2.1.0", "", {}, "sha512-176grgAgU2U303rD2/vcOmNg0kGPbhzckuH1TEP2al7n0AQipZIy9P15usd2TKQCG1g+E1jX/ZVQSzs4sUDwgA=="],
+ "@autumn/shared/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
+
"@autumn/vite/@types/node": ["@types/node@22.19.19", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew=="],
"@autumn/vite/date-fns": ["date-fns@3.6.0", "", {}, "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww=="],
@@ -6214,6 +6255,8 @@
"@infisical/sdk/@aws-sdk/credential-providers": ["@aws-sdk/credential-providers@3.600.0", "", { "dependencies": { "@aws-sdk/client-cognito-identity": "3.600.0", "@aws-sdk/client-sso": "3.598.0", "@aws-sdk/client-sts": "3.600.0", "@aws-sdk/credential-provider-cognito-identity": "3.600.0", "@aws-sdk/credential-provider-env": "3.598.0", "@aws-sdk/credential-provider-http": "3.598.0", "@aws-sdk/credential-provider-ini": "3.598.0", "@aws-sdk/credential-provider-node": "3.600.0", "@aws-sdk/credential-provider-process": "3.598.0", "@aws-sdk/credential-provider-sso": "3.598.0", "@aws-sdk/credential-provider-web-identity": "3.598.0", "@aws-sdk/types": "3.598.0", "@smithy/credential-provider-imds": "^3.1.1", "@smithy/property-provider": "^3.1.1", "@smithy/types": "^3.1.0", "tslib": "^2.6.2" } }, "sha512-cC9uqmX0rgx1efiJGqeR+i0EXr8RQ5SAzH7M45WNBZpYiLEe6reWgIYJY9hmOxuaoMdWSi8kekuN3IjTIORRjw=="],
+ "@infisical/sdk/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
+
"@inquirer/core/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
"@inquirer/external-editor/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
@@ -6926,8 +6969,6 @@
"@typespec/ts-http-runtime/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
- "@useautumn/sdk/typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
-
"@useautumn/sdk/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
"@vercel/sdk/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
@@ -6988,6 +7029,8 @@
"atmn/react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
+ "atmn/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
+
"atmn/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
"autumn-js/@types/node": ["@types/node@22.19.19", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew=="],
@@ -6998,6 +7041,8 @@
"autumn-js/react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
+ "autumn-js/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
+
"autumn-js/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
"ava/ci-info": ["ci-info@3.9.0", "", {}, "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ=="],
@@ -7062,6 +7107,8 @@
"checkout/react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
+ "checkout/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
+
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"clean-regexp/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="],
@@ -7524,6 +7571,8 @@
"sdk-test/react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="],
+ "sdk-test/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
+
"send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"send/mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="],
@@ -7606,6 +7655,8 @@
"ts-to-zod/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
+ "ts-to-zod/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
+
"ts-to-zod/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
"tsc-alias/commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="],
@@ -7614,6 +7665,8 @@
"tshy/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
+ "tshy/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
+
"tsup/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"tsutils/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="],
@@ -7768,8 +7821,12 @@
"@asyncapi/parser/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
+ "@autumn/auth/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
+
"@autumn/leaf/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
+ "@autumn/logging/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
+
"@autumn/mcp/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
"@autumn/server/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
diff --git a/docker/Dockerfile b/docker/Dockerfile
index 74a8aaeb7..7e9bec93a 100644
--- a/docker/Dockerfile
+++ b/docker/Dockerfile
@@ -27,8 +27,10 @@ COPY apps/sdk-test/package.json apps/sdk-test/
COPY apps/website/package.json apps/website/
COPY packages/atmn/package.json packages/atmn/
COPY packages/atmn-tests/package.json packages/atmn-tests/
+COPY packages/auth/package.json packages/auth/
COPY packages/autumn-js/package.json packages/autumn-js/
COPY packages/ksuid/package.json packages/ksuid/
+COPY packages/logging/package.json packages/logging/
COPY packages/mcp/package.json packages/mcp/
COPY packages/openapi/package.json packages/openapi/
COPY packages/sdk/package.json packages/sdk/
diff --git a/docker/dev.dockerfile b/docker/dev.dockerfile
deleted file mode 100644
index 4fec00979..000000000
--- a/docker/dev.dockerfile
+++ /dev/null
@@ -1,45 +0,0 @@
-# Multi-stage Dockerfile for Autumn development
-FROM oven/bun:latest AS base
-
-WORKDIR /app
-
-# Skip Puppeteer Chromium download to speed up install
-ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
-ENV PUPPETEER_SKIP_DOWNLOAD=true
-
-COPY package.json ./
-COPY bun.lock ./
-COPY shared/package*.json ./shared/
-COPY server/package*.json ./server/
-COPY vite/package*.json ./vite/
-
-RUN bun install
-
-# Stage 1: /localtunnel
-FROM base AS localtunnel
-WORKDIR /app
-COPY localtunnel-start.sh ./
-CMD ["sh", "localtunnel-start.sh"]
-
-# Stage 2: /vite
-FROM base AS vite
-COPY shared/ ./shared/
-WORKDIR /app/vite
-COPY vite/ ./
-EXPOSE 3000
-CMD ["bun", "dev"]
-
-# Stage 3: /server
-FROM base AS server
-COPY shared/ ./shared/
-COPY server/ ./server/
-WORKDIR /app/server
-EXPOSE 8080
-CMD ["bun", "dev"]
-
-# Stage 4: Workers
-FROM base AS workers
-COPY shared/ ./shared/
-COPY server/ ./server/
-WORKDIR /app/server
-CMD ["bun", "workers:dev"]
\ No newline at end of file
diff --git a/others/python-sdk/.speakeasy/code-samples.overlay.yaml b/others/python-sdk/.speakeasy/code-samples.overlay.yaml
index e56237595..b040c0111 100644
--- a/others/python-sdk/.speakeasy/code-samples.overlay.yaml
+++ b/others/python-sdk/.speakeasy/code-samples.overlay.yaml
@@ -773,6 +773,63 @@ actions:
"interval": "month",
}, create_in_stripe=True, archived=False)
+ # Handle response
+ print(res)
+ - target: $["paths"]["/v1/platform.get_revenuecat_keys"]["post"]
+ update:
+ x-codeSamples:
+ - lang: python
+ label: Python (SDK)
+ source: |-
+ from autumn_sdk import Autumn
+
+
+ with Autumn(
+ x_api_version="2.3.0",
+ secret_key="",
+ ) as autumn:
+
+ res = autumn.platform.get_revenue_cat_keys(organization_slug="acme", env="test")
+
+ # Handle response
+ print(res)
+ - target: $["paths"]["/v1/platform.link_revenuecat"]["post"]
+ update:
+ x-codeSamples:
+ - lang: python
+ label: Python (SDK)
+ source: |-
+ from autumn_sdk import Autumn
+
+
+ with Autumn(
+ x_api_version="2.3.0",
+ secret_key="",
+ ) as autumn:
+
+ res = autumn.platform.link_revenue_cat(organization_slug="acme", env="test", project_name="acme-mobile", redirect_url="https://dashboard.useautumn.com/dev?tab=revenuecat")
+
+ # Handle response
+ print(res)
+ - target: $["paths"]["/v1/platform.sync_revenuecat"]["post"]
+ update:
+ x-codeSamples:
+ - lang: python
+ label: Python (SDK)
+ source: |-
+ from autumn_sdk import Autumn
+
+
+ with Autumn(
+ x_api_version="2.3.0",
+ secret_key="",
+ ) as autumn:
+
+ res = autumn.platform.sync_revenue_cat(organization_slug="acme", env="test", product_ids=[
+ "pro",
+ "premium",
+ ])
+
# Handle response
print(res)
- target: $["paths"]["/v1/referrals.create_code"]["post"]
diff --git a/others/python-sdk/.speakeasy/gen.lock b/others/python-sdk/.speakeasy/gen.lock
index fe8f28fa5..eee59c8d7 100644
--- a/others/python-sdk/.speakeasy/gen.lock
+++ b/others/python-sdk/.speakeasy/gen.lock
@@ -1,19 +1,20 @@
lockVersion: 2.0.0
id: 05940b80-1ef8-40f4-9878-822fb2792070
management:
- docChecksum: 3b9e3c6fa63e6d5faadf490512c769a8
+ docChecksum: 69c7a38357ec2752bfddc3d3fe3c8217
docVersion: 2.3.0
speakeasyVersion: 1.762.0
generationVersion: 2.882.0
releaseVersion: 0.4.18
configChecksum: 2263d20254e354a1792248274002f650
persistentEdits:
- generation_id: b518426a-4c9e-4984-92d3-43bc77843949
- pristine_commit_hash: 7a491e8dfc11b8aa551892f546a7bd3ec03f6613
- pristine_tree_hash: 9d6dc1b3ba101c63df68d2cc7daab4b190b03a7b
+ generation_id: eeb45171-4135-499a-b86f-e278f1f1d8ab
+ pristine_commit_hash: 8339a7c4802bb87e0ebf10bdc7cab95b507d4599
+ pristine_tree_hash: 7a4a651f3fd98b3d736fe7bb6dbc87d904b8af0c
features:
python:
additionalDependencies: 1.0.0
+ additionalProperties: 1.0.1
constsAndDefaults: 1.0.7
core: 6.0.21
defaultEnabledRetries: 0.2.0
@@ -67,6 +68,10 @@ trackedFiles:
id: b597cfc651ae
last_write_checksum: sha1:641c0d27fe8e8242429da6d8083fe10ab70b570c
pristine_git_object: 2fd7b5fb0f426ec942cb43aa30d3a85287ef724a
+ docs/models/apikey.md:
+ id: 3cd1b4235d4f
+ last_write_checksum: sha1:5c30cc7199e1f3886bcea8052365d549277e1795
+ pristine_git_object: 673e261bea89e033c21752bddec54413cae7d856
docs/models/attachaction.md:
id: 984e5ad5e280
last_write_checksum: sha1:162c4a0771f000b3835c06765eafdd843fb8d2a7
@@ -639,6 +644,14 @@ trackedFiles:
id: 41de438d57cd
last_write_checksum: sha1:ab7c54bfe0c657851a59fedd447c99bb3acdb4c2
pristine_git_object: 7848c33fdbffc71c09fadd8ad859214182f00e0a
+ docs/models/checkproduct1.md:
+ id: 22c131b2d914
+ last_write_checksum: sha1:a156ce217c8853244160cd4dc347ef660a4b4688
+ pristine_git_object: 8d8fe115ad5a73bcd5b21e22700ebc537105d36f
+ docs/models/checkproduct2.md:
+ id: f002dbaf8c20
+ last_write_checksum: sha1:3140655b5efa528c4d839550d3d17a04de6f3f43
+ pristine_git_object: 340e33a4d0afb0f7433a7b6f30d0329bd9f4015c
docs/models/checkresponse.md:
id: b988b0f4b781
last_write_checksum: sha1:9522d4ecea7631ce3ae3d80b341a068ccb2dcca4
@@ -1895,6 +1908,26 @@ trackedFiles:
id: afbdf26f0f4b
last_write_checksum: sha1:6d5dfd04c5d95c235f56eb23e31f497c9ed91247
pristine_git_object: 7390b496108568e19aba42d36c5c3b508e3d42b3
+ docs/models/getrevenuecatkeysapp.md:
+ id: b47525a2f9de
+ last_write_checksum: sha1:1de286cbb4c475583313a05f5d89c8cdc90fcbf1
+ pristine_git_object: 18eb7cb3f4c2077bbe2e19015f6098703a65ce03
+ docs/models/getrevenuecatkeysenv.md:
+ id: a9081dfb1e62
+ last_write_checksum: sha1:f44bbe9fd99bac368b4e679a7a05c4d22f030259
+ pristine_git_object: 5c9236f2a8fb8a3c6f762a47e8b2c40a3f7c39cc
+ docs/models/getrevenuecatkeysglobals.md:
+ id: 3e0c18c79f36
+ last_write_checksum: sha1:a264613009362faa9a1497f94403cf9fe1a9f869
+ pristine_git_object: 412e603c39750e0d782e772d72d7172779298506
+ docs/models/getrevenuecatkeysparams.md:
+ id: 76cd38034f68
+ last_write_checksum: sha1:99ff618181876cae820a5f42b6e06beb255c248c
+ pristine_git_object: f5c96fdacda480ae30ebc28d71826d5dffe80cef
+ docs/models/getrevenuecatkeysresponse.md:
+ id: 4b23c9d5ea78
+ last_write_checksum: sha1:4eb6f4db2d614bbb53dfb512b73030035dbb6d86
+ pristine_git_object: be7a493d4f7d1670084a8882a2ab4b60a8b367ad
docs/models/includedusage1.md:
id: 7ceb62e48016
last_write_checksum: sha1:9a7a940ec67dc041a2fb2064f8e5b433c9cb12ed
@@ -1919,6 +1952,22 @@ trackedFiles:
id: 40dd7473ab87
last_write_checksum: sha1:76b0d2926283b9d9cb79c92fc0c1baec458f06af
pristine_git_object: cfe1e33316af7bc0ca8cca5ecb59e72e5f3182bd
+ docs/models/linkrevenuecatenv.md:
+ id: 57a0ac8d952a
+ last_write_checksum: sha1:6a2280c9c13ebd82da37f3398be0d4427950bf3c
+ pristine_git_object: 419027171edab8fd89e8bcddb02e374d8b263649
+ docs/models/linkrevenuecatglobals.md:
+ id: 3a3c92d9666d
+ last_write_checksum: sha1:bd4c5dae75935cfffe3496713fcdcbad66a84fba
+ pristine_git_object: dfab4e4be248fa1f702de0f92f898dd41de56c09
+ docs/models/linkrevenuecatparams.md:
+ id: fdd3864d636f
+ last_write_checksum: sha1:aa4605a7153b9e78f325afc477a091b17bae9b98
+ pristine_git_object: 6e209832568861dae3b88067bc70762132db5595
+ docs/models/linkrevenuecatresponse.md:
+ id: a598227583fb
+ last_write_checksum: sha1:8481a4f7b9efb12da31da74469889eafb03cedb5
+ pristine_git_object: ea11c719bceb2bd254286e7f8f0c1ba5161d15b8
docs/models/listcustomersautotopup.md:
id: 43e50adc0195
last_write_checksum: sha1:36e4e209d3743d6ff7911104da4f54ebd484f923
@@ -2589,12 +2638,12 @@ trackedFiles:
pristine_git_object: 5add1a3cab3d01077452a4df8d9037c6279da319
docs/models/preview1.md:
id: 203e34d3c393
- last_write_checksum: sha1:b9b1ce18639c5e83d9eb6bd83e1d99bd683291cd
- pristine_git_object: ac258d817302c6d7f841b006ff7a7931cfe13df7
+ last_write_checksum: sha1:116b6aa31a514f220d270164fdc3572eacb16be8
+ pristine_git_object: d82654857e15b247088aa29b1edc997bfbaa2df5
docs/models/preview2.md:
id: 96d6fae57a72
- last_write_checksum: sha1:4839487a437486dbc9567dec57a136234230ddc9
- pristine_git_object: 9ad954e94d0ec60a5def86f162fe804fad115518
+ last_write_checksum: sha1:9654ce4f91d0c8ff6409117c1e6a12f6f6789802
+ pristine_git_object: 5080785d6528a0cd52d1f86d217efe31cbbbfa3c
docs/models/previewattachadditembillingmethod.md:
id: 341766b0f910
last_write_checksum: sha1:a8357c2f1f1f0e394f9ca1aabf6c19c360496d57
@@ -3323,14 +3372,6 @@ trackedFiles:
id: 446cf8386114
last_write_checksum: sha1:c917a956f1af010a60b2e7d43bad09cf54e44274
pristine_git_object: c42de1ea48789d1cd09dcce226e3673f29b2a747
- docs/models/product1.md:
- id: 880ca8ae9886
- last_write_checksum: sha1:d6c959243c6b293682d7faed288627aca8b42712
- pristine_git_object: 555d0ec5c226b413aebb505ac316c7a14cf1fc4b
- docs/models/product2.md:
- id: 6262b044d234
- last_write_checksum: sha1:d650f24a1fee7d7b93f1f3d1f0c3a9b727b5f111
- pristine_git_object: c1b81dedf250c04788acef9878b406b03b3747cc
docs/models/productdisplay1.md:
id: b5bdefcde7af
last_write_checksum: sha1:4cbda567685f39d23b4a83d5a9bc20c4bf2fa21b
@@ -3411,6 +3452,10 @@ trackedFiles:
id: a15f5440d48c
last_write_checksum: sha1:792ae2d550cfbb56888ee2fe6342a0666c83d219
pristine_git_object: 49346d4858b332cd3d0e59f46c4af0116346bf14
+ docs/models/result.md:
+ id: b850437752c3
+ last_write_checksum: sha1:3ee32bdd6689dc5dda87df145ffe124a1e78c80a
+ pristine_git_object: 1718a5b0274ab83f20f2fd21d269934722c64f01
docs/models/revenuecat.md:
id: 5418b6373a80
last_write_checksum: sha1:3cfb5781a5762c564d5b48ef1af6cba5db229435
@@ -3615,6 +3660,10 @@ trackedFiles:
id: a0fe36809906
last_write_checksum: sha1:0506cb5fd620fb73affa03da445160a245e94fd6
pristine_git_object: caa4f8813f21dd5e60c7f1fd5e7591340247fb1f
+ docs/models/storepush.md:
+ id: 0bff2a8f0cc7
+ last_write_checksum: sha1:220f7b38e22cb56d9ac3c5b9f2adfd2da41c2842
+ pristine_git_object: 2a58923b830a788b0746bea3e55670ab79c1fb6e
docs/models/stripe.md:
id: ef8fa4c7fedd
last_write_checksum: sha1:304cbcf780ff0569d9ea00886e26043a8ad2cf88
@@ -3627,6 +3676,38 @@ trackedFiles:
id: 87bc56fd272b
last_write_checksum: sha1:d5bd8497d81e3928c11257ad6db7bd0df6ac2b91
pristine_git_object: d2281ec892b2bb9f1210643cf0efcf4166106876
+ docs/models/syncrevenuecatapp.md:
+ id: 73d2264f26a6
+ last_write_checksum: sha1:64526bb1b58191cb623c4f4f696eff4c80848a1c
+ pristine_git_object: eafb5e618296dc86ee5b3fda114a3686da4ef8e1
+ docs/models/syncrevenuecatenv.md:
+ id: 012dc6089488
+ last_write_checksum: sha1:f81a04a3c0f08c817f0da7dc2401a3fd7a9a94e9
+ pristine_git_object: 9b2eedb9eb3836bd27951ad04a006d88bca67db7
+ docs/models/syncrevenuecatglobals.md:
+ id: 5c889c9a0be8
+ last_write_checksum: sha1:6b865a42d969203431455ffed358fb72f586ee7c
+ pristine_git_object: ddab5b6ab7d0f08bb6618eadfa9744603f75e0a5
+ docs/models/syncrevenuecatparams.md:
+ id: 9ab677e7cf8f
+ last_write_checksum: sha1:8d1b3cde4fc5b3a03019107eb0f65620e53a8285
+ pristine_git_object: 5a65e517e6beef0c6afc7adfbcd450c979f4804a
+ docs/models/syncrevenuecatprice.md:
+ id: 9a5d5482c4e0
+ last_write_checksum: sha1:98d655ac8f6bfde40101ea7069c9c29a25bd27c8
+ pristine_git_object: f1f2e2af312104537761410fe1da470cc67466d0
+ docs/models/syncrevenuecatproduct.md:
+ id: 1f316b101f9a
+ last_write_checksum: sha1:4cd1e623d4f8789444f7c6b03835ced8bf00421c
+ pristine_git_object: def6ce10cef765494237dacc1b5add17c84a2be1
+ docs/models/syncrevenuecatresponse.md:
+ id: 7a818b200e7d
+ last_write_checksum: sha1:9e9b2f8c540bd84a81597d1be89b1b74d9a05719
+ pristine_git_object: ec12b07addf1937d6b6b95352233907ada3de1ce
+ docs/models/syncrevenuecatstatus.md:
+ id: a4921afbf845
+ last_write_checksum: sha1:5aaa82e010e6dc5f35a20e11a644b7a2e6afd252
+ pristine_git_object: d56a56f2bd0f2d6f9a847acbcc03c41079ab1d41
docs/models/total.md:
id: f4060c3b4657
last_write_checksum: sha1:08c2c14481fcae1bcc1c550d6e2c95f14bb1efb0
@@ -4239,6 +4320,10 @@ trackedFiles:
id: 2d8c741fff57
last_write_checksum: sha1:a956c9b35832ad1b4b988220bbf133aa87c73301
pristine_git_object: 25a47ac0fdb8bc31110b3d96d2656bf744969f37
+ docs/sdks/platform/README.md:
+ id: b66219e9cd4d
+ last_write_checksum: sha1:e18d4e05e801ff5b52414fc51e98e10f33cfbb04
+ pristine_git_object: 68bfa16724239e249cfa4263dd4e029e148d9af8
docs/sdks/referrals/README.md:
id: 50b71f597f20
last_write_checksum: sha1:de99a40759f0d5c8850f2f59613d20d646161c53
@@ -4337,8 +4422,8 @@ trackedFiles:
pristine_git_object: 89560b566073785535643e694c112bedbd3db13d
src/autumn_sdk/models/__init__.py:
id: bcf3802243ff
- last_write_checksum: sha1:2340350b2b45b845b17559c2d6148cbe798cd19c
- pristine_git_object: 52418f1a7720d58ebd5f6d74de74baca5fdc6e5e
+ last_write_checksum: sha1:0300fd72a06e0e2bb6fdf06654e88e670fd575bb
+ pristine_git_object: 923db4da1df1e544bfa79c2fc5b55c90a1a3fb5e
src/autumn_sdk/models/aggregateeventsop.py:
id: 01321099f2a5
last_write_checksum: sha1:bbaf78080f665b38e531d2427c787e40ca0635a7
@@ -4361,8 +4446,8 @@ trackedFiles:
pristine_git_object: ac6c4e6ec0e3ab0c7990440e6d6f04fbef840bdb
src/autumn_sdk/models/checkop.py:
id: 31c2f84723c6
- last_write_checksum: sha1:7cb0cbbf11f480372aa2c5a3466b07f78e301226
- pristine_git_object: b165ecfba6d2d2d1ccf9a21029d6fa87db67b470
+ last_write_checksum: sha1:8711ca8b987fad934aea167a38b5d614836bcdb9
+ pristine_git_object: 6c98274a990207b1690198e94541c51531e01c4e
src/autumn_sdk/models/createbalanceop.py:
id: 27daf4da75bf
last_write_checksum: sha1:f034893074f11952de2b174c8f2991232e512858
@@ -4443,6 +4528,10 @@ trackedFiles:
id: 590fb77ac88d
last_write_checksum: sha1:4b545b24018986ba9d93107df7840f39fb1f861c
pristine_git_object: e619e84014109d0ac73df596b4926a8f6dc92ee3
+ src/autumn_sdk/models/getrevenuecatkeysop.py:
+ id: 015155862a71
+ last_write_checksum: sha1:6b3f88a48b721093562ec59db3f6dae2540fa139
+ pristine_git_object: a51f5d26227a36d16cce914b32e92363eb5a95ff
src/autumn_sdk/models/internal/__init__.py:
id: 2906fe7f2cde
last_write_checksum: sha1:1905b58b74ecc52346d8f5c24ded2b6d6e1dad4a
@@ -4451,6 +4540,10 @@ trackedFiles:
id: 4e33eb99f463
last_write_checksum: sha1:b12bb60e74b8678b0fd0543223c09cc3d77dcc8b
pristine_git_object: 2614675c993545d5df1e516d043649e50281c2a3
+ src/autumn_sdk/models/linkrevenuecatop.py:
+ id: 8dd3e355d8d5
+ last_write_checksum: sha1:f5d06c40f8031e1791bd515fdad831f539a6ecaf
+ pristine_git_object: 2a117515b5cac185ba0b7eb21d3b2b4d8260d194
src/autumn_sdk/models/listcustomersop.py:
id: d7074740b8b0
last_write_checksum: sha1:64e98dae9bc3719c50a81e306ce270ad59d2e216
@@ -4511,6 +4604,10 @@ trackedFiles:
id: 603339ee67e3
last_write_checksum: sha1:46900f03adbb063677a4190d461c0460c470618e
pristine_git_object: 417d43b99b409c1d4e003c593097ac05ec5da795
+ src/autumn_sdk/models/syncrevenuecatop.py:
+ id: faddfbfd1214
+ last_write_checksum: sha1:a2b90222b09b4ca95bc78d3573f5b17d4d50208b
+ pristine_git_object: 7d12a3d78b0ab1e19f8d66fe3ed367066c145fc2
src/autumn_sdk/models/trackop.py:
id: 2a744315e781
last_write_checksum: sha1:216a03a195bb90ad24e42f62294a3de606b29142
@@ -4539,6 +4636,10 @@ trackedFiles:
id: cf1ebabb687c
last_write_checksum: sha1:8cca1565af6b67ab6947b016d0ab8f7a431fa824
pristine_git_object: 4c299d37ebbc9a9185bb496926a5a326334dcbe7
+ src/autumn_sdk/platform.py:
+ id: aee79240c441
+ last_write_checksum: sha1:2e80cdba550e5487a2ac16c063e24caf3f2b265f
+ pristine_git_object: 9a76e9815155fa2a01056e20ea2d7ec640718cd9
src/autumn_sdk/py.typed:
id: 9b75cee1c007
last_write_checksum: sha1:8efc425ffe830805ffcc0f3055871bdcdc542c60
@@ -4553,8 +4654,8 @@ trackedFiles:
pristine_git_object: c52c86dd77e753356560ebcf0ee10f8dc46de593
src/autumn_sdk/sdk.py:
id: 9e733b372628
- last_write_checksum: sha1:a51e0822250581aeecd610abcb8207849cd3c73c
- pristine_git_object: 19d77062eea9e5010e454d71cb64f137f7001314
+ last_write_checksum: sha1:da2019a4fd1bc539f99076623e758d53baec25b7
+ pristine_git_object: 8feede30ce5471864788d9ce7e6779ab9be5157c
src/autumn_sdk/sdkconfiguration.py:
id: e65df2e44fc0
last_write_checksum: sha1:233b710dff940202f00e389e0c8fa6a33f6ae7b4
@@ -5232,4 +5333,34 @@ examples:
responses:
"202":
application/json: {"success": true}
+ linkRevenueCat:
+ speakeasy-default-link-revenue-cat:
+ parameters:
+ header:
+ x-api-version: "2.3.0"
+ requestBody:
+ application/json: {"organization_slug": "acme", "env": "test", "project_name": "acme-mobile", "redirect_url": "https://dashboard.useautumn.com/dev?tab=revenuecat"}
+ responses:
+ "200":
+ application/json: {"oauth_url": "https://api.revenuecat.com/oauth2/authorize?client_id=...&redirect_uri=...&response_type=code&scope=project.read+project.write"}
+ syncRevenueCat:
+ speakeasy-default-sync-revenue-cat:
+ parameters:
+ header:
+ x-api-version: "2.3.0"
+ requestBody:
+ application/json: {"organization_slug": "acme", "env": "test", "product_ids": ["pro", "premium"]}
+ responses:
+ "200":
+ application/json: {"results": [{"plan_id": "pro", "status": "synced", "store_identifier": "autumn.sandbox.org_123.pro", "apps": [{"app_id": "app_test", "app_type": "test_store", "product": "created", "store_push": "skipped", "price": "set"}]}]}
+ getRevenueCatKeys:
+ speakeasy-default-get-revenue-cat-keys:
+ parameters:
+ header:
+ x-api-version: "2.3.0"
+ requestBody:
+ application/json: {"organization_slug": "acme", "env": "test"}
+ responses:
+ "200":
+ application/json: {"apps": [{"app_id": "app1a2b3c4d", "app_type": "test_store", "name": "Acme (Test Store)", "api_keys": [{"id": "apikey12345", "key": "test_aBcDeFgHiJkLmNoPqRsTuVwXyZ", "environment": "production", "app_id": "app1a2b3c4"}]}], "oauth_access_token": "atk_aBcDeFgHiJkLmNoPqRsTuVwXyZ"}
examplesVersion: 1.0.2
diff --git a/others/python-sdk/README.md b/others/python-sdk/README.md
index c382d6ce5..737b6c390 100644
--- a/others/python-sdk/README.md
+++ b/others/python-sdk/README.md
@@ -296,6 +296,12 @@ Use this to permanently remove a feature. Note: features that are used in produc
* [update](docs/sdks/plans/README.md#update) - Update a plan
* [delete](docs/sdks/plans/README.md#delete) - Delete a plan
+### [Platform](docs/sdks/platform/README.md)
+
+* [link_revenue_cat](docs/sdks/platform/README.md#link_revenue_cat) - Generate a RevenueCat OAuth URL for linking a project to an organization.
+* [sync_revenue_cat](docs/sdks/platform/README.md#sync_revenue_cat) - Push an organization's plans into RevenueCat as products (creating or renaming them across the project's apps) and set test-store prices from each plan's price. Requires the org to have linked RevenueCat via OAuth.
+* [get_revenue_cat_keys](docs/sdks/platform/README.md#get_revenue_cat_keys) - Retrieve a managed organization's RevenueCat public (SDK) API keys, grouped by app — for the test store, App Store, and Google Play Store. Use these to configure the RevenueCat SDK in the org's mobile app.
+
### [Referrals](docs/sdks/referrals/README.md)
* [create_code](docs/sdks/referrals/README.md#create_code) - Create or fetch a referral code for a customer in a referral program.
diff --git a/others/python-sdk/src/autumn_sdk/models/__init__.py b/others/python-sdk/src/autumn_sdk/models/__init__.py
index 52418f1a7..923db4da1 100644
--- a/others/python-sdk/src/autumn_sdk/models/__init__.py
+++ b/others/python-sdk/src/autumn_sdk/models/__init__.py
@@ -256,6 +256,10 @@ if TYPE_CHECKING:
CheckOnIncrease2,
CheckParams,
CheckParamsTypedDict,
+ CheckProduct1,
+ CheckProduct1TypedDict,
+ CheckProduct2,
+ CheckProduct2TypedDict,
CheckResponse,
CheckResponseBody1,
CheckResponseBody1TypedDict,
@@ -292,10 +296,6 @@ if TYPE_CHECKING:
Preview1TypedDict,
Preview2,
Preview2TypedDict,
- Product1,
- Product1TypedDict,
- Product2,
- Product2TypedDict,
ProductDisplay1,
ProductDisplay1TypedDict,
ProductDisplay2,
@@ -862,6 +862,28 @@ if TYPE_CHECKING:
GetPlanTierBehavior,
GetPlanType,
)
+ from .getrevenuecatkeysop import (
+ APIKey,
+ APIKeyTypedDict,
+ GetRevenueCatKeysApp,
+ GetRevenueCatKeysAppTypedDict,
+ GetRevenueCatKeysEnv,
+ GetRevenueCatKeysGlobals,
+ GetRevenueCatKeysGlobalsTypedDict,
+ GetRevenueCatKeysParams,
+ GetRevenueCatKeysParamsTypedDict,
+ GetRevenueCatKeysResponse,
+ GetRevenueCatKeysResponseTypedDict,
+ )
+ from .linkrevenuecatop import (
+ LinkRevenueCatEnv,
+ LinkRevenueCatGlobals,
+ LinkRevenueCatGlobalsTypedDict,
+ LinkRevenueCatParams,
+ LinkRevenueCatParamsTypedDict,
+ LinkRevenueCatResponse,
+ LinkRevenueCatResponseTypedDict,
+ )
from .listcustomersop import (
ListCustomersAutoTopup,
ListCustomersAutoTopupTypedDict,
@@ -1561,6 +1583,23 @@ if TYPE_CHECKING:
SetupPaymentResponse,
SetupPaymentResponseTypedDict,
)
+ from .syncrevenuecatop import (
+ Result,
+ ResultTypedDict,
+ StorePush,
+ SyncRevenueCatApp,
+ SyncRevenueCatAppTypedDict,
+ SyncRevenueCatEnv,
+ SyncRevenueCatGlobals,
+ SyncRevenueCatGlobalsTypedDict,
+ SyncRevenueCatParams,
+ SyncRevenueCatParamsTypedDict,
+ SyncRevenueCatPrice,
+ SyncRevenueCatProduct,
+ SyncRevenueCatResponse,
+ SyncRevenueCatResponseTypedDict,
+ SyncRevenueCatStatus,
+ )
from .trackop import (
Deduction1,
Deduction1TypedDict,
@@ -1811,6 +1850,8 @@ if TYPE_CHECKING:
from . import internal
__all__ = [
+ "APIKey",
+ "APIKeyTypedDict",
"AggregateEventsCustomRange",
"AggregateEventsCustomRangeTypedDict",
"AggregateEventsFeatureID",
@@ -2044,6 +2085,10 @@ __all__ = [
"CheckOnIncrease2",
"CheckParams",
"CheckParamsTypedDict",
+ "CheckProduct1",
+ "CheckProduct1TypedDict",
+ "CheckProduct2",
+ "CheckProduct2TypedDict",
"CheckResponse",
"CheckResponseBody1",
"CheckResponseBody1TypedDict",
@@ -2568,6 +2613,15 @@ __all__ = [
"GetPlanStatus",
"GetPlanTierBehavior",
"GetPlanType",
+ "GetRevenueCatKeysApp",
+ "GetRevenueCatKeysAppTypedDict",
+ "GetRevenueCatKeysEnv",
+ "GetRevenueCatKeysGlobals",
+ "GetRevenueCatKeysGlobalsTypedDict",
+ "GetRevenueCatKeysParams",
+ "GetRevenueCatKeysParamsTypedDict",
+ "GetRevenueCatKeysResponse",
+ "GetRevenueCatKeysResponseTypedDict",
"IncludedUsage1",
"IncludedUsage1TypedDict",
"IncludedUsage2",
@@ -2577,6 +2631,13 @@ __all__ = [
"InvoiceTypedDict",
"Item",
"ItemTypedDict",
+ "LinkRevenueCatEnv",
+ "LinkRevenueCatGlobals",
+ "LinkRevenueCatGlobalsTypedDict",
+ "LinkRevenueCatParams",
+ "LinkRevenueCatParamsTypedDict",
+ "LinkRevenueCatResponse",
+ "LinkRevenueCatResponseTypedDict",
"ListCustomersAutoTopup",
"ListCustomersAutoTopupTypedDict",
"ListCustomersBillingControls",
@@ -3161,10 +3222,6 @@ __all__ = [
"ProcessorType",
"Processors",
"ProcessorsTypedDict",
- "Product1",
- "Product1TypedDict",
- "Product2",
- "Product2TypedDict",
"ProductDisplay1",
"ProductDisplay1TypedDict",
"ProductDisplay2",
@@ -3199,6 +3256,8 @@ __all__ = [
"ReferralTypedDict",
"RequestBody",
"RequestBodyTypedDict",
+ "Result",
+ "ResultTypedDict",
"Revenuecat",
"RevenuecatTypedDict",
"Rewards",
@@ -3279,11 +3338,24 @@ __all__ = [
"SetupPaymentRemoveItemInterval",
"SetupPaymentResponse",
"SetupPaymentResponseTypedDict",
+ "StorePush",
"Stripe",
"StripeTypedDict",
"Subscription",
"SubscriptionScope",
"SubscriptionTypedDict",
+ "SyncRevenueCatApp",
+ "SyncRevenueCatAppTypedDict",
+ "SyncRevenueCatEnv",
+ "SyncRevenueCatGlobals",
+ "SyncRevenueCatGlobalsTypedDict",
+ "SyncRevenueCatParams",
+ "SyncRevenueCatParamsTypedDict",
+ "SyncRevenueCatPrice",
+ "SyncRevenueCatProduct",
+ "SyncRevenueCatResponse",
+ "SyncRevenueCatResponseTypedDict",
+ "SyncRevenueCatStatus",
"Total",
"TotalTypedDict",
"TrackGlobals",
@@ -3768,6 +3840,10 @@ _dynamic_imports: dict[str, str] = {
"CheckOnIncrease2": ".checkop",
"CheckParams": ".checkop",
"CheckParamsTypedDict": ".checkop",
+ "CheckProduct1": ".checkop",
+ "CheckProduct1TypedDict": ".checkop",
+ "CheckProduct2": ".checkop",
+ "CheckProduct2TypedDict": ".checkop",
"CheckResponse": ".checkop",
"CheckResponseBody1": ".checkop",
"CheckResponseBody1TypedDict": ".checkop",
@@ -3804,10 +3880,6 @@ _dynamic_imports: dict[str, str] = {
"Preview1TypedDict": ".checkop",
"Preview2": ".checkop",
"Preview2TypedDict": ".checkop",
- "Product1": ".checkop",
- "Product1TypedDict": ".checkop",
- "Product2": ".checkop",
- "Product2TypedDict": ".checkop",
"ProductDisplay1": ".checkop",
"ProductDisplay1TypedDict": ".checkop",
"ProductDisplay2": ".checkop",
@@ -4335,6 +4407,24 @@ _dynamic_imports: dict[str, str] = {
"GetPlanStatus": ".getplanop",
"GetPlanTierBehavior": ".getplanop",
"GetPlanType": ".getplanop",
+ "APIKey": ".getrevenuecatkeysop",
+ "APIKeyTypedDict": ".getrevenuecatkeysop",
+ "GetRevenueCatKeysApp": ".getrevenuecatkeysop",
+ "GetRevenueCatKeysAppTypedDict": ".getrevenuecatkeysop",
+ "GetRevenueCatKeysEnv": ".getrevenuecatkeysop",
+ "GetRevenueCatKeysGlobals": ".getrevenuecatkeysop",
+ "GetRevenueCatKeysGlobalsTypedDict": ".getrevenuecatkeysop",
+ "GetRevenueCatKeysParams": ".getrevenuecatkeysop",
+ "GetRevenueCatKeysParamsTypedDict": ".getrevenuecatkeysop",
+ "GetRevenueCatKeysResponse": ".getrevenuecatkeysop",
+ "GetRevenueCatKeysResponseTypedDict": ".getrevenuecatkeysop",
+ "LinkRevenueCatEnv": ".linkrevenuecatop",
+ "LinkRevenueCatGlobals": ".linkrevenuecatop",
+ "LinkRevenueCatGlobalsTypedDict": ".linkrevenuecatop",
+ "LinkRevenueCatParams": ".linkrevenuecatop",
+ "LinkRevenueCatParamsTypedDict": ".linkrevenuecatop",
+ "LinkRevenueCatResponse": ".linkrevenuecatop",
+ "LinkRevenueCatResponseTypedDict": ".linkrevenuecatop",
"ListCustomersAutoTopup": ".listcustomersop",
"ListCustomersAutoTopupTypedDict": ".listcustomersop",
"ListCustomersBillingControls": ".listcustomersop",
@@ -5007,6 +5097,21 @@ _dynamic_imports: dict[str, str] = {
"SetupPaymentRemoveItemInterval": ".setuppaymentop",
"SetupPaymentResponse": ".setuppaymentop",
"SetupPaymentResponseTypedDict": ".setuppaymentop",
+ "Result": ".syncrevenuecatop",
+ "ResultTypedDict": ".syncrevenuecatop",
+ "StorePush": ".syncrevenuecatop",
+ "SyncRevenueCatApp": ".syncrevenuecatop",
+ "SyncRevenueCatAppTypedDict": ".syncrevenuecatop",
+ "SyncRevenueCatEnv": ".syncrevenuecatop",
+ "SyncRevenueCatGlobals": ".syncrevenuecatop",
+ "SyncRevenueCatGlobalsTypedDict": ".syncrevenuecatop",
+ "SyncRevenueCatParams": ".syncrevenuecatop",
+ "SyncRevenueCatParamsTypedDict": ".syncrevenuecatop",
+ "SyncRevenueCatPrice": ".syncrevenuecatop",
+ "SyncRevenueCatProduct": ".syncrevenuecatop",
+ "SyncRevenueCatResponse": ".syncrevenuecatop",
+ "SyncRevenueCatResponseTypedDict": ".syncrevenuecatop",
+ "SyncRevenueCatStatus": ".syncrevenuecatop",
"Deduction1": ".trackop",
"Deduction1TypedDict": ".trackop",
"Deduction2": ".trackop",
diff --git a/others/python-sdk/src/autumn_sdk/models/checkop.py b/others/python-sdk/src/autumn_sdk/models/checkop.py
index b165ecfba..6c98274a9 100644
--- a/others/python-sdk/src/autumn_sdk/models/checkop.py
+++ b/others/python-sdk/src/autumn_sdk/models/checkop.py
@@ -890,7 +890,7 @@ class Properties2(BaseModel):
return m
-class Product2TypedDict(TypedDict):
+class CheckProduct2TypedDict(TypedDict):
id: str
r"""The ID of the product you set when creating the product"""
name: str
@@ -920,7 +920,7 @@ class Product2TypedDict(TypedDict):
properties: NotRequired[Properties2TypedDict]
-class Product2(BaseModel):
+class CheckProduct2(BaseModel):
id: str
r"""The ID of the product you set when creating the product"""
@@ -1001,7 +1001,7 @@ class Preview2TypedDict(TypedDict):
r"""The ID of the feature that was checked."""
feature_name: str
r"""The display name of the feature."""
- products: List[Product2TypedDict]
+ products: List[CheckProduct2TypedDict]
r"""Products that would grant access to this feature. Use to display upgrade options."""
@@ -1023,7 +1023,7 @@ class Preview2(BaseModel):
feature_name: str
r"""The display name of the feature."""
- products: List[Product2]
+ products: List[CheckProduct2]
r"""Products that would grant access to this feature. Use to display upgrade options."""
@@ -1832,7 +1832,7 @@ class Properties1(BaseModel):
return m
-class Product1TypedDict(TypedDict):
+class CheckProduct1TypedDict(TypedDict):
id: str
r"""The ID of the product you set when creating the product"""
name: str
@@ -1862,7 +1862,7 @@ class Product1TypedDict(TypedDict):
properties: NotRequired[Properties1TypedDict]
-class Product1(BaseModel):
+class CheckProduct1(BaseModel):
id: str
r"""The ID of the product you set when creating the product"""
@@ -1943,7 +1943,7 @@ class Preview1TypedDict(TypedDict):
r"""The ID of the feature that was checked."""
feature_name: str
r"""The display name of the feature."""
- products: List[Product1TypedDict]
+ products: List[CheckProduct1TypedDict]
r"""Products that would grant access to this feature. Use to display upgrade options."""
@@ -1965,7 +1965,7 @@ class Preview1(BaseModel):
feature_name: str
r"""The display name of the feature."""
- products: List[Product1]
+ products: List[CheckProduct1]
r"""Products that would grant access to this feature. Use to display upgrade options."""
diff --git a/others/python-sdk/src/autumn_sdk/models/getrevenuecatkeysop.py b/others/python-sdk/src/autumn_sdk/models/getrevenuecatkeysop.py
new file mode 100644
index 000000000..a51f5d262
--- /dev/null
+++ b/others/python-sdk/src/autumn_sdk/models/getrevenuecatkeysop.py
@@ -0,0 +1,179 @@
+"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT."""
+
+from __future__ import annotations
+from autumn_sdk.types import (
+ BaseModel,
+ Nullable,
+ OptionalNullable,
+ UNSET,
+ UNSET_SENTINEL,
+)
+from autumn_sdk.utils import FieldMetadata, HeaderMetadata
+import pydantic
+from pydantic import ConfigDict, model_serializer
+from typing import Any, Dict, List, Literal, Optional
+from typing_extensions import Annotated, NotRequired, TypedDict
+
+
+class GetRevenueCatKeysGlobalsTypedDict(TypedDict):
+ x_api_version: NotRequired[str]
+
+
+class GetRevenueCatKeysGlobals(BaseModel):
+ x_api_version: Annotated[
+ Optional[str],
+ pydantic.Field(alias="x-api-version"),
+ FieldMetadata(header=HeaderMetadata(style="simple", explode=False)),
+ ] = "2.3.0"
+
+ @model_serializer(mode="wrap")
+ def serialize_model(self, handler):
+ optional_fields = set(["x-api-version"])
+ serialized = handler(self)
+ m = {}
+
+ for n, f in type(self).model_fields.items():
+ k = f.alias or n
+ val = serialized.get(k, serialized.get(n))
+
+ if val != UNSET_SENTINEL:
+ if val is not None or k not in optional_fields:
+ m[k] = val
+
+ return m
+
+
+GetRevenueCatKeysEnv = Literal[
+ "test",
+ "sandbox",
+ "live",
+]
+r"""\"test\" and \"sandbox\" both target the sandbox environment"""
+
+
+class GetRevenueCatKeysParamsTypedDict(TypedDict):
+ organization_slug: str
+ env: GetRevenueCatKeysEnv
+ r"""\"test\" and \"sandbox\" both target the sandbox environment"""
+
+
+class GetRevenueCatKeysParams(BaseModel):
+ organization_slug: str
+
+ env: GetRevenueCatKeysEnv
+ r"""\"test\" and \"sandbox\" both target the sandbox environment"""
+
+
+class APIKeyTypedDict(TypedDict):
+ id: str
+ key: str
+ r"""The public SDK API key value"""
+ environment: NotRequired[Nullable[str]]
+ r"""e.g. \"production\" / \"sandbox\" """
+ app_id: NotRequired[Nullable[str]]
+ created_at: NotRequired[float]
+
+
+class APIKey(BaseModel):
+ model_config = ConfigDict(
+ populate_by_name=True, arbitrary_types_allowed=True, extra="allow"
+ )
+ __pydantic_extra__: Dict[str, Any] = pydantic.Field(init=False)
+
+ id: str
+
+ key: str
+ r"""The public SDK API key value"""
+
+ environment: OptionalNullable[str] = UNSET
+ r"""e.g. \"production\" / \"sandbox\" """
+
+ app_id: OptionalNullable[str] = UNSET
+
+ created_at: Optional[float] = None
+
+ @property
+ def additional_properties(self):
+ return self.__pydantic_extra__
+
+ @additional_properties.setter
+ def additional_properties(self, value):
+ self.__pydantic_extra__ = value # pyright: ignore[reportIncompatibleVariableOverride]
+
+ @model_serializer(mode="wrap")
+ def serialize_model(self, handler):
+ optional_fields = set(["environment", "app_id", "created_at"])
+ nullable_fields = set(["environment", "app_id"])
+ serialized = handler(self)
+ m = {}
+
+ for n, f in type(self).model_fields.items():
+ k = f.alias or n
+ val = serialized.get(k, serialized.get(n))
+ serialized.pop(k, serialized.pop(n, None))
+ is_nullable_and_explicitly_set = (
+ k in nullable_fields
+ and (self.__pydantic_fields_set__.intersection({n})) # pylint: disable=no-member
+ )
+
+ if val != UNSET_SENTINEL:
+ if (
+ val is not None
+ or k not in optional_fields
+ or is_nullable_and_explicitly_set
+ ):
+ m[k] = val
+ for k, v in serialized.items():
+ m[k] = v
+
+ return m
+
+
+class GetRevenueCatKeysAppTypedDict(TypedDict):
+ app_id: str
+ app_type: str
+ r"""RevenueCat store type, e.g. test_store / app_store / play_store"""
+ name: str
+ api_keys: List[APIKeyTypedDict]
+
+
+class GetRevenueCatKeysApp(BaseModel):
+ app_id: str
+
+ app_type: str
+ r"""RevenueCat store type, e.g. test_store / app_store / play_store"""
+
+ name: str
+
+ api_keys: List[APIKey]
+
+
+class GetRevenueCatKeysResponseTypedDict(TypedDict):
+ r"""OK"""
+
+ apps: List[GetRevenueCatKeysAppTypedDict]
+ oauth_access_token: Nullable[str]
+ r"""Freshly-refreshed RevenueCat OAuth access token for the org (null for api-key orgs). The refresh token is never exposed — call this endpoint again for a new access token."""
+
+
+class GetRevenueCatKeysResponse(BaseModel):
+ r"""OK"""
+
+ apps: List[GetRevenueCatKeysApp]
+
+ oauth_access_token: Nullable[str]
+ r"""Freshly-refreshed RevenueCat OAuth access token for the org (null for api-key orgs). The refresh token is never exposed — call this endpoint again for a new access token."""
+
+ @model_serializer(mode="wrap")
+ def serialize_model(self, handler):
+ serialized = handler(self)
+ m = {}
+
+ for n, f in type(self).model_fields.items():
+ k = f.alias or n
+ val = serialized.get(k, serialized.get(n))
+
+ if val != UNSET_SENTINEL:
+ m[k] = val
+
+ return m
diff --git a/others/python-sdk/src/autumn_sdk/models/linkrevenuecatop.py b/others/python-sdk/src/autumn_sdk/models/linkrevenuecatop.py
new file mode 100644
index 000000000..2a117515b
--- /dev/null
+++ b/others/python-sdk/src/autumn_sdk/models/linkrevenuecatop.py
@@ -0,0 +1,72 @@
+"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT."""
+
+from __future__ import annotations
+from autumn_sdk.types import BaseModel, UNSET_SENTINEL
+from autumn_sdk.utils import FieldMetadata, HeaderMetadata
+import pydantic
+from pydantic import model_serializer
+from typing import Literal, Optional
+from typing_extensions import Annotated, NotRequired, TypedDict
+
+
+class LinkRevenueCatGlobalsTypedDict(TypedDict):
+ x_api_version: NotRequired[str]
+
+
+class LinkRevenueCatGlobals(BaseModel):
+ x_api_version: Annotated[
+ Optional[str],
+ pydantic.Field(alias="x-api-version"),
+ FieldMetadata(header=HeaderMetadata(style="simple", explode=False)),
+ ] = "2.3.0"
+
+ @model_serializer(mode="wrap")
+ def serialize_model(self, handler):
+ optional_fields = set(["x-api-version"])
+ serialized = handler(self)
+ m = {}
+
+ for n, f in type(self).model_fields.items():
+ k = f.alias or n
+ val = serialized.get(k, serialized.get(n))
+
+ if val != UNSET_SENTINEL:
+ if val is not None or k not in optional_fields:
+ m[k] = val
+
+ return m
+
+
+LinkRevenueCatEnv = Literal[
+ "test",
+ "live",
+]
+
+
+class LinkRevenueCatParamsTypedDict(TypedDict):
+ organization_slug: str
+ env: LinkRevenueCatEnv
+ project_name: str
+ redirect_url: str
+
+
+class LinkRevenueCatParams(BaseModel):
+ organization_slug: str
+
+ env: LinkRevenueCatEnv
+
+ project_name: str
+
+ redirect_url: str
+
+
+class LinkRevenueCatResponseTypedDict(TypedDict):
+ r"""OK"""
+
+ oauth_url: str
+
+
+class LinkRevenueCatResponse(BaseModel):
+ r"""OK"""
+
+ oauth_url: str
diff --git a/others/python-sdk/src/autumn_sdk/models/syncrevenuecatop.py b/others/python-sdk/src/autumn_sdk/models/syncrevenuecatop.py
new file mode 100644
index 000000000..7d12a3d78
--- /dev/null
+++ b/others/python-sdk/src/autumn_sdk/models/syncrevenuecatop.py
@@ -0,0 +1,206 @@
+"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT."""
+
+from __future__ import annotations
+from autumn_sdk.types import BaseModel, UNSET_SENTINEL, UnrecognizedStr
+from autumn_sdk.utils import FieldMetadata, HeaderMetadata
+import pydantic
+from pydantic import model_serializer
+from typing import List, Literal, Optional, Union
+from typing_extensions import Annotated, NotRequired, TypedDict
+
+
+class SyncRevenueCatGlobalsTypedDict(TypedDict):
+ x_api_version: NotRequired[str]
+
+
+class SyncRevenueCatGlobals(BaseModel):
+ x_api_version: Annotated[
+ Optional[str],
+ pydantic.Field(alias="x-api-version"),
+ FieldMetadata(header=HeaderMetadata(style="simple", explode=False)),
+ ] = "2.3.0"
+
+ @model_serializer(mode="wrap")
+ def serialize_model(self, handler):
+ optional_fields = set(["x-api-version"])
+ serialized = handler(self)
+ m = {}
+
+ for n, f in type(self).model_fields.items():
+ k = f.alias or n
+ val = serialized.get(k, serialized.get(n))
+
+ if val != UNSET_SENTINEL:
+ if val is not None or k not in optional_fields:
+ m[k] = val
+
+ return m
+
+
+SyncRevenueCatEnv = Literal[
+ "test",
+ "sandbox",
+ "live",
+]
+r"""\"test\" and \"sandbox\" both target the sandbox environment"""
+
+
+class SyncRevenueCatParamsTypedDict(TypedDict):
+ organization_slug: str
+ env: SyncRevenueCatEnv
+ r"""\"test\" and \"sandbox\" both target the sandbox environment"""
+ product_ids: NotRequired[List[str]]
+ r"""Plans to push. Omit to sync every plan in the org/env."""
+
+
+class SyncRevenueCatParams(BaseModel):
+ organization_slug: str
+
+ env: SyncRevenueCatEnv
+ r"""\"test\" and \"sandbox\" both target the sandbox environment"""
+
+ product_ids: Optional[List[str]] = None
+ r"""Plans to push. Omit to sync every plan in the org/env."""
+
+ @model_serializer(mode="wrap")
+ def serialize_model(self, handler):
+ optional_fields = set(["product_ids"])
+ serialized = handler(self)
+ m = {}
+
+ for n, f in type(self).model_fields.items():
+ k = f.alias or n
+ val = serialized.get(k, serialized.get(n))
+
+ if val != UNSET_SENTINEL:
+ if val is not None or k not in optional_fields:
+ m[k] = val
+
+ return m
+
+
+SyncRevenueCatStatus = Union[
+ Literal[
+ "synced",
+ "skipped",
+ "error",
+ ],
+ UnrecognizedStr,
+]
+
+
+SyncRevenueCatProduct = Union[
+ Literal[
+ "created",
+ "updated",
+ "exists",
+ ],
+ UnrecognizedStr,
+]
+
+
+StorePush = Union[
+ Literal[
+ "pushed",
+ "failed",
+ "skipped",
+ ],
+ UnrecognizedStr,
+]
+
+
+SyncRevenueCatPrice = Union[
+ Literal[
+ "set",
+ "skipped",
+ "failed",
+ ],
+ UnrecognizedStr,
+]
+
+
+class SyncRevenueCatAppTypedDict(TypedDict):
+ app_id: str
+ app_type: str
+ product: SyncRevenueCatProduct
+ store_push: NotRequired[StorePush]
+ price: NotRequired[SyncRevenueCatPrice]
+ message: NotRequired[str]
+
+
+class SyncRevenueCatApp(BaseModel):
+ app_id: str
+
+ app_type: str
+
+ product: SyncRevenueCatProduct
+
+ store_push: Optional[StorePush] = None
+
+ price: Optional[SyncRevenueCatPrice] = None
+
+ message: Optional[str] = None
+
+ @model_serializer(mode="wrap")
+ def serialize_model(self, handler):
+ optional_fields = set(["store_push", "price", "message"])
+ serialized = handler(self)
+ m = {}
+
+ for n, f in type(self).model_fields.items():
+ k = f.alias or n
+ val = serialized.get(k, serialized.get(n))
+
+ if val != UNSET_SENTINEL:
+ if val is not None or k not in optional_fields:
+ m[k] = val
+
+ return m
+
+
+class ResultTypedDict(TypedDict):
+ plan_id: str
+ status: SyncRevenueCatStatus
+ store_identifier: NotRequired[str]
+ apps: NotRequired[List[SyncRevenueCatAppTypedDict]]
+ message: NotRequired[str]
+
+
+class Result(BaseModel):
+ plan_id: str
+
+ status: SyncRevenueCatStatus
+
+ store_identifier: Optional[str] = None
+
+ apps: Optional[List[SyncRevenueCatApp]] = None
+
+ message: Optional[str] = None
+
+ @model_serializer(mode="wrap")
+ def serialize_model(self, handler):
+ optional_fields = set(["store_identifier", "apps", "message"])
+ serialized = handler(self)
+ m = {}
+
+ for n, f in type(self).model_fields.items():
+ k = f.alias or n
+ val = serialized.get(k, serialized.get(n))
+
+ if val != UNSET_SENTINEL:
+ if val is not None or k not in optional_fields:
+ m[k] = val
+
+ return m
+
+
+class SyncRevenueCatResponseTypedDict(TypedDict):
+ r"""OK"""
+
+ results: List[ResultTypedDict]
+
+
+class SyncRevenueCatResponse(BaseModel):
+ r"""OK"""
+
+ results: List[Result]
diff --git a/others/python-sdk/src/autumn_sdk/platform.py b/others/python-sdk/src/autumn_sdk/platform.py
new file mode 100644
index 000000000..9a76e9815
--- /dev/null
+++ b/others/python-sdk/src/autumn_sdk/platform.py
@@ -0,0 +1,586 @@
+"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT."""
+
+from .basesdk import BaseSDK
+from autumn_sdk import errors, models, utils
+from autumn_sdk._hooks import HookContext
+from autumn_sdk.types import OptionalNullable, UNSET
+from autumn_sdk.utils.unmarshal_json_response import unmarshal_json_response
+from typing import List, Mapping, Optional
+
+
+class Platform(BaseSDK):
+ def link_revenue_cat(
+ self,
+ *,
+ organization_slug: str,
+ env: models.LinkRevenueCatEnv,
+ project_name: str,
+ redirect_url: str,
+ retries: OptionalNullable[utils.RetryConfig] = UNSET,
+ server_url: Optional[str] = None,
+ timeout_ms: Optional[int] = None,
+ http_headers: Optional[Mapping[str, str]] = None,
+ ) -> models.LinkRevenueCatResponse:
+ r"""Generate a RevenueCat OAuth URL for linking a project to an organization.
+
+ :param organization_slug:
+ :param env:
+ :param project_name:
+ :param redirect_url:
+ :param retries: Override the default retry configuration for this method
+ :param server_url: Override the default server URL for this method
+ :param timeout_ms: Override the default request timeout configuration for this method in milliseconds
+ :param http_headers: Additional headers to set or replace on requests.
+ """
+ base_url = None
+ url_variables = None
+ if timeout_ms is None:
+ timeout_ms = self.sdk_configuration.timeout_ms
+
+ if server_url is not None:
+ base_url = server_url
+ else:
+ base_url = self._get_url(base_url, url_variables)
+
+ request = models.LinkRevenueCatParams(
+ organization_slug=organization_slug,
+ env=env,
+ project_name=project_name,
+ redirect_url=redirect_url,
+ )
+
+ req = self._build_request(
+ method="POST",
+ path="/v1/platform.link_revenuecat",
+ base_url=base_url,
+ url_variables=url_variables,
+ request=request,
+ request_body_required=True,
+ request_has_path_params=False,
+ request_has_query_params=True,
+ user_agent_header="user-agent",
+ accept_header_value="application/json",
+ http_headers=http_headers,
+ _globals=models.LinkRevenueCatGlobals(
+ x_api_version=self.sdk_configuration.globals.x_api_version,
+ ),
+ security=self.sdk_configuration.security,
+ get_serialized_body=lambda: utils.serialize_request_body(
+ request, False, False, "json", models.LinkRevenueCatParams
+ ),
+ allow_empty_value=None,
+ timeout_ms=timeout_ms,
+ )
+
+ if retries == UNSET:
+ if self.sdk_configuration.retry_config is not UNSET:
+ retries = self.sdk_configuration.retry_config
+
+ retry_config = None
+ if isinstance(retries, utils.RetryConfig):
+ retry_config = (retries, ["429", "500", "502", "503", "504"])
+
+ http_res = self.do_request(
+ hook_ctx=HookContext(
+ config=self.sdk_configuration,
+ base_url=base_url or "",
+ operation_id="linkRevenueCat",
+ oauth2_scopes=None,
+ security_source=self.sdk_configuration.security,
+ ),
+ request=req,
+ is_error_status_code=lambda c: utils.match_status_codes(["4XX", "5XX"], c),
+ retry_config=retry_config,
+ )
+
+ if utils.match_response(http_res, "200", "application/json"):
+ return unmarshal_json_response(models.LinkRevenueCatResponse, http_res)
+ if utils.match_response(http_res, "4XX", "*"):
+ http_res_text = utils.stream_to_text(http_res)
+ raise errors.AutumnDefaultError(
+ "API error occurred", http_res, http_res_text
+ )
+ if utils.match_response(http_res, "5XX", "*"):
+ http_res_text = utils.stream_to_text(http_res)
+ raise errors.AutumnDefaultError(
+ "API error occurred", http_res, http_res_text
+ )
+
+ raise errors.AutumnDefaultError("Unexpected response received", http_res)
+
+ async def link_revenue_cat_async(
+ self,
+ *,
+ organization_slug: str,
+ env: models.LinkRevenueCatEnv,
+ project_name: str,
+ redirect_url: str,
+ retries: OptionalNullable[utils.RetryConfig] = UNSET,
+ server_url: Optional[str] = None,
+ timeout_ms: Optional[int] = None,
+ http_headers: Optional[Mapping[str, str]] = None,
+ ) -> models.LinkRevenueCatResponse:
+ r"""Generate a RevenueCat OAuth URL for linking a project to an organization.
+
+ :param organization_slug:
+ :param env:
+ :param project_name:
+ :param redirect_url:
+ :param retries: Override the default retry configuration for this method
+ :param server_url: Override the default server URL for this method
+ :param timeout_ms: Override the default request timeout configuration for this method in milliseconds
+ :param http_headers: Additional headers to set or replace on requests.
+ """
+ base_url = None
+ url_variables = None
+ if timeout_ms is None:
+ timeout_ms = self.sdk_configuration.timeout_ms
+
+ if server_url is not None:
+ base_url = server_url
+ else:
+ base_url = self._get_url(base_url, url_variables)
+
+ request = models.LinkRevenueCatParams(
+ organization_slug=organization_slug,
+ env=env,
+ project_name=project_name,
+ redirect_url=redirect_url,
+ )
+
+ req = self._build_request_async(
+ method="POST",
+ path="/v1/platform.link_revenuecat",
+ base_url=base_url,
+ url_variables=url_variables,
+ request=request,
+ request_body_required=True,
+ request_has_path_params=False,
+ request_has_query_params=True,
+ user_agent_header="user-agent",
+ accept_header_value="application/json",
+ http_headers=http_headers,
+ _globals=models.LinkRevenueCatGlobals(
+ x_api_version=self.sdk_configuration.globals.x_api_version,
+ ),
+ security=self.sdk_configuration.security,
+ get_serialized_body=lambda: utils.serialize_request_body(
+ request, False, False, "json", models.LinkRevenueCatParams
+ ),
+ allow_empty_value=None,
+ timeout_ms=timeout_ms,
+ )
+
+ if retries == UNSET:
+ if self.sdk_configuration.retry_config is not UNSET:
+ retries = self.sdk_configuration.retry_config
+
+ retry_config = None
+ if isinstance(retries, utils.RetryConfig):
+ retry_config = (retries, ["429", "500", "502", "503", "504"])
+
+ http_res = await self.do_request_async(
+ hook_ctx=HookContext(
+ config=self.sdk_configuration,
+ base_url=base_url or "",
+ operation_id="linkRevenueCat",
+ oauth2_scopes=None,
+ security_source=self.sdk_configuration.security,
+ ),
+ request=req,
+ is_error_status_code=lambda c: utils.match_status_codes(["4XX", "5XX"], c),
+ retry_config=retry_config,
+ )
+
+ if utils.match_response(http_res, "200", "application/json"):
+ return unmarshal_json_response(models.LinkRevenueCatResponse, http_res)
+ if utils.match_response(http_res, "4XX", "*"):
+ http_res_text = await utils.stream_to_text_async(http_res)
+ raise errors.AutumnDefaultError(
+ "API error occurred", http_res, http_res_text
+ )
+ if utils.match_response(http_res, "5XX", "*"):
+ http_res_text = await utils.stream_to_text_async(http_res)
+ raise errors.AutumnDefaultError(
+ "API error occurred", http_res, http_res_text
+ )
+
+ raise errors.AutumnDefaultError("Unexpected response received", http_res)
+
+ def sync_revenue_cat(
+ self,
+ *,
+ organization_slug: str,
+ env: models.SyncRevenueCatEnv,
+ product_ids: Optional[List[str]] = None,
+ retries: OptionalNullable[utils.RetryConfig] = UNSET,
+ server_url: Optional[str] = None,
+ timeout_ms: Optional[int] = None,
+ http_headers: Optional[Mapping[str, str]] = None,
+ ) -> models.SyncRevenueCatResponse:
+ r"""Push an organization's plans into RevenueCat as products (creating or renaming them across the project's apps) and set test-store prices from each plan's price. Requires the org to have linked RevenueCat via OAuth.
+
+ :param organization_slug:
+ :param env: \"test\" and \"sandbox\" both target the sandbox environment
+ :param product_ids: Plans to push. Omit to sync every plan in the org/env.
+ :param retries: Override the default retry configuration for this method
+ :param server_url: Override the default server URL for this method
+ :param timeout_ms: Override the default request timeout configuration for this method in milliseconds
+ :param http_headers: Additional headers to set or replace on requests.
+ """
+ base_url = None
+ url_variables = None
+ if timeout_ms is None:
+ timeout_ms = self.sdk_configuration.timeout_ms
+
+ if server_url is not None:
+ base_url = server_url
+ else:
+ base_url = self._get_url(base_url, url_variables)
+
+ request = models.SyncRevenueCatParams(
+ organization_slug=organization_slug,
+ env=env,
+ product_ids=product_ids,
+ )
+
+ req = self._build_request(
+ method="POST",
+ path="/v1/platform.sync_revenuecat",
+ base_url=base_url,
+ url_variables=url_variables,
+ request=request,
+ request_body_required=True,
+ request_has_path_params=False,
+ request_has_query_params=True,
+ user_agent_header="user-agent",
+ accept_header_value="application/json",
+ http_headers=http_headers,
+ _globals=models.SyncRevenueCatGlobals(
+ x_api_version=self.sdk_configuration.globals.x_api_version,
+ ),
+ security=self.sdk_configuration.security,
+ get_serialized_body=lambda: utils.serialize_request_body(
+ request, False, False, "json", models.SyncRevenueCatParams
+ ),
+ allow_empty_value=None,
+ timeout_ms=timeout_ms,
+ )
+
+ if retries == UNSET:
+ if self.sdk_configuration.retry_config is not UNSET:
+ retries = self.sdk_configuration.retry_config
+
+ retry_config = None
+ if isinstance(retries, utils.RetryConfig):
+ retry_config = (retries, ["429", "500", "502", "503", "504"])
+
+ http_res = self.do_request(
+ hook_ctx=HookContext(
+ config=self.sdk_configuration,
+ base_url=base_url or "",
+ operation_id="syncRevenueCat",
+ oauth2_scopes=None,
+ security_source=self.sdk_configuration.security,
+ ),
+ request=req,
+ is_error_status_code=lambda c: utils.match_status_codes(["4XX", "5XX"], c),
+ retry_config=retry_config,
+ )
+
+ if utils.match_response(http_res, "200", "application/json"):
+ return unmarshal_json_response(models.SyncRevenueCatResponse, http_res)
+ if utils.match_response(http_res, "4XX", "*"):
+ http_res_text = utils.stream_to_text(http_res)
+ raise errors.AutumnDefaultError(
+ "API error occurred", http_res, http_res_text
+ )
+ if utils.match_response(http_res, "5XX", "*"):
+ http_res_text = utils.stream_to_text(http_res)
+ raise errors.AutumnDefaultError(
+ "API error occurred", http_res, http_res_text
+ )
+
+ raise errors.AutumnDefaultError("Unexpected response received", http_res)
+
+ async def sync_revenue_cat_async(
+ self,
+ *,
+ organization_slug: str,
+ env: models.SyncRevenueCatEnv,
+ product_ids: Optional[List[str]] = None,
+ retries: OptionalNullable[utils.RetryConfig] = UNSET,
+ server_url: Optional[str] = None,
+ timeout_ms: Optional[int] = None,
+ http_headers: Optional[Mapping[str, str]] = None,
+ ) -> models.SyncRevenueCatResponse:
+ r"""Push an organization's plans into RevenueCat as products (creating or renaming them across the project's apps) and set test-store prices from each plan's price. Requires the org to have linked RevenueCat via OAuth.
+
+ :param organization_slug:
+ :param env: \"test\" and \"sandbox\" both target the sandbox environment
+ :param product_ids: Plans to push. Omit to sync every plan in the org/env.
+ :param retries: Override the default retry configuration for this method
+ :param server_url: Override the default server URL for this method
+ :param timeout_ms: Override the default request timeout configuration for this method in milliseconds
+ :param http_headers: Additional headers to set or replace on requests.
+ """
+ base_url = None
+ url_variables = None
+ if timeout_ms is None:
+ timeout_ms = self.sdk_configuration.timeout_ms
+
+ if server_url is not None:
+ base_url = server_url
+ else:
+ base_url = self._get_url(base_url, url_variables)
+
+ request = models.SyncRevenueCatParams(
+ organization_slug=organization_slug,
+ env=env,
+ product_ids=product_ids,
+ )
+
+ req = self._build_request_async(
+ method="POST",
+ path="/v1/platform.sync_revenuecat",
+ base_url=base_url,
+ url_variables=url_variables,
+ request=request,
+ request_body_required=True,
+ request_has_path_params=False,
+ request_has_query_params=True,
+ user_agent_header="user-agent",
+ accept_header_value="application/json",
+ http_headers=http_headers,
+ _globals=models.SyncRevenueCatGlobals(
+ x_api_version=self.sdk_configuration.globals.x_api_version,
+ ),
+ security=self.sdk_configuration.security,
+ get_serialized_body=lambda: utils.serialize_request_body(
+ request, False, False, "json", models.SyncRevenueCatParams
+ ),
+ allow_empty_value=None,
+ timeout_ms=timeout_ms,
+ )
+
+ if retries == UNSET:
+ if self.sdk_configuration.retry_config is not UNSET:
+ retries = self.sdk_configuration.retry_config
+
+ retry_config = None
+ if isinstance(retries, utils.RetryConfig):
+ retry_config = (retries, ["429", "500", "502", "503", "504"])
+
+ http_res = await self.do_request_async(
+ hook_ctx=HookContext(
+ config=self.sdk_configuration,
+ base_url=base_url or "",
+ operation_id="syncRevenueCat",
+ oauth2_scopes=None,
+ security_source=self.sdk_configuration.security,
+ ),
+ request=req,
+ is_error_status_code=lambda c: utils.match_status_codes(["4XX", "5XX"], c),
+ retry_config=retry_config,
+ )
+
+ if utils.match_response(http_res, "200", "application/json"):
+ return unmarshal_json_response(models.SyncRevenueCatResponse, http_res)
+ if utils.match_response(http_res, "4XX", "*"):
+ http_res_text = await utils.stream_to_text_async(http_res)
+ raise errors.AutumnDefaultError(
+ "API error occurred", http_res, http_res_text
+ )
+ if utils.match_response(http_res, "5XX", "*"):
+ http_res_text = await utils.stream_to_text_async(http_res)
+ raise errors.AutumnDefaultError(
+ "API error occurred", http_res, http_res_text
+ )
+
+ raise errors.AutumnDefaultError("Unexpected response received", http_res)
+
+ def get_revenue_cat_keys(
+ self,
+ *,
+ organization_slug: str,
+ env: models.GetRevenueCatKeysEnv,
+ retries: OptionalNullable[utils.RetryConfig] = UNSET,
+ server_url: Optional[str] = None,
+ timeout_ms: Optional[int] = None,
+ http_headers: Optional[Mapping[str, str]] = None,
+ ) -> models.GetRevenueCatKeysResponse:
+ r"""Retrieve a managed organization's RevenueCat public (SDK) API keys, grouped by app — for the test store, App Store, and Google Play Store. Use these to configure the RevenueCat SDK in the org's mobile app.
+
+ :param organization_slug:
+ :param env: \"test\" and \"sandbox\" both target the sandbox environment
+ :param retries: Override the default retry configuration for this method
+ :param server_url: Override the default server URL for this method
+ :param timeout_ms: Override the default request timeout configuration for this method in milliseconds
+ :param http_headers: Additional headers to set or replace on requests.
+ """
+ base_url = None
+ url_variables = None
+ if timeout_ms is None:
+ timeout_ms = self.sdk_configuration.timeout_ms
+
+ if server_url is not None:
+ base_url = server_url
+ else:
+ base_url = self._get_url(base_url, url_variables)
+
+ request = models.GetRevenueCatKeysParams(
+ organization_slug=organization_slug,
+ env=env,
+ )
+
+ req = self._build_request(
+ method="POST",
+ path="/v1/platform.get_revenuecat_keys",
+ base_url=base_url,
+ url_variables=url_variables,
+ request=request,
+ request_body_required=True,
+ request_has_path_params=False,
+ request_has_query_params=True,
+ user_agent_header="user-agent",
+ accept_header_value="application/json",
+ http_headers=http_headers,
+ _globals=models.GetRevenueCatKeysGlobals(
+ x_api_version=self.sdk_configuration.globals.x_api_version,
+ ),
+ security=self.sdk_configuration.security,
+ get_serialized_body=lambda: utils.serialize_request_body(
+ request, False, False, "json", models.GetRevenueCatKeysParams
+ ),
+ allow_empty_value=None,
+ timeout_ms=timeout_ms,
+ )
+
+ if retries == UNSET:
+ if self.sdk_configuration.retry_config is not UNSET:
+ retries = self.sdk_configuration.retry_config
+
+ retry_config = None
+ if isinstance(retries, utils.RetryConfig):
+ retry_config = (retries, ["429", "500", "502", "503", "504"])
+
+ http_res = self.do_request(
+ hook_ctx=HookContext(
+ config=self.sdk_configuration,
+ base_url=base_url or "",
+ operation_id="getRevenueCatKeys",
+ oauth2_scopes=None,
+ security_source=self.sdk_configuration.security,
+ ),
+ request=req,
+ is_error_status_code=lambda c: utils.match_status_codes(["4XX", "5XX"], c),
+ retry_config=retry_config,
+ )
+
+ if utils.match_response(http_res, "200", "application/json"):
+ return unmarshal_json_response(models.GetRevenueCatKeysResponse, http_res)
+ if utils.match_response(http_res, "4XX", "*"):
+ http_res_text = utils.stream_to_text(http_res)
+ raise errors.AutumnDefaultError(
+ "API error occurred", http_res, http_res_text
+ )
+ if utils.match_response(http_res, "5XX", "*"):
+ http_res_text = utils.stream_to_text(http_res)
+ raise errors.AutumnDefaultError(
+ "API error occurred", http_res, http_res_text
+ )
+
+ raise errors.AutumnDefaultError("Unexpected response received", http_res)
+
+ async def get_revenue_cat_keys_async(
+ self,
+ *,
+ organization_slug: str,
+ env: models.GetRevenueCatKeysEnv,
+ retries: OptionalNullable[utils.RetryConfig] = UNSET,
+ server_url: Optional[str] = None,
+ timeout_ms: Optional[int] = None,
+ http_headers: Optional[Mapping[str, str]] = None,
+ ) -> models.GetRevenueCatKeysResponse:
+ r"""Retrieve a managed organization's RevenueCat public (SDK) API keys, grouped by app — for the test store, App Store, and Google Play Store. Use these to configure the RevenueCat SDK in the org's mobile app.
+
+ :param organization_slug:
+ :param env: \"test\" and \"sandbox\" both target the sandbox environment
+ :param retries: Override the default retry configuration for this method
+ :param server_url: Override the default server URL for this method
+ :param timeout_ms: Override the default request timeout configuration for this method in milliseconds
+ :param http_headers: Additional headers to set or replace on requests.
+ """
+ base_url = None
+ url_variables = None
+ if timeout_ms is None:
+ timeout_ms = self.sdk_configuration.timeout_ms
+
+ if server_url is not None:
+ base_url = server_url
+ else:
+ base_url = self._get_url(base_url, url_variables)
+
+ request = models.GetRevenueCatKeysParams(
+ organization_slug=organization_slug,
+ env=env,
+ )
+
+ req = self._build_request_async(
+ method="POST",
+ path="/v1/platform.get_revenuecat_keys",
+ base_url=base_url,
+ url_variables=url_variables,
+ request=request,
+ request_body_required=True,
+ request_has_path_params=False,
+ request_has_query_params=True,
+ user_agent_header="user-agent",
+ accept_header_value="application/json",
+ http_headers=http_headers,
+ _globals=models.GetRevenueCatKeysGlobals(
+ x_api_version=self.sdk_configuration.globals.x_api_version,
+ ),
+ security=self.sdk_configuration.security,
+ get_serialized_body=lambda: utils.serialize_request_body(
+ request, False, False, "json", models.GetRevenueCatKeysParams
+ ),
+ allow_empty_value=None,
+ timeout_ms=timeout_ms,
+ )
+
+ if retries == UNSET:
+ if self.sdk_configuration.retry_config is not UNSET:
+ retries = self.sdk_configuration.retry_config
+
+ retry_config = None
+ if isinstance(retries, utils.RetryConfig):
+ retry_config = (retries, ["429", "500", "502", "503", "504"])
+
+ http_res = await self.do_request_async(
+ hook_ctx=HookContext(
+ config=self.sdk_configuration,
+ base_url=base_url or "",
+ operation_id="getRevenueCatKeys",
+ oauth2_scopes=None,
+ security_source=self.sdk_configuration.security,
+ ),
+ request=req,
+ is_error_status_code=lambda c: utils.match_status_codes(["4XX", "5XX"], c),
+ retry_config=retry_config,
+ )
+
+ if utils.match_response(http_res, "200", "application/json"):
+ return unmarshal_json_response(models.GetRevenueCatKeysResponse, http_res)
+ if utils.match_response(http_res, "4XX", "*"):
+ http_res_text = await utils.stream_to_text_async(http_res)
+ raise errors.AutumnDefaultError(
+ "API error occurred", http_res, http_res_text
+ )
+ if utils.match_response(http_res, "5XX", "*"):
+ http_res_text = await utils.stream_to_text_async(http_res)
+ raise errors.AutumnDefaultError(
+ "API error occurred", http_res, http_res_text
+ )
+
+ raise errors.AutumnDefaultError("Unexpected response received", http_res)
diff --git a/others/python-sdk/src/autumn_sdk/sdk.py b/others/python-sdk/src/autumn_sdk/sdk.py
index 19d77062e..8feede30c 100644
--- a/others/python-sdk/src/autumn_sdk/sdk.py
+++ b/others/python-sdk/src/autumn_sdk/sdk.py
@@ -34,6 +34,7 @@ if TYPE_CHECKING:
from autumn_sdk.events import Events
from autumn_sdk.features import Features
from autumn_sdk.plans import Plans
+ from autumn_sdk.platform import Platform
from autumn_sdk.referrals import Referrals
from autumn_sdk.rewards_sdk import RewardsSDK
@@ -48,6 +49,7 @@ class Autumn(BaseSDK):
entities: "Entities"
referrals: "Referrals"
rewards: "RewardsSDK"
+ platform: "Platform"
_sub_sdk_map = {
"customers": ("autumn_sdk.customers", "Customers"),
"plans": ("autumn_sdk.plans", "Plans"),
@@ -58,6 +60,7 @@ class Autumn(BaseSDK):
"entities": ("autumn_sdk.entities", "Entities"),
"referrals": ("autumn_sdk.referrals", "Referrals"),
"rewards": ("autumn_sdk.rewards_sdk", "RewardsSDK"),
+ "platform": ("autumn_sdk.platform", "Platform"),
}
def __init__(
diff --git a/package.json b/package.json
index 60385337c..cecf16bbb 100644
--- a/package.json
+++ b/package.json
@@ -15,6 +15,8 @@
"apps/sdk-test",
"packages/atmn",
"packages/atmn-tests",
+ "packages/auth",
+ "packages/logging",
"packages/mcp",
"packages/sdk",
"packages/autumn-js",
@@ -113,6 +115,9 @@
"tb": "bun scripts/tinybird/index.ts",
"tb:prod": "bun scripts/tinybird/index.ts prod",
"tb:prod-legacy": "bun scripts/tinybird/index.ts prod-legacy",
+ "axiom": "ENV_FILE=.env infisical run --env=dev --recursive -- bun scripts/axiom/cli.ts",
+ "axiom:prod": "ENV_FILE=.env.prod infisical run --env=prod --recursive -- bun scripts/axiom/cli.ts",
+ "add-mcp": "bun scripts/mcp/addMcp.ts",
"trigger:deploy": "bunx trigger.dev deploy",
"setupci": "node scripts/setup/setupci.js",
"replicate": "bun scripts/db/replicate.ts",
@@ -133,7 +138,7 @@
"site": "cd apps/website && bun dev && cd ../..",
"docs": "bun -F @autumn/docs dev",
"docs:build": "bun -F @autumn/docs build",
- "ts": "turbo run ts --filter=@autumn/server --filter=autumn-js --filter=@autumn/openapi --filter=atmn --filter=checkout --filter=@autumn/mcp --filter=@autumn/leaf",
+ "ts": "turbo run ts --filter=@autumn/server --filter=autumn-js --filter=@autumn/openapi --filter=atmn --filter=checkout --filter=@autumn/auth --filter=@autumn/mcp --filter=@autumn/leaf",
"kill:ts": "while pgrep -f tsgo > /dev/null; do pkill -9 -f tsgo; sleep 0.1; done",
"atmn:build": "bun -F atmn build",
"openapi:ts": "bun -F @autumn/openapi ts",
diff --git a/packages/atmn/src/commands/auth/constants.ts b/packages/atmn/src/commands/auth/constants.ts
index b174d59a2..704a3f480 100644
--- a/packages/atmn/src/commands/auth/constants.ts
+++ b/packages/atmn/src/commands/auth/constants.ts
@@ -1,9 +1,10 @@
// OAuth constants for CLI authentication
-/** The OAuth client ID for the CLI (public client) */
-// export const CLI_CLIENT_ID = "khicXGthBbGMIWmpgodOTDcCCJHJMDpN"; (local i think)
-// export const CLI_CLIENT_ID = "NiKwaSyAfaeEEKEvFaUYihTXdTPtIRCk" (dev i think)
-export const CLI_CLIENT_ID = "hAWUopQqLnsSwuRgeRzIBzKslwXmQUSr"; // (prod i think)
+// Historical Better Auth OAuth clients for atmn CLI environments.
+// Server auth should identify atmn from oauth_client metadata/name instead.
+export const LOCAL_CLI_CLIENT_ID = "khicXGthBbGMIWmpgodOTDcCCJHJMDpN";
+export const DEV_CLI_CLIENT_ID = "NiKwaSyAfaeEEKEvFaUYihTXdTPtIRCk";
+export const CLI_CLIENT_ID = "hAWUopQqLnsSwuRgeRzIBzKslwXmQUSr";
/** Base port for the local OAuth callback server */
export const OAUTH_PORT_BASE = 31448;
diff --git a/packages/auth/package.json b/packages/auth/package.json
new file mode 100644
index 000000000..27c430f23
--- /dev/null
+++ b/packages/auth/package.json
@@ -0,0 +1,36 @@
+{
+ "name": "@autumn/auth",
+ "version": "0.0.1",
+ "author": "Autumn",
+ "type": "module",
+ "sideEffects": false,
+ "exports": {
+ ".": {
+ "types": "./src/index.ts",
+ "import": "./src/index.ts",
+ "default": "./src/index.ts"
+ },
+ "./utils": {
+ "types": "./src/utils/index.ts",
+ "import": "./src/utils/index.ts",
+ "default": "./src/utils/index.ts"
+ },
+ "./oauth": {
+ "types": "./src/oauth/index.ts",
+ "import": "./src/oauth/index.ts",
+ "default": "./src/oauth/index.ts"
+ }
+ },
+ "files": ["src"],
+ "scripts": {
+ "build": "tsc",
+ "ts": "tsc --noEmit",
+ "prepack": "bun run build",
+ "prepublishOnly": "bun run build"
+ },
+ "devDependencies": {
+ "@types/bun": "^1.2.13",
+ "@types/node": "^18.19.3",
+ "typescript": "~5.8.3"
+ }
+}
diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts
new file mode 100644
index 000000000..68d71e8cd
--- /dev/null
+++ b/packages/auth/src/index.ts
@@ -0,0 +1,2 @@
+export * from "./oauth/index.js";
+export * from "./utils/index.js";
diff --git a/packages/auth/src/oauth/index.ts b/packages/auth/src/oauth/index.ts
new file mode 100644
index 000000000..59a766887
--- /dev/null
+++ b/packages/auth/src/oauth/index.ts
@@ -0,0 +1 @@
+export * from "./oauthUrls.js";
diff --git a/packages/auth/src/oauth/oauthUrls.ts b/packages/auth/src/oauth/oauthUrls.ts
new file mode 100644
index 000000000..33a8ff48f
--- /dev/null
+++ b/packages/auth/src/oauth/oauthUrls.ts
@@ -0,0 +1,32 @@
+const trimTrailingSlash = (url: string) =>
+ url.endsWith("/") ? url.slice(0, -1) : url;
+
+export const getOAuthIssuerUrl = ({
+ authPath = "/api/auth",
+ baseUrl,
+}: {
+ authPath?: string;
+ baseUrl: string;
+}): string => trimTrailingSlash(new URL(authPath, baseUrl).href);
+
+export const getProtectedResourceMetadataUrl = ({
+ resourceUrl,
+}: {
+ resourceUrl: string;
+}): string => {
+ const url = new URL(resourceUrl);
+ const path = url.pathname === "/" ? "" : url.pathname;
+ return new URL(`/.well-known/oauth-protected-resource${path}`, url).href;
+};
+
+export const getWwwAuthenticateHeader = ({
+ error,
+ resourceMetadataUrl,
+}: {
+ error?: string;
+ resourceMetadataUrl: string;
+}): string => {
+ const params = [`resource_metadata="${resourceMetadataUrl}"`];
+ if (error) params.push(`error="${error}"`);
+ return `Bearer ${params.join(", ")}`;
+};
diff --git a/packages/auth/src/utils/authTokenUtils.ts b/packages/auth/src/utils/authTokenUtils.ts
new file mode 100644
index 000000000..71880a5bf
--- /dev/null
+++ b/packages/auth/src/utils/authTokenUtils.ts
@@ -0,0 +1,23 @@
+const AUTUMN_SECRET_KEY_PREFIX = "am_sk";
+const AUTUMN_PUBLISHABLE_KEY_PREFIX = "am_pk";
+const AUTUMN_OAUTH_TOKEN_PREFIX = "am_oauth_";
+
+export const isSecretKeyPrefix = ({ token }: { token: string }) =>
+ token.startsWith(AUTUMN_SECRET_KEY_PREFIX);
+
+export const isPublishableKeyPrefix = ({ token }: { token: string }) =>
+ token.startsWith(AUTUMN_PUBLISHABLE_KEY_PREFIX);
+
+export const isAutumnApiKey = ({ token }: { token: string }) =>
+ isSecretKeyPrefix({ token }) || isPublishableKeyPrefix({ token });
+
+export const isOAuthToken = ({ token }: { token: string }) =>
+ token.startsWith(AUTUMN_OAUTH_TOKEN_PREFIX);
+
+export const prefixOAuthToken = ({ token }: { token: string }) =>
+ isOAuthToken({ token }) ? token : `${AUTUMN_OAUTH_TOKEN_PREFIX}${token}`;
+
+export const stripOAuthTokenPrefix = ({ token }: { token: string }) =>
+ isOAuthToken({ token })
+ ? token.slice(AUTUMN_OAUTH_TOKEN_PREFIX.length)
+ : token;
diff --git a/packages/auth/src/utils/getBearerToken.ts b/packages/auth/src/utils/getBearerToken.ts
new file mode 100644
index 000000000..a704c92b7
--- /dev/null
+++ b/packages/auth/src/utils/getBearerToken.ts
@@ -0,0 +1,13 @@
+const BEARER_PREFIX = "Bearer ";
+
+export const getBearerToken = ({
+ headers,
+}: {
+ headers: Headers;
+}): string | undefined => {
+ const authorization = headers.get("authorization");
+ if (!authorization?.startsWith(BEARER_PREFIX)) return undefined;
+
+ const token = authorization.slice(BEARER_PREFIX.length).trim();
+ return token.length ? token : undefined;
+};
diff --git a/packages/auth/src/utils/index.ts b/packages/auth/src/utils/index.ts
new file mode 100644
index 000000000..5a76706d0
--- /dev/null
+++ b/packages/auth/src/utils/index.ts
@@ -0,0 +1,2 @@
+export * from "./authTokenUtils.js";
+export * from "./getBearerToken.js";
diff --git a/packages/auth/tsconfig.json b/packages/auth/tsconfig.json
new file mode 100644
index 000000000..ef9ed3373
--- /dev/null
+++ b/packages/auth/tsconfig.json
@@ -0,0 +1,34 @@
+{
+ "compilerOptions": {
+ "allowJs": true,
+ "allowUnreachableCode": false,
+ "allowUnusedLabels": false,
+ "checkJs": true,
+ "declaration": true,
+ "declarationMap": true,
+ "esModuleInterop": true,
+ "exactOptionalPropertyTypes": false,
+ "forceConsistentCasingInFileNames": true,
+ "incremental": false,
+ "isolatedModules": true,
+ "lib": ["dom", "dom.iterable", "es2024"],
+ "module": "Preserve",
+ "moduleResolution": "bundler",
+ "noFallthroughCasesInSwitch": true,
+ "noImplicitOverride": false,
+ "noImplicitReturns": false,
+ "noPropertyAccessFromIndexSignature": false,
+ "noUncheckedIndexedAccess": false,
+ "noUnusedLocals": false,
+ "noUnusedParameters": false,
+ "noEmit": true,
+ "skipLibCheck": true,
+ "sourceMap": true,
+ "strict": true,
+ "target": "es2022",
+ "types": ["bun", "node"],
+ "useUnknownInCatchVariables": true
+ },
+ "exclude": ["node_modules"],
+ "include": ["src/**/*.ts"]
+}
diff --git a/packages/logging/package.json b/packages/logging/package.json
new file mode 100644
index 000000000..c4912dd34
--- /dev/null
+++ b/packages/logging/package.json
@@ -0,0 +1,30 @@
+{
+ "name": "@autumn/logging",
+ "version": "0.0.1",
+ "author": "Autumn",
+ "type": "module",
+ "sideEffects": false,
+ "exports": {
+ ".": "./src/index.ts"
+ },
+ "files": [
+ "README.md",
+ "src"
+ ],
+ "scripts": {
+ "build": "tsc",
+ "ts": "tsc --noEmit",
+ "test": "bun test tests/unit",
+ "prepack": "bun run build",
+ "prepublishOnly": "bun run build"
+ },
+ "dependencies": {
+ "@axiomhq/pino": "^1.3.1",
+ "pino": "^9.6.0"
+ },
+ "devDependencies": {
+ "@types/bun": "^1.2.13",
+ "@types/node": "^18.19.3",
+ "typescript": "~5.8.3"
+ }
+}
diff --git a/packages/logging/src/context/addContextToLogs.ts b/packages/logging/src/context/addContextToLogs.ts
new file mode 100644
index 000000000..45a7a63e9
--- /dev/null
+++ b/packages/logging/src/context/addContextToLogs.ts
@@ -0,0 +1,38 @@
+import type { AutumnLogger } from "../types.js";
+import type {
+ LogAppContext,
+ LogRequestContext,
+ LogTriggerContext,
+} from "./types.js";
+
+export const addRequestToLogs = ({
+ logger,
+ requestContext,
+}: {
+ logger: AutumnLogger;
+ requestContext: LogRequestContext;
+}): AutumnLogger => logger.child({ context: { req: requestContext } });
+
+export const addAppContextToLogs = ({
+ logger,
+ appContext,
+}: {
+ logger: AutumnLogger;
+ appContext: LogAppContext;
+}): AutumnLogger => logger.child({ context: { context: appContext } });
+
+export const addTriggerToLogs = ({
+ logger,
+ triggerContext,
+}: {
+ logger: AutumnLogger;
+ triggerContext: LogTriggerContext;
+}): AutumnLogger => logger.child({ context: { trigger: triggerContext } });
+
+export const addExtrasToLogs = ({
+ logger,
+ extras,
+}: {
+ logger: AutumnLogger;
+ extras: Record;
+}): AutumnLogger => logger.child({ context: { extras } });
diff --git a/packages/logging/src/context/types.ts b/packages/logging/src/context/types.ts
new file mode 100644
index 000000000..830a0a861
--- /dev/null
+++ b/packages/logging/src/context/types.ts
@@ -0,0 +1,35 @@
+export type LogRequestContext = {
+ id: string;
+ method: string;
+ url: string;
+ timestamp: number;
+ customer_id?: string;
+ entity_id?: string;
+ user_agent?: string;
+ ip_address?: string;
+ region?: string;
+ query: Record;
+ body: unknown;
+ name: string;
+};
+
+export type LogAppContext = {
+ org_id?: string;
+ org_slug?: string;
+ env?: string;
+ auth_type?: string;
+ customer_id?: string;
+ entity_id?: string;
+ user_id?: string;
+ user_email?: string;
+ api_version?: string;
+ scopes?: string[];
+ full_subject_bucket?: number;
+ full_subject_rollout_enabled?: boolean;
+};
+
+export type LogTriggerContext = {
+ run_id: string;
+ task_id: string;
+ attempt_number?: number;
+};
diff --git a/packages/logging/src/ids/createSessionId.ts b/packages/logging/src/ids/createSessionId.ts
new file mode 100644
index 000000000..4cd3832a1
--- /dev/null
+++ b/packages/logging/src/ids/createSessionId.ts
@@ -0,0 +1,21 @@
+import { createHash } from "node:crypto";
+
+const stableStringify = ({ value }: { value: unknown }): string => {
+ if (!value || typeof value !== "object") return JSON.stringify(value);
+ if (Array.isArray(value))
+ return `[${value.map((item) => stableStringify({ value: item })).join(",")}]`;
+
+ return `{${Object.entries(value)
+ .sort(([a], [b]) => a.localeCompare(b))
+ .map(
+ ([key, item]) =>
+ `${JSON.stringify(key)}:${stableStringify({ value: item })}`,
+ )
+ .join(",")}}`;
+};
+
+export const createSessionId = ({ parts }: { parts: unknown }): string =>
+ createHash("sha256")
+ .update(stableStringify({ value: parts }))
+ .digest("hex")
+ .slice(0, 24);
diff --git a/packages/logging/src/ids/createTraceId.ts b/packages/logging/src/ids/createTraceId.ts
new file mode 100644
index 000000000..124381e7d
--- /dev/null
+++ b/packages/logging/src/ids/createTraceId.ts
@@ -0,0 +1,3 @@
+import { randomUUID } from "node:crypto";
+
+export const createTraceId = (): string => randomUUID();
diff --git a/packages/logging/src/index.ts b/packages/logging/src/index.ts
new file mode 100644
index 000000000..fbf99ad76
--- /dev/null
+++ b/packages/logging/src/index.ts
@@ -0,0 +1,40 @@
+export {
+ addAppContextToLogs,
+ addExtrasToLogs,
+ addRequestToLogs,
+ addTriggerToLogs,
+} from "./context/addContextToLogs.js";
+export type {
+ LogAppContext,
+ LogRequestContext,
+ LogTriggerContext,
+} from "./context/types.js";
+export { createSessionId } from "./ids/createSessionId.js";
+export { createTraceId } from "./ids/createTraceId.js";
+export {
+ createAppLogger,
+ createAutumnLogger,
+} from "./logger/autumnLogger.js";
+export { createConsoleLogger } from "./logger/consoleLogger.js";
+export { createLogger } from "./logger/createLogger.js";
+export {
+ mirrorLogger,
+ withLogPrefix,
+} from "./logger/loggerWrappers.js";
+export { resolveLoggerOptions } from "./logger/resolveLoggerOptions.js";
+export { asAxiomMap } from "./payload/asAxiomMap.js";
+export {
+ type GuardLogPayloadOptions,
+ guardLogPayload,
+} from "./payload/guardLogPayload.js";
+export type {
+ AutumnLogger,
+ ConsoleLogger,
+ ConsoleLoggerLevel,
+ CreateLoggerParams,
+ LoggerLevel,
+ LoggerOutput,
+ LoggerPreset,
+ PinoLogger,
+ ResolvedLoggerOptions,
+} from "./types.js";
diff --git a/packages/logging/src/logger/autumnLogger.ts b/packages/logging/src/logger/autumnLogger.ts
new file mode 100644
index 000000000..ccf946ac7
--- /dev/null
+++ b/packages/logging/src/logger/autumnLogger.ts
@@ -0,0 +1,69 @@
+import type pino from "pino";
+import type {
+ AutumnLogger,
+ ConsoleLoggerLevel,
+ CreateLoggerParams,
+ LogArgs,
+} from "../types.js";
+import { createLogger } from "./createLogger.js";
+
+const rewriteAppPath = (value: string): string =>
+ value.replace("file:///app/", "./").replace(/\/app\//g, "./");
+
+const errorToObject = (error: Error) => ({
+ name: error.name,
+ message: error.message,
+ stack: error.stack ? rewriteAppPath(error.stack) : undefined,
+});
+
+const normalizeLogArgs = ({ args }: { args: LogArgs }) => {
+ const strings = args
+ .filter((arg): arg is string => typeof arg === "string")
+ .map(rewriteAppPath);
+ const objects = args
+ .filter(
+ (arg) => typeof arg !== "string" && arg !== null && arg !== undefined,
+ )
+ .map((arg) => (arg instanceof Error ? { error: errorToObject(arg) } : arg));
+ const error = args.find((arg): arg is Error => arg instanceof Error);
+ const message =
+ strings.at(-1) ??
+ (error
+ ? rewriteAppPath(error.stack || error.message || "Error occurred")
+ : "");
+
+ return {
+ message,
+ merged: Object.assign({}, ...objects) as Record,
+ };
+};
+
+const createLogMethod =
+ ({ method }: { method: pino.LogFn }) =>
+ (...args: LogArgs) => {
+ const { message, merged } = normalizeLogArgs({ args });
+ if (Object.keys(merged).length > 0) method(merged, message);
+ else method(message);
+ };
+
+export const createAutumnLogger = ({
+ logger,
+}: {
+ logger: pino.Logger;
+}): AutumnLogger => ({
+ level: logger.level as ConsoleLoggerLevel,
+ debug: createLogMethod({ method: logger.debug.bind(logger) }),
+ info: createLogMethod({ method: logger.info.bind(logger) }),
+ warn: createLogMethod({ method: logger.warn.bind(logger) }),
+ warning: createLogMethod({ method: logger.warn.bind(logger) }),
+ error: createLogMethod({ method: logger.error.bind(logger) }),
+ child: ({ context, onlyProd = false }) => {
+ if (onlyProd && process.env.NODE_ENV !== "production") {
+ return createAutumnLogger({ logger });
+ }
+ return createAutumnLogger({ logger: logger.child(context) });
+ },
+});
+
+export const createAppLogger = (params: CreateLoggerParams): AutumnLogger =>
+ createAutumnLogger({ logger: createLogger(params) });
diff --git a/packages/logging/src/logger/consoleLogger.ts b/packages/logging/src/logger/consoleLogger.ts
new file mode 100644
index 000000000..e6c5795b4
--- /dev/null
+++ b/packages/logging/src/logger/consoleLogger.ts
@@ -0,0 +1,28 @@
+import type { ConsoleLogger, ConsoleLoggerLevel, LogArgs } from "../types.js";
+
+export const createConsoleLogger = ({
+ level,
+}: {
+ level: ConsoleLoggerLevel;
+}): ConsoleLogger => {
+ const levels: ConsoleLoggerLevel[] = ["debug", "info", "warning", "error"];
+ const min = levels.indexOf(level);
+ const noop = () => {};
+ const log =
+ ({ method }: { method: "debug" | "info" | "warn" | "error" }) =>
+ (...args: LogArgs) => {
+ console[method](...args);
+ };
+
+ const logger: ConsoleLogger = {
+ level,
+ debug: min <= 0 ? log({ method: "debug" }) : noop,
+ info: min <= 1 ? log({ method: "info" }) : noop,
+ warn: min <= 2 ? log({ method: "warn" }) : noop,
+ warning: min <= 2 ? log({ method: "warn" }) : noop,
+ error: min <= 3 ? log({ method: "error" }) : noop,
+ child: () => logger,
+ };
+
+ return logger;
+};
diff --git a/packages/logging/src/logger/createLogger.ts b/packages/logging/src/logger/createLogger.ts
new file mode 100644
index 000000000..ff4b08c20
--- /dev/null
+++ b/packages/logging/src/logger/createLogger.ts
@@ -0,0 +1,60 @@
+import pino from "pino";
+import { createConsoleJsonStream } from "../streams/consoleJsonStream.js";
+import { createPrettyLogStream } from "../streams/prettyLogStream.js";
+import type { CreateLoggerParams } from "../types.js";
+import { resolveLoggerOptions } from "./resolveLoggerOptions.js";
+
+export const createLogger = (params: CreateLoggerParams): pino.Logger => {
+ const resolved = resolveLoggerOptions({ options: params });
+ const axiomToken = params.axiomToken ?? process.env.AXIOM_TOKEN;
+ const axiomOrgId = params.axiomOrgId ?? process.env.AXIOM_ORG_ID;
+ const streams: pino.StreamEntry[] = [];
+
+ for (const output of resolved.outputs) {
+ if (output === "console-pretty") {
+ streams.push({
+ level: resolved.level,
+ stream: createPrettyLogStream({
+ trailingNewline: resolved.preset !== "dual",
+ useConsoleLog: params.useConsoleLog ?? resolved.preset === "dual",
+ }),
+ });
+ }
+
+ if (output === "console-json") {
+ streams.push({
+ level: resolved.level,
+ stream: createConsoleJsonStream(),
+ });
+ }
+
+ if (output === "axiom" && axiomToken) {
+ streams.push({
+ level: resolved.level,
+ stream: pino.transport({
+ target: "@axiomhq/pino",
+ options: {
+ dataset: resolved.dataset,
+ token: axiomToken,
+ orgId: axiomOrgId,
+ },
+ }),
+ });
+ }
+ }
+
+ return pino(
+ {
+ level: resolved.level,
+ base: {
+ service: resolved.service,
+ ...(params.context ?? {}),
+ },
+ mixin: params.mixin,
+ formatters: {
+ level: (label: string) => ({ level: label.toUpperCase() }),
+ },
+ },
+ pino.multistream(streams),
+ );
+};
diff --git a/packages/logging/src/logger/loggerWrappers.ts b/packages/logging/src/logger/loggerWrappers.ts
new file mode 100644
index 000000000..08d51e1f4
--- /dev/null
+++ b/packages/logging/src/logger/loggerWrappers.ts
@@ -0,0 +1,71 @@
+import type { AutumnLogger, LogArgs } from "../types.js";
+
+const logToStdout = ({
+ level,
+ args,
+}: {
+ level: "debug" | "info" | "warn" | "error";
+ args: LogArgs;
+}) => {
+ const method =
+ level === "debug"
+ ? console.debug
+ : level === "info"
+ ? console.info
+ : level === "warn"
+ ? console.warn
+ : console.error;
+ method(...args);
+};
+
+export const mirrorLogger = ({
+ logger,
+}: {
+ logger: AutumnLogger;
+}): AutumnLogger => ({
+ debug: (...args) => {
+ logger.debug(...args);
+ logToStdout({ level: "debug", args });
+ },
+ info: (...args) => {
+ logger.info(...args);
+ logToStdout({ level: "info", args });
+ },
+ warn: (...args) => {
+ logger.warn(...args);
+ logToStdout({ level: "warn", args });
+ },
+ warning: (...args) => {
+ logger.warn(...args);
+ logToStdout({ level: "warn", args });
+ },
+ error: (...args) => {
+ logger.error(...args);
+ logToStdout({ level: "error", args });
+ },
+ child: (params) => mirrorLogger({ logger: logger.child(params) }),
+});
+
+const prefixArgs = ({ prefix, args }: { prefix: string; args: LogArgs }) => {
+ if (typeof args[0] !== "string") return [prefix, ...args];
+ if (args[0].startsWith(prefix)) return args;
+ return [`${prefix} ${args[0]}`, ...args.slice(1)];
+};
+
+export const withLogPrefix = ({
+ logger,
+ label,
+}: {
+ logger: AutumnLogger;
+ label: string;
+}): AutumnLogger => {
+ const prefix = `[${label}]`;
+ return {
+ debug: (...args) => logger.debug(...prefixArgs({ prefix, args })),
+ info: (...args) => logger.info(...prefixArgs({ prefix, args })),
+ warn: (...args) => logger.warn(...prefixArgs({ prefix, args })),
+ warning: (...args) => logger.warn(...prefixArgs({ prefix, args })),
+ error: (...args) => logger.error(...prefixArgs({ prefix, args })),
+ child: (params) => withLogPrefix({ logger: logger.child(params), label }),
+ };
+};
diff --git a/packages/logging/src/logger/resolveLoggerOptions.ts b/packages/logging/src/logger/resolveLoggerOptions.ts
new file mode 100644
index 000000000..ddd79caa4
--- /dev/null
+++ b/packages/logging/src/logger/resolveLoggerOptions.ts
@@ -0,0 +1,67 @@
+import type {
+ CreateLoggerParams,
+ LoggerLevel,
+ LoggerOutput,
+ ResolvedLoggerOptions,
+} from "../types.js";
+
+const parseOutputs = (
+ value: string | undefined,
+): LoggerOutput[] | undefined => {
+ if (!value) return undefined;
+ const outputs = value
+ .split(",")
+ .map((part) => part.trim())
+ .filter(Boolean);
+
+ if (
+ outputs.every(
+ (output): output is LoggerOutput =>
+ output === "console-pretty" ||
+ output === "console-json" ||
+ output === "axiom",
+ )
+ ) {
+ return outputs;
+ }
+
+ return undefined;
+};
+
+export const resolveLoggerOptions = ({
+ options,
+ env = process.env,
+}: {
+ options: CreateLoggerParams;
+ env?: NodeJS.ProcessEnv;
+}): ResolvedLoggerOptions => {
+ const preset = options.preset ?? "default";
+ const isDevOrTest = env.NODE_ENV === "development" || env.NODE_ENV === "test";
+ const hasAxiomToken = Boolean(options.axiomToken ?? env.AXIOM_TOKEN);
+
+ let outputs = options.outputs ?? parseOutputs(env.LOG_OUTPUTS);
+ if (!outputs) {
+ if (preset === "console-only") outputs = ["console-pretty"];
+ else if (preset === "axiom-only") outputs = ["axiom"];
+ else if (preset === "dual")
+ outputs = [isDevOrTest ? "console-pretty" : "console-json", "axiom"];
+ else if (isDevOrTest) outputs = ["console-pretty", "axiom"];
+ else outputs = ["axiom"];
+ }
+
+ const filteredOutputs = outputs.filter(
+ (output) => output !== "axiom" || hasAxiomToken,
+ );
+
+ return {
+ service: options.service,
+ dataset: options.dataset ?? options.service,
+ preset,
+ level:
+ options.level ??
+ ((env.LOG_LEVEL as LoggerLevel | undefined) ||
+ (isDevOrTest || preset === "dual" ? "debug" : "info")),
+ outputs: filteredOutputs.length > 0 ? filteredOutputs : ["console-pretty"],
+ hasAxiomToken,
+ };
+};
diff --git a/packages/logging/src/payload/asAxiomMap.ts b/packages/logging/src/payload/asAxiomMap.ts
new file mode 100644
index 000000000..7c73aa94c
--- /dev/null
+++ b/packages/logging/src/payload/asAxiomMap.ts
@@ -0,0 +1,8 @@
+export const asAxiomMap = ({
+ value,
+}: {
+ value: unknown;
+}): Record =>
+ value && typeof value === "object" && !Array.isArray(value)
+ ? (value as Record)
+ : { value };
diff --git a/packages/logging/src/payload/guardLogPayload.ts b/packages/logging/src/payload/guardLogPayload.ts
new file mode 100644
index 000000000..64171178e
--- /dev/null
+++ b/packages/logging/src/payload/guardLogPayload.ts
@@ -0,0 +1,147 @@
+const defaultMaxPayloadBytes = 512_000;
+const defaultTruncateAboveBytes = 4_000;
+const defaultMaxArrayItems = 5;
+const defaultMaxStringLength = 500;
+const defaultMaxDepth = 6;
+
+export type GuardLogPayloadOptions = {
+ maxPayloadBytes?: number;
+ truncateAboveBytes?: number;
+ maxArrayItems?: number;
+ maxStringLength?: number;
+ maxDepth?: number;
+};
+
+const envNumber = ({
+ value,
+ fallback,
+}: {
+ value?: string;
+ fallback: number;
+}) => {
+ const parsed = Number(value);
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
+};
+
+const resolveOptions = ({
+ options = {},
+}: {
+ options?: GuardLogPayloadOptions;
+}) => ({
+ maxPayloadBytes:
+ options.maxPayloadBytes ??
+ envNumber({
+ value: process.env.LOG_MAX_PAYLOAD_BYTES,
+ fallback: defaultMaxPayloadBytes,
+ }),
+ truncateAboveBytes:
+ options.truncateAboveBytes ??
+ envNumber({
+ value: process.env.LOG_TRUNCATE_ABOVE_BYTES,
+ fallback: defaultTruncateAboveBytes,
+ }),
+ maxArrayItems:
+ options.maxArrayItems ??
+ envNumber({
+ value: process.env.LOG_MAX_ARRAY_ITEMS,
+ fallback: defaultMaxArrayItems,
+ }),
+ maxStringLength:
+ options.maxStringLength ??
+ envNumber({
+ value: process.env.LOG_MAX_STRING_LENGTH,
+ fallback: defaultMaxStringLength,
+ }),
+ maxDepth: options.maxDepth ?? defaultMaxDepth,
+});
+
+type ResolvedGuardOptions = ReturnType;
+
+const truncateString = ({
+ value,
+ maxStringLength,
+}: {
+ value: string;
+ maxStringLength: number;
+}): string =>
+ value.length > maxStringLength
+ ? `${value.slice(0, maxStringLength)}...[+${value.length - maxStringLength} chars]`
+ : value;
+
+const truncateValue = ({
+ value,
+ options,
+ depth = 0,
+}: {
+ value: unknown;
+ options: ResolvedGuardOptions;
+ depth?: number;
+}): unknown => {
+ if (typeof value === "string")
+ return truncateString({
+ value,
+ maxStringLength: options.maxStringLength,
+ });
+ if (!value || typeof value !== "object") return value;
+
+ if (depth >= options.maxDepth) {
+ if (Array.isArray(value)) return `...[${value.length} items]`;
+ return "...[object]";
+ }
+
+ if (Array.isArray(value)) {
+ const kept = value.slice(0, options.maxArrayItems).map((item) =>
+ truncateValue({
+ value: item,
+ options,
+ depth: depth + 1,
+ }),
+ );
+ if (value.length > options.maxArrayItems) {
+ kept.push(`...[+${value.length - options.maxArrayItems} more items]`);
+ }
+ return kept;
+ }
+
+ if (value instanceof Error) {
+ return {
+ name: value.name,
+ message: value.message,
+ stack: value.stack,
+ };
+ }
+
+ const out: Record = {};
+ for (const [key, item] of Object.entries(value)) {
+ out[key] = truncateValue({
+ value: item,
+ options,
+ depth: depth + 1,
+ });
+ }
+ return out;
+};
+
+export const guardLogPayload = ({
+ value,
+ options: guardOptions,
+}: {
+ value: unknown;
+ options?: GuardLogPayloadOptions;
+}): unknown => {
+ if (value === undefined) return undefined;
+ const options = resolveOptions({ options: guardOptions });
+ try {
+ const json = JSON.stringify(value);
+ if (!json || json.length <= options.truncateAboveBytes) return value;
+
+ const truncated = truncateValue({ value, options });
+ const truncatedJson = JSON.stringify(truncated);
+ if (truncatedJson && truncatedJson.length > options.maxPayloadBytes) {
+ return { _truncated: true, _bytes: truncatedJson.length };
+ }
+ return truncated;
+ } catch {
+ return { _unserializable: true };
+ }
+};
diff --git a/packages/logging/src/streams/consoleJsonStream.ts b/packages/logging/src/streams/consoleJsonStream.ts
new file mode 100644
index 000000000..a204dde79
--- /dev/null
+++ b/packages/logging/src/streams/consoleJsonStream.ts
@@ -0,0 +1,9 @@
+import { Writable } from "node:stream";
+
+export const createConsoleJsonStream = () =>
+ new Writable({
+ write(chunk, _encoding, callback) {
+ console.log(chunk.toString().trimEnd());
+ callback();
+ },
+ });
diff --git a/packages/logging/src/streams/prettyLogStream.ts b/packages/logging/src/streams/prettyLogStream.ts
new file mode 100644
index 000000000..e9bce2f08
--- /dev/null
+++ b/packages/logging/src/streams/prettyLogStream.ts
@@ -0,0 +1,117 @@
+import { Writable } from "node:stream";
+
+const FORMATTED_LOG_EXCLUDE_FIELDS = new Set([
+ "time",
+ "level",
+ "msg",
+ "pid",
+ "hostname",
+ "req",
+ "res",
+ "statusCode",
+ "body",
+ "query",
+ "durationMs",
+ "duration_ms",
+ "event",
+ "context",
+ "workflow",
+ "trigger",
+ "stripe_event",
+ "vercel_event",
+ "worker",
+ "extras",
+ "type",
+ "data",
+ "aws",
+ "service",
+]);
+
+const colors = {
+ reset: "\x1b[0m",
+ bright: "\x1b[1m",
+ red: "\x1b[31m",
+ green: "\x1b[32m",
+ yellow: "\x1b[33m",
+ blue: "\x1b[34m",
+ white: "\x1b[37m",
+ gray: "\x1b[90m",
+ bgRed: "\x1b[41m",
+};
+
+const levelColors: Record = {
+ 10: colors.gray,
+ 20: colors.blue,
+ 30: colors.green,
+ 40: colors.yellow,
+ 50: colors.red,
+ 60: colors.bgRed,
+ TRACE: colors.gray,
+ DEBUG: colors.blue,
+ INFO: colors.green,
+ WARN: colors.yellow,
+ ERROR: colors.red,
+ FATAL: colors.bgRed,
+};
+
+const levelNames: Record = {
+ 10: "TRACE",
+ 20: "DEBUG",
+ 30: "INFO",
+ 40: "WARN",
+ 50: "ERROR",
+ 60: "FATAL",
+ TRACE: "TRACE",
+ DEBUG: "DEBUG",
+ INFO: "INFO",
+ WARN: "WARN",
+ ERROR: "ERROR",
+ FATAL: "FATAL",
+};
+
+export const createPrettyLogStream = ({
+ trailingNewline = true,
+ useConsoleLog = false,
+}: {
+ trailingNewline?: boolean;
+ useConsoleLog?: boolean;
+} = {}) =>
+ new Writable({
+ write(chunk, _encoding, callback) {
+ try {
+ const log = JSON.parse(chunk.toString());
+ const timestamp = new Date(log.time)
+ .toISOString()
+ .replace("T", " ")
+ .replace("Z", "");
+ const level = log.level;
+ const levelColor = levelColors[level] || colors.white;
+ const levelName =
+ levelNames[level] || (typeof level === "string" ? level : "UNKNOWN");
+ let message = log.msg || "";
+
+ const additionalFields = Object.keys(log)
+ .filter((key) => !FORMATTED_LOG_EXCLUDE_FIELDS.has(key))
+ .reduce(
+ (acc, key) => {
+ acc[key] = log[key];
+ return acc;
+ },
+ {} as Record,
+ );
+
+ if (Object.keys(additionalFields).length > 0) {
+ message += ` ${JSON.stringify(additionalFields, null, 2)}`;
+ }
+
+ const formattedLog = `${colors.gray}${timestamp}${colors.reset} ${levelColor}${colors.bright}${levelName}${colors.reset} ${message}${trailingNewline ? "\n" : ""}`;
+ if (useConsoleLog) console.log(formattedLog);
+ else process.stdout.write(formattedLog);
+ callback();
+ } catch {
+ if (useConsoleLog) console.log(chunk.toString());
+ else process.stdout.write(chunk);
+ callback();
+ }
+ },
+ });
diff --git a/packages/logging/src/types.ts b/packages/logging/src/types.ts
new file mode 100644
index 000000000..0a46ee93d
--- /dev/null
+++ b/packages/logging/src/types.ts
@@ -0,0 +1,56 @@
+import type pino from "pino";
+
+export type LoggerOutput = "console-pretty" | "console-json" | "axiom";
+export type LoggerPreset = "default" | "dual" | "console-only" | "axiom-only";
+export type LoggerLevel =
+ | "trace"
+ | "debug"
+ | "info"
+ | "warn"
+ | "error"
+ | "fatal";
+
+export type CreateLoggerParams = {
+ service: string;
+ dataset?: string;
+ level?: LoggerLevel;
+ preset?: LoggerPreset;
+ outputs?: LoggerOutput[];
+ context?: Record;
+ mixin?: () => Record;
+ axiomToken?: string;
+ axiomOrgId?: string;
+ useConsoleLog?: boolean;
+};
+
+export type ResolvedLoggerOptions = Required<
+ Pick
+> & {
+ dataset: string;
+ level: LoggerLevel;
+ outputs: LoggerOutput[];
+ hasAxiomToken: boolean;
+};
+
+export type LogArgs = unknown[];
+
+export type AutumnLogger = {
+ level?: string;
+ debug: (...args: LogArgs) => void;
+ info: (...args: LogArgs) => void;
+ warn: (...args: LogArgs) => void;
+ warning: (...args: LogArgs) => void;
+ error: (...args: LogArgs) => void;
+ child: (params: {
+ context: Record;
+ onlyProd?: boolean;
+ }) => AutumnLogger;
+};
+
+export type ConsoleLoggerLevel = "debug" | "info" | "warning" | "error";
+
+export type ConsoleLogger = AutumnLogger & {
+ level: ConsoleLoggerLevel;
+};
+
+export type PinoLogger = pino.Logger;
diff --git a/packages/logging/tsconfig.json b/packages/logging/tsconfig.json
new file mode 100644
index 000000000..c4f77bb62
--- /dev/null
+++ b/packages/logging/tsconfig.json
@@ -0,0 +1,34 @@
+{
+ "compilerOptions": {
+ "allowJs": true,
+ "allowUnreachableCode": false,
+ "allowUnusedLabels": false,
+ "checkJs": true,
+ "declaration": true,
+ "declarationMap": true,
+ "esModuleInterop": true,
+ "exactOptionalPropertyTypes": false,
+ "forceConsistentCasingInFileNames": true,
+ "incremental": false,
+ "isolatedModules": true,
+ "lib": ["es2024"],
+ "module": "Preserve",
+ "moduleResolution": "bundler",
+ "noFallthroughCasesInSwitch": true,
+ "noImplicitOverride": false,
+ "noImplicitReturns": false,
+ "noPropertyAccessFromIndexSignature": false,
+ "noUncheckedIndexedAccess": false,
+ "noUnusedLocals": false,
+ "noUnusedParameters": false,
+ "noEmit": true,
+ "skipLibCheck": true,
+ "sourceMap": true,
+ "strict": true,
+ "target": "es2022",
+ "types": ["bun", "node"],
+ "useUnknownInCatchVariables": true
+ },
+ "exclude": ["node_modules"],
+ "include": ["src/**/*.ts", "tests/**/*.ts"]
+}
diff --git a/packages/mcp/README.md b/packages/mcp/README.md
index 9abe0f38b..3cc133297 100644
--- a/packages/mcp/README.md
+++ b/packages/mcp/README.md
@@ -2,11 +2,10 @@
Mastra-backed MCP library for Autumn operations.
-The hosted runtime lives in `apps/leaf` (see `src/mcp/http.ts`) and exposes two
-Streamable HTTP MCP routes:
+The hosted runtime lives in `apps/leaf` (see `src/mcp/mcpRouter.ts`) and exposes a
+Streamable HTTP MCP route:
- `/mcp` - public, API-shaped operational tools.
-- `/internal/mcp` - internal Autumn agent tool.
## `/mcp`
@@ -31,19 +30,6 @@ The write tools are marked destructive. Clients should call the matching preview
tool first where one exists and only call a write tool after explicit user
confirmation.
-## `/internal/mcp`
-
-Use this for Autumn-controlled agent flows.
-
-Tools:
-
-- `ask_autumn({ message, context? })`
-
-`ask_autumn` can look up customers/plans, inspect scoped Axiom logs when
-available, preview billing changes, and apply confirmed billing writes. Billing
-writes are preview-first: the server stores the pending action internally and
-executes it only after a follow-up confirmation.
-
## Local
The routes are served by the `@autumn/leaf` app. From the repo root:
@@ -52,15 +38,13 @@ The routes are served by the `@autumn/leaf` app. From the repo root:
bun run leaf
```
-This starts both MCP routes (on the leaf port, `3099` by default):
+This starts the MCP route (on the leaf port, `3099` by default):
- `http://localhost:3099/mcp`
-- `http://localhost:3099/internal/mcp`
OAuth metadata is route-aware:
- `http://localhost:3099/.well-known/oauth-protected-resource/mcp`
-- `http://localhost:3099/.well-known/oauth-protected-resource/internal/mcp`
OAuth uses the Autumn Better Auth issuer from `--server-url`:
OAuth uses the Autumn Better Auth issuer from `MCP_SERVER_URL`:
diff --git a/packages/mcp/package.json b/packages/mcp/package.json
index 4976ab7f5..0cffccb4c 100644
--- a/packages/mcp/package.json
+++ b/packages/mcp/package.json
@@ -20,7 +20,9 @@
"prepublishOnly": "bun run build"
},
"dependencies": {
- "@autumn/shared": "workspace:*",
+ "@autumn/auth": "workspace:*",
+ "@autumn/logging": "workspace:*",
+ "@autumn/shared": "workspace:*",
"@axiomhq/js": "^1.6.1",
"@mastra/core": "^1.36.0",
"@mastra/mcp": "^1.8.0",
diff --git a/packages/mcp/src/mcp-server/agent/axiom.ts b/packages/mcp/src/agent/axiom.ts
similarity index 79%
rename from packages/mcp/src/mcp-server/agent/axiom.ts
rename to packages/mcp/src/agent/axiom.ts
index 82f7fe73e..dcfcd640a 100644
--- a/packages/mcp/src/mcp-server/agent/axiom.ts
+++ b/packages/mcp/src/agent/axiom.ts
@@ -1,4 +1,10 @@
import { createHash } from "node:crypto";
+import {
+ makeScopeChecker,
+ type ScopeString,
+ Scopes,
+} from "@autumn/shared/scopeDefinitions";
+import { ms } from "@autumn/shared/unixUtils";
import { Axiom } from "@axiomhq/js";
import { createTool } from "@mastra/core/tools";
import {
@@ -9,18 +15,12 @@ import {
isValid,
parseISO,
} from "date-fns";
-import {
- makeScopeChecker,
- Scopes,
- type ScopeString,
-} from "@autumn/shared/scopeDefinitions";
-import { ms } from "@autumn/shared/unixUtils";
import * as z from "zod/v4";
import {
+ type AutumnMcpAuth,
createAutumnClient,
getAutumnAuth,
- type AutumnMcpAuth,
-} from "./auth.js";
+} from "../server/auth/auth.js";
const axiomDataset = "express";
const defaultStartTime = "now-30m";
@@ -28,8 +28,10 @@ const defaultEndTime = "now";
const maxRangeMs = ms.days(7);
const searchMaxRangeMs = ms.hours(1);
+type AutumnOrg = { id: string; slug?: string | undefined };
+
let axiomClient: Axiom | null = null;
-const orgCache = new Map();
+const orgCache = new Map();
const getAxiomClient = () => {
if (!process.env.AXIOM_ADMIN_TOKEN) {
@@ -78,14 +80,22 @@ const getRangeMs = (startTime: string, endTime: string) => {
};
const assertCanUseAxiom = (auth: AutumnMcpAuth) => {
- if (!makeScopeChecker(auth.scopes).has(Scopes.Analytics.Read as ScopeString)) {
+ if (
+ !makeScopeChecker(auth.scopes).has(Scopes.Analytics.Read as ScopeString)
+ ) {
throw new Error("analytics:read scope is required to query Axiom logs.");
}
};
-export const resolveAutumnOrgId = async (auth: AutumnMcpAuth) => {
- if (auth.orgId) return auth.orgId;
-
+/**
+ * Resolves the Autumn org (id + slug) for an authenticated request. Cached
+ * (~5min) per credential. Unlike `resolveAutumnOrgId`, this always hits
+ * `/v1/organization` when uncached so the slug is available — the id alone may
+ * already be on `auth`, but the slug never is.
+ */
+export const resolveAutumnOrg = async (
+ auth: AutumnMcpAuth,
+): Promise => {
const cacheKey = [
auth.serverURL ?? "https://api.useautumn.com",
auth.env,
@@ -94,7 +104,7 @@ export const resolveAutumnOrgId = async (auth: AutumnMcpAuth) => {
String(auth.failOpen),
].join(":");
const cached = orgCache.get(cacheKey);
- if (cached && isFuture(cached.expiresAt)) return cached.orgId;
+ if (cached && isFuture(cached.expiresAt)) return cached.org;
const client = createAutumnClient(auth);
const response = await fetch(new URL("/v1/organization", client.baseUrl), {
@@ -105,17 +115,26 @@ export const resolveAutumnOrgId = async (auth: AutumnMcpAuth) => {
throw new Error("Could not resolve Autumn organization for MCP request.");
}
- const body = (await response.json()) as { id?: unknown };
+ const body = (await response.json()) as { id?: unknown; slug?: unknown };
if (typeof body.id !== "string" || !body.id) {
throw new Error("Autumn organization response did not include an id.");
}
+ const org: AutumnOrg = {
+ id: body.id,
+ slug: typeof body.slug === "string" ? body.slug : undefined,
+ };
orgCache.set(cacheKey, {
- orgId: body.id,
+ org,
expiresAt: addMilliseconds(new Date(), ms.minutes(5)),
});
- return body.id;
+ return org;
+};
+
+export const resolveAutumnOrgId = async (auth: AutumnMcpAuth) => {
+ if (auth.orgId) return auth.orgId;
+ return (await resolveAutumnOrg(auth)).id;
};
export const prepareAxiomQuery = ({
@@ -133,7 +152,9 @@ export const prepareAxiomQuery = ({
const rangeMs = getRangeMs(startTime, endTime);
if (rangeMs === null || rangeMs <= 0 || rangeMs > maxRangeMs) {
- throw new Error("Axiom queries must use a bounded time range of at most 7 days.");
+ throw new Error(
+ "Axiom queries must use a bounded time range of at most 7 days.",
+ );
}
const trimmed = apl.trim();
@@ -152,7 +173,9 @@ export const prepareAxiomQuery = ({
}
if (/\|\s*\[\s*['"][^'"]+['"]\s*\](?=\s*(?:\||$))/i.test(rest)) {
- throw new Error("Axiom queries may only use the express dataset source once.");
+ throw new Error(
+ "Axiom queries may only use the express dataset source once.",
+ );
}
if (/\bsearch\b/i.test(rest) && rangeMs > searchMaxRangeMs) {
@@ -165,7 +188,9 @@ export const prepareAxiomQuery = ({
`| where ['context.org_id'] == '${escapeAplString(auth.orgId)}'`,
`| where ['context.env'] == '${escapeAplString(auth.env)}'`,
rest,
- ].filter(Boolean).join("\n"),
+ ]
+ .filter(Boolean)
+ .join("\n"),
startTime,
endTime,
};
@@ -180,11 +205,13 @@ export const createAxiomTools = () => ({
id: "queryAxiomLogs",
description:
"Run a read-only Axiom APL query against Autumn logs. The query is always constrained to the authenticated Autumn org and environment.",
- inputSchema: z.object({
- apl: z.string().min(1),
- startTime: z.string().optional(),
- endTime: z.string().optional(),
- }).strict(),
+ inputSchema: z
+ .object({
+ apl: z.string().min(1),
+ startTime: z.string().optional(),
+ endTime: z.string().optional(),
+ })
+ .strict(),
execute: async ({ apl, startTime, endTime }, context) => {
const auth = await withAxiomOrg(getAutumnAuth(context));
const query = prepareAxiomQuery({ auth, apl, startTime, endTime });
@@ -198,9 +225,11 @@ export const createAxiomTools = () => ({
id: "getAxiomDatasetFields",
description:
"List available Axiom field metadata for the express dataset, scoped to the authenticated Autumn org and environment.",
- inputSchema: z.object({
- dataset: z.literal(axiomDataset),
- }).strict(),
+ inputSchema: z
+ .object({
+ dataset: z.literal(axiomDataset),
+ })
+ .strict(),
execute: async ({ dataset }, context) => {
const auth = await withAxiomOrg(getAutumnAuth(context));
const query = prepareAxiomQuery({
diff --git a/packages/mcp/src/mcp-server/agent/pending-actions.ts b/packages/mcp/src/agent/pending-actions.ts
similarity index 95%
rename from packages/mcp/src/mcp-server/agent/pending-actions.ts
rename to packages/mcp/src/agent/pending-actions.ts
index 7ca55f55d..5cfd9512d 100644
--- a/packages/mcp/src/mcp-server/agent/pending-actions.ts
+++ b/packages/mcp/src/agent/pending-actions.ts
@@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
import { ms } from "@autumn/shared/unixUtils";
import { addMilliseconds, isPast } from "date-fns";
import { Redis } from "ioredis";
-import type { AutumnMcpAuth } from "./auth.js";
+import type { AutumnMcpAuth } from "../server/auth/auth.js";
export type BillingToolName =
| "attach"
@@ -89,7 +89,7 @@ const getRedis = (): PendingActionRedis => {
};
const parseStoredAction = (value: string | null) =>
- (value ? (JSON.parse(value) as PendingBillingAction) : null);
+ value ? (JSON.parse(value) as PendingBillingAction) : null;
const createAction = ({
auth,
@@ -150,7 +150,11 @@ export const claimLatestPendingAction = async (auth: AutumnMcpAuth) => {
if (!token || !action || isExpired(action)) {
logPendingAction("claim-miss", {
backend: "redis",
- reason: !token ? "missing_latest" : !action ? "missing_action" : "expired",
+ reason: !token
+ ? "missing_latest"
+ : !action
+ ? "missing_action"
+ : "expired",
token: token ? shortHash(token) : null,
...actionDebug(auth),
});
diff --git a/packages/mcp/src/analytics/analyticsSink.ts b/packages/mcp/src/analytics/analyticsSink.ts
new file mode 100644
index 000000000..928754031
--- /dev/null
+++ b/packages/mcp/src/analytics/analyticsSink.ts
@@ -0,0 +1,37 @@
+import type { AnalyticsSink } from "./analyticsTypes.js";
+import { createLoggerAnalyticsSink } from "./loggerSink.js";
+
+const DEFAULT_DATASET = "leaf";
+
+const noopSink: AnalyticsSink = {
+ emit() {},
+ flush: async () => {},
+};
+
+let cachedSink: AnalyticsSink | null | undefined;
+let overrideSink: AnalyticsSink | null | undefined;
+
+/**
+ * Override the analytics sink (tests, or wiring a pino/OTEL sink from the host
+ * app). Pass `null` to disable. Pass `undefined` to fall back to env defaults.
+ */
+export const setAnalyticsSink = (sink: AnalyticsSink | null | undefined) => {
+ overrideSink = sink;
+ if (sink !== undefined) cachedSink = undefined;
+};
+
+export const getAnalyticsSink = (): AnalyticsSink => {
+ if (overrideSink !== undefined) return overrideSink ?? noopSink;
+ if (cachedSink === undefined) {
+ cachedSink = createLoggerAnalyticsSink({
+ token: process.env.AXIOM_TOKEN,
+ orgId: process.env.AXIOM_ORG_ID,
+ dataset: process.env.MCP_ANALYTICS_DATASET ?? DEFAULT_DATASET,
+ });
+ }
+ return cachedSink ?? noopSink;
+};
+
+/** True when a real sink is configured — lets callers skip hot-path work. */
+export const isAnalyticsEnabled = (): boolean =>
+ getAnalyticsSink() !== noopSink;
diff --git a/packages/mcp/src/analytics/analyticsTypes.ts b/packages/mcp/src/analytics/analyticsTypes.ts
new file mode 100644
index 000000000..7307a3557
--- /dev/null
+++ b/packages/mcp/src/analytics/analyticsTypes.ts
@@ -0,0 +1,54 @@
+/**
+ * Where a tool call originated:
+ * - `mcp` — an external MCP client hitting our hosted server (e.g. Claude
+ * Code, Cursor). The #1 usage-analytics target.
+ * - `agent` — our own Autumn Ops agent (e.g. Slack) invoking tools
+ * internally. Drives agent reliability / failure detection.
+ */
+export type McpAnalyticsSurface = "mcp" | "agent";
+
+/**
+ * Org/auth context for a tool call. Mirrors the server's `context.*` log shape
+ * (see server/src/utils/logging) so MCP analytics and agent logs unify cleanly.
+ */
+export type McpAnalyticsContext = {
+ /** Autumn org id. Resolved lazily; may be absent if resolution fails. */
+ orgId?: string | undefined;
+ /** Autumn org slug. Resolved lazily; may be absent if resolution fails. */
+ orgSlug?: string | undefined;
+ env: string;
+ scopes?: string[] | undefined;
+};
+
+export type McpAnalyticsEvent = {
+ event: "mcp.tool_call";
+ surface: McpAnalyticsSurface;
+ tool: string;
+ /** One-sentence statement of what the caller is trying to do. */
+ intent?: string | undefined;
+ status: "ok" | "error";
+ durationMs: number;
+ principalId: string;
+ /** HTTP User-Agent of the calling MCP client. Absent for `agent` surface. */
+ client?: string | undefined;
+ /** MCP transport session id, or fallback hash(principal + client + window). */
+ sessionId: string;
+ context: McpAnalyticsContext;
+ /** Tool request payload (stored as an Axiom map field). */
+ input?: unknown;
+ /** Tool result payload (stored as an Axiom map field). */
+ output?: unknown;
+ error?: string | undefined;
+};
+
+/**
+ * Pluggable destination for analytics events. Implementations must be
+ * non-blocking: `emit` runs on the hot path of every tool call and must never
+ * throw or await network I/O inline. Swap this (pino/Axiom, an OTEL exporter,
+ * a test spy) without touching the instrumentation layer.
+ */
+export interface AnalyticsSink {
+ emit(event: McpAnalyticsEvent): void;
+ /** Drain any buffered events. Call on graceful shutdown. */
+ flush(): Promise;
+}
diff --git a/packages/mcp/src/analytics/emitToolEvent.ts b/packages/mcp/src/analytics/emitToolEvent.ts
new file mode 100644
index 000000000..86a342968
--- /dev/null
+++ b/packages/mcp/src/analytics/emitToolEvent.ts
@@ -0,0 +1,78 @@
+import { resolveAutumnOrg } from "../agent/axiom.js";
+import type { AutumnMcpAuth } from "../server/auth/auth.js";
+import { getAnalyticsSink } from "./analyticsSink.js";
+import type { McpAnalyticsSurface } from "./analyticsTypes.js";
+import { deriveSessionId } from "./sessionId.js";
+
+/**
+ * Builds and dispatches a single tool-call analytics event. Org resolution and
+ * the actual sink write run off the hot path so the tool response is never
+ * delayed by analytics.
+ */
+export const emitMcpToolEvent = ({
+ surface,
+ toolId,
+ auth,
+ client,
+ transportSessionId,
+ intent,
+ status,
+ durationMs,
+ input,
+ output,
+ error,
+}: {
+ surface: McpAnalyticsSurface;
+ toolId: string;
+ auth: AutumnMcpAuth;
+ client: string | undefined;
+ transportSessionId?: string | undefined;
+ intent?: string | undefined;
+ status: "ok" | "error";
+ durationMs: number;
+ input?: unknown;
+ output?: unknown;
+ error?: string | undefined;
+}) => {
+ const sink = getAnalyticsSink();
+
+ // Resolve org off the hot path; resolveAutumnOrg is cached (~5min).
+ void (async () => {
+ let orgId = auth.orgId;
+ let orgSlug: string | undefined;
+ try {
+ const org = await resolveAutumnOrg(auth);
+ orgId = org.id;
+ orgSlug = org.slug;
+ } catch {
+ // Best-effort: emit without org context rather than dropping the event.
+ }
+ const now = Date.now();
+ sink.emit({
+ event: "mcp.tool_call",
+ surface,
+ tool: toolId,
+ intent,
+ status,
+ durationMs,
+ principalId: auth.principalId,
+ client,
+ sessionId:
+ transportSessionId ??
+ deriveSessionId({
+ principalId: auth.principalId,
+ client,
+ now,
+ }),
+ context: {
+ orgId,
+ orgSlug,
+ env: auth.env,
+ scopes: auth.scopes,
+ },
+ input,
+ output,
+ error,
+ });
+ })();
+};
diff --git a/packages/mcp/src/analytics/index.ts b/packages/mcp/src/analytics/index.ts
new file mode 100644
index 000000000..7c2769d98
--- /dev/null
+++ b/packages/mcp/src/analytics/index.ts
@@ -0,0 +1,15 @@
+export {
+ getAnalyticsSink,
+ isAnalyticsEnabled,
+ setAnalyticsSink,
+} from "./analyticsSink.js";
+export type {
+ AnalyticsSink,
+ McpAnalyticsEvent,
+ McpAnalyticsSurface,
+} from "./analyticsTypes.js";
+export { instrumentToolsWithAnalytics } from "./instrumentTools.js";
+export {
+ createAxiomAnalyticsSink,
+ createLoggerAnalyticsSink,
+} from "./loggerSink.js";
diff --git a/packages/mcp/src/analytics/instrumentTools.ts b/packages/mcp/src/analytics/instrumentTools.ts
new file mode 100644
index 000000000..a7c989391
--- /dev/null
+++ b/packages/mcp/src/analytics/instrumentTools.ts
@@ -0,0 +1,115 @@
+import type { createTool } from "@mastra/core/tools";
+import { type AutumnMcpAuth, getAutumnAuth } from "../server/auth/auth.js";
+import { getIntent } from "../tools/utils/intent.js";
+import { isAnalyticsEnabled } from "./analyticsSink.js";
+import type { McpAnalyticsSurface } from "./analyticsTypes.js";
+import { emitMcpToolEvent } from "./emitToolEvent.js";
+
+type AnyTool = ReturnType;
+type ToolContext = Parameters>[1];
+
+const getHeadersFromContext = (
+ context: ToolContext,
+): Record | undefined => {
+ const extra = (
+ context as {
+ mcp?: {
+ extra?: {
+ requestInfo?: { headers?: Record };
+ };
+ };
+ }
+ )?.mcp?.extra;
+ return extra?.requestInfo?.headers;
+};
+
+const getHeader = (
+ headers: Record | undefined,
+ name: string,
+): string | undefined => {
+ const direct = headers?.[name] ?? headers?.[name.toLowerCase()];
+ if (direct) return direct;
+ const entry = Object.entries(headers ?? {}).find(
+ ([key]) => key.toLowerCase() === name.toLowerCase(),
+ );
+ return entry?.[1];
+};
+
+const extractRequest = (input: unknown): unknown =>
+ input && typeof input === "object" && "request" in input
+ ? (input as { request: unknown }).request
+ : input;
+
+/**
+ * Wraps each tool's `execute` to emit a usage event per call. Auth/identity is
+ * read from the same MCP context the tools already use, so an unauthenticated
+ * call simply skips analytics (it would have failed in the tool anyway).
+ *
+ * Tools are wrapped once when the MCP server is created. The wrapper keeps no
+ * per-request mutable state; auth/session data is read from the execution
+ * context for each tool call.
+ *
+ * @param tools The toolset to instrument (mutated in place and returned).
+ * @param surface Origin of the calls — `mcp` (external clients) or `agent`
+ * (our own Autumn Ops agent).
+ */
+export const instrumentToolsWithAnalytics = <
+ T extends Record,
+>({
+ tools,
+ surface,
+}: {
+ tools: T;
+ surface: McpAnalyticsSurface;
+}): T => {
+ if (!isAnalyticsEnabled()) return tools;
+
+ for (const [toolId, tool] of Object.entries(tools)) {
+ const original = tool.execute;
+ if (!original) continue;
+ tool.execute = (async (input: unknown, context: ToolContext) => {
+ const started = Date.now();
+ let auth: AutumnMcpAuth | undefined;
+ try {
+ auth = getAutumnAuth(context);
+ } catch {
+ return original(input as never, context as never);
+ }
+ const headers = getHeadersFromContext(context);
+ const client = getHeader(headers, "user-agent");
+ const transportSessionId = getHeader(headers, "mcp-session-id");
+ const intent = getIntent(input);
+ try {
+ const output = await original(input as never, context as never);
+ emitMcpToolEvent({
+ surface,
+ toolId,
+ auth,
+ client,
+ transportSessionId,
+ intent,
+ status: "ok",
+ durationMs: Date.now() - started,
+ input: extractRequest(input),
+ output,
+ });
+ return output;
+ } catch (error) {
+ emitMcpToolEvent({
+ surface,
+ toolId,
+ auth,
+ client,
+ transportSessionId,
+ intent,
+ status: "error",
+ durationMs: Date.now() - started,
+ input: extractRequest(input),
+ error: error instanceof Error ? error.message : String(error),
+ });
+ throw error;
+ }
+ }) as AnyTool["execute"];
+ }
+ return tools;
+};
diff --git a/packages/mcp/src/analytics/loggerSink.ts b/packages/mcp/src/analytics/loggerSink.ts
new file mode 100644
index 000000000..6fb15c4fc
--- /dev/null
+++ b/packages/mcp/src/analytics/loggerSink.ts
@@ -0,0 +1,60 @@
+import { asAxiomMap, createLogger, guardLogPayload } from "@autumn/logging";
+import type { AnalyticsSink, McpAnalyticsEvent } from "./analyticsTypes.js";
+
+const toLoggerRecord = (event: McpAnalyticsEvent) => ({
+ _time: new Date().toISOString(),
+ event: event.event,
+ surface: event.surface,
+ tool: event.tool,
+ intent: event.intent,
+ status: event.status,
+ duration_ms: event.durationMs,
+ principal_id: event.principalId,
+ client: event.client,
+ session_id: event.sessionId,
+ context: {
+ org_id: event.context.orgId,
+ org_slug: event.context.orgSlug,
+ env: event.context.env,
+ scopes: event.context.scopes,
+ },
+ input: asAxiomMap({ value: guardLogPayload({ value: event.input }) }),
+ output: asAxiomMap({ value: guardLogPayload({ value: event.output }) }),
+ error: event.error,
+});
+
+export const createLoggerAnalyticsSink = ({
+ token,
+ orgId,
+ dataset,
+}: {
+ token?: string | undefined;
+ orgId?: string | undefined;
+ dataset: string;
+}): AnalyticsSink | null => {
+ if (!token) return null;
+ const logger = createLogger({
+ service: "mcp",
+ dataset,
+ preset: "axiom-only",
+ outputs: ["axiom"],
+ axiomToken: token,
+ axiomOrgId: orgId,
+ });
+
+ return {
+ emit(event) {
+ logger.info(toLoggerRecord(event), "MCP tool call");
+ },
+ flush: async () => {
+ await new Promise((resolve) => {
+ const flush = logger.flush;
+ if (typeof flush !== "function") return resolve();
+ flush.call(logger, () => resolve());
+ });
+ },
+ };
+};
+
+/** @deprecated Use createLoggerAnalyticsSink. */
+export const createAxiomAnalyticsSink = createLoggerAnalyticsSink;
diff --git a/packages/mcp/src/analytics/sessionId.ts b/packages/mcp/src/analytics/sessionId.ts
new file mode 100644
index 000000000..717386cfd
--- /dev/null
+++ b/packages/mcp/src/analytics/sessionId.ts
@@ -0,0 +1,23 @@
+import { createHash } from "node:crypto";
+import { ms } from "@autumn/shared/unixUtils";
+
+const sessionWindowMs = ms.minutes(30);
+
+const hash = (value: string) =>
+ createHash("sha256").update(value).digest("hex").slice(0, 32);
+
+/**
+ * Fallback session grouping. Stateful MCP clients send Mcp-Session-Id; when it
+ * is absent, synthesize a coarse principal/client bucket so calls from the same
+ * client within the window still collapse into one session.
+ */
+export const deriveSessionId = ({
+ principalId,
+ client,
+ now,
+}: {
+ principalId: string;
+ client: string | undefined;
+ now: number;
+}) =>
+ hash(`${principalId}|${client ?? ""}|${Math.floor(now / sessionWindowMs)}`);
diff --git a/packages/mcp/src/mcp-server/console-logger.ts b/packages/mcp/src/console-logger.ts
similarity index 91%
rename from packages/mcp/src/mcp-server/console-logger.ts
rename to packages/mcp/src/console-logger.ts
index 299a1e0ea..bf1da90f1 100644
--- a/packages/mcp/src/mcp-server/console-logger.ts
+++ b/packages/mcp/src/console-logger.ts
@@ -16,7 +16,8 @@ export type ConsoleLogger = Record & {
export function createConsoleLogger(level: ConsoleLoggerLevel): ConsoleLogger {
const min = consoleLoggerLevels.indexOf(level);
const noop = () => {};
- const log = (method: "debug" | "info" | "warn" | "error"): LogMethod =>
+ const log =
+ (method: "debug" | "info" | "warn" | "error"): LogMethod =>
(message, data) => {
if (data) console[method](message, data);
else console[method](message);
diff --git a/packages/mcp/src/constants.ts b/packages/mcp/src/constants.ts
new file mode 100644
index 000000000..f6482c8f2
--- /dev/null
+++ b/packages/mcp/src/constants.ts
@@ -0,0 +1,18 @@
+import type { ScopeString } from "@autumn/shared/scopeDefinitions";
+import { Scopes } from "@autumn/shared/scopeDefinitions";
+
+/** Shared defaults for talking to the Autumn API from the MCP server. */
+export const DEFAULT_AUTUMN_API_URL = "https://api.useautumn.com";
+export const DEFAULT_API_VERSION = "2.3.0";
+
+/** Scopes requested when exchanging an OAuth token for an Autumn API key. */
+export const MCP_OAUTH_SCOPES = [
+ Scopes.Customers.Read,
+ Scopes.Customers.Write,
+ Scopes.Plans.Read,
+ Scopes.Plans.Write,
+ Scopes.Billing.Read,
+ Scopes.Billing.Write,
+ Scopes.Balances.Write,
+ Scopes.Analytics.Read,
+] as const satisfies readonly ScopeString[];
diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts
index c37c05ee5..1d333828f 100644
--- a/packages/mcp/src/index.ts
+++ b/packages/mcp/src/index.ts
@@ -1,19 +1,27 @@
export {
- createAskAutumnMCPServer,
- createAutumnOperationsMCPServer,
- createMCPServer,
-} from "./mcp-server/agent/server.js";
+ type AnalyticsSink,
+ createAxiomAnalyticsSink,
+ getAnalyticsSink,
+ isAnalyticsEnabled,
+ type McpAnalyticsEvent,
+ type McpAnalyticsSurface,
+ setAnalyticsSink,
+} from "./analytics/index.js";
export {
type ConsoleLogger,
type ConsoleLoggerLevel,
consoleLoggerLevels,
createConsoleLogger,
-} from "./mcp-server/console-logger.js";
-export type { MCPServerFlags } from "./mcp-server/flags.js";
+} from "./console-logger.js";
export {
- buildAuthForRequest,
- getAuthorizationServerMetadata,
- getProtectedResourceMetadata,
+ DEFAULT_API_VERSION,
+ DEFAULT_AUTUMN_API_URL,
+ MCP_OAUTH_SCOPES,
+} from "./constants.js";
+export {
+ type AutumnMcpAuth,
+ environmentSchema,
type OAuthEnvironment,
- OAuthHttpError,
-} from "./mcp-server/oauth.js";
+} from "./server/auth/auth.js";
+export type { MCPServerFlags } from "./server/flags.js";
+export { createAutumnOperationsMCPServer } from "./server/server.js";
diff --git a/packages/mcp/src/mcp-server/agent/ask-autumn.ts b/packages/mcp/src/mcp-server/agent/ask-autumn.ts
deleted file mode 100644
index 9eb082363..000000000
--- a/packages/mcp/src/mcp-server/agent/ask-autumn.ts
+++ /dev/null
@@ -1,124 +0,0 @@
-import { Agent } from "@mastra/core/agent";
-import { createTool } from "@mastra/core/tools";
-import * as z from "zod/v4";
-import {
- type AutumnMcpAuth,
- createRequestContext,
- getAutumnAuth,
-} from "./auth.js";
-import { getLatestPendingAction } from "./pending-actions.js";
-import { createAgentAutumnOperationTools } from "./tools.js";
-
-const model = "anthropic/claude-sonnet-4-6";
-
-const instructions = `You are Autumn's operational billing assistant.
-Use Autumn tools for customer, plan, and billing work.
-Use Axiom tools only for read-only investigation of Autumn logs.
-
-Rules:
-- Read requests can be answered directly.
-- For plan-attribute queries, call listPlans first and filter returned plans locally.
-- For customer-heavy queries, push filters into listCustomers and paginate for complete results.
-- For customer lookup, use listCustomers first when the id/email/name is ambiguous.
-- For plan lookup, use listPlans first when the plan is ambiguous.
-- Avoid getCustomer fan-out unless listCustomers is missing details required by the user.
-- For customer creation, use createCustomer only when the user explicitly asks to create or pre-create a customer.
-- For plan creation, gather plan id, name, price, items/features, trials, and add-on/default behavior before calling createPlan.
-- For standalone credit or balance grants, use previewCreateBalance before createBalance. Use entity_id for entity-scoped grants, included_grant for the granted amount, expires_at in milliseconds for expiring grants, and omit reset when using expires_at.
-- For multi-phase billing schedules, gather customer, optional entity, ordered phase start times, and phase plans before calling previewCreateSchedule.
-- Use dateToEpochMilliseconds to convert user-facing dates into epoch milliseconds before calling tools with starts_at or expires_at fields; if a named timezone matters, ask for or use an explicit offset.
-- If a fee schedule says year 1 is already paid or has no billing changes, do not add an immediate/year-1 phase; start the schedule at the first future billing change.
-- For custom consumable grants, map "per month/year" to customize.items[].reset.interval. Omit reset only for unlimited, non-consumable, or clearly one-time grants.
-- For billing changes, call previewAttach or previewUpdateSubscription first. These preview tools automatically create the pending billing action.
-- previewCreateSchedule stores the pending createSchedule write; after it returns pending, ask the user to confirm the exact schedule before applying it.
-- previewCreateBalance stores the pending createBalance write; after it returns pending, ask the user to confirm the exact balance grant before applying it.
-- createPlan stores a pending write; after it returns pending, ask the user to confirm the exact plan configuration before applying it.
-- Never expose internal ids or server bookkeeping details.
-- After a billing preview, tell the user to explicitly apply or approve the exact previewed change.
-- If the user semantically confirms, applies, or approves a billing preview, call confirmBillingAction even if the preview is not visible in the current message. The tool validates whether a pending action exists.
-- Never claim a billing write has been applied unless confirmBillingAction succeeds.
-- If customer, plan, entity, subscription, or environment is ambiguous, ask a short clarifying question.
-- Keep responses concise. Use JSON only when it materially helps debugging.`;
-
-// To be added when we add axiom:
-// - For log investigations, start with narrow structured fields such as context.customer_id, context.org_slug, req.url, req.id, stripe_event.id, stripe_event.type, workflow.id, or workflow.name.
-// - For wide log windows, use a cheap aggregate query first, then focused <= 1 hour queries. Prefer ERROR/WARN levels first.
-// - Axiom queries are already scoped to the authenticated org and environment; do not add or mention separate org filters unless useful to explain the investigation.
-// - Axiom tools are read-only and must never be used as part of a billing confirmation or write flow.
-
-const createAgent = () =>
- new Agent({
- id: "autumn-ops",
- name: "Autumn Ops",
- description:
- "Answers Autumn customer, plan, and billing questions using controlled Autumn operations.",
- instructions,
- model,
- tools: createAgentAutumnOperationTools(),
- });
-
-const getAuth = (
- toolContext: Parameters<
- NonNullable["execute"]>
- >[1],
- defaultAuth?: AutumnMcpAuth,
-) => {
- try {
- return getAutumnAuth(toolContext);
- } catch (error) {
- if (defaultAuth) return defaultAuth;
- throw error;
- }
-};
-
-const getPendingAction = async (auth: AutumnMcpAuth) => {
- try {
- return await getLatestPendingAction(auth);
- } catch {
- return null;
- }
-};
-
-export const createAskAutumnTool = (defaultAuth?: AutumnMcpAuth) =>
- createTool({
- id: "ask_autumn",
- description:
- "Ask Autumn to look up customers/plans or safely preview and confirm billing changes.",
- inputSchema: z.object({
- message: z.string().min(1),
- context: z.record(z.string(), z.unknown()).optional(),
- }),
- mcp: {
- annotations: {
- title: "Ask Autumn",
- readOnlyHint: false,
- destructiveHint: true,
- idempotentHint: false,
- openWorldHint: false,
- },
- },
- execute: async ({ message, context }, toolContext) => {
- const auth = getAuth(toolContext, defaultAuth);
- const pendingAction = await getPendingAction(auth);
- const contextText = context
- ? `\n\nCaller context:\n${JSON.stringify(context, null, 2)}`
- : "";
- const pendingText = pendingAction
- ? `\n\nPending billing action:\nTool: ${pendingAction.toolName}\nPreview: ${pendingAction.preview}\nIf the user confirms this preview, call confirmBillingAction.`
- : "";
- const output = await createAgent().generate(message, {
- maxSteps: 8,
- requestContext: createRequestContext(auth),
- context: [
- {
- role: "system",
- content: `Current Autumn environment: ${auth.env}.${pendingText}${contextText}`,
- },
- ],
- });
-
- return output.text;
- },
- });
-
-export const askAutumnTool = createAskAutumnTool();
diff --git a/packages/mcp/src/mcp-server/agent/auth.ts b/packages/mcp/src/mcp-server/agent/auth.ts
deleted file mode 100644
index aba42a7ab..000000000
--- a/packages/mcp/src/mcp-server/agent/auth.ts
+++ /dev/null
@@ -1,53 +0,0 @@
-import { createHash } from "node:crypto";
-import { RequestContext } from "@mastra/core/request-context";
-import type { ToolExecutionContext } from "@mastra/core/tools";
-import type { OAuthEnvironment } from "../oauth.js";
-
-export type AutumnMcpAuth = {
- apiKey: string;
- env: OAuthEnvironment;
- principalId: string;
- resource: string;
- scopes: string[];
- orgId?: string | undefined;
- serverURL?: string | undefined;
- xApiVersion?: string | undefined;
- failOpen?: boolean | undefined;
-};
-
-type MaybeToolContext = Pick;
-
-const hash = (value: string) =>
- createHash("sha256").update(value).digest("hex").slice(0, 32);
-
-export const principalFromSecret = (kind: string, value: string) =>
- `${kind}:${hash(value)}`;
-
-export const createAutumnClient = (auth: AutumnMcpAuth) => ({
- baseUrl: auth.serverURL ?? "https://api.useautumn.com",
- headers: {
- Authorization: `Bearer ${auth.apiKey}`,
- "Content-Type": "application/json",
- Accept: "application/json",
- "x-api-version": auth.xApiVersion ?? "2.3.0",
- ...(auth.failOpen === undefined
- ? {}
- : { "fail-open": String(auth.failOpen) }),
- },
-});
-
-export const getAutumnAuth = (context?: MaybeToolContext): AutumnMcpAuth => {
- const direct = context?.mcp?.extra?.authInfo as AutumnMcpAuth | undefined;
- const nested = context?.requestContext?.get?.("mcp.extra") as
- | { authInfo?: AutumnMcpAuth }
- | undefined;
- const auth = direct ?? nested?.authInfo;
- if (!auth?.apiKey) throw new Error("Autumn MCP authentication is required.");
- return auth;
-};
-
-export const createRequestContext = (auth: AutumnMcpAuth) => {
- const requestContext = new RequestContext();
- requestContext.set("mcp.extra", { authInfo: auth });
- return requestContext;
-};
diff --git a/packages/mcp/src/mcp-server/agent/server.ts b/packages/mcp/src/mcp-server/agent/server.ts
deleted file mode 100644
index 3750e4c0b..000000000
--- a/packages/mcp/src/mcp-server/agent/server.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-import { MCPServer } from "@mastra/mcp";
-import { createAskAutumnTool } from "./ask-autumn.js";
-import type { AutumnMcpAuth } from "./auth.js";
-import { autumnMcpResources } from "./resources.js";
-import { createRawAutumnOperationTools } from "./tools.js";
-
-export const createAskAutumnMCPServer = (_opts?: {
- defaultAuth?: AutumnMcpAuth;
-}) =>
- new MCPServer({
- id: "autumn-internal-mcp",
- name: "Autumn Internal MCP",
- version: "0.0.1",
- description:
- "Ask Autumn to safely operate on customers, plans, and billing.",
- instructions:
- "Use ask_autumn for all Autumn work. Billing writes require preview and explicit user confirmation.",
- tools: {
- ask_autumn: createAskAutumnTool(_opts?.defaultAuth),
- },
- resources: autumnMcpResources,
- });
-
-export const createAutumnOperationsMCPServer = () =>
- new MCPServer({
- id: "autumn-mcp",
- name: "Autumn MCP",
- version: "0.0.1",
- description: "Operate on Autumn customers, plans, and billing.",
- instructions:
- "Use preview tools before billing writes. Write tools are destructive and should only be called after explicit user confirmation.",
- tools: createRawAutumnOperationTools(),
- resources: autumnMcpResources,
- });
-
-export const createMCPServer = createAskAutumnMCPServer;
diff --git a/packages/mcp/src/mcp-server/agent/tools.ts b/packages/mcp/src/mcp-server/agent/tools.ts
deleted file mode 100644
index b3ae76f65..000000000
--- a/packages/mcp/src/mcp-server/agent/tools.ts
+++ /dev/null
@@ -1,554 +0,0 @@
-import {
- AttachParamsV1Schema,
- CreateBalanceParamsV0Schema,
- CreateCustomerParamsV1Schema,
- CreatePlanParamsV2Schema,
- CreateSchedulePhaseSchema,
- CreateScheduleParamsV0Schema,
- GetCustomerParamsV1Schema,
- GetPlanParamsV0Schema,
- ListCustomersV2_3ParamsSchema,
- ListPlanParamsSchema,
- UpdateSubscriptionV1ParamsSchema,
-} from "@autumn/shared/publicApiSchemas";
-import { createTool } from "@mastra/core/tools";
-import { isValid, parseISO } from "date-fns";
-import * as z from "zod/v4";
-import { createAutumnClient, getAutumnAuth } from "./auth.js";
-import {
- claimLatestPendingAction,
- createPendingAction,
-} from "./pending-actions.js";
-
-type ToolContext = Parameters<
- NonNullable["execute"]>
->[1];
-type ConfirmedWriteToolName =
- | "attach"
- | "updateSubscription"
- | "createPlan"
- | "createSchedule"
- | "createBalance";
-type OperationToolConfig = {
- id: string;
- description: string;
- schema: z.ZodType;
- endpoint: string;
- destructive?: boolean;
- idempotent?: boolean;
-};
-type BillingPreviewToolConfig = {
- id: string;
- description: string;
- schema: z.ZodType;
- previewEndpoint: string;
- writeToolName: ConfirmedWriteToolName;
-};
-type LocalPreviewToolConfig = {
- id: string;
- description: string;
- schema: z.ZodType;
- writeToolName: ConfirmedWriteToolName;
- preview: (request: unknown) => unknown;
-};
-
-export const endpointByTool = {
- listCustomers: "/v1/customers.list",
- createCustomer: "/v1/customers.get_or_create",
- getCustomer: "/v1/customers.get",
- listPlans: "/v1/plans.list",
- createPlan: "/v1/plans.create",
- getPlan: "/v1/plans.get",
- previewAttach: "/v1/billing.preview_attach",
- attach: "/v1/billing.attach",
- previewUpdateSubscription: "/v1/billing.preview_update",
- updateSubscription: "/v1/billing.update",
- previewCreateSchedule: "/v1/billing.preview_create_schedule",
- createSchedule: "/v1/billing.create_schedule",
- createBalance: "/v1/balances.create",
-} as const;
-
-const epochMillisecondsSchema = z
- .union([z.number(), z.string()])
- .transform((value, context) => {
- if (typeof value === "number") {
- if (Number.isFinite(value)) return value;
- } else {
- const normalized = /^\d{4}-\d{2}-\d{2}$/.test(value)
- ? `${value}T00:00:00.000Z`
- : value;
- const hasExplicitZone = /(?:z|[+-]\d{2}:?\d{2})$/i.test(normalized);
- const parsed = parseISO(hasExplicitZone ? normalized : `${normalized}Z`);
- if (isValid(parsed)) return parsed.getTime();
- }
-
- context.addIssue({
- code: "custom",
- message:
- "Expected epoch milliseconds or an ISO date/timestamp string.",
- });
- return z.NEVER;
- });
-
-const createSchedulePhaseMcpSchema = CreateSchedulePhaseSchema.extend({
- starts_at: epochMillisecondsSchema.meta({
- description:
- "Phase start time as epoch milliseconds or an ISO date string. Date-only values use midnight UTC.",
- }),
-});
-
-const createScheduleMcpSchema = CreateScheduleParamsV0Schema.extend({
- phases: z.tuple([createSchedulePhaseMcpSchema]).rest(
- createSchedulePhaseMcpSchema,
- ),
-});
-
-const createBalanceMcpSchema = CreateBalanceParamsV0Schema.extend({
- expires_at: epochMillisecondsSchema.optional().meta({
- description:
- "Expiry time as epoch milliseconds or an ISO date string. Date-only values use midnight UTC.",
- }),
-});
-
-const listCustomersMcpSchema = ListCustomersV2_3ParamsSchema.extend({
- limit: z
- .preprocess(
- (value) => (typeof value === "number" && value > 1000 ? 1000 : value),
- z.number().int().positive().max(1000).optional(),
- )
- .meta({ description: "Maximum customers per page. Max 1000." }),
-});
-
-const writeSchemaByTool = {
- attach: AttachParamsV1Schema,
- updateSubscription: UpdateSubscriptionV1ParamsSchema,
- createPlan: CreatePlanParamsV2Schema,
- createSchedule: createScheduleMcpSchema,
- createBalance: createBalanceMcpSchema,
-} as const satisfies Record;
-
-export const schemaByTool = {
- listCustomers: listCustomersMcpSchema,
- createCustomer: CreateCustomerParamsV1Schema,
- getCustomer: GetCustomerParamsV1Schema,
- listPlans: ListPlanParamsSchema,
- createPlan: CreatePlanParamsV2Schema,
- getPlan: GetPlanParamsV0Schema,
- previewAttach: AttachParamsV1Schema,
- attach: AttachParamsV1Schema,
- previewUpdateSubscription: UpdateSubscriptionV1ParamsSchema,
- updateSubscription: UpdateSubscriptionV1ParamsSchema,
- previewCreateSchedule: createScheduleMcpSchema,
- createSchedule: createScheduleMcpSchema,
- previewCreateBalance: createBalanceMcpSchema,
- createBalance: createBalanceMcpSchema,
-} as const satisfies Record<
- keyof typeof endpointByTool | "previewCreateBalance",
- z.ZodType
->;
-
-const toolConfigs: OperationToolConfig[] = [
- {
- id: "listCustomers",
- description:
- "List Autumn customers. Use search, plans, subscription_status, and processors filters for customer-heavy queries. limit max is 1000. For queued/upcoming plan version queries, use subscription_status scheduled and omit the earliest matching version unless the user asks for all historical versions (versions 1,2,3 -> filter 2,3). 'live', 'paying', and active subscribers usually mean subscription_status active. When a plan is named, include the plans filter instead of listing broad customer sets. If listPlans returned matching versions, pass only relevant versions in plans[].versions, never guessed versions. For every/all/complete requests, paginate by calling again with start_cursor set to the previous response's next_cursor until next_cursor is empty.",
- schema: listCustomersMcpSchema,
- endpoint: endpointByTool.listCustomers,
- },
- {
- id: "createCustomer",
- description:
- "Create an Autumn customer, or return the existing customer with the same id. Use when the user explicitly wants a customer record created.",
- schema: CreateCustomerParamsV1Schema,
- endpoint: endpointByTool.createCustomer,
- idempotent: true,
- },
- {
- id: "getCustomer",
- description: "Fetch one Autumn customer by id.",
- schema: GetCustomerParamsV1Schema,
- endpoint: endpointByTool.getCustomer,
- },
- {
- id: "listPlans",
- description:
- "List Autumn plans. This is usually a cheap full scan; filter returned plans locally and use matching id/version pairs before customer queries based on plan attributes.",
- schema: ListPlanParamsSchema,
- endpoint: endpointByTool.listPlans,
- },
- {
- id: "createPlan",
- description:
- "Create an Autumn plan. Destructive configuration write: gather plan_id, name, price, features/items, trials, and confirmation before running.",
- schema: CreatePlanParamsV2Schema,
- endpoint: endpointByTool.createPlan,
- destructive: true,
- },
- {
- id: "createBalance",
- description:
- "Create a standalone customer balance grant. Use when a user asks to give, add, grant, or provision credits/balance to a customer or entity. Destructive: preview first; use entity_id for entity-scoped credits, included_grant for the grant amount, expires_at for expiring grants, and omit reset when using expires_at. For relative expiries like '2 months', use calendar months, not a 30-day approximation. expires_at accepts epoch milliseconds or ISO/date strings.",
- schema: createBalanceMcpSchema,
- endpoint: endpointByTool.createBalance,
- destructive: true,
- },
- {
- id: "getPlan",
- description: "Fetch one Autumn plan by id and optional version.",
- schema: GetPlanParamsV0Schema,
- endpoint: endpointByTool.getPlan,
- },
-];
-
-const localPreviewConfigs: LocalPreviewToolConfig[] = [
- {
- id: "previewCreateBalance",
- description:
- "Preview a standalone balance grant before createBalance. Use when a user asks to give, add, grant, or provision credits/balance to a customer or entity. Use for one-time credit grants, referral/promotional credits, and entity-scoped credits. Does not mutate Autumn. For relative expiries like '2 months', use calendar months. expires_at accepts epoch milliseconds or ISO/date strings.",
- schema: createBalanceMcpSchema,
- writeToolName: "createBalance",
- preview: (request) => ({
- action: "createBalance",
- request,
- impact:
- "Creates a standalone balance grant. If entity_id is present, the balance is scoped to that entity. If expires_at is present, the grant expires at that timestamp.",
- }),
- },
-];
-
-const billingPreviewConfigs: BillingPreviewToolConfig[] = [
- {
- id: "previewAttach",
- description:
- "Preview attaching a plan before attach. Include feature_quantities and custom items/prices; map recurring custom grants like 'per month/year' to reset.interval.",
- schema: AttachParamsV1Schema,
- previewEndpoint: endpointByTool.previewAttach,
- writeToolName: "attach",
- },
- {
- id: "previewUpdateSubscription",
- description:
- "Preview updating a subscription before updateSubscription. Include quantity/custom item changes; recurring custom grants need reset.interval.",
- schema: UpdateSubscriptionV1ParamsSchema,
- previewEndpoint: endpointByTool.previewUpdateSubscription,
- writeToolName: "updateSubscription",
- },
- {
- id: "previewCreateSchedule",
- description:
- "Preview billing impact of a multi-phase schedule before createSchedule. starts_at accepts epoch milliseconds or ISO/date strings; preserve exact calendar dates from the user or contract. Use redirect_mode if_required unless the user explicitly asks to disable checkout/redirects. If changing an existing/customer contract schedule, inspect the customer first. For schedules, put phase-specific feature quantities and contract feature limits/overrides in plan.customize.items, not feature_quantities; map 'per month/year' to reset.interval month/year. If the user says year 1 is already paid or should have no billing changes, do not add a year-1 phase; start phases at the first future billing change.",
- schema: createScheduleMcpSchema,
- previewEndpoint: endpointByTool.previewCreateSchedule,
- writeToolName: "createSchedule",
- },
-];
-
-const confirmedWriteConfigs: OperationToolConfig[] = [
- {
- id: "attach",
- description:
- "Attach a plan to a customer. Destructive: preview first; preserve feature_quantities, custom prices/items, reset intervals, discounts, and checkout behavior.",
- schema: AttachParamsV1Schema,
- endpoint: endpointByTool.attach,
- destructive: true,
- },
- {
- id: "updateSubscription",
- description:
- "Update a subscription. Destructive: preview first; preserve quantity/custom item changes and reset intervals from the previewed request.",
- schema: UpdateSubscriptionV1ParamsSchema,
- endpoint: endpointByTool.updateSubscription,
- destructive: true,
- },
- {
- id: "createSchedule",
- description:
- "Create a multi-phase billing schedule. Destructive: preview first; preserve phase starts_at and redirect_mode values from the previewed request. Use redirect_mode if_required unless the user explicitly asks to disable checkout/redirects. If changing an existing/customer contract schedule, inspect the customer first. For schedules, put phase-specific feature quantities and contract feature limits/overrides in plan.customize.items, not feature_quantities. If year 1 is already paid/no billing changes, do not add a year-1 phase; start at the first future billing change.",
- schema: createScheduleMcpSchema,
- endpoint: endpointByTool.createSchedule,
- destructive: true,
- },
-];
-
-export const dateToEpochMillisecondsTool = createTool({
- id: "dateToEpochMilliseconds",
- description:
- "Convert a calendar date or ISO timestamp to UTC epoch milliseconds for API timestamp fields. Date-only values default to midnight UTC; include an explicit offset in the date string when timezone matters.",
- inputSchema: z
- .object({
- date: z.string(),
- })
- .strict(),
- execute: async ({ date }) => toEpochMilliseconds(date),
-});
-
-const toEpochMilliseconds = (date: string) => {
- const normalized = /^\d{4}-\d{2}-\d{2}$/.test(date)
- ? `${date}T00:00:00.000`
- : date;
- const hasExplicitZone = /(?:z|[+-]\d{2}:?\d{2})$/i.test(normalized);
- const parsed = parseISO(hasExplicitZone ? normalized : `${normalized}Z`);
-
- if (!isValid(parsed)) throw new Error(`Invalid date: ${date}`);
- return parsed.getTime();
-};
-
-const callAutumn = async ({
- context,
- endpoint,
- request,
-}: {
- context?: ToolContext;
- endpoint: string;
- request: unknown;
-}) => {
- const auth = getAutumnAuth(context);
- const client = createAutumnClient(auth);
- const init: RequestInit = {
- method: "POST",
- headers: client.headers,
- body: JSON.stringify(request),
- };
- if (context?.mcp?.extra?.signal) init.signal = context.mcp.extra.signal;
- const response = await fetch(new URL(endpoint, client.baseUrl), init);
- const text = await response.text();
- const body = text ? parseBody(text) : null;
- if (!response.ok) {
- throw new Error(
- `Autumn API request failed (${response.status}): ${typeof body === "string" ? body : JSON.stringify(body)}`,
- );
- }
- return body;
-};
-
-const parseBody = (text: string): unknown => {
- try {
- return JSON.parse(text);
- } catch {
- return text;
- }
-};
-const logTool = (event: string, data: Record) => {
- if (process.env.MCP_DEBUG_PENDING_ACTIONS !== "1") return;
- console.log(`[mcp:agent-tools] ${event} ${JSON.stringify(data)}`);
-};
-
-const mcpAnnotations = (destructive = false, idempotent = false) => ({
- readOnlyHint: !destructive && !idempotent,
- destructiveHint: destructive,
- idempotentHint: idempotent,
- openWorldHint: false,
-});
-
-const toTools = (
- configs: Config[],
- create: (config: Config) => ReturnType,
-) => Object.fromEntries(configs.map((config) => [config.id, create(config)]));
-
-const operationTool = ({
- id,
- description,
- schema,
- endpoint,
- destructive = false,
- idempotent = false,
-}: OperationToolConfig) =>
- createTool({
- id,
- description,
- inputSchema: z.object({ request: schema }).strict(),
- mcp: {
- annotations: mcpAnnotations(destructive, idempotent),
- },
- execute: (input, context) =>
- callAutumn({
- context,
- endpoint,
- request: schema.parse((input as { request: unknown }).request),
- }),
- });
-
-const agentBillingPreviewTool = ({
- id,
- description,
- schema,
- previewEndpoint,
- writeToolName,
-}: {
- id: string;
- description: string;
- schema: z.ZodType;
- previewEndpoint: string;
- writeToolName: ConfirmedWriteToolName;
-}) =>
- createTool({
- id,
- description: `${description} Store the exact pending billing action for later confirmation.`,
- inputSchema: z.object({ request: schema }).strict(),
- mcp: {
- annotations: mcpAnnotations(),
- },
- execute: async (input, context) => {
- const request = (input as { request: unknown }).request;
- const parsedRequest = schema.parse(request);
- const auth = getAutumnAuth(context);
- logTool("preview-start", { previewTool: id, writeToolName });
- const preview = await callAutumn({
- context,
- endpoint: previewEndpoint,
- request: parsedRequest,
- });
- await createPendingAction({
- auth,
- toolName: writeToolName,
- request: parsedRequest,
- preview: JSON.stringify(preview),
- });
- logTool("preview-stored", { previewTool: id, writeToolName });
- return {
- preview,
- pending: true,
- message:
- "Preview ready. Ask the user to explicitly apply or approve this exact change.",
- };
- },
- });
-
-const rawLocalPreviewTool = ({
- id,
- description,
- schema,
- preview,
-}: LocalPreviewToolConfig) =>
- createTool({
- id,
- description,
- inputSchema: z.object({ request: schema }).strict(),
- mcp: {
- annotations: mcpAnnotations(),
- },
- execute: async (input) =>
- preview(schema.parse((input as { request: unknown }).request)),
- });
-
-const agentLocalPreviewTool = ({
- id,
- description,
- schema,
- writeToolName,
- preview,
-}: LocalPreviewToolConfig) =>
- createTool({
- id,
- description: `${description} Store the exact pending billing action for later confirmation.`,
- inputSchema: z.object({ request: schema }).strict(),
- mcp: {
- annotations: mcpAnnotations(),
- },
- execute: async (input, context) => {
- const request = (input as { request: unknown }).request;
- const parsedRequest = schema.parse(request);
- const previewResult = preview(parsedRequest);
- await createPendingAction({
- auth: getAutumnAuth(context),
- toolName: writeToolName,
- request: parsedRequest,
- preview: JSON.stringify(previewResult),
- });
- return {
- preview: previewResult,
- pending: true,
- message:
- "Preview ready. Ask the user to explicitly apply or approve this exact change.",
- };
- },
- });
-
-const agentPendingWriteTool = ({
- id,
- description,
- schema,
-}: OperationToolConfig) =>
- createTool({
- id,
- description: `${description} This internal agent tool stores the exact request for later confirmation instead of applying it immediately.`,
- inputSchema: z.object({ request: schema }).strict(),
- mcp: {
- annotations: mcpAnnotations(),
- },
- execute: async (input, context) => {
- const request = (input as { request: unknown }).request;
- const parsedRequest = schema.parse(request);
- await createPendingAction({
- auth: getAutumnAuth(context),
- toolName: id as ConfirmedWriteToolName,
- request: parsedRequest,
- preview: JSON.stringify(parsedRequest),
- });
- return {
- pending: true,
- request: parsedRequest,
- message:
- "Request ready. Ask the user to explicitly apply or approve this exact change.",
- };
- },
- });
-
-export const createRawAutumnOperationTools = () => ({
- ...toTools(toolConfigs, operationTool),
- ...toTools(billingPreviewConfigs, (config) =>
- operationTool({ ...config, endpoint: config.previewEndpoint }),
- ),
- ...toTools(localPreviewConfigs, rawLocalPreviewTool),
- ...toTools(confirmedWriteConfigs, operationTool),
-});
-
-export const createAgentAutumnOperationTools = () => ({
- ...toTools(
- toolConfigs.filter(({ destructive }) => !destructive),
- operationTool,
- ),
- ...toTools(
- toolConfigs.filter(({ destructive }) => destructive),
- agentPendingWriteTool,
- ),
- ...toTools(billingPreviewConfigs, agentBillingPreviewTool),
- ...toTools(localPreviewConfigs, agentLocalPreviewTool),
- dateToEpochMilliseconds: dateToEpochMillisecondsTool,
- confirmBillingAction: createTool({
- id: "confirmBillingAction",
- description:
- "Apply the latest pending billing action after the user semantically confirms the preview.",
- inputSchema: z.object({}).strict(),
- execute: async (_input, context) => {
- const auth = getAutumnAuth(context);
- logTool("confirm-start", { env: auth.env });
- const action = await claimLatestPendingAction(auth);
- logTool("confirm-claimed", { toolName: action.toolName });
- const result = await executeConfirmedBillingAction({
- auth,
- toolName: action.toolName,
- request: action.request,
- });
- return {
- message: `Confirmed and applied ${action.toolName}.`,
- result,
- };
- },
- }),
-});
-
-export const executeConfirmedBillingAction = async ({
- auth,
- toolName,
- request,
-}: {
- auth: ReturnType;
- toolName: ConfirmedWriteToolName;
- request: unknown;
-}) =>
- callAutumn({
- context: { mcp: { extra: { authInfo: auth } } } as never,
- endpoint: endpointByTool[toolName],
- request: writeSchemaByTool[toolName].parse(request),
- });
diff --git a/packages/mcp/src/mcp-server/oauth.ts b/packages/mcp/src/mcp-server/oauth.ts
deleted file mode 100644
index 95291fd9c..000000000
--- a/packages/mcp/src/mcp-server/oauth.ts
+++ /dev/null
@@ -1,333 +0,0 @@
-import { type ScopeString, Scopes } from "@autumn/shared/scopeDefinitions";
-import { ms } from "@autumn/shared/unixUtils";
-import { addMilliseconds, isFuture } from "date-fns";
-import * as z from "zod/v4";
-import type { AutumnMcpAuth } from "./agent/auth.js";
-import { principalFromSecret } from "./agent/auth.js";
-import type { ConsoleLogger } from "./console-logger.js";
-import type { MCPServerFlags } from "./flags.js";
-
-export const MCP_OAUTH_SCOPES = [
- Scopes.Customers.Read,
- Scopes.Customers.Write,
- Scopes.Plans.Read,
- Scopes.Plans.Write,
- Scopes.Billing.Read,
- Scopes.Billing.Write,
- Scopes.Balances.Write,
- Scopes.Analytics.Read,
-] as const satisfies readonly ScopeString[];
-
-const environmentSchema = z.enum(["sandbox", "live"]);
-const xApiVersionSchema = z.string().default("2.3.0");
-const failOpenSchema = z
- .union([
- z.boolean(),
- z.enum(["true", "false"]).transform((v) => v === "true"),
- ])
- .default(true);
-const secretKeySchema = z.string().min(1).optional();
-const tokenExchangeSchema = z.object({
- sandbox_key: z.string().optional(),
- prod_key: z.string().optional(),
- org_id: z.string().optional(),
- user_id: z.string().optional(),
- client_id: z.string().optional(),
- scopes: z.array(z.string()).optional(),
-});
-
-export type OAuthEnvironment = z.infer;
-
-export interface MCPOAuthFlags extends MCPServerFlags {
- readonly "oauth-enabled"?: boolean | undefined;
- readonly "oauth-environment"?: OAuthEnvironment | undefined;
-}
-
-export class OAuthHttpError extends Error {
- constructor(
- readonly status: number,
- message: string,
- readonly error = "invalid_token",
- readonly wwwAuthenticate?: string,
- ) {
- super(message);
- }
-}
-
-const apiKeyCache = new Map<
- string,
- {
- key: string;
- orgId?: string | undefined;
- userId?: string | undefined;
- clientId?: string | undefined;
- scopes?: string[] | undefined;
- expiresAt: Date;
- }
->();
-
-function trimTrailingSlash(url: string): string {
- return url.endsWith("/") ? url.slice(0, -1) : url;
-}
-
-export function getResourceUrl(
- headers: Headers,
- _flags: MCPOAuthFlags,
- resourcePath = "/mcp",
-): string {
- const host =
- headers.get("x-autumn-forwarded-host") ??
- headers.get("x-forwarded-host") ??
- headers.get("host");
- if (!host) {
- throw new OAuthHttpError(400, "Missing Host header", "invalid_request");
- }
-
- const proto =
- headers.get("x-autumn-forwarded-proto") ??
- headers.get("x-forwarded-proto") ??
- "http";
- return new URL(resourcePath, `${proto}://${host}`).href;
-}
-
-export function getProtectedResourceMetadataUrl(resourceUrl: string): string {
- const url = new URL(resourceUrl);
- const path = url.pathname === "/" ? "" : url.pathname;
- return new URL(`/.well-known/oauth-protected-resource${path}`, url).href;
-}
-
-function getIssuerUrl(flags: MCPOAuthFlags): string {
- return trimTrailingSlash(
- new URL("/api/auth", flags["server-url"] ?? "https://api.useautumn.com")
- .href,
- );
-}
-
-function getApiKeyUrl(flags: MCPOAuthFlags): string {
- return new URL("/cli/api-keys", getIssuerUrl(flags)).href;
-}
-
-function getWWWAuthenticate(resourceUrl: string, error?: string): string {
- const params = [
- `resource_metadata="${getProtectedResourceMetadataUrl(resourceUrl)}"`,
- ];
- if (error) params.push(`error="${error}"`);
- return `Bearer ${params.join(", ")}`;
-}
-
-export function getProtectedResourceMetadata(
- headers: Headers,
- flags: MCPOAuthFlags,
- resourcePath = "/mcp",
-) {
- const resource = getResourceUrl(headers, flags, resourcePath);
- return {
- resource,
- authorization_servers: [getIssuerUrl(flags)],
- scopes_supported: [...MCP_OAUTH_SCOPES],
- bearer_methods_supported: ["header"],
- resource_name: "Autumn MCP",
- };
-}
-
-export function getAuthorizationServerMetadata(flags: MCPOAuthFlags) {
- const issuer = getIssuerUrl(flags);
- return {
- issuer,
- authorization_endpoint: `${issuer}/oauth2/authorize`,
- token_endpoint: `${issuer}/oauth2/token`,
- registration_endpoint: `${issuer}/oauth2/register`,
- revocation_endpoint: `${issuer}/oauth2/revoke`,
- introspection_endpoint: `${issuer}/oauth2/introspect`,
- response_types_supported: ["code"],
- grant_types_supported: ["authorization_code", "refresh_token"],
- token_endpoint_auth_methods_supported: [
- "client_secret_post",
- "client_secret_basic",
- "none",
- ],
- code_challenge_methods_supported: ["S256"],
- scopes_supported: [...MCP_OAUTH_SCOPES],
- };
-}
-
-function getEnvironment(
- headers: Headers,
- flags: MCPOAuthFlags,
-): OAuthEnvironment {
- const value =
- headers.get("x-autumn-environment") ??
- flags["oauth-environment"] ??
- "sandbox";
- const parsed = environmentSchema.safeParse(value);
- if (parsed.success) return parsed.data;
-
- throw new OAuthHttpError(
- 400,
- "Invalid x-autumn-environment",
- "invalid_request",
- );
-}
-
-function parseRequestOption(
- value: unknown,
- schema: z.ZodType,
- message: string,
-): T {
- const parsed = schema.safeParse(value);
- if (parsed.success) return parsed.data;
-
- throw new OAuthHttpError(400, message, "invalid_request");
-}
-
-async function exchangeOAuthToken(
- headers: Headers,
- flags: MCPOAuthFlags,
- resource: string,
- token: string,
-): Promise<{
- key: string;
- orgId?: string | undefined;
- userId?: string | undefined;
- clientId?: string | undefined;
- scopes?: string[];
-}> {
- const env = getEnvironment(headers, flags);
- const cacheKey = `${token}:${resource}:${env}`;
- const cached = apiKeyCache.get(cacheKey);
- if (cached && isFuture(cached.expiresAt)) return cached;
-
- const response = await fetch(getApiKeyUrl(flags), {
- method: "POST",
- headers: {
- Authorization: `Bearer ${token}`,
- "Content-Type": "application/json",
- },
- body: JSON.stringify({ resource, scopes: MCP_OAUTH_SCOPES }),
- });
-
- if (!response.ok) {
- throw new OAuthHttpError(
- response.status === 403 ? 403 : 401,
- await response.text(),
- response.status === 403 ? "insufficient_scope" : "invalid_token",
- response.status === 403
- ? undefined
- : getWWWAuthenticate(resource, "invalid_token"),
- );
- }
-
- const data = tokenExchangeSchema.parse(await response.json());
- const key = env === "live" ? data.prod_key : data.sandbox_key;
- if (!key) {
- throw new OAuthHttpError(
- 502,
- "OAuth key exchange did not return an API key",
- );
- }
-
- const exchanged = {
- key,
- orgId: data.org_id,
- userId: data.user_id,
- clientId: data.client_id,
- scopes: data.scopes,
- expiresAt: addMilliseconds(new Date(), ms.minutes(1)),
- };
- apiKeyCache.set(cacheKey, exchanged);
- return exchanged;
-}
-
-function getOAuthPrincipalId(
- token: string,
- exchanged: Awaited>,
-) {
- if (!exchanged.orgId) return principalFromSecret("oauth", token);
-
- return [
- "oauth",
- exchanged.orgId,
- exchanged.userId ?? "unknown-user",
- exchanged.clientId ?? "unknown-client",
- ].join(":");
-}
-
-function getStaticApiKey(headers: Headers, flags: MCPOAuthFlags) {
- const secretKey = headers.get("secret-key");
- if (secretKey) return secretKey;
-
- const authorization = headers.get("authorization");
- const bearer = authorization?.startsWith("Bearer ")
- ? authorization.slice("Bearer ".length)
- : undefined;
- if (bearer?.startsWith("am_")) return bearer;
-
- return flags["oauth-enabled"] ? undefined : flags["secret-key"];
-}
-
-export async function buildAuthForRequest(
- headers: Headers,
- flags: MCPOAuthFlags,
- logger: ConsoleLogger,
- resourcePath = "/mcp",
-): Promise {
- const env = getEnvironment(headers, flags);
- const resource = getResourceUrl(headers, flags, resourcePath);
- const xApiVersion = parseRequestOption(
- headers.get("x-api-version") ?? flags["x-api-version"],
- xApiVersionSchema,
- "Invalid x-api-version",
- );
- const failOpen = parseRequestOption(
- headers.get("fail-open") ?? flags["fail-open"],
- failOpenSchema,
- "Invalid fail-open",
- );
- const apiKey = parseRequestOption(
- getStaticApiKey(headers, flags),
- secretKeySchema,
- "Invalid secret-key",
- );
-
- if (apiKey) {
- return {
- apiKey,
- env,
- resource,
- principalId: principalFromSecret("secret-key", apiKey),
- scopes: [...MCP_OAUTH_SCOPES],
- serverURL: flags["server-url"],
- xApiVersion,
- failOpen,
- };
- }
-
- if (flags["oauth-enabled"]) {
- const authHeader = headers.get("authorization");
- if (!authHeader?.startsWith("Bearer ")) {
- throw new OAuthHttpError(
- 401,
- "Missing Authorization bearer token",
- "invalid_token",
- getWWWAuthenticate(resource),
- );
- }
-
- const token = authHeader.slice("Bearer ".length);
- const exchanged = await exchangeOAuthToken(headers, flags, resource, token);
- return {
- apiKey: exchanged.key,
- env,
- resource,
- principalId: getOAuthPrincipalId(token, exchanged),
- scopes: exchanged.scopes ?? [...MCP_OAUTH_SCOPES],
- orgId: exchanged.orgId,
- serverURL: flags["server-url"],
- xApiVersion,
- failOpen,
- };
- }
-
- logger.warning("Missing secret-key for MCP request");
- throw new OAuthHttpError(401, "Missing secret-key", "invalid_token");
-}
diff --git a/packages/mcp/src/mcp-server/agent/resources.ts b/packages/mcp/src/resources/index.ts
similarity index 91%
rename from packages/mcp/src/mcp-server/agent/resources.ts
rename to packages/mcp/src/resources/index.ts
index 79140f969..46498306c 100644
--- a/packages/mcp/src/mcp-server/agent/resources.ts
+++ b/packages/mcp/src/resources/index.ts
@@ -1,7 +1,28 @@
import type { MCPServerResources } from "@mastra/mcp";
-const docs = {
- "autumn://docs/tool-composition": {
+type DocInput = {
+ name: string;
+ title: string;
+ description: string;
+ text: string;
+};
+
+/**
+ * Builds a single Autumn docs resource. The `autumn://docs/` URI is
+ * derived from `name` so each doc is declared once, with no duplicated key.
+ */
+const defineDoc = ({ name, title, description, text }: DocInput) => ({
+ uri: `autumn://docs/${name}`,
+ name,
+ title,
+ description,
+ text,
+});
+
+type Doc = ReturnType;
+
+const docs: Doc[] = [
+ defineDoc({
name: "tool-composition",
title: "Tool Composition",
description: "How to compose Autumn MCP tools for operational questions.",
@@ -21,8 +42,8 @@ Use Autumn tools as composable primitives.
- For billing writes, always preview first and wait for explicit user confirmation before applying.
Docs index: https://docs.useautumn.com/llms.txt`,
- },
- "autumn://docs/querying-plans": {
+ }),
+ defineDoc({
name: "querying-plans",
title: "Querying Plans",
description: "How to answer plan-filtering questions with listPlans.",
@@ -39,8 +60,8 @@ Use listPlans for questions about:
- plan features and included quantities
Filter the returned plans locally. If the user asks for customers on matching plans, first resolve the matching plans, then call listCustomers with those plan ids. For upcoming, queued, or scheduled version queries, pass only the relevant target versions to listCustomers; with numeric versions, exclude the earliest historical version unless the user asks for all historical versions.`,
- },
- "autumn://docs/creating-plans": {
+ }),
+ defineDoc({
name: "creating-plans",
title: "Creating Plans",
description: "How to gather plan details before using createPlan.",
@@ -59,8 +80,8 @@ Before creating a plan, resolve:
For consumable features, recurring grants need reset intervals. "500 credits per month" means included 500 with reset.interval "month"; one-time grants use "one_off".
If any required pricing or feature detail is ambiguous, ask a concise clarification question before creating the plan.`,
- },
- "autumn://docs/querying-customers": {
+ }),
+ defineDoc({
name: "querying-customers",
title: "Querying Customers",
description: "How to answer customer-heavy questions with listCustomers.",
@@ -76,8 +97,8 @@ Prefer server-side filters before local filtering:
Use limit 1000 for broad scans; that is the maximum page size.
Always paginate until next_cursor is empty when the user asks for complete results. Use getCustomer only for details not returned by listCustomers.`,
- },
- "autumn://docs/schedules": {
+ }),
+ defineDoc({
name: "schedules",
title: "Billing Schedules",
description: "How to create multi-phase billing schedules safely.",
@@ -101,8 +122,8 @@ Custom feature mapping:
- Omit reset only for non-consumable, unlimited, or clearly one-time grants.
There is no separate public update-schedule tool. For existing subscription changes, use previewUpdateSubscription and updateSubscription when the requested change fits that endpoint. For a new multi-phase transition, call previewCreateSchedule first, show the immediate billing impact and ordered phases, then call createSchedule only after explicit confirmation.`,
- },
- "autumn://docs/balances": {
+ }),
+ defineDoc({
name: "balances",
title: "Standalone Balances",
description:
@@ -133,8 +154,8 @@ Useful docs:
- https://docs.useautumn.com/documentation/customers/balances
- https://docs.useautumn.com/documentation/modelling-pricing/sub-entity-balances
- https://docs.useautumn.com/api-reference/balances/createBalance`,
- },
- "autumn://docs/billing-safety": {
+ }),
+ defineDoc({
name: "billing-safety",
title: "Billing Safety",
description: "Preview-first rules for Autumn billing changes.",
@@ -157,13 +178,15 @@ Useful docs:
- https://docs.useautumn.com/api-reference/billing/attach
- https://docs.useautumn.com/documentation/concepts/plan-items
- https://docs.useautumn.com/documentation/customers/balances`,
- },
-} as const;
+ }),
+];
+
+const docByUri = new Map(docs.map((doc) => [doc.uri, doc]));
export const autumnMcpResources: MCPServerResources = {
listResources: async () =>
- Object.entries(docs).map(([uri, doc]) => ({
- uri,
+ docs.map((doc) => ({
+ uri: doc.uri,
name: doc.name,
title: doc.title,
description: doc.description,
@@ -175,13 +198,12 @@ export const autumnMcpResources: MCPServerResources = {
},
})),
getResourceContent: async ({ uri }) => {
- if (!Object.hasOwn(docs, uri)) {
+ const doc = docByUri.get(uri);
+ if (!doc) {
throw new Error(`Unknown Autumn MCP resource: ${uri}`);
}
-
- const doc = docs[uri as keyof typeof docs];
return { text: doc.text };
},
};
-export const autumnMcpResourceUris = Object.keys(docs);
+export const autumnMcpResourceUris = docs.map((doc) => doc.uri);
diff --git a/packages/mcp/src/server/auth/auth.ts b/packages/mcp/src/server/auth/auth.ts
new file mode 100644
index 000000000..c17bc8e61
--- /dev/null
+++ b/packages/mcp/src/server/auth/auth.ts
@@ -0,0 +1,85 @@
+import { RequestContext } from "@mastra/core/request-context";
+import * as z from "zod/v4";
+import {
+ DEFAULT_API_VERSION,
+ DEFAULT_AUTUMN_API_URL,
+} from "../../constants.js";
+
+export const environmentSchema = z.enum(["sandbox", "live"]);
+export type OAuthEnvironment = z.infer;
+
+/**
+ * Authenticated Autumn identity attached to every MCP request. Defined as a zod
+ * schema so the same definition both types the value and validates it when read
+ * back from the (loosely-typed) MCP execution context — no casts required.
+ */
+export const autumnMcpAuthSchema = z.object({
+ apiKey: z.string().min(1),
+ authMethod: z.enum(["secret-key", "oauth"]).optional(),
+ env: environmentSchema,
+ principalId: z.string(),
+ resource: z.string(),
+ scopes: z.array(z.string()),
+ orgId: z.string().optional(),
+ serverURL: z.string().optional(),
+ xApiVersion: z.string().optional(),
+ failOpen: z.boolean().optional(),
+});
+
+export type AutumnMcpAuth = z.infer;
+
+/**
+ * Minimal structural view of the MCP tool execution context we read auth from.
+ * Kept intentionally loose so any Mastra `ToolExecutionContext` satisfies it
+ * without callers having to cast.
+ */
+type AuthContext = {
+ mcp?: { extra?: { authInfo?: unknown } | undefined } | undefined;
+ requestContext?: { get?: (key: string) => unknown } | undefined;
+};
+
+/** Reads `mcp.extra.authInfo` back out of a serialized request context. */
+const readNestedAuthInfo = (
+ requestContext: AuthContext["requestContext"],
+): unknown => {
+ const extra = requestContext?.get?.("mcp.extra");
+ if (typeof extra === "object" && extra !== null && "authInfo" in extra) {
+ return extra.authInfo;
+ }
+ return undefined;
+};
+
+export const getAutumnAuth = (context?: AuthContext): AutumnMcpAuth => {
+ const candidate =
+ context?.mcp?.extra?.authInfo ??
+ readNestedAuthInfo(context?.requestContext);
+
+ const parsed = autumnMcpAuthSchema.safeParse(candidate);
+ if (!parsed.success) {
+ throw new Error("Autumn MCP authentication is required.");
+ }
+ return parsed.data;
+};
+
+export const createRequestContext = (auth: AutumnMcpAuth) => {
+ const requestContext = new RequestContext();
+ requestContext.set("mcp.extra", { authInfo: auth });
+ return requestContext;
+};
+
+export const createAutumnClient = (auth: AutumnMcpAuth) => ({
+ baseUrl: auth.serverURL ?? DEFAULT_AUTUMN_API_URL,
+ headers: {
+ Authorization: `Bearer ${auth.apiKey}`,
+ "Content-Type": "application/json",
+ Accept: "application/json",
+ "x-api-version": auth.xApiVersion ?? DEFAULT_API_VERSION,
+ "x-autumn-environment": auth.env,
+ ...(auth.authMethod === "oauth"
+ ? { "x-autumn-oauth-resource": auth.resource }
+ : {}),
+ ...(auth.failOpen === undefined
+ ? {}
+ : { "fail-open": String(auth.failOpen) }),
+ },
+});
diff --git a/packages/mcp/src/mcp-server/flags.ts b/packages/mcp/src/server/flags.ts
similarity index 100%
rename from packages/mcp/src/mcp-server/flags.ts
rename to packages/mcp/src/server/flags.ts
diff --git a/packages/mcp/src/server/server.ts b/packages/mcp/src/server/server.ts
new file mode 100644
index 000000000..f0ec8e2c3
--- /dev/null
+++ b/packages/mcp/src/server/server.ts
@@ -0,0 +1,15 @@
+import { MCPServer } from "@mastra/mcp";
+import { autumnMcpResources } from "../resources/index.js";
+import { createRawAutumnOperationTools } from "../tools/index.js";
+
+export const createAutumnOperationsMCPServer = () =>
+ new MCPServer({
+ id: "autumn-mcp",
+ name: "Autumn MCP",
+ version: "0.0.1",
+ description: "Operate on Autumn customers, plans, and billing.",
+ instructions:
+ "Use preview tools before billing writes. Write tools are destructive and should only be called after explicit user confirmation.",
+ tools: createRawAutumnOperationTools(),
+ resources: autumnMcpResources,
+ });
diff --git a/packages/mcp/src/tools/balances.ts b/packages/mcp/src/tools/balances.ts
new file mode 100644
index 000000000..85aabe459
--- /dev/null
+++ b/packages/mcp/src/tools/balances.ts
@@ -0,0 +1,49 @@
+import { CreateBalanceParamsV0Schema } from "@autumn/shared/publicApiSchemas";
+import { createDomainTools } from "./utils/builders.js";
+import { epochMillisecondsSchema } from "./utils/dates.js";
+import type { ToolDomain } from "./utils/types.js";
+
+const createBalanceMcpSchema = CreateBalanceParamsV0Schema.extend({
+ expires_at: epochMillisecondsSchema.optional().meta({
+ description:
+ "Expiry time as epoch milliseconds or an ISO date string. Date-only values use midnight UTC.",
+ }),
+});
+
+const endpoints = {
+ createBalance: "/v1/balances.create",
+} as const;
+
+const schemas = {
+ previewCreateBalance: createBalanceMcpSchema,
+ createBalance: createBalanceMcpSchema,
+} as const;
+
+const { operation, localPreview } = createDomainTools({ endpoints, schemas });
+
+const domain = {
+ operations: [
+ operation({
+ id: "createBalance",
+ description:
+ "Create a standalone customer balance grant. Use when a user asks to give, add, grant, or provision credits/balance to a customer or entity. Destructive: preview first; use entity_id for entity-scoped credits, included_grant for the grant amount, expires_at for expiring grants, and omit reset when using expires_at. For relative expiries like '2 months', use calendar months, not a 30-day approximation. expires_at accepts epoch milliseconds or ISO/date strings.",
+ destructive: true,
+ }),
+ ],
+ localPreviews: [
+ localPreview({
+ id: "previewCreateBalance",
+ description:
+ "Preview a standalone balance grant before createBalance. Use when a user asks to give, add, grant, or provision credits/balance to a customer or entity. Use for one-time credit grants, referral/promotional credits, and entity-scoped credits. Does not mutate Autumn. For relative expiries like '2 months', use calendar months. expires_at accepts epoch milliseconds or ISO/date strings.",
+ writeToolName: "createBalance",
+ preview: (request) => ({
+ action: "createBalance",
+ request,
+ impact:
+ "Creates a standalone balance grant. If entity_id is present, the balance is scoped to that entity. If expires_at is present, the grant expires at that timestamp.",
+ }),
+ }),
+ ],
+} satisfies ToolDomain;
+
+export const balances = { endpoints, schemas, domain };
diff --git a/packages/mcp/src/tools/billing.ts b/packages/mcp/src/tools/billing.ts
new file mode 100644
index 000000000..e2fd1b3a0
--- /dev/null
+++ b/packages/mcp/src/tools/billing.ts
@@ -0,0 +1,88 @@
+import {
+ AttachParamsV1Schema,
+ CreateScheduleParamsV0Schema,
+ CreateSchedulePhaseSchema,
+ UpdateSubscriptionV1ParamsSchema,
+} from "@autumn/shared/publicApiSchemas";
+import * as z from "zod/v4";
+import { createDomainTools } from "./utils/builders.js";
+import { epochMillisecondsSchema } from "./utils/dates.js";
+import type { ToolDomain } from "./utils/types.js";
+
+const createSchedulePhaseMcpSchema = CreateSchedulePhaseSchema.extend({
+ starts_at: epochMillisecondsSchema.meta({
+ description:
+ "Phase start time as epoch milliseconds or an ISO date string. Date-only values use midnight UTC.",
+ }),
+});
+
+const createScheduleMcpSchema = CreateScheduleParamsV0Schema.extend({
+ phases: z
+ .tuple([createSchedulePhaseMcpSchema])
+ .rest(createSchedulePhaseMcpSchema),
+});
+
+const endpoints = {
+ previewAttach: "/v1/billing.preview_attach",
+ attach: "/v1/billing.attach",
+ previewUpdateSubscription: "/v1/billing.preview_update",
+ updateSubscription: "/v1/billing.update",
+ previewCreateSchedule: "/v1/billing.preview_create_schedule",
+ createSchedule: "/v1/billing.create_schedule",
+} as const;
+
+const schemas = {
+ previewAttach: AttachParamsV1Schema,
+ attach: AttachParamsV1Schema,
+ previewUpdateSubscription: UpdateSubscriptionV1ParamsSchema,
+ updateSubscription: UpdateSubscriptionV1ParamsSchema,
+ previewCreateSchedule: createScheduleMcpSchema,
+ createSchedule: createScheduleMcpSchema,
+} as const;
+
+const { billingPreview, confirmedWrite } = createDomainTools({
+ endpoints,
+ schemas,
+});
+
+const domain = {
+ billingPreviews: [
+ billingPreview({
+ id: "previewAttach",
+ description:
+ "Preview attaching a plan before attach. Include feature_quantities and custom items/prices; map recurring custom grants like 'per month/year' to reset.interval.",
+ writeToolName: "attach",
+ }),
+ billingPreview({
+ id: "previewUpdateSubscription",
+ description:
+ "Preview updating a subscription before updateSubscription. Include quantity/custom item changes; recurring custom grants need reset.interval.",
+ writeToolName: "updateSubscription",
+ }),
+ billingPreview({
+ id: "previewCreateSchedule",
+ description:
+ "Preview billing impact of a multi-phase schedule before createSchedule. starts_at accepts epoch milliseconds or ISO/date strings; preserve exact calendar dates from the user or contract. Use redirect_mode if_required unless the user explicitly asks to disable checkout/redirects. If changing an existing/customer contract schedule, inspect the customer first. For schedules, put phase-specific feature quantities and contract feature limits/overrides in plan.customize.items, not feature_quantities; map 'per month/year' to reset.interval month/year. If the user says year 1 is already paid or should have no billing changes, do not add a year-1 phase; start phases at the first future billing change.",
+ writeToolName: "createSchedule",
+ }),
+ ],
+ confirmedWrites: [
+ confirmedWrite({
+ id: "attach",
+ description:
+ "Attach a plan to a customer. Destructive: preview first; preserve feature_quantities, custom prices/items, reset intervals, discounts, and checkout behavior.",
+ }),
+ confirmedWrite({
+ id: "updateSubscription",
+ description:
+ "Update a subscription. Destructive: preview first; preserve quantity/custom item changes and reset intervals from the previewed request.",
+ }),
+ confirmedWrite({
+ id: "createSchedule",
+ description:
+ "Create a multi-phase billing schedule. Destructive: preview first; preserve phase starts_at and redirect_mode values from the previewed request. Use redirect_mode if_required unless the user explicitly asks to disable checkout/redirects. If changing an existing/customer contract schedule, inspect the customer first. For schedules, put phase-specific feature quantities and contract feature limits/overrides in plan.customize.items, not feature_quantities. If year 1 is already paid/no billing changes, do not add a year-1 phase; start at the first future billing change.",
+ }),
+ ],
+} satisfies ToolDomain;
+
+export const billing = { endpoints, schemas, domain };
diff --git a/packages/mcp/src/tools/customers.ts b/packages/mcp/src/tools/customers.ts
new file mode 100644
index 000000000..acc26db9d
--- /dev/null
+++ b/packages/mcp/src/tools/customers.ts
@@ -0,0 +1,53 @@
+import {
+ CreateCustomerParamsV1Schema,
+ GetCustomerParamsV1Schema,
+ ListCustomersV2_3ParamsSchema,
+} from "@autumn/shared/publicApiSchemas";
+import * as z from "zod/v4";
+import { createDomainTools } from "./utils/builders.js";
+import type { ToolDomain } from "./utils/types.js";
+
+const listCustomersSchema = ListCustomersV2_3ParamsSchema.extend({
+ limit: z
+ .preprocess(
+ (value) => (typeof value === "number" && value > 1000 ? 1000 : value),
+ z.number().int().positive().max(1000).optional(),
+ )
+ .meta({ description: "Maximum customers per page. Max 1000." }),
+});
+
+const endpoints = {
+ listCustomers: "/v1/customers.list",
+ createCustomer: "/v1/customers.get_or_create",
+ getCustomer: "/v1/customers.get",
+} as const;
+
+const schemas = {
+ listCustomers: listCustomersSchema,
+ createCustomer: CreateCustomerParamsV1Schema,
+ getCustomer: GetCustomerParamsV1Schema,
+} as const;
+
+const { operation } = createDomainTools({ endpoints, schemas });
+
+const domain = {
+ operations: [
+ operation({
+ id: "listCustomers",
+ description:
+ "List Autumn customers. Use search, plans, subscription_status, and processors filters for customer-heavy queries. limit max is 1000. For queued/upcoming plan version queries, use subscription_status scheduled and omit the earliest matching version unless the user asks for all historical versions (versions 1,2,3 -> filter 2,3). 'live', 'paying', and active subscribers usually mean subscription_status active. When a plan is named, include the plans filter instead of listing broad customer sets. If listPlans returned matching versions, pass only relevant versions in plans[].versions, never guessed versions. For every/all/complete requests, paginate by calling again with start_cursor set to the previous response's next_cursor until next_cursor is empty.",
+ }),
+ operation({
+ id: "createCustomer",
+ description:
+ "Create an Autumn customer, or return the existing customer with the same id. Use when the user explicitly wants a customer record created.",
+ idempotent: true,
+ }),
+ operation({
+ id: "getCustomer",
+ description: "Fetch one Autumn customer by id.",
+ }),
+ ],
+} satisfies ToolDomain;
+
+export const customers = { endpoints, schemas, domain };
diff --git a/packages/mcp/src/tools/index.ts b/packages/mcp/src/tools/index.ts
new file mode 100644
index 000000000..c2f5d30dd
--- /dev/null
+++ b/packages/mcp/src/tools/index.ts
@@ -0,0 +1,140 @@
+import { createTool } from "@mastra/core/tools";
+import * as z from "zod/v4";
+import { claimLatestPendingAction } from "../agent/pending-actions.js";
+import { instrumentToolsWithAnalytics } from "../analytics/index.js";
+import { type AutumnMcpAuth, getAutumnAuth } from "../server/auth/auth.js";
+import { balances } from "./balances.js";
+import { billing } from "./billing.js";
+import { customers } from "./customers.js";
+import { orgTools } from "./org.js";
+import { plans } from "./plans.js";
+import { callAutumn } from "./utils/client.js";
+import { dateToEpochMillisecondsTool } from "./utils/dates.js";
+import { logTool } from "./utils/debug.js";
+import {
+ agentBillingPreviewTool,
+ agentLocalPreviewTool,
+ agentPendingWriteTool,
+ operationTool,
+ rawLocalPreviewTool,
+ toTools,
+} from "./utils/factories.js";
+import { requireIntentOnTools } from "./utils/intent.js";
+import type { ConfirmedWriteToolName, ToolDomain } from "./utils/types.js";
+
+export { dateToEpochMillisecondsTool } from "./utils/dates.js";
+
+/** Endpoint each tool calls, keyed by tool id (preview tools use their preview path). */
+export const endpointByTool = {
+ ...customers.endpoints,
+ ...plans.endpoints,
+ ...billing.endpoints,
+ ...balances.endpoints,
+} as const;
+
+/** Request schema each tool validates against, keyed by tool id. */
+export const schemaByTool = {
+ ...customers.schemas,
+ ...plans.schemas,
+ ...billing.schemas,
+ ...balances.schemas,
+} as const satisfies Record<
+ keyof typeof endpointByTool | "previewCreateBalance",
+ z.ZodType
+>;
+
+const domains: ToolDomain[] = [
+ customers.domain,
+ plans.domain,
+ billing.domain,
+ balances.domain,
+];
+const operations = domains.flatMap((domain) => domain.operations ?? []);
+const billingPreviews = domains.flatMap(
+ (domain) => domain.billingPreviews ?? [],
+);
+const localPreviews = domains.flatMap((domain) => domain.localPreviews ?? []);
+const confirmedWrites = domains.flatMap(
+ (domain) => domain.confirmedWrites ?? [],
+);
+
+/**
+ * Public MCP toolset: previews call Autumn's preview endpoints directly and
+ * writes apply immediately (external clients gate destructive calls themselves).
+ */
+export const createRawAutumnOperationTools = () =>
+ instrumentToolsWithAnalytics({
+ // Require a one-sentence `intent` on every external tool call so we can
+ // see what clients are actually trying to do (captured in analytics).
+ tools: requireIntentOnTools({
+ ...toTools(operations, operationTool),
+ ...toTools(billingPreviews, (config) =>
+ operationTool({ ...config, endpoint: config.previewEndpoint }),
+ ),
+ ...toTools(localPreviews, rawLocalPreviewTool),
+ ...toTools(confirmedWrites, operationTool),
+ ...orgTools,
+ } as Record>),
+ surface: "mcp",
+ });
+
+/** Applies a previously-staged billing write after the user confirms it. */
+export const executeConfirmedBillingAction = ({
+ auth,
+ toolName,
+ request,
+}: {
+ auth: AutumnMcpAuth;
+ toolName: ConfirmedWriteToolName;
+ request: unknown;
+}) =>
+ callAutumn({
+ auth,
+ endpoint: endpointByTool[toolName],
+ request: schemaByTool[toolName].parse(request),
+ });
+
+/**
+ * Agent toolset: destructive operations and billing writes are staged as pending
+ * actions (preview-first), then applied via `confirmBillingAction` once approved.
+ */
+const createAgentAutumnOperationToolset = () => ({
+ ...toTools(
+ operations.filter(({ destructive }) => !destructive),
+ operationTool,
+ ),
+ ...toTools(
+ operations.filter(({ destructive }) => destructive),
+ agentPendingWriteTool,
+ ),
+ ...toTools(billingPreviews, agentBillingPreviewTool),
+ ...toTools(localPreviews, agentLocalPreviewTool),
+ dateToEpochMilliseconds: dateToEpochMillisecondsTool,
+ confirmBillingAction: createTool({
+ id: "confirmBillingAction",
+ description:
+ "Apply the latest pending billing action after the user semantically confirms the preview.",
+ inputSchema: z.object({}).strict(),
+ execute: async (_input, context) => {
+ const auth = getAutumnAuth(context);
+ logTool("confirm-start", { env: auth.env });
+ const action = await claimLatestPendingAction(auth);
+ logTool("confirm-claimed", { toolName: action.toolName });
+ const result = await executeConfirmedBillingAction({
+ auth,
+ toolName: action.toolName,
+ request: action.request,
+ });
+ return {
+ message: `Confirmed and applied ${action.toolName}.`,
+ result,
+ };
+ },
+ }),
+});
+
+export const createAgentAutumnOperationTools = () =>
+ instrumentToolsWithAnalytics({
+ tools: createAgentAutumnOperationToolset(),
+ surface: "agent",
+ });
diff --git a/packages/mcp/src/tools/org.ts b/packages/mcp/src/tools/org.ts
new file mode 100644
index 000000000..ab567d5e3
--- /dev/null
+++ b/packages/mcp/src/tools/org.ts
@@ -0,0 +1,34 @@
+import { createTool } from "@mastra/core/tools";
+import * as z from "zod/v4";
+import { getAutumnAuth } from "../server/auth/auth.js";
+import { mcpAnnotations } from "./utils/annotations.js";
+import { callAutumnGet } from "./utils/client.js";
+
+const organizationMeSchema = z
+ .object({
+ name: z.string(),
+ slug: z.string(),
+ env: z.string(),
+ })
+ .strict();
+
+const signalOf = (context: { mcp?: { extra?: { signal?: AbortSignal } } }) =>
+ context?.mcp?.extra?.signal;
+
+export const orgTools = {
+ getCurrentOrganization: createTool({
+ id: "getCurrentOrganization",
+ description:
+ "Fetch the current Autumn organization name, slug, and environment.",
+ inputSchema: z.object({}).strict(),
+ mcp: { annotations: mcpAnnotations() },
+ execute: async (_input, context) =>
+ organizationMeSchema.parse(
+ await callAutumnGet({
+ auth: getAutumnAuth(context),
+ endpoint: "/v1/organization/me",
+ signal: signalOf(context),
+ }),
+ ),
+ }),
+} as const;
diff --git a/packages/mcp/src/tools/plans.ts b/packages/mcp/src/tools/plans.ts
new file mode 100644
index 000000000..1eb37c1bb
--- /dev/null
+++ b/packages/mcp/src/tools/plans.ts
@@ -0,0 +1,43 @@
+import {
+ CreatePlanParamsV2Schema,
+ GetPlanParamsV0Schema,
+ ListPlanParamsSchema,
+} from "@autumn/shared/publicApiSchemas";
+import { createDomainTools } from "./utils/builders.js";
+import type { ToolDomain } from "./utils/types.js";
+
+const endpoints = {
+ listPlans: "/v1/plans.list",
+ createPlan: "/v1/plans.create",
+ getPlan: "/v1/plans.get",
+} as const;
+
+const schemas = {
+ listPlans: ListPlanParamsSchema,
+ createPlan: CreatePlanParamsV2Schema,
+ getPlan: GetPlanParamsV0Schema,
+} as const;
+
+const { operation } = createDomainTools({ endpoints, schemas });
+
+const domain = {
+ operations: [
+ operation({
+ id: "listPlans",
+ description:
+ "List Autumn plans. This is usually a cheap full scan; filter returned plans locally and use matching id/version pairs before customer queries based on plan attributes.",
+ }),
+ operation({
+ id: "createPlan",
+ description:
+ "Create an Autumn plan. Destructive configuration write: gather plan_id, name, price, features/items, trials, and confirmation before running.",
+ destructive: true,
+ }),
+ operation({
+ id: "getPlan",
+ description: "Fetch one Autumn plan by id and optional version.",
+ }),
+ ],
+} satisfies ToolDomain;
+
+export const plans = { endpoints, schemas, domain };
diff --git a/packages/mcp/src/tools/utils/annotations.ts b/packages/mcp/src/tools/utils/annotations.ts
new file mode 100644
index 000000000..0abf7af86
--- /dev/null
+++ b/packages/mcp/src/tools/utils/annotations.ts
@@ -0,0 +1,13 @@
+/** MCP tool hints describing the side effects of a tool call. */
+export const mcpAnnotations = ({
+ destructive = false,
+ idempotent = false,
+}: {
+ destructive?: boolean;
+ idempotent?: boolean;
+} = {}) => ({
+ readOnlyHint: !destructive && !idempotent,
+ destructiveHint: destructive,
+ idempotentHint: idempotent,
+ openWorldHint: false,
+});
diff --git a/packages/mcp/src/tools/utils/builders.ts b/packages/mcp/src/tools/utils/builders.ts
new file mode 100644
index 000000000..07663dbaa
--- /dev/null
+++ b/packages/mcp/src/tools/utils/builders.ts
@@ -0,0 +1,100 @@
+import type * as z from "zod/v4";
+import type {
+ BillingPreviewToolConfig,
+ ConfirmedWriteToolName,
+ LocalPreviewToolConfig,
+ OperationToolConfig,
+} from "./types.js";
+
+/**
+ * Domain-scoped config composers bound to a domain's `endpoints` and `schemas`
+ * maps. A tool's `id` keys into both maps, so each tool declares its id,
+ * description, and semantics once — the schema and endpoint are looked up rather
+ * than repeated. The `id` is type-checked against the relevant map keys.
+ */
+export const createDomainTools = <
+ E extends Record,
+ S extends Record,
+>({
+ endpoints,
+ schemas,
+}: {
+ endpoints: E;
+ schemas: S;
+}) => {
+ type EndpointId = Extract;
+ type SchemaId = Extract;
+
+ /** A tool that calls its endpoint directly with the parsed request. */
+ const operation = ({
+ id,
+ description,
+ destructive = false,
+ idempotent = false,
+ }: {
+ id: EndpointId;
+ description: string;
+ destructive?: boolean;
+ idempotent?: boolean;
+ }): OperationToolConfig => ({
+ id,
+ description,
+ schema: schemas[id],
+ endpoint: endpoints[id],
+ destructive,
+ idempotent,
+ });
+
+ /** A preview tool that stages a pending billing write via its preview endpoint. */
+ const billingPreview = ({
+ id,
+ description,
+ writeToolName,
+ }: {
+ id: EndpointId;
+ description: string;
+ writeToolName: ConfirmedWriteToolName;
+ }): BillingPreviewToolConfig => ({
+ id,
+ description,
+ schema: schemas[id],
+ previewEndpoint: endpoints[id],
+ writeToolName,
+ });
+
+ /** A destructive write applied only after the user confirms a preview. */
+ const confirmedWrite = ({
+ id,
+ description,
+ }: {
+ id: EndpointId;
+ description: string;
+ }): OperationToolConfig => ({
+ id,
+ description,
+ schema: schemas[id],
+ endpoint: endpoints[id],
+ destructive: true,
+ });
+
+ /** A preview computed locally (no Autumn call) before a billing write. */
+ const localPreview = ({
+ id,
+ description,
+ writeToolName,
+ preview,
+ }: {
+ id: SchemaId;
+ description: string;
+ writeToolName: ConfirmedWriteToolName;
+ preview: (request: unknown) => unknown;
+ }): LocalPreviewToolConfig => ({
+ id,
+ description,
+ schema: schemas[id],
+ writeToolName,
+ preview,
+ });
+
+ return { operation, billingPreview, confirmedWrite, localPreview };
+};
diff --git a/packages/mcp/src/tools/utils/client.ts b/packages/mcp/src/tools/utils/client.ts
new file mode 100644
index 000000000..a5033f9f3
--- /dev/null
+++ b/packages/mcp/src/tools/utils/client.ts
@@ -0,0 +1,74 @@
+import {
+ type AutumnMcpAuth,
+ createAutumnClient,
+} from "../../server/auth/auth.js";
+
+const parseBody = (text: string): unknown => {
+ try {
+ return JSON.parse(text);
+ } catch {
+ return text;
+ }
+};
+
+/** POSTs a request to an Autumn endpoint using the caller's resolved auth. */
+export const callAutumn = async ({
+ auth,
+ endpoint,
+ request,
+ signal,
+}: {
+ auth: AutumnMcpAuth;
+ endpoint: string;
+ request: unknown;
+ signal?: AbortSignal | undefined;
+}) => {
+ const client = createAutumnClient(auth);
+ const init: RequestInit = {
+ method: "POST",
+ headers: client.headers,
+ body: JSON.stringify(request),
+ };
+ if (signal) init.signal = signal;
+
+ const response = await fetch(new URL(endpoint, client.baseUrl), init);
+ const text = await response.text();
+ const body = text ? parseBody(text) : null;
+ if (!response.ok) {
+ throw new Error(
+ `Autumn API request failed (${response.status}): ${
+ typeof body === "string" ? body : JSON.stringify(body)
+ }`,
+ );
+ }
+ return body;
+};
+
+export const callAutumnGet = async ({
+ auth,
+ endpoint,
+ signal,
+}: {
+ auth: AutumnMcpAuth;
+ endpoint: string;
+ signal?: AbortSignal | undefined;
+}) => {
+ const client = createAutumnClient(auth);
+ const init: RequestInit = {
+ method: "GET",
+ headers: client.headers,
+ };
+ if (signal) init.signal = signal;
+
+ const response = await fetch(new URL(endpoint, client.baseUrl), init);
+ const text = await response.text();
+ const body = text ? parseBody(text) : null;
+ if (!response.ok) {
+ throw new Error(
+ `Autumn API request failed (${response.status}): ${
+ typeof body === "string" ? body : JSON.stringify(body)
+ }`,
+ );
+ }
+ return body;
+};
diff --git a/packages/mcp/src/tools/utils/dates.ts b/packages/mcp/src/tools/utils/dates.ts
new file mode 100644
index 000000000..66671b398
--- /dev/null
+++ b/packages/mcp/src/tools/utils/dates.ts
@@ -0,0 +1,53 @@
+import { createTool } from "@mastra/core/tools";
+import { isValid, parseISO } from "date-fns";
+import * as z from "zod/v4";
+
+/**
+ * Parses an ISO date/timestamp string to UTC epoch milliseconds. Date-only
+ * values (`YYYY-MM-DD`) and zone-less timestamps are treated as UTC. Returns
+ * `null` when the input is not a valid date.
+ */
+const parseToEpochMilliseconds = (value: string): number | null => {
+ const normalized = /^\d{4}-\d{2}-\d{2}$/.test(value)
+ ? `${value}T00:00:00.000`
+ : value;
+ const hasExplicitZone = /(?:z|[+-]\d{2}:?\d{2})$/i.test(normalized);
+ const parsed = parseISO(hasExplicitZone ? normalized : `${normalized}Z`);
+ return isValid(parsed) ? parsed.getTime() : null;
+};
+
+/** Accepts epoch milliseconds or an ISO date/timestamp string; outputs epoch ms. */
+export const epochMillisecondsSchema = z
+ .union([z.number(), z.string()])
+ .transform((value, context) => {
+ if (typeof value === "number") {
+ if (Number.isFinite(value)) return value;
+ } else {
+ const epoch = parseToEpochMilliseconds(value);
+ if (epoch !== null) return epoch;
+ }
+
+ context.addIssue({
+ code: "custom",
+ message: "Expected epoch milliseconds or an ISO date/timestamp string.",
+ });
+ return z.NEVER;
+ });
+
+const toEpochMilliseconds = (date: string): number => {
+ const epoch = parseToEpochMilliseconds(date);
+ if (epoch === null) throw new Error(`Invalid date: ${date}`);
+ return epoch;
+};
+
+export const dateToEpochMillisecondsTool = createTool({
+ id: "dateToEpochMilliseconds",
+ description:
+ "Convert a calendar date or ISO timestamp to UTC epoch milliseconds for API timestamp fields. Date-only values default to midnight UTC; include an explicit offset in the date string when timezone matters.",
+ inputSchema: z
+ .object({
+ date: z.string(),
+ })
+ .strict(),
+ execute: async ({ date }) => toEpochMilliseconds(date),
+});
diff --git a/packages/mcp/src/tools/utils/debug.ts b/packages/mcp/src/tools/utils/debug.ts
new file mode 100644
index 000000000..9fb358454
--- /dev/null
+++ b/packages/mcp/src/tools/utils/debug.ts
@@ -0,0 +1,5 @@
+/** Opt-in tracing for the pending-action flow (set MCP_DEBUG_PENDING_ACTIONS=1). */
+export const logTool = (event: string, data: Record) => {
+ if (process.env.MCP_DEBUG_PENDING_ACTIONS !== "1") return;
+ console.log(`[mcp:agent-tools] ${event} ${JSON.stringify(data)}`);
+};
diff --git a/packages/mcp/src/tools/utils/factories.ts b/packages/mcp/src/tools/utils/factories.ts
new file mode 100644
index 000000000..2af613aff
--- /dev/null
+++ b/packages/mcp/src/tools/utils/factories.ts
@@ -0,0 +1,164 @@
+import { createTool } from "@mastra/core/tools";
+import * as z from "zod/v4";
+import { createPendingAction } from "../../agent/pending-actions.js";
+import { getAutumnAuth } from "../../server/auth/auth.js";
+import { mcpAnnotations } from "./annotations.js";
+import { callAutumn } from "./client.js";
+import { logTool } from "./debug.js";
+import {
+ type BillingPreviewToolConfig,
+ isConfirmedWriteToolName,
+ type LocalPreviewToolConfig,
+ type OperationToolConfig,
+} from "./types.js";
+
+const PENDING_MESSAGE =
+ "Preview ready. Ask the user to explicitly apply or approve this exact change.";
+
+/** Reads the `request` payload out of a tool input without casting. */
+const getRequest = (input: unknown): unknown =>
+ input && typeof input === "object" && "request" in input
+ ? input.request
+ : undefined;
+
+const signalOf = (context: { mcp?: { extra?: { signal?: AbortSignal } } }) =>
+ context?.mcp?.extra?.signal;
+
+/** Builds a `{ id: tool }` record from a list of configs. */
+export const toTools = (
+ configs: Config[],
+ create: (config: Config) => ReturnType,
+) => Object.fromEntries(configs.map((config) => [config.id, create(config)]));
+
+/** Calls an Autumn endpoint directly with the parsed request. */
+export const operationTool = ({
+ id,
+ description,
+ schema,
+ endpoint,
+ destructive = false,
+ idempotent = false,
+}: OperationToolConfig) =>
+ createTool({
+ id,
+ description,
+ inputSchema: z.object({ request: schema }).strict(),
+ mcp: { annotations: mcpAnnotations({ destructive, idempotent }) },
+ execute: (input, context) =>
+ callAutumn({
+ auth: getAutumnAuth(context),
+ endpoint,
+ request: schema.parse(getRequest(input)),
+ signal: signalOf(context),
+ }),
+ });
+
+/** Agent variant: previews via Autumn, then stages a pending billing write. */
+export const agentBillingPreviewTool = ({
+ id,
+ description,
+ schema,
+ previewEndpoint,
+ writeToolName,
+}: BillingPreviewToolConfig) =>
+ createTool({
+ id,
+ description: `${description} Store the exact pending billing action for later confirmation.`,
+ inputSchema: z.object({ request: schema }).strict(),
+ mcp: { annotations: mcpAnnotations() },
+ execute: async (input, context) => {
+ const parsedRequest = schema.parse(getRequest(input));
+ const auth = getAutumnAuth(context);
+ logTool("preview-start", { previewTool: id, writeToolName });
+ const preview = await callAutumn({
+ auth,
+ endpoint: previewEndpoint,
+ request: parsedRequest,
+ signal: signalOf(context),
+ });
+ await createPendingAction({
+ auth,
+ toolName: writeToolName,
+ request: parsedRequest,
+ preview: JSON.stringify(preview),
+ });
+ logTool("preview-stored", { previewTool: id, writeToolName });
+ return { preview, pending: true, message: PENDING_MESSAGE };
+ },
+ });
+
+/** Raw variant of a local preview: just returns the computed preview. */
+export const rawLocalPreviewTool = ({
+ id,
+ description,
+ schema,
+ preview,
+}: LocalPreviewToolConfig) =>
+ createTool({
+ id,
+ description,
+ inputSchema: z.object({ request: schema }).strict(),
+ mcp: { annotations: mcpAnnotations() },
+ execute: async (input) => preview(schema.parse(getRequest(input))),
+ });
+
+/** Agent variant of a local preview: stages a pending billing write. */
+export const agentLocalPreviewTool = ({
+ id,
+ description,
+ schema,
+ writeToolName,
+ preview,
+}: LocalPreviewToolConfig) =>
+ createTool({
+ id,
+ description: `${description} Store the exact pending billing action for later confirmation.`,
+ inputSchema: z.object({ request: schema }).strict(),
+ mcp: { annotations: mcpAnnotations() },
+ execute: async (input, context) => {
+ const parsedRequest = schema.parse(getRequest(input));
+ const previewResult = preview(parsedRequest);
+ await createPendingAction({
+ auth: getAutumnAuth(context),
+ toolName: writeToolName,
+ request: parsedRequest,
+ preview: JSON.stringify(previewResult),
+ });
+ return {
+ preview: previewResult,
+ pending: true,
+ message: PENDING_MESSAGE,
+ };
+ },
+ });
+
+/** Agent variant of a destructive operation: stages the request instead of applying it. */
+export const agentPendingWriteTool = ({
+ id,
+ description,
+ schema,
+}: OperationToolConfig) =>
+ createTool({
+ id,
+ description: `${description} This internal agent tool stores the exact request for later confirmation instead of applying it immediately.`,
+ inputSchema: z.object({ request: schema }).strict(),
+ mcp: { annotations: mcpAnnotations() },
+ execute: async (input, context) => {
+ if (!isConfirmedWriteToolName(id)) {
+ throw new Error(`Cannot stage a pending write for tool: ${id}`);
+ }
+ const parsedRequest = schema.parse(getRequest(input));
+ await createPendingAction({
+ auth: getAutumnAuth(context),
+ toolName: id,
+ request: parsedRequest,
+ preview: JSON.stringify(parsedRequest),
+ });
+ return {
+ pending: true,
+ request: parsedRequest,
+ message:
+ "Request ready. Ask the user to explicitly apply or approve this exact change.",
+ };
+ },
+ });
diff --git a/packages/mcp/src/tools/utils/intent.ts b/packages/mcp/src/tools/utils/intent.ts
new file mode 100644
index 000000000..0b72b97a5
--- /dev/null
+++ b/packages/mcp/src/tools/utils/intent.ts
@@ -0,0 +1,47 @@
+import type { createTool } from "@mastra/core/tools";
+import * as z from "zod/v4";
+
+type AnyTool = ReturnType;
+
+export const INTENT_DESCRIPTION =
+ "Required. One concise sentence, in plain language, describing what the user " +
+ "asked you (the agent) to do — their original request in their own terms, " +
+ "not a restatement of the arguments or the tool name. If this call is one " +
+ 'step toward a larger ask, state that larger ask. Example: "Find customers ' +
+ 'on the Pro plan so we can email them about the new add-on."';
+
+/** Required single-sentence statement of what the caller is trying to do. */
+export const intentSchema = z.string().min(1).describe(INTENT_DESCRIPTION);
+
+/** Reads the `intent` string out of a tool input without casting. */
+export const getIntent = (input: unknown): string | undefined =>
+ input &&
+ typeof input === "object" &&
+ "intent" in input &&
+ typeof input.intent === "string"
+ ? input.intent
+ : undefined;
+
+/**
+ * Adds a required `intent` field to every tool's input schema, in place, so
+ * external MCP clients must declare their goal on every call. Call this once on
+ * a fully-built toolset (the intent is captured by the analytics layer).
+ *
+ * Tools whose input isn't a plain object are left untouched.
+ */
+export const requireIntentOnTools = >(
+ tools: T,
+): T => {
+ for (const tool of Object.values(tools)) {
+ const schema = tool.inputSchema;
+ if (schema instanceof z.ZodObject) {
+ // Runtime value is a plain zod object, but Mastra types the field as its
+ // JSON-schema-augmented schema (incompatible at the type level only), so
+ // route the reassignment through `unknown`.
+ tool.inputSchema = schema.extend({
+ intent: intentSchema,
+ }) as unknown as typeof tool.inputSchema;
+ }
+ }
+ return tools;
+};
diff --git a/packages/mcp/src/tools/utils/types.ts b/packages/mcp/src/tools/utils/types.ts
new file mode 100644
index 000000000..e943f6694
--- /dev/null
+++ b/packages/mcp/src/tools/utils/types.ts
@@ -0,0 +1,61 @@
+import type * as z from "zod/v4";
+
+/**
+ * Tool names that mutate billing state. These are the only tools that can be
+ * staged as a pending action and later applied via `confirmBillingAction`.
+ * Declared as a tuple so the union type and runtime guard stay in sync.
+ */
+export const CONFIRMED_WRITE_TOOL_NAMES = [
+ "attach",
+ "updateSubscription",
+ "createPlan",
+ "createSchedule",
+ "createBalance",
+] as const;
+
+export type ConfirmedWriteToolName =
+ (typeof CONFIRMED_WRITE_TOOL_NAMES)[number];
+
+export const isConfirmedWriteToolName = (
+ id: string,
+): id is ConfirmedWriteToolName =>
+ CONFIRMED_WRITE_TOOL_NAMES.some((name) => name === id);
+
+/** A tool that calls a single Autumn endpoint with the parsed request. */
+export type OperationToolConfig = {
+ id: string;
+ description: string;
+ schema: z.ZodType;
+ endpoint: string;
+ destructive?: boolean;
+ idempotent?: boolean;
+};
+
+/** A preview tool whose result is staged as a pending billing write. */
+export type BillingPreviewToolConfig = {
+ id: string;
+ description: string;
+ schema: z.ZodType;
+ previewEndpoint: string;
+ writeToolName: ConfirmedWriteToolName;
+};
+
+/** A preview tool computed locally (no Autumn call) before a billing write. */
+export type LocalPreviewToolConfig = {
+ id: string;
+ description: string;
+ schema: z.ZodType;
+ writeToolName: ConfirmedWriteToolName;
+ preview: (request: unknown) => unknown;
+};
+
+/**
+ * One business domain's tool declarations, grouped by behaviour. The top-level
+ * `index.ts` composes these into the raw (MCP) and agent toolsets.
+ */
+export type ToolDomain = {
+ operations?: OperationToolConfig[];
+ billingPreviews?: BillingPreviewToolConfig[];
+ localPreviews?: LocalPreviewToolConfig[];
+ confirmedWrites?: OperationToolConfig[];
+};
diff --git a/packages/mcp/tests/evals/create-balance-evals.test.ts b/packages/mcp/tests/evals/create-balance-evals.test.ts
index ad41fa4f1..b3891321a 100644
--- a/packages/mcp/tests/evals/create-balance-evals.test.ts
+++ b/packages/mcp/tests/evals/create-balance-evals.test.ts
@@ -45,10 +45,13 @@ test("previews and creates an entity-scoped expiring credit grant", async () =>
},
});
- await generate([
- "Looking to give entity ent_689d243e2c03da31e0ac90d0 on customer cus_687672c4c0d36fa5679f8c7a 50k credits on the credits feature that expire in 2 months. Can you set that up in Autumn?",
- "These should not be permanent credits.",
- ], 6);
+ await generate(
+ [
+ "Looking to give entity ent_689d243e2c03da31e0ac90d0 on customer cus_687672c4c0d36fa5679f8c7a 50k credits on the credits feature that expire in 2 months. Can you set that up in Autumn?",
+ "These should not be permanent credits.",
+ ],
+ 6,
+ );
expectToolCall(toolCalls, "previewCreateBalance", expectedGrant);
expectNoApiCall(api, "createBalance");
diff --git a/packages/mcp/tests/evals/list-customers-evals.test.ts b/packages/mcp/tests/evals/list-customers-evals.test.ts
index d542c4c51..367991638 100644
--- a/packages/mcp/tests/evals/list-customers-evals.test.ts
+++ b/packages/mcp/tests/evals/list-customers-evals.test.ts
@@ -35,14 +35,18 @@ test("lists all matching customers with compound filters and cursor pagination",
name: "Acme US",
email: "billing@acme.example",
processors: { stripe: { id: "cus_stripe_us" } },
- subscriptions: [{ planId: "pro", version: 3, status: "active" }],
+ subscriptions: [
+ { planId: "pro", version: 3, status: "active" },
+ ],
},
{
id: "cus_acme_eu",
name: "Acme EU",
email: "finance@acme.example",
processors: { stripe: { id: "cus_stripe_eu" } },
- subscriptions: [{ planId: "pro", version: 2, status: "active" }],
+ subscriptions: [
+ { planId: "pro", version: 2, status: "active" },
+ ],
},
],
next_cursor: "cursor_acme_2",
@@ -175,7 +179,9 @@ test("resolves plan attributes before listing scheduled Vercel customers", async
return (
call.body.subscription_status === "scheduled" &&
call.body.processors?.includes("vercel") &&
- Array.from(new Set(versions ?? [])).sort().join(",") === "4,5"
+ Array.from(new Set(versions ?? []))
+ .sort()
+ .join(",") === "4,5"
);
});
expect(
diff --git a/packages/mcp/tests/unit/mcp-server/agent/ask-autumn.test.ts b/packages/mcp/tests/unit/mcp-server/agent/ask-autumn.test.ts
deleted file mode 100644
index e3639d404..000000000
--- a/packages/mcp/tests/unit/mcp-server/agent/ask-autumn.test.ts
+++ /dev/null
@@ -1,244 +0,0 @@
-import { describe, expect, mock, test } from "bun:test";
-import type { AutumnMcpAuth } from "../../../../src/mcp-server/agent/auth.js";
-import { setPendingActionsRedis } from "../../../../src/mcp-server/agent/pending-actions.js";
-import { createTestRedis } from "../../../utils/test-redis.js";
-
-const systemPrompts: string[] = [];
-let agentConfirms = true;
-let agentCalls = 0;
-
-mock.module("@mastra/core/agent", () => ({
- Agent: class {
- private readonly tools: Record;
-
- constructor(config: { tools: Record }) {
- this.tools = config.tools;
- }
-
- async generate(
- message: string,
- options: {
- requestContext: unknown;
- context: { content: string }[];
- },
- ) {
- agentCalls += 1;
- const systemPrompt = options.context[0]?.content ?? "";
- systemPrompts.push(systemPrompt);
- const context = { requestContext: options.requestContext };
- if (message.toLowerCase().includes("customers")) {
- const result = await this.tools.listCustomers.execute?.(
- { request: {} },
- context,
- );
- return { text: JSON.stringify(result) };
- }
-
- if (agentConfirms && systemPrompt.includes("Pending billing action")) {
- const result = await this.tools.confirmBillingAction.execute?.(
- {},
- context,
- );
- return { text: JSON.stringify(result) };
- }
- if (systemPrompt.includes("Pending billing action")) {
- return { text: "There is no pending billing action to confirm." };
- }
-
- const result = await this.tools.previewAttach.execute?.(
- { request: { customer_id: "cus_1", plan_id: "pro" } },
- context,
- );
- return { text: JSON.stringify(result) };
- }
- },
-}));
-
-const { createAskAutumnTool } = await import(
- "../../../../src/mcp-server/agent/ask-autumn.js"
-);
-
-const auth: AutumnMcpAuth = {
- apiKey: "sk_test",
- env: "sandbox",
- principalId: "user_1",
- resource: "http://localhost:2718/mcp",
- scopes: ["billing:read", "billing:write"],
- serverURL: "http://localhost:8080",
-};
-
-const mockFetch = (calls: { url: string; body: unknown }[]) => {
- const originalFetch = globalThis.fetch;
- globalThis.fetch = (async (url, init) => {
- const body = JSON.parse(init?.body as string);
- calls.push({ url: String(url), body });
-
- if (String(url).endsWith("/v1/billing.preview_attach")) {
- return Response.json({ total: 50 });
- }
-
- if (String(url).endsWith("/v1/billing.attach")) {
- return Response.json({ applied: true });
- }
-
- if (String(url).endsWith("/v1/customers.list")) {
- return Response.json({ customers: [] });
- }
-
- return Response.json({ error: "unexpected" }, { status: 500 });
- }) as typeof fetch;
- return () => {
- globalThis.fetch = originalFetch;
- };
-};
-
-describe("ask_autumn billing confirmation flow", () => {
- test("confirms a pending attach across separate ask_autumn calls", async () => {
- setPendingActionsRedis(createTestRedis());
- systemPrompts.length = 0;
- agentConfirms = true;
- agentCalls = 0;
- const calls: { url: string; body: unknown }[] = [];
- const restoreFetch = mockFetch(calls);
-
- try {
- const tool = createAskAutumnTool();
- if (!tool.execute) throw new Error("ask_autumn is not executable");
- const context = { mcp: { extra: { authInfo: auth } } } as never;
-
- const preview = await tool.execute(
- { message: "attach pro to cus_1" },
- context,
- );
- expect(String(preview)).toContain("Preview ready");
- expect(systemPrompts.at(-1)).not.toContain("Pending billing action");
- expect(calls.map((call) => call.url)).toEqual([
- "http://localhost:8080/v1/billing.preview_attach",
- ]);
-
- const confirm = await tool.execute({ message: "confirm" }, context);
- expect(String(confirm)).toContain("Confirmed and applied attach.");
- expect(calls).toEqual([
- {
- url: "http://localhost:8080/v1/billing.preview_attach",
- body: {
- customer_id: "cus_1",
- plan_id: "pro",
- redirect_mode: "if_required",
- },
- },
- {
- url: "http://localhost:8080/v1/billing.attach",
- body: {
- customer_id: "cus_1",
- plan_id: "pro",
- redirect_mode: "if_required",
- },
- },
- ]);
- } finally {
- restoreFetch();
- }
- });
-
- test("semantic confirmation gets the pending preview context", async () => {
- setPendingActionsRedis(createTestRedis());
- systemPrompts.length = 0;
- agentConfirms = true;
- agentCalls = 0;
- const calls: { url: string; body: unknown }[] = [];
- const restoreFetch = mockFetch(calls);
-
- try {
- const tool = createAskAutumnTool();
- if (!tool.execute) throw new Error("ask_autumn is not executable");
- const context = { mcp: { extra: { authInfo: auth } } } as never;
-
- await tool.execute({ message: "attach pro to cus_1" }, context);
- expect(agentCalls).toBe(1);
-
- const confirm = await tool.execute(
- { message: "that looks good, go ahead" },
- context,
- );
- expect(String(confirm)).toContain("Confirmed and applied attach.");
- expect(agentCalls).toBe(2);
- expect(systemPrompts.at(-1)).toContain("Pending billing action:");
- expect(systemPrompts.at(-1)).toContain("Preview:");
- expect(systemPrompts.at(-1)).toContain('"total":50');
- expect(calls.map((call) => call.url)).toEqual([
- "http://localhost:8080/v1/billing.preview_attach",
- "http://localhost:8080/v1/billing.attach",
- ]);
- } finally {
- restoreFetch();
- }
- });
-
- test("question-like confirmation text does not bypass the agent", async () => {
- setPendingActionsRedis(createTestRedis());
- systemPrompts.length = 0;
- agentConfirms = false;
- agentCalls = 0;
- const calls: { url: string; body: unknown }[] = [];
- const restoreFetch = mockFetch(calls);
-
- try {
- const tool = createAskAutumnTool();
- if (!tool.execute) throw new Error("ask_autumn is not executable");
- const context = { mcp: { extra: { authInfo: auth } } } as never;
-
- await tool.execute({ message: "attach pro to cus_1" }, context);
- const response = await tool.execute(
- { message: "can you confirm what this changes?" },
- context,
- );
-
- expect(String(response)).toContain("no pending billing action");
- expect(agentCalls).toBe(2);
- expect(systemPrompts.at(-1)).toContain("Pending billing action:");
- expect(calls.map((call) => call.url)).toEqual([
- "http://localhost:8080/v1/billing.preview_attach",
- ]);
- } finally {
- restoreFetch();
- }
- });
-
- test("read requests continue when pending lookup fails", async () => {
- setPendingActionsRedis({
- multi: () => {
- throw new Error("unavailable");
- },
- get: async () => {
- throw new Error("unavailable");
- },
- getdel: async () => {
- throw new Error("unavailable");
- },
- del: async () => undefined,
- keys: async () => [],
- });
- systemPrompts.length = 0;
- agentConfirms = true;
- agentCalls = 0;
- const calls: { url: string; body: unknown }[] = [];
- const restoreFetch = mockFetch(calls);
-
- try {
- const tool = createAskAutumnTool();
- if (!tool.execute) throw new Error("ask_autumn is not executable");
- const context = { mcp: { extra: { authInfo: auth } } } as never;
-
- const response = await tool.execute({ message: "list customers" }, context);
-
- expect(String(response)).toContain("customers");
- expect(agentCalls).toBe(1);
- expect(calls.map((call) => call.url)).toEqual([
- "http://localhost:8080/v1/customers.list",
- ]);
- } finally {
- restoreFetch();
- }
- });
-});
diff --git a/packages/mcp/tests/unit/mcp-server/agent/axiom.test.ts b/packages/mcp/tests/unit/mcp-server/agent/axiom.test.ts
index d5c9c2a4c..e94bbed88 100644
--- a/packages/mcp/tests/unit/mcp-server/agent/axiom.test.ts
+++ b/packages/mcp/tests/unit/mcp-server/agent/axiom.test.ts
@@ -1,7 +1,10 @@
import { describe, expect, test } from "bun:test";
import { Scopes } from "@autumn/shared/scopeDefinitions";
-import type { AutumnMcpAuth } from "../../../../src/mcp-server/agent/auth.js";
-import { prepareAxiomQuery, resolveAutumnOrgId } from "../../../../src/mcp-server/agent/axiom.js";
+import {
+ prepareAxiomQuery,
+ resolveAutumnOrgId,
+} from "../../../../src/agent/axiom.js";
+import type { AutumnMcpAuth } from "../../../../src/server/auth/auth.js";
const auth: AutumnMcpAuth & { orgId: string } = {
apiKey: "sk_test",
diff --git a/packages/mcp/tests/unit/mcp-server/agent/pending-actions.test.ts b/packages/mcp/tests/unit/mcp-server/agent/pending-actions.test.ts
index 2b1ccbf27..b872a0263 100644
--- a/packages/mcp/tests/unit/mcp-server/agent/pending-actions.test.ts
+++ b/packages/mcp/tests/unit/mcp-server/agent/pending-actions.test.ts
@@ -1,11 +1,11 @@
import { describe, expect, test } from "bun:test";
-import type { AutumnMcpAuth } from "../../../../src/mcp-server/agent/auth.js";
import {
claimLatestPendingAction,
clearPendingActions,
createPendingAction,
setPendingActionsRedis,
-} from "../../../../src/mcp-server/agent/pending-actions.js";
+} from "../../../../src/agent/pending-actions.js";
+import type { AutumnMcpAuth } from "../../../../src/server/auth/auth.js";
import { createTestRedis } from "../../../utils/test-redis.js";
setPendingActionsRedis(createTestRedis());
@@ -38,7 +38,9 @@ describe("pending billing actions", () => {
plan_id: "pro",
},
});
- await expect(claimLatestPendingAction(auth())).rejects.toThrow("No pending");
+ await expect(claimLatestPendingAction(auth())).rejects.toThrow(
+ "No pending",
+ );
});
test("claims the latest matching action without exposing tokens", async () => {
@@ -75,11 +77,11 @@ describe("pending billing actions", () => {
claimLatestPendingAction(auth()),
]);
- expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(
- 1,
- );
- expect(results.filter((result) => result.status === "rejected")).toHaveLength(
- 1,
- );
+ expect(
+ results.filter((result) => result.status === "fulfilled"),
+ ).toHaveLength(1);
+ expect(
+ results.filter((result) => result.status === "rejected"),
+ ).toHaveLength(1);
});
});
diff --git a/packages/mcp/tests/unit/mcp-server/agent/server.test.ts b/packages/mcp/tests/unit/mcp-server/agent/server.test.ts
index 996ae2c1b..4f0df3b75 100644
--- a/packages/mcp/tests/unit/mcp-server/agent/server.test.ts
+++ b/packages/mcp/tests/unit/mcp-server/agent/server.test.ts
@@ -1,9 +1,6 @@
import { describe, expect, test } from "bun:test";
-import {
- createAskAutumnMCPServer,
- createAutumnOperationsMCPServer,
-} from "../../../../src/mcp-server/agent/server.js";
-import { autumnMcpResourceUris } from "../../../../src/mcp-server/agent/resources.js";
+import { autumnMcpResourceUris } from "../../../../src/resources/index.js";
+import { createAutumnOperationsMCPServer } from "../../../../src/server/server.js";
describe("Autumn MCP server", () => {
test("public server advertises raw operation tools", async () => {
@@ -15,8 +12,8 @@ describe("Autumn MCP server", () => {
"getCustomer",
"listPlans",
"createPlan",
- "createBalance",
"getPlan",
+ "createBalance",
"previewAttach",
"previewUpdateSubscription",
"previewCreateSchedule",
@@ -24,6 +21,7 @@ describe("Autumn MCP server", () => {
"attach",
"updateSubscription",
"createSchedule",
+ "getCurrentOrganization",
]);
expect(tools.tools.map((tool) => tool.name)).not.toContain("ask_autumn");
expect(tools.tools.map((tool) => tool.name)).not.toContain(
@@ -31,14 +29,6 @@ describe("Autumn MCP server", () => {
);
});
- test("internal server advertises only ask_autumn", async () => {
- const tools = await createAskAutumnMCPServer().getToolListInfo();
-
- expect(tools.tools.map((tool) => tool.name)).toEqual(["ask_autumn"]);
- expect(tools.tools.map((tool) => tool.name)).not.toContain("attach");
- expect(tools.tools.map((tool) => tool.name)).not.toContain("listCustomers");
- });
-
test("billing tool schemas avoid legacy JSON Schema ids", async () => {
const tools = await createAutumnOperationsMCPServer().getToolListInfo();
@@ -53,11 +43,8 @@ describe("Autumn MCP server", () => {
}
});
- test.each([
- ["public", createAutumnOperationsMCPServer],
- ["internal", createAskAutumnMCPServer],
- ])("%s server exposes Autumn composition docs", async (_name, createServer) => {
- const server = createServer();
+ test("public server exposes Autumn composition docs", async () => {
+ const server = createAutumnOperationsMCPServer();
const resources = await server.listResources();
expect(resources.resources.map((resource) => resource.uri)).toEqual(
diff --git a/packages/mcp/tests/unit/mcp-server/agent/tools.test.ts b/packages/mcp/tests/unit/mcp-server/agent/tools.test.ts
index 452ddd0df..fa3f17a60 100644
--- a/packages/mcp/tests/unit/mcp-server/agent/tools.test.ts
+++ b/packages/mcp/tests/unit/mcp-server/agent/tools.test.ts
@@ -1,17 +1,17 @@
import { describe, expect, test } from "bun:test";
-import type { AutumnMcpAuth } from "../../../../src/mcp-server/agent/auth.js";
import {
- clearPendingActions,
claimLatestPendingAction,
+ clearPendingActions,
createPendingAction,
setPendingActionsRedis,
-} from "../../../../src/mcp-server/agent/pending-actions.js";
-import { createTestRedis } from "../../../utils/test-redis.js";
+} from "../../../../src/agent/pending-actions.js";
+import type { AutumnMcpAuth } from "../../../../src/server/auth/auth.js";
import {
createAgentAutumnOperationTools,
createRawAutumnOperationTools,
dateToEpochMillisecondsTool,
-} from "../../../../src/mcp-server/agent/tools.js";
+} from "../../../../src/tools/index.js";
+import { createTestRedis } from "../../../utils/test-redis.js";
setPendingActionsRedis(createTestRedis());
@@ -33,7 +33,9 @@ describe("Autumn operation tools", () => {
const tools = createRawAutumnOperationTools();
expect(tools.listPlans.description).toContain("cheap full scan");
- expect(tools.listPlans.description).toContain("filter returned plans locally");
+ expect(tools.listPlans.description).toContain(
+ "filter returned plans locally",
+ );
expect(tools.listCustomers.description).toContain("plans");
expect(tools.listCustomers.description).toContain("paginate");
expect(tools.createPlan.description).toContain("confirmation");
@@ -41,6 +43,7 @@ describe("Autumn operation tools", () => {
expect(tools.previewCreateBalance.description).toContain("Does not mutate");
expect(tools.createSchedule.description).toContain("starts_at");
expect(tools.previewCreateSchedule.description).toContain("billing impact");
+ expect(tools.getCurrentOrganization.description).toContain("organization");
});
test("write tools are annotated as destructive", () => {
@@ -65,6 +68,7 @@ describe("Autumn operation tools", () => {
"previewUpdateSubscription",
"previewCreateSchedule",
"previewCreateBalance",
+ "getCurrentOrganization",
] as const) {
expect(tools[name].mcp?.annotations?.destructiveHint).toBe(false);
}
@@ -72,7 +76,8 @@ describe("Autumn operation tools", () => {
test("dateToEpochMilliseconds converts UTC dates and offsets", async () => {
const tool = dateToEpochMillisecondsTool as ExecutableTool;
- if (!tool.execute) throw new Error("dateToEpochMilliseconds is not executable");
+ if (!tool.execute)
+ throw new Error("dateToEpochMilliseconds is not executable");
await expect(tool.execute({ date: "2027-01-01" }, {})).resolves.toBe(
Date.UTC(2027, 0, 1),
@@ -85,7 +90,9 @@ describe("Autumn operation tools", () => {
test("raw createCustomer calls the get-or-create endpoint", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (url, init) => {
- expect(String(url)).toBe("http://localhost:8080/v1/customers.get_or_create");
+ expect(String(url)).toBe(
+ "http://localhost:8080/v1/customers.get_or_create",
+ );
expect(JSON.parse(init?.body as string)).toMatchObject({
customer_id: "cus_1",
email: "charlie@example.com",
@@ -99,7 +106,10 @@ describe("Autumn operation tools", () => {
await expect(
tool.execute(
- { request: { customer_id: "cus_1", email: "charlie@example.com" } },
+ {
+ intent: "create a customer",
+ request: { customer_id: "cus_1", email: "charlie@example.com" },
+ },
{ mcp: { extra: { authInfo: auth } } } as never,
),
).resolves.toEqual({ id: "cus_1" });
@@ -125,8 +135,10 @@ describe("Autumn operation tools", () => {
await expect(
tool.execute(
- { request: { plan_id: "pro", name: "Pro" } },
- { mcp: { extra: { authInfo: auth } } } as never,
+ { intent: "create a plan", request: { plan_id: "pro", name: "Pro" } },
+ {
+ mcp: { extra: { authInfo: auth } },
+ } as never,
),
).resolves.toEqual({ id: "pro" });
} finally {
@@ -137,7 +149,9 @@ describe("Autumn operation tools", () => {
test("raw createSchedule calls the create schedule endpoint", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (url, init) => {
- expect(String(url)).toBe("http://localhost:8080/v1/billing.create_schedule");
+ expect(String(url)).toBe(
+ "http://localhost:8080/v1/billing.create_schedule",
+ );
expect(JSON.parse(init?.body as string)).toMatchObject({
customer_id: "cus_1",
});
@@ -151,11 +165,10 @@ describe("Autumn operation tools", () => {
await expect(
tool.execute(
{
+ intent: "create a schedule",
request: {
customer_id: "cus_1",
- phases: [
- { starts_at: Date.now(), plans: [{ plan_id: "pro" }] },
- ],
+ phases: [{ starts_at: Date.now(), plans: [{ plan_id: "pro" }] }],
},
},
{ mcp: { extra: { authInfo: auth } } } as never,
@@ -181,13 +194,13 @@ describe("Autumn operation tools", () => {
try {
const tool = createRawAutumnOperationTools().previewCreateBalance;
- if (!tool.execute) throw new Error("previewCreateBalance is not executable");
+ if (!tool.execute)
+ throw new Error("previewCreateBalance is not executable");
await expect(
- tool.execute(
- { request },
- { mcp: { extra: { authInfo: auth } } } as never,
- ),
+ tool.execute({ intent: "preview a balance grant", request }, {
+ mcp: { extra: { authInfo: auth } },
+ } as never),
).resolves.toMatchObject({
action: "createBalance",
request,
@@ -218,6 +231,7 @@ describe("Autumn operation tools", () => {
await expect(
tool.execute(
{
+ intent: "grant a balance",
request: {
customer_id: "cus_1",
entity_id: "workspace_1",
@@ -255,11 +269,10 @@ describe("Autumn operation tools", () => {
await expect(
tool.execute(
{
+ intent: "preview a schedule",
request: {
customer_id: "cus_1",
- phases: [
- { starts_at: Date.now(), plans: [{ plan_id: "pro" }] },
- ],
+ phases: [{ starts_at: Date.now(), plans: [{ plan_id: "pro" }] }],
},
},
{ mcp: { extra: { authInfo: auth } } } as never,
@@ -287,8 +300,13 @@ describe("Autumn operation tools", () => {
await expect(
tool.execute(
- { request: { limit: 5000, search: "charlie" } },
- { mcp: { extra: { authInfo: auth } } } as never,
+ {
+ intent: "list customers",
+ request: { limit: 5000, search: "charlie" },
+ },
+ {
+ mcp: { extra: { authInfo: auth } },
+ } as never,
),
).resolves.toEqual({ customers: [] });
} finally {
@@ -296,11 +314,47 @@ describe("Autumn operation tools", () => {
}
});
+ test("raw getCurrentOrganization calls the organization me endpoint", async () => {
+ const originalFetch = globalThis.fetch;
+ globalThis.fetch = (async (url, init) => {
+ expect(String(url)).toBe("http://localhost:8080/v1/organization/me");
+ expect(init?.method).toBe("GET");
+ expect(init?.body).toBeUndefined();
+ return Response.json({
+ name: "Unit Tests",
+ slug: "unit-tests",
+ env: "sandbox",
+ });
+ }) as typeof fetch;
+
+ try {
+ const tool = createRawAutumnOperationTools().getCurrentOrganization;
+ if (!tool.execute) {
+ throw new Error("getCurrentOrganization is not executable");
+ }
+
+ await expect(
+ tool.execute(
+ { intent: "check which Autumn organization is connected" },
+ { mcp: { extra: { authInfo: auth } } } as never,
+ ),
+ ).resolves.toEqual({
+ name: "Unit Tests",
+ slug: "unit-tests",
+ env: "sandbox",
+ });
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
+ });
+
test("raw previewAttach does not create a pending action", async () => {
await clearPendingActions();
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (url, init) => {
- expect(String(url)).toBe("http://localhost:8080/v1/billing.preview_attach");
+ expect(String(url)).toBe(
+ "http://localhost:8080/v1/billing.preview_attach",
+ );
expect(JSON.parse(init?.body as string)).toEqual({
customer_id: "cus_1",
plan_id: "pro",
@@ -315,11 +369,18 @@ describe("Autumn operation tools", () => {
await expect(
tool.execute(
- { request: { customer_id: "cus_1", plan_id: "pro" } },
- { mcp: { extra: { authInfo: auth } } } as never,
+ {
+ intent: "preview an attach",
+ request: { customer_id: "cus_1", plan_id: "pro" },
+ },
+ {
+ mcp: { extra: { authInfo: auth } },
+ } as never,
),
).resolves.toEqual({ total: 50 });
- await expect(claimLatestPendingAction(auth)).rejects.toThrow("No pending");
+ await expect(claimLatestPendingAction(auth)).rejects.toThrow(
+ "No pending",
+ );
} finally {
globalThis.fetch = originalFetch;
}
@@ -343,8 +404,13 @@ describe("Autumn operation tools", () => {
await expect(
tool.execute(
- { request: { customer_id: "cus_1", plan_id: "pro" } },
- { mcp: { extra: { authInfo: auth } } } as never,
+ {
+ intent: "attach a plan",
+ request: { customer_id: "cus_1", plan_id: "pro" },
+ },
+ {
+ mcp: { extra: { authInfo: auth } },
+ } as never,
),
).resolves.toEqual({ ok: true });
} finally {
@@ -356,7 +422,9 @@ describe("Autumn operation tools", () => {
await clearPendingActions();
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (url, init) => {
- expect(String(url)).toBe("http://localhost:8080/v1/billing.preview_attach");
+ expect(String(url)).toBe(
+ "http://localhost:8080/v1/billing.preview_attach",
+ );
expect(JSON.parse(init?.body as string)).toEqual({
customer_id: "cus_1",
plan_id: "pro",
@@ -376,10 +444,9 @@ describe("Autumn operation tools", () => {
if (!tool.execute) throw new Error("previewAttach is not executable");
await expect(
- tool.execute(
- { request: { customer_id: "cus_1", plan_id: "pro" } },
- { mcp: { extra: { authInfo: auth } } } as never,
- ),
+ tool.execute({ request: { customer_id: "cus_1", plan_id: "pro" } }, {
+ mcp: { extra: { authInfo: auth } },
+ } as never),
).resolves.toMatchObject({ pending: true, preview: { total: 50 } });
await expect(claimLatestPendingAction(auth)).resolves.toMatchObject({
@@ -411,10 +478,9 @@ describe("Autumn operation tools", () => {
if (!tool.execute) throw new Error("createPlan is not executable");
await expect(
- tool.execute(
- { request: { plan_id: "pro", name: "Pro" } },
- { mcp: { extra: { authInfo: auth } } } as never,
- ),
+ tool.execute({ request: { plan_id: "pro", name: "Pro" } }, {
+ mcp: { extra: { authInfo: auth } },
+ } as never),
).resolves.toMatchObject({ pending: true });
await expect(claimLatestPendingAction(auth)).resolves.toMatchObject({
@@ -451,10 +517,9 @@ describe("Autumn operation tools", () => {
}
await expect(
- tool.execute(
- { request },
- { mcp: { extra: { authInfo: auth } } } as never,
- ),
+ tool.execute({ request }, {
+ mcp: { extra: { authInfo: auth } },
+ } as never),
).resolves.toMatchObject({ pending: true });
await expect(claimLatestPendingAction(auth)).resolves.toMatchObject({
@@ -491,10 +556,9 @@ describe("Autumn operation tools", () => {
}
await expect(
- tool.execute(
- { request },
- { mcp: { extra: { authInfo: auth } } } as never,
- ),
+ tool.execute({ request }, {
+ mcp: { extra: { authInfo: auth } },
+ } as never),
).resolves.toMatchObject({ pending: true });
await expect(claimLatestPendingAction(auth)).resolves.toMatchObject({
@@ -527,7 +591,8 @@ describe("Autumn operation tools", () => {
try {
const tool = createAgentAutumnOperationTools().confirmBillingAction;
- if (!tool.execute) throw new Error("confirmBillingAction is not executable");
+ if (!tool.execute)
+ throw new Error("confirmBillingAction is not executable");
await expect(
tool.execute({}, { mcp: { extra: { authInfo: auth } } } as never),
@@ -535,7 +600,9 @@ describe("Autumn operation tools", () => {
message: "Confirmed and applied attach.",
result: { ok: true },
});
- await expect(claimLatestPendingAction(auth)).rejects.toThrow("No pending");
+ await expect(claimLatestPendingAction(auth)).rejects.toThrow(
+ "No pending",
+ );
} finally {
globalThis.fetch = originalFetch;
}
@@ -565,7 +632,8 @@ describe("Autumn operation tools", () => {
try {
const tool = createAgentAutumnOperationTools().confirmBillingAction;
- if (!tool.execute) throw new Error("confirmBillingAction is not executable");
+ if (!tool.execute)
+ throw new Error("confirmBillingAction is not executable");
await expect(
tool.execute({}, { mcp: { extra: { authInfo: auth } } } as never),
diff --git a/packages/mcp/tests/unit/mcp-server/analytics.test.ts b/packages/mcp/tests/unit/mcp-server/analytics.test.ts
new file mode 100644
index 000000000..7b9d77666
--- /dev/null
+++ b/packages/mcp/tests/unit/mcp-server/analytics.test.ts
@@ -0,0 +1,130 @@
+import { describe, expect, test } from "bun:test";
+import { createTool } from "@mastra/core/tools";
+import * as z from "zod/v4";
+import {
+ instrumentToolsWithAnalytics,
+ type McpAnalyticsEvent,
+ setAnalyticsSink,
+} from "../../../src/analytics/index.js";
+import type { AutumnMcpAuth } from "../../../src/server/auth/auth.js";
+
+const auth: AutumnMcpAuth = {
+ apiKey: "sk_test",
+ env: "sandbox",
+ principalId: "user_1",
+ resource: "http://localhost:2718/mcp",
+ scopes: ["billing:read"],
+ serverURL: "http://localhost:8080",
+};
+
+const waitForEvent = async (events: McpAnalyticsEvent[]) => {
+ for (let i = 0; i < 20; i++) {
+ if (events.length > 0) return events[0];
+ await new Promise((resolve) => setTimeout(resolve, 5));
+ }
+ throw new Error("Timed out waiting for analytics event");
+};
+
+describe("MCP analytics instrumentation", () => {
+ test("emits successful tool calls", async () => {
+ const events: McpAnalyticsEvent[] = [];
+ const originalFetch = globalThis.fetch;
+ globalThis.fetch = (async () =>
+ Response.json({ id: "org_1", slug: "acme" })) as unknown as typeof fetch;
+ setAnalyticsSink({
+ emit: (event: McpAnalyticsEvent) => events.push(event),
+ flush: async () => {},
+ });
+
+ try {
+ const tools = instrumentToolsWithAnalytics({
+ surface: "mcp",
+ tools: {
+ echo: createTool({
+ id: "echo",
+ description: "Echo input",
+ inputSchema: z.object({ intent: z.string(), request: z.unknown() }),
+ execute: async ({ request }) => ({ request }),
+ }),
+ },
+ });
+
+ await expect(
+ tools.echo.execute?.({ intent: "echo input", request: { ok: true } }, {
+ mcp: {
+ extra: {
+ authInfo: auth,
+ requestInfo: {
+ headers: {
+ "mcp-session-id": "mcp_session_1",
+ "user-agent": "Claude Code",
+ },
+ },
+ },
+ },
+ } as never),
+ ).resolves.toEqual({ request: { ok: true } });
+
+ await expect(waitForEvent(events)).resolves.toMatchObject({
+ event: "mcp.tool_call",
+ surface: "mcp",
+ tool: "echo",
+ intent: "echo input",
+ status: "ok",
+ principalId: "user_1",
+ client: "Claude Code",
+ sessionId: "mcp_session_1",
+ context: {
+ orgId: "org_1",
+ orgSlug: "acme",
+ env: "sandbox",
+ },
+ input: { ok: true },
+ output: { request: { ok: true } },
+ });
+ } finally {
+ setAnalyticsSink(undefined);
+ globalThis.fetch = originalFetch;
+ }
+ });
+
+ test("emits errors and rethrows", async () => {
+ const events: McpAnalyticsEvent[] = [];
+ setAnalyticsSink({
+ emit: (event: McpAnalyticsEvent) => events.push(event),
+ flush: async () => {},
+ });
+
+ try {
+ const tools = instrumentToolsWithAnalytics({
+ surface: "agent",
+ tools: {
+ fail: createTool({
+ id: "fail",
+ description: "Fail input",
+ inputSchema: z.object({ intent: z.string() }),
+ execute: async () => {
+ throw new Error("nope");
+ },
+ }),
+ },
+ });
+
+ await expect(
+ tools.fail.execute?.({ intent: "fail intentionally" }, {
+ mcp: { extra: { authInfo: auth } },
+ } as never),
+ ).rejects.toThrow("nope");
+
+ await expect(waitForEvent(events)).resolves.toMatchObject({
+ surface: "agent",
+ tool: "fail",
+ intent: "fail intentionally",
+ status: "error",
+ error: "nope",
+ });
+ } finally {
+ setAnalyticsSink(undefined);
+ }
+ });
+});
diff --git a/packages/mcp/tests/unit/mcp-server/oauth.test.ts b/packages/mcp/tests/unit/mcp-server/oauth.test.ts
deleted file mode 100644
index 9f24b9e03..000000000
--- a/packages/mcp/tests/unit/mcp-server/oauth.test.ts
+++ /dev/null
@@ -1,187 +0,0 @@
-import { Scopes } from "@autumn/shared/scopeDefinitions";
-import { describe, expect, test } from "bun:test";
-import {
- buildAuthForRequest,
- getProtectedResourceMetadata,
- MCP_OAUTH_SCOPES,
- OAuthHttpError,
- type MCPOAuthFlags,
-} from "../../../src/mcp-server/oauth.js";
-
-const flags = {
- "oauth-enabled": true,
- "oauth-environment": "sandbox",
- "server-url": "http://localhost:8080",
-} satisfies Partial;
-
-const logger = {
- warning: () => {},
-} as never;
-
-describe("MCP OAuth auth resolution", () => {
- test("requests scopes required by public write tools", () => {
- expect(MCP_OAUTH_SCOPES).toEqual(
- expect.arrayContaining([
- Scopes.Customers.Write,
- Scopes.Plans.Write,
- Scopes.Billing.Write,
- Scopes.Balances.Write,
- ]),
- );
- });
-
- test("returns a WWW-Authenticate challenge without a bearer token", async () => {
- await expect(
- buildAuthForRequest(
- new Headers({ host: "localhost:2718" }),
- flags as MCPOAuthFlags,
- logger,
- ),
- ).rejects.toMatchObject({
- status: 401,
- error: "invalid_token",
- wwwAuthenticate:
- 'Bearer resource_metadata="http://localhost:2718/.well-known/oauth-protected-resource/mcp"',
- } satisfies Partial);
- });
-
- test("returns an internal MCP resource challenge", async () => {
- await expect(
- buildAuthForRequest(
- new Headers({ host: "localhost:2718" }),
- flags as MCPOAuthFlags,
- logger,
- "/internal/mcp",
- ),
- ).rejects.toMatchObject({
- status: 401,
- error: "invalid_token",
- wwwAuthenticate:
- 'Bearer resource_metadata="http://localhost:2718/.well-known/oauth-protected-resource/internal/mcp"',
- } satisfies Partial);
- });
-
- test("exchanges a bearer token for Autumn API credentials", async () => {
- const originalFetch = globalThis.fetch;
- globalThis.fetch = (async (_url, init) => {
- expect(init?.headers).toEqual({
- Authorization: "Bearer oauth_token",
- "Content-Type": "application/json",
- });
- expect(JSON.parse(init?.body as string)).toEqual({
- resource: "http://localhost:2718/mcp",
- scopes: MCP_OAUTH_SCOPES,
- });
- return Response.json({
- sandbox_key: "sk_sandbox",
- prod_key: "sk_live",
- org_id: "org_123",
- user_id: "user_123",
- client_id: "client_123",
- scopes: MCP_OAUTH_SCOPES,
- });
- }) as typeof fetch;
-
- try {
- const auth = await buildAuthForRequest(
- new Headers({
- authorization: "Bearer oauth_token",
- host: "localhost:2718",
- }),
- flags as MCPOAuthFlags,
- logger,
- );
-
- expect(auth.apiKey).toBe("sk_sandbox");
- expect(auth.env).toBe("sandbox");
- expect(auth.resource).toBe("http://localhost:2718/mcp");
- expect(auth.principalId).toBe("oauth:org_123:user_123:client_123");
- expect(auth.scopes).toEqual([...MCP_OAUTH_SCOPES]);
- expect(auth.orgId).toBe("org_123");
- } finally {
- globalThis.fetch = originalFetch;
- }
- });
-
- test("accepts a static secret-key when OAuth is enabled", async () => {
- const auth = await buildAuthForRequest(
- new Headers({
- host: "localhost:2718",
- "secret-key": "am_sk_test_chat",
- }),
- flags as MCPOAuthFlags,
- logger,
- );
-
- expect(auth.apiKey).toBe("am_sk_test_chat");
- expect(auth.principalId).toStartWith("secret-key:");
- expect(auth.resource).toBe("http://localhost:2718/mcp");
- });
-
- test("accepts an Autumn API key bearer token when OAuth is enabled", async () => {
- const auth = await buildAuthForRequest(
- new Headers({
- authorization: "Bearer am_sk_test_chat",
- host: "localhost:2718",
- }),
- flags as MCPOAuthFlags,
- logger,
- );
-
- expect(auth.apiKey).toBe("am_sk_test_chat");
- expect(auth.principalId).toStartWith("secret-key:");
- });
-
- test("uses route-specific resource URLs", async () => {
- const originalFetch = globalThis.fetch;
- globalThis.fetch = (async (_url, init) => {
- expect(JSON.parse(init?.body as string)).toMatchObject({
- resource: "http://localhost:2718/internal/mcp",
- });
- return Response.json({
- sandbox_key: "sk_sandbox",
- org_id: "org_123",
- scopes: MCP_OAUTH_SCOPES,
- });
- }) as typeof fetch;
-
- try {
- const auth = await buildAuthForRequest(
- new Headers({
- authorization: "Bearer internal_oauth_token",
- host: "localhost:2718",
- }),
- flags as MCPOAuthFlags,
- logger,
- "/internal/mcp",
- );
-
- expect(auth.resource).toBe("http://localhost:2718/internal/mcp");
- expect(
- getProtectedResourceMetadata(
- new Headers({ host: "localhost:2718" }),
- flags as MCPOAuthFlags,
- "/internal/mcp",
- ).resource,
- ).toBe("http://localhost:2718/internal/mcp");
- } finally {
- globalThis.fetch = originalFetch;
- }
- });
-
- test("missing static secret-key returns the auth error path", async () => {
- await expect(
- buildAuthForRequest(
- new Headers({ host: "localhost:2718" }),
- {
- ...flags,
- "oauth-enabled": false,
- } as MCPOAuthFlags,
- logger,
- ),
- ).rejects.toMatchObject({
- status: 401,
- error: "invalid_token",
- } satisfies Partial);
- });
-});
diff --git a/packages/mcp/tests/utils/eval-test-utils.ts b/packages/mcp/tests/utils/eval-test-utils.ts
index 9436668fb..571c5f866 100644
--- a/packages/mcp/tests/utils/eval-test-utils.ts
+++ b/packages/mcp/tests/utils/eval-test-utils.ts
@@ -1,5 +1,5 @@
-import { createServer, type IncomingMessage, type Server } from "node:http";
import { afterEach, expect } from "bun:test";
+import { createServer, type IncomingMessage, type Server } from "node:http";
import { Agent } from "@mastra/core/agent";
import type { MessageListItem } from "@mastra/core/agent/message-list";
import { Mastra } from "@mastra/core/mastra";
@@ -9,12 +9,9 @@ import type * as z from "zod/v4";
import {
type AutumnMcpAuth,
createRequestContext,
-} from "../../src/mcp-server/agent/auth.js";
-import { createAutumnOperationsMCPServer } from "../../src/mcp-server/agent/server.js";
-import {
- endpointByTool,
- schemaByTool,
-} from "../../src/mcp-server/agent/tools.js";
+} from "../../src/server/auth/auth.js";
+import { createAutumnOperationsMCPServer } from "../../src/server/server.js";
+import { endpointByTool, schemaByTool } from "../../src/tools/index.js";
type ToolName = keyof typeof schemaByTool;
type EndpointToolName = keyof typeof endpointByTool;
diff --git a/packages/mcp/tests/utils/test-redis.ts b/packages/mcp/tests/utils/test-redis.ts
index 6d69c3e80..a97f52c61 100644
--- a/packages/mcp/tests/utils/test-redis.ts
+++ b/packages/mcp/tests/utils/test-redis.ts
@@ -1,7 +1,7 @@
import type {
PendingActionRedis,
PendingActionRedisMulti,
-} from "../../src/mcp-server/agent/pending-actions.js";
+} from "../../src/agent/pending-actions.js";
export const createTestRedis = (): PendingActionRedis => {
const store = new Map();
diff --git a/packages/mcp/tsconfig.json b/packages/mcp/tsconfig.json
index d9d31a185..457afcd7c 100644
--- a/packages/mcp/tsconfig.json
+++ b/packages/mcp/tsconfig.json
@@ -33,6 +33,7 @@
"@api/*": ["../../shared/api/*"],
"@models/*": ["../../shared/models/*"],
"@utils/*": ["../../shared/utils/*"],
+ "@autumn/logging": ["../logging/src/index.ts"],
"@autumn/ksuid": ["../ksuid/src/index.ts"]
},
"useUnknownInCatchVariables": true,
diff --git a/packages/openapi/openapi-stripped.yml b/packages/openapi/openapi-stripped.yml
index b2876fa39..02c3117f8 100644
--- a/packages/openapi/openapi-stripped.yml
+++ b/packages/openapi/openapi-stripped.yml
@@ -18621,6 +18621,301 @@ paths:
x-speakeasy-name-override: redeemCode
parameters:
- *a5
+ /v1/platform.link_revenuecat:
+ post:
+ operationId: linkRevenueCat
+ description: Generate a RevenueCat OAuth URL for linking a project to an organization.
+ tags:
+ - platform
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ organization_slug:
+ type: string
+ minLength: 1
+ env:
+ enum:
+ - test
+ - live
+ type: string
+ project_name:
+ type: string
+ minLength: 1
+ maxLength: 255
+ redirect_url:
+ type: string
+ format: uri
+ required:
+ - organization_slug
+ - env
+ - project_name
+ - redirect_url
+ title: LinkRevenueCatParams
+ examples:
+ - &a77
+ organization_slug: acme
+ env: test
+ project_name: acme-mobile
+ redirect_url: https://dashboard.useautumn.com/dev?tab=revenuecat
+ example: *a77
+ responses:
+ "200":
+ description: OK
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ oauth_url:
+ type: string
+ required:
+ - oauth_url
+ title: LinkRevenueCatResponse
+ examples:
+ - &a78
+ oauth_url: https://api.revenuecat.com/oauth2/authorize?client_id=...&redirect_uri=...&response_type=code&scope=project.read+project.write
+ example: *a78
+ x-speakeasy-name-override: linkRevenueCat
+ parameters:
+ - *a5
+ /v1/platform.sync_revenuecat:
+ post:
+ operationId: syncRevenueCat
+ description: Push an organization's plans into RevenueCat as products (creating
+ or renaming them across the project's apps) and set test-store prices
+ from each plan's price. Requires the org to have linked RevenueCat via
+ OAuth.
+ tags:
+ - platform
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ organization_slug:
+ type: string
+ minLength: 1
+ env:
+ enum:
+ - test
+ - sandbox
+ - live
+ type: string
+ description: '"test" and "sandbox" both target the sandbox environment'
+ product_ids:
+ type: array
+ items:
+ type: string
+ description: Plans to push. Omit to sync every plan in the org/env.
+ required:
+ - organization_slug
+ - env
+ title: SyncRevenueCatParams
+ examples:
+ - &a79
+ organization_slug: acme
+ env: test
+ product_ids:
+ - pro
+ - premium
+ example: *a79
+ responses:
+ "200":
+ description: OK
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ results:
+ type: array
+ items:
+ type: object
+ properties:
+ plan_id:
+ type: string
+ status:
+ enum:
+ - synced
+ - skipped
+ - error
+ type: string
+ store_identifier:
+ type: string
+ apps:
+ type: array
+ items:
+ type: object
+ properties:
+ app_id:
+ type: string
+ app_type:
+ type: string
+ product:
+ enum:
+ - created
+ - updated
+ - exists
+ type: string
+ store_push:
+ enum:
+ - pushed
+ - failed
+ - skipped
+ type: string
+ price:
+ enum:
+ - set
+ - skipped
+ - failed
+ type: string
+ message:
+ type: string
+ required:
+ - app_id
+ - app_type
+ - product
+ message:
+ type: string
+ required:
+ - plan_id
+ - status
+ required:
+ - results
+ title: SyncRevenueCatResponse
+ examples:
+ - &a80
+ results:
+ - plan_id: pro
+ status: synced
+ store_identifier: autumn.sandbox.org_123.pro
+ apps:
+ - app_id: app_test
+ app_type: test_store
+ product: created
+ store_push: skipped
+ price: set
+ example: *a80
+ x-speakeasy-name-override: syncRevenueCat
+ parameters:
+ - *a5
+ /v1/platform.get_revenuecat_keys:
+ post:
+ operationId: getRevenueCatKeys
+ description: Retrieve a managed organization's RevenueCat public (SDK) API keys,
+ grouped by app — for the test store, App Store, and Google Play Store.
+ Use these to configure the RevenueCat SDK in the org's mobile app.
+ tags:
+ - platform
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ organization_slug:
+ type: string
+ minLength: 1
+ env:
+ enum:
+ - test
+ - sandbox
+ - live
+ type: string
+ description: '"test" and "sandbox" both target the sandbox environment'
+ required:
+ - organization_slug
+ - env
+ title: GetRevenueCatKeysParams
+ examples:
+ - &a81
+ organization_slug: acme
+ env: test
+ example: *a81
+ responses:
+ "200":
+ description: OK
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ apps:
+ type: array
+ items:
+ type: object
+ properties:
+ app_id:
+ type: string
+ app_type:
+ type: string
+ description: RevenueCat store type, e.g. test_store / app_store / play_store
+ name:
+ type: string
+ api_keys:
+ type: array
+ items:
+ type: object
+ properties:
+ id:
+ type: string
+ key:
+ type: string
+ description: The public SDK API key value
+ environment:
+ anyOf:
+ - type: string
+ - type: "null"
+ description: e.g. "production" / "sandbox"
+ app_id:
+ anyOf:
+ - type: string
+ - type: "null"
+ created_at:
+ type: number
+ required:
+ - id
+ - key
+ additionalProperties: {}
+ required:
+ - app_id
+ - app_type
+ - name
+ - api_keys
+ oauth_access_token:
+ anyOf:
+ - type: string
+ - type: "null"
+ description: Freshly-refreshed RevenueCat OAuth access token for the org (null
+ for api-key orgs). The refresh token is never exposed —
+ call this endpoint again for a new access token.
+ required:
+ - apps
+ - oauth_access_token
+ title: GetRevenueCatKeysResponse
+ examples:
+ - &a82
+ apps:
+ - app_id: app1a2b3c4d
+ app_type: test_store
+ name: Acme (Test Store)
+ api_keys:
+ - id: apikey12345
+ key: test_aBcDeFgHiJkLmNoPqRsTuVwXyZ
+ environment: production
+ app_id: app1a2b3c4
+ oauth_access_token: atk_aBcDeFgHiJkLmNoPqRsTuVwXyZ
+ example: *a82
+ x-speakeasy-name-override: getRevenueCatKeys
+ parameters:
+ - *a5
security:
- secretKey: []
x-speakeasy-globals:
diff --git a/packages/openapi/openapi.yml b/packages/openapi/openapi.yml
index a1c12209f..ab68de215 100644
--- a/packages/openapi/openapi.yml
+++ b/packages/openapi/openapi.yml
@@ -8745,7 +8745,7 @@ paths:
@example
```typescript
// Schedule a transition from a trial plan to a paid plan
- const response = await client.billing.createSchedule({ customerId: "cus_123", phases: [{"startsAt":1780512803523,"plans":[{"planId":"trial_plan"}]},{"startsAt":1781722403523,"plans":[{"planId":"pro_plan"}]}] });
+ const response = await client.billing.createSchedule({ customerId: "cus_123", phases: [{"startsAt":1780584084429,"plans":[{"planId":"trial_plan"}]},{"startsAt":1781793684429,"plans":[{"planId":"pro_plan"}]}] });
```
@param customerId - The ID of the customer to create the schedule for.
@@ -19586,6 +19586,289 @@ paths:
x-speakeasy-name-override: redeemCode
parameters:
- *a1
+ /v1/platform.link_revenuecat:
+ post:
+ operationId: linkRevenueCat
+ description: Generate a RevenueCat OAuth URL for linking a project to an organization.
+ tags:
+ - platform
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ organization_slug:
+ type: string
+ minLength: 1
+ env:
+ enum:
+ - test
+ - live
+ type: string
+ project_name:
+ type: string
+ minLength: 1
+ maxLength: 255
+ redirect_url:
+ type: string
+ format: uri
+ required:
+ - organization_slug
+ - env
+ - project_name
+ - redirect_url
+ title: LinkRevenueCatParams
+ examples:
+ - organization_slug: acme
+ env: test
+ project_name: acme-mobile
+ redirect_url: https://dashboard.useautumn.com/dev?tab=revenuecat
+ responses:
+ "200":
+ description: OK
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ oauth_url:
+ type: string
+ required:
+ - oauth_url
+ title: LinkRevenueCatResponse
+ examples:
+ - oauth_url: https://api.revenuecat.com/oauth2/authorize?client_id=...&redirect_uri=...&response_type=code&scope=project.read+project.write
+ x-speakeasy-name-override: linkRevenueCat
+ parameters:
+ - *a1
+ /v1/platform.sync_revenuecat:
+ post:
+ operationId: syncRevenueCat
+ description: Push an organization's plans into RevenueCat as products (creating
+ or renaming them across the project's apps) and set test-store prices
+ from each plan's price. Requires the org to have linked RevenueCat via
+ OAuth.
+ tags:
+ - platform
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ organization_slug:
+ type: string
+ minLength: 1
+ env:
+ enum:
+ - test
+ - sandbox
+ - live
+ type: string
+ description: '"test" and "sandbox" both target the sandbox environment'
+ product_ids:
+ type: array
+ items:
+ type: string
+ description: Plans to push. Omit to sync every plan in the org/env.
+ required:
+ - organization_slug
+ - env
+ title: SyncRevenueCatParams
+ examples:
+ - organization_slug: acme
+ env: test
+ product_ids:
+ - pro
+ - premium
+ responses:
+ "200":
+ description: OK
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ results:
+ type: array
+ items:
+ type: object
+ properties:
+ plan_id:
+ type: string
+ status:
+ enum:
+ - synced
+ - skipped
+ - error
+ type: string
+ store_identifier:
+ type: string
+ apps:
+ type: array
+ items:
+ type: object
+ properties:
+ app_id:
+ type: string
+ app_type:
+ type: string
+ product:
+ enum:
+ - created
+ - updated
+ - exists
+ type: string
+ store_push:
+ enum:
+ - pushed
+ - failed
+ - skipped
+ type: string
+ price:
+ enum:
+ - set
+ - skipped
+ - failed
+ type: string
+ message:
+ type: string
+ required:
+ - app_id
+ - app_type
+ - product
+ message:
+ type: string
+ required:
+ - plan_id
+ - status
+ required:
+ - results
+ title: SyncRevenueCatResponse
+ examples:
+ - results:
+ - plan_id: pro
+ status: synced
+ store_identifier: autumn.sandbox.org_123.pro
+ apps:
+ - app_id: app_test
+ app_type: test_store
+ product: created
+ store_push: skipped
+ price: set
+ x-speakeasy-name-override: syncRevenueCat
+ parameters:
+ - *a1
+ /v1/platform.get_revenuecat_keys:
+ post:
+ operationId: getRevenueCatKeys
+ description: Retrieve a managed organization's RevenueCat public (SDK) API keys,
+ grouped by app — for the test store, App Store, and Google Play Store.
+ Use these to configure the RevenueCat SDK in the org's mobile app.
+ tags:
+ - platform
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ organization_slug:
+ type: string
+ minLength: 1
+ env:
+ enum:
+ - test
+ - sandbox
+ - live
+ type: string
+ description: '"test" and "sandbox" both target the sandbox environment'
+ required:
+ - organization_slug
+ - env
+ title: GetRevenueCatKeysParams
+ examples:
+ - organization_slug: acme
+ env: test
+ responses:
+ "200":
+ description: OK
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ apps:
+ type: array
+ items:
+ type: object
+ properties:
+ app_id:
+ type: string
+ app_type:
+ type: string
+ description: RevenueCat store type, e.g. test_store / app_store / play_store
+ name:
+ type: string
+ api_keys:
+ type: array
+ items:
+ type: object
+ properties:
+ id:
+ type: string
+ key:
+ type: string
+ description: The public SDK API key value
+ environment:
+ anyOf:
+ - type: string
+ - type: "null"
+ description: e.g. "production" / "sandbox"
+ app_id:
+ anyOf:
+ - type: string
+ - type: "null"
+ created_at:
+ type: number
+ required:
+ - id
+ - key
+ additionalProperties: {}
+ required:
+ - app_id
+ - app_type
+ - name
+ - api_keys
+ oauth_access_token:
+ anyOf:
+ - type: string
+ - type: "null"
+ description: Freshly-refreshed RevenueCat OAuth access token for the org (null
+ for api-key orgs). The refresh token is never exposed —
+ call this endpoint again for a new access token.
+ required:
+ - apps
+ - oauth_access_token
+ title: GetRevenueCatKeysResponse
+ examples:
+ - apps:
+ - app_id: app1a2b3c4d
+ app_type: test_store
+ name: Acme (Test Store)
+ api_keys:
+ - id: apikey12345
+ key: test_aBcDeFgHiJkLmNoPqRsTuVwXyZ
+ environment: production
+ app_id: app1a2b3c4
+ oauth_access_token: atk_aBcDeFgHiJkLmNoPqRsTuVwXyZ
+ x-speakeasy-name-override: getRevenueCatKeys
+ parameters:
+ - *a1
security:
- secretKey: []
x-speakeasy-globals:
diff --git a/packages/openapi/v2.3/contracts/index.ts b/packages/openapi/v2.3/contracts/index.ts
index 42528cf6d..a4f3d70d2 100644
--- a/packages/openapi/v2.3/contracts/index.ts
+++ b/packages/openapi/v2.3/contracts/index.ts
@@ -51,6 +51,11 @@ import {
listPlansContract,
updatePlanContract,
} from "./plansContract.js";
+import {
+ platformGetRevenueCatKeysContract,
+ platformLinkRevenueCatContract,
+ platformSyncRevenueCatContract,
+} from "./platformContract.js";
import {
referralsCreateCodeContract,
referralsRedeemCodeContract,
@@ -114,4 +119,9 @@ export const v2_3ContractRouter = oc.router({
referralsCreateCode: referralsCreateCodeContract,
referralsRedeemCode: referralsRedeemCodeContract,
rewardsRedeemCode: rewardsRedeemCodeContract,
+
+ // Platform
+ platformLinkRevenueCat: platformLinkRevenueCatContract,
+ platformSyncRevenueCat: platformSyncRevenueCatContract,
+ platformGetRevenueCatKeys: platformGetRevenueCatKeysContract,
});
diff --git a/packages/openapi/v2.3/contracts/platformContract.ts b/packages/openapi/v2.3/contracts/platformContract.ts
new file mode 100644
index 000000000..2b1b942d7
--- /dev/null
+++ b/packages/openapi/v2.3/contracts/platformContract.ts
@@ -0,0 +1,143 @@
+import {
+ GetRevenueCatKeysResponseSchema,
+ GetRevenueCatKeysSchema,
+ LinkRevenueCatResponseSchema,
+ LinkRevenueCatSchema,
+ SyncRevenueCatResponseSchema,
+ SyncRevenueCatSchema,
+} from "@autumn/shared";
+import { oc } from "@orpc/contract";
+
+export const platformLinkRevenueCatContract = oc
+ .route({
+ method: "POST",
+ path: "/v1/platform.link_revenuecat",
+ operationId: "linkRevenueCat",
+ tags: ["platform"],
+ description:
+ "Generate a RevenueCat OAuth URL for linking a project to an organization.",
+ spec: (spec) => ({
+ ...spec,
+ "x-speakeasy-name-override": "linkRevenueCat",
+ }),
+ })
+ .input(
+ LinkRevenueCatSchema.meta({
+ title: "LinkRevenueCatParams",
+ examples: [
+ {
+ organization_slug: "acme",
+ env: "test",
+ project_name: "acme-mobile",
+ redirect_url: "https://dashboard.useautumn.com/dev?tab=revenuecat",
+ },
+ ],
+ }),
+ )
+ .output(
+ LinkRevenueCatResponseSchema.meta({
+ title: "LinkRevenueCatResponse",
+ examples: [
+ {
+ oauth_url:
+ "https://api.revenuecat.com/oauth2/authorize?client_id=...&redirect_uri=...&response_type=code&scope=project.read+project.write",
+ },
+ ],
+ }),
+ );
+
+export const platformSyncRevenueCatContract = oc
+ .route({
+ method: "POST",
+ path: "/v1/platform.sync_revenuecat",
+ operationId: "syncRevenueCat",
+ tags: ["platform"],
+ description:
+ "Push an organization's plans into RevenueCat as products (creating or renaming them across the project's apps) and set test-store prices from each plan's price. Requires the org to have linked RevenueCat via OAuth.",
+ spec: (spec) => ({
+ ...spec,
+ "x-speakeasy-name-override": "syncRevenueCat",
+ }),
+ })
+ .input(
+ SyncRevenueCatSchema.meta({
+ title: "SyncRevenueCatParams",
+ examples: [
+ {
+ organization_slug: "acme",
+ env: "test",
+ product_ids: ["pro", "premium"],
+ },
+ ],
+ }),
+ )
+ .output(
+ SyncRevenueCatResponseSchema.meta({
+ title: "SyncRevenueCatResponse",
+ examples: [
+ {
+ results: [
+ {
+ plan_id: "pro",
+ status: "synced",
+ store_identifier: "autumn.sandbox.org_123.pro",
+ apps: [
+ {
+ app_id: "app_test",
+ app_type: "test_store",
+ product: "created",
+ store_push: "skipped",
+ price: "set",
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ }),
+ );
+
+export const platformGetRevenueCatKeysContract = oc
+ .route({
+ method: "POST",
+ path: "/v1/platform.get_revenuecat_keys",
+ operationId: "getRevenueCatKeys",
+ tags: ["platform"],
+ description:
+ "Retrieve a managed organization's RevenueCat public (SDK) API keys, grouped by app — for the test store, App Store, and Google Play Store. Use these to configure the RevenueCat SDK in the org's mobile app.",
+ spec: (spec) => ({
+ ...spec,
+ "x-speakeasy-name-override": "getRevenueCatKeys",
+ }),
+ })
+ .input(
+ GetRevenueCatKeysSchema.meta({
+ title: "GetRevenueCatKeysParams",
+ examples: [{ organization_slug: "acme", env: "test" }],
+ }),
+ )
+ .output(
+ GetRevenueCatKeysResponseSchema.meta({
+ title: "GetRevenueCatKeysResponse",
+ examples: [
+ {
+ apps: [
+ {
+ app_id: "app1a2b3c4d",
+ app_type: "test_store",
+ name: "Acme (Test Store)",
+ api_keys: [
+ {
+ id: "apikey12345",
+ key: "test_aBcDeFgHiJkLmNoPqRsTuVwXyZ",
+ environment: "production",
+ app_id: "app1a2b3c4",
+ },
+ ],
+ },
+ ],
+ oauth_access_token: "atk_aBcDeFgHiJkLmNoPqRsTuVwXyZ",
+ },
+ ],
+ }),
+ );
diff --git a/packages/sdk/.speakeasy/code-samples.overlay.yaml b/packages/sdk/.speakeasy/code-samples.overlay.yaml
index 06d3950ce..e839f89ef 100644
--- a/packages/sdk/.speakeasy/code-samples.overlay.yaml
+++ b/packages/sdk/.speakeasy/code-samples.overlay.yaml
@@ -962,6 +962,81 @@ actions:
console.log(result);
}
+ run();
+ - target: $["paths"]["/v1/platform.get_revenuecat_keys"]["post"]
+ update:
+ x-codeSamples:
+ - lang: typescript
+ label: Typescript (SDK)
+ source: |-
+ import { Autumn } from "@useautumn/sdk";
+
+ const autumn = new Autumn({
+ xApiVersion: "2.3.0",
+ secretKey: process.env["AUTUMN_SECRET_KEY"] ?? "",
+ });
+
+ async function run() {
+ const result = await autumn.platform.getRevenueCatKeys({
+ organizationSlug: "acme",
+ env: "test",
+ });
+
+ console.log(result);
+ }
+
+ run();
+ - target: $["paths"]["/v1/platform.link_revenuecat"]["post"]
+ update:
+ x-codeSamples:
+ - lang: typescript
+ label: Typescript (SDK)
+ source: |-
+ import { Autumn } from "@useautumn/sdk";
+
+ const autumn = new Autumn({
+ xApiVersion: "2.3.0",
+ secretKey: process.env["AUTUMN_SECRET_KEY"] ?? "",
+ });
+
+ async function run() {
+ const result = await autumn.platform.linkRevenueCat({
+ organizationSlug: "acme",
+ env: "test",
+ projectName: "acme-mobile",
+ redirectUrl: "https://dashboard.useautumn.com/dev?tab=revenuecat",
+ });
+
+ console.log(result);
+ }
+
+ run();
+ - target: $["paths"]["/v1/platform.sync_revenuecat"]["post"]
+ update:
+ x-codeSamples:
+ - lang: typescript
+ label: Typescript (SDK)
+ source: |-
+ import { Autumn } from "@useautumn/sdk";
+
+ const autumn = new Autumn({
+ xApiVersion: "2.3.0",
+ secretKey: process.env["AUTUMN_SECRET_KEY"] ?? "",
+ });
+
+ async function run() {
+ const result = await autumn.platform.syncRevenueCat({
+ organizationSlug: "acme",
+ env: "test",
+ productIds: [
+ "pro",
+ "premium",
+ ],
+ });
+
+ console.log(result);
+ }
+
run();
- target: $["paths"]["/v1/referrals.create_code"]["post"]
update:
diff --git a/packages/sdk/.speakeasy/gen.lock b/packages/sdk/.speakeasy/gen.lock
index ad760545e..7275c6adf 100644
--- a/packages/sdk/.speakeasy/gen.lock
+++ b/packages/sdk/.speakeasy/gen.lock
@@ -1,19 +1,20 @@
lockVersion: 2.0.0
id: 7b300647-cd76-49e9-bf77-7d1bf5446d66
management:
- docChecksum: e390e89b1d94851ffb6b3ce6ed8c1ce6
+ docChecksum: 8871f01e9bcff4b338df44c213b1e355
docVersion: 2.3.0
speakeasyVersion: 1.762.0
generationVersion: 2.882.0
releaseVersion: 0.10.17
configChecksum: 4722f16a8dee67ebd4038caf3c345296
persistentEdits:
- generation_id: 0967a67e-44f5-480b-8618-908113bb6ac3
- pristine_commit_hash: 5733a696f1b502aa39fad55bdc2a94cb7896f905
- pristine_tree_hash: 04e95997860cfc9b50ea04151ba3165cefc2af8a
+ generation_id: f374e041-3680-4d4c-bb37-4ab0614209de
+ pristine_commit_hash: cb76a996f7b1f4a11fb853cf30564f860c859ffd
+ pristine_tree_hash: 00b78979361e0ebbbac1ef29531df0aeb71cba05
features:
typescript:
additionalDependencies: 0.1.0
+ additionalProperties: 0.1.3
constsAndDefaults: 0.1.14
core: 3.26.50
defaultEnabledRetries: 0.1.0
@@ -90,6 +91,10 @@ trackedFiles:
id: 147b886181ab
last_write_checksum: sha1:745ae2d8cabb73e63fed45a1d83682e602daa81b
pristine_git_object: 39e2e27b82c7b641f0a498550c99017a00321785
+ docs/models/api-key.md:
+ id: 49935f80e61b
+ last_write_checksum: sha1:4fbedd42011cb6919ce65469d7cda5be719b5971
+ pristine_git_object: 1690222ff6b428eff348de3b09fb103bee30e9b5
docs/models/attach-action.md:
id: 8de20a26b9b9
last_write_checksum: sha1:44c1a79c9374746d732856aa68bf5844c148bdf8
@@ -662,6 +667,14 @@ trackedFiles:
id: 1f4a01957fbe
last_write_checksum: sha1:c8c546b4aa4d1e459aef8131e522e6bf41ccc7c7
pristine_git_object: 5815e9a4d8b5921e63cce39209bb8172a65659d8
+ docs/models/check-product1.md:
+ id: 9ba1f3f0f2e9
+ last_write_checksum: sha1:98b546b9cebd5b773c0d82b0241cbc9e2df91692
+ pristine_git_object: eea36f3d8674d15e62974845a13361705dee1eb1
+ docs/models/check-product2.md:
+ id: 7d4ef4fdab22
+ last_write_checksum: sha1:8aae0bb7098cda51316e5e6eb83497f73a740fcc
+ pristine_git_object: a5cf026e769ee5a7302238cbbcab7752bcf0de9e
docs/models/check-response-body1.md:
id: fe7fb45cea45
last_write_checksum: sha1:8844e03a5e1af097130d9578157d06c96ecec130
@@ -1918,6 +1931,26 @@ trackedFiles:
id: 5300ef539ed8
last_write_checksum: sha1:b334efb80b4e13e356c43248994d2f2640ffcf27
pristine_git_object: b6ff1ca2773ceb3a33da7acc3bf2144f2823c16a
+ docs/models/get-revenue-cat-keys-app.md:
+ id: 8ff879eb81c5
+ last_write_checksum: sha1:23379e8e1717339e91ff62f63c9a5115c03a8645
+ pristine_git_object: 72a6b23198b2ae6607ea5a1b6e837959b08d1bee
+ docs/models/get-revenue-cat-keys-env.md:
+ id: 8fac28e0850c
+ last_write_checksum: sha1:55e6cc463e3ec7dc30dc5f947498665dbb4b10a9
+ pristine_git_object: 2970169afd4db5e7f45502de6d81acfd22c26e47
+ docs/models/get-revenue-cat-keys-globals.md:
+ id: c16f86821cb5
+ last_write_checksum: sha1:dc3c016b33a02bc5d989a84faeede70d292a26fc
+ pristine_git_object: 594305da769e390d0b500044f781edfa178838c2
+ docs/models/get-revenue-cat-keys-params.md:
+ id: 1eccc6003eaa
+ last_write_checksum: sha1:e2f7cf31d2a3631b84cef36087e071bd80a7a58a
+ pristine_git_object: 53e5c4b12907bb9c35af3da1fc7cc5a92a7d34bf
+ docs/models/get-revenue-cat-keys-response.md:
+ id: 23fbb157aef1
+ last_write_checksum: sha1:8b52d7474285395cdc4850ec3575c34a48bd6894
+ pristine_git_object: e4188090653f6bb5d14d111effa8f5891a2874e1
docs/models/included-usage1.md:
id: a4def1415784
last_write_checksum: sha1:83f97786c0a5ca88c4f689e318146531cdd84ba8
@@ -1938,6 +1971,22 @@ trackedFiles:
id: 40dd7473ab87
last_write_checksum: sha1:5a9566a4c38c01c126b5024eb5178b00696eec1a
pristine_git_object: c090aed3f7bd612068482258027339e35a59633e
+ docs/models/link-revenue-cat-env.md:
+ id: cb0ccb8f0c80
+ last_write_checksum: sha1:1ac365d9491e8286e3127d81892998b66ea71410
+ pristine_git_object: 73950de2835b7e83b726bc3c79c10cd3c17c6bd2
+ docs/models/link-revenue-cat-globals.md:
+ id: f4756c9e50ec
+ last_write_checksum: sha1:8078995f5223a872d9fd096689b4e3ee681488c3
+ pristine_git_object: f2b91703b4d9d47b169953e042a46791b3fbde11
+ docs/models/link-revenue-cat-params.md:
+ id: ab17a5cb36c9
+ last_write_checksum: sha1:b5f8698a52fc265067baa8917ab92a46a1789eb0
+ pristine_git_object: 4c42fb2a6a88ff9ac5a9b78955e65852cd3d9498
+ docs/models/link-revenue-cat-response.md:
+ id: 2aaf8d83ed87
+ last_write_checksum: sha1:55f93a53e4775d761e24c52886c27d40068c1f95
+ pristine_git_object: ec7ad309188875f2945342887e9e584b8a554ac5
docs/models/list-customers-auto-topup.md:
id: 2ee1f1219e0a
last_write_checksum: sha1:d052f3f37235db8af434e1d5f143bfb5f1baff39
@@ -3328,12 +3377,12 @@ trackedFiles:
pristine_git_object: f62b42a1a7d41787d477f99ef1702a735196a400
docs/models/preview1.md:
id: 203e34d3c393
- last_write_checksum: sha1:baa476c6f9eca778b764c559b11eb985d9396c46
- pristine_git_object: 6b9852fd043a11b17349ff52cbdafb537ac867e0
+ last_write_checksum: sha1:4da79d0ac7f4d510c2455484f81cd167f5aae690
+ pristine_git_object: 64e7b72a04137b9dcc655a0f9287cca57559d11f
docs/models/preview2.md:
id: 96d6fae57a72
- last_write_checksum: sha1:728c176a1f265ca95c54c05ec4b62bb429b26aa2
- pristine_git_object: 3abb2cf65f13570a222275a9b2c7c38be94d5f81
+ last_write_checksum: sha1:25a6b7488d3f6054be8a4ac5e709f25172c113f7
+ pristine_git_object: ab4ed7ae6c9b76470f8e19fc109c6fc3da27eb1f
docs/models/processor-type.md:
id: 4e1ee9632454
last_write_checksum: sha1:e5c091efb3ac5d256119b589918fb9e3fd91b92f
@@ -3366,14 +3415,6 @@ trackedFiles:
id: c75d4dab1916
last_write_checksum: sha1:0e5d9f554d0ab6ebd5073b00b14d5eddaf42c825
pristine_git_object: 5add6b6648df42f5dd60678e644fec5a3f2626f4
- docs/models/product1.md:
- id: 880ca8ae9886
- last_write_checksum: sha1:9253388ef4ce974184b84ebeb012d18defed9e21
- pristine_git_object: 67ec68bcf882341e97e4575429c1656c5a5bbafa
- docs/models/product2.md:
- id: 6262b044d234
- last_write_checksum: sha1:eba731a0429ff4824f8e658bc87b6b9047ff4b3a
- pristine_git_object: 0a1d5f6fda8ab96d8987a5617bb55fd3882ffbd4
docs/models/properties1.md:
id: d1dd750f2ed3
last_write_checksum: sha1:2336ba133059be249159bdcd6a7981282ad69796
@@ -3430,6 +3471,10 @@ trackedFiles:
id: c8db17c466f9
last_write_checksum: sha1:2d2515519b8dc6b4ce1b5d663e73307c775732c3
pristine_git_object: 4f54fb31462275086e474968960b97b1f2ad877d
+ docs/models/result.md:
+ id: b850437752c3
+ last_write_checksum: sha1:efb16a202bfd987b865e4f7dc312954a0d21a000
+ pristine_git_object: d9d7b5b15d8221e0f6d95a7a6f434568fa8c2240
docs/models/revenuecat.md:
id: 5418b6373a80
last_write_checksum: sha1:70e79f733d444a07f8d4e770469e87bdf4a3d946
@@ -3634,6 +3679,10 @@ trackedFiles:
id: 9706acdb1f5d
last_write_checksum: sha1:e11936ae1dd8e969bcbe07a7b69ec433da66041c
pristine_git_object: 8167bcd124d1a988869aa0b277f3c2ce7d2a7784
+ docs/models/store-push.md:
+ id: 368a0872dffb
+ last_write_checksum: sha1:46e3a5598b870c4dae584c3e9e02913cf2a41096
+ pristine_git_object: d590d604b6395e0340a510a23fb177e98ab4f8cb
docs/models/stripe.md:
id: ef8fa4c7fedd
last_write_checksum: sha1:344040d40ef640447f1762e50bb8b86c55b78087
@@ -3646,6 +3695,38 @@ trackedFiles:
id: 4a200793e0f4
last_write_checksum: sha1:6296413c9481ca2a0ce2e6cc3975292efe9c613b
pristine_git_object: ada66d89888fcfc5e0bd712ba37e271cd278fbd9
+ docs/models/sync-revenue-cat-app.md:
+ id: af68876c4d0c
+ last_write_checksum: sha1:dbb71b010e8dc4d4ae83791d661d7c4742c65812
+ pristine_git_object: 202f3f4146446b29250e6d5a2a8ac51dd82c06bc
+ docs/models/sync-revenue-cat-env.md:
+ id: 5b8b7785ab8e
+ last_write_checksum: sha1:fc268634d9cc547c8d7bb0b100c3178da3d02cfe
+ pristine_git_object: 7268b9b55c92cc680531015bd25c526f33b136d4
+ docs/models/sync-revenue-cat-globals.md:
+ id: eacabe894f64
+ last_write_checksum: sha1:7259d36665c402542d0fa4a69ea0b906c082d142
+ pristine_git_object: 9020c46603d47d2e3c93bf79c78b4a2f61a7ed9d
+ docs/models/sync-revenue-cat-params.md:
+ id: b0fed933a28a
+ last_write_checksum: sha1:e3b6bec5943c7c8111993e7080db06c7af52090f
+ pristine_git_object: 6fead8cadbfa7f6af37480d1f8197d6c1fc5f0b2
+ docs/models/sync-revenue-cat-price.md:
+ id: 9e6732387f49
+ last_write_checksum: sha1:cca7e269586c0e1f28041acadff318664829a9ef
+ pristine_git_object: fafbf8c29d8c6ac7c7ddfaf9ee90c863da273026
+ docs/models/sync-revenue-cat-product.md:
+ id: a722198b8468
+ last_write_checksum: sha1:389a69f1b61d7179c63fb7e7f87269b9e15a3c0f
+ pristine_git_object: 1069042a078dea9e7e8ef32e564428a3cde0f41e
+ docs/models/sync-revenue-cat-response.md:
+ id: 033dce5b01bf
+ last_write_checksum: sha1:532a509b85652f8ad8564c713a801bcebec3b819
+ pristine_git_object: 14c33612c79a8eb8d661bbdeaa473ed703ee8c69
+ docs/models/sync-revenue-cat-status.md:
+ id: c940a544edd1
+ last_write_checksum: sha1:aec948fabbb6eb7529cadd8df2680f875ed80af1
+ pristine_git_object: 4440b0dca17123cb54413ecc5241ef0571c8219c
docs/models/total.md:
id: f4060c3b4657
last_write_checksum: sha1:db27b4c0beb158424465eff3298baec188ae6bee
@@ -4232,8 +4313,8 @@ trackedFiles:
pristine_git_object: 0ebe5146cd24c153cb9b7655e4f502c09f7d4abd
docs/sdks/billing/README.md:
id: dc915331dd9d
- last_write_checksum: sha1:85e715516c45ca61431fb64f2b1368b30c350b06
- pristine_git_object: 7bc3894e03acf37e5bddfcd736d645130079d200
+ last_write_checksum: sha1:a29da461dcad1fea4fca629be432ee960ffa6cbf
+ pristine_git_object: c07f3e93c879598d44b58834323533c6f1897aef
docs/sdks/customers/README.md:
id: 9332759cffc2
last_write_checksum: sha1:74cd5f6cf800e1d86b2c332fed3c3cd53f3eeb6b
@@ -4254,6 +4335,10 @@ trackedFiles:
id: 2d8c741fff57
last_write_checksum: sha1:57e57bb309355ca9bd404327d90d5ec43e26c6da
pristine_git_object: b74f515e33be0c7a50ffbdca8f70a015dde8bf06
+ docs/sdks/platform/README.md:
+ id: b66219e9cd4d
+ last_write_checksum: sha1:2b78aeff4d3c1023b1c461eba75a15fe864c04f9
+ pristine_git_object: a699333506973bbe1cf50a094e48ed30d5a52d9a
docs/sdks/referrals/README.md:
id: 50b71f597f20
last_write_checksum: sha1:b10fdf64bd7d821c93d721f1ef481f045e046438
@@ -4320,8 +4405,8 @@ trackedFiles:
pristine_git_object: d1d2c39eb61de5da6dc66da31605995ee35edd8b
src/funcs/billing-create-schedule.ts:
id: fd662bfcdc10
- last_write_checksum: sha1:9e3968bbb050e0d6c450b5a062152910b297ae7c
- pristine_git_object: 828fbb7fd1ebc2247754ce45379e4e336bb9b187
+ last_write_checksum: sha1:a11b4f10543ae07e6dc84797e6ce0789ec0351ae
+ pristine_git_object: cb81459b84bdf23fd77e3fdb6b499e8258c4929c
src/funcs/billing-multi-attach.ts:
id: 67491e2d8249
last_write_checksum: sha1:00ba80c1f98e7a8be29db0cf5a6957433f687861
@@ -4442,6 +4527,18 @@ trackedFiles:
id: 86e469e08973
last_write_checksum: sha1:3a7e13fcd2455ee3e6841b4829e50f5b9ea1fcb4
pristine_git_object: 4c6bbef3d3c4078f2df79d353d8071451f602ba0
+ src/funcs/platform-get-revenue-cat-keys.ts:
+ id: 3f6df7d12152
+ last_write_checksum: sha1:ea7b7e7d13cec4a7dc9dfb20431bc67e91f6efb6
+ pristine_git_object: 7cbda750834f8f08674bec0a634512e0a8371319
+ src/funcs/platform-link-revenue-cat.ts:
+ id: 8f8f215b8cf9
+ last_write_checksum: sha1:4fda8ddda1ef317eb44ee94ec61ccc4686144450
+ pristine_git_object: 831bad4585f7918ea27524a68152e3cec2a6c204
+ src/funcs/platform-sync-revenue-cat.ts:
+ id: 6d5ff105f444
+ last_write_checksum: sha1:d41d0c72ab12a834ce3384200513758c53dba517
+ pristine_git_object: cbf471baac869b86393dced84b2e02f57bdf80ff
src/funcs/referrals-create-code.ts:
id: f2088dbf847d
last_write_checksum: sha1:2c980d8a56a6b8b7a15023cea02b9899454807be
@@ -4568,8 +4665,8 @@ trackedFiles:
pristine_git_object: 53ddec09ce985548f2425cd7298b7f46a5d33249
src/models/check-op.ts:
id: 42085bda016a
- last_write_checksum: sha1:dc22bb11dc8320f6196490df90a7ec600279ae18
- pristine_git_object: 4826a7e94a4a1831b344634dd60ed50deaf2e8ed
+ last_write_checksum: sha1:89080a0266713ae7e46a401322f538874539e7e8
+ pristine_git_object: b6c962e140cdc5f81795a593008095b4a2e3d002
src/models/create-balance-op.ts:
id: 537b8ff86863
last_write_checksum: sha1:4d14f12804833140651eb101cef96b9305b45164
@@ -4650,14 +4747,22 @@ trackedFiles:
id: 91c8f8dda7c8
last_write_checksum: sha1:bce81a8cef1f6bf579185174603c9c414a26b258
pristine_git_object: 43ea5f27ac16bea209d71a03ad517a798fd05c5e
+ src/models/get-revenue-cat-keys-op.ts:
+ id: 2d8e9e87f071
+ last_write_checksum: sha1:e1c1f2eaf75a3db81b4800c63cbe2ea6e5b1fa56
+ pristine_git_object: e52b5aaf5f34339dcfa8913a8471efb4c2e3ff7e
src/models/http-client-errors.ts:
id: 5f17dcf0d62b
last_write_checksum: sha1:994ced121c54fecd0af038ccfb7855fbfd3868ec
pristine_git_object: b34f612124c797c2a1106b9735708f679a90b74f
src/models/index.ts:
id: f93644b0f37e
- last_write_checksum: sha1:99b491a94a7a8810c6916539ef699036680e132c
- pristine_git_object: e63734a484c768b68cce9cc70f4886c577840340
+ last_write_checksum: sha1:2c57b1fdb9734c9ccb0f127da60d510b6d4a164a
+ pristine_git_object: 9c3a0afcd755cc0dcc073565280651597367188f
+ src/models/link-revenue-cat-op.ts:
+ id: 6cc62c90b574
+ last_write_checksum: sha1:a2f11a5efb037c6c640da26f07b2a407b7dfffad
+ pristine_git_object: 2f5338db9bdf89719dab6c323e46c6bde381246d
src/models/list-customers-op.ts:
id: b391692c8429
last_write_checksum: sha1:2b1ab0d5e34f41d91a1d92ac4abd87ec54596bd1
@@ -4726,6 +4831,10 @@ trackedFiles:
id: 0e97e999ff3c
last_write_checksum: sha1:c09cf100a8eaedfc307aa7037081eece0db24d82
pristine_git_object: 888b6921e5d7f7526d33f3dfc3a5c4eceb93e55b
+ src/models/sync-revenue-cat-op.ts:
+ id: bf3c25067f7c
+ last_write_checksum: sha1:a5992af2f44badc20be2d55c8091ed4c22dfc903
+ pristine_git_object: 922cf35b3f602cbd31665e4656ab11caedacafb8
src/models/track-op.ts:
id: 5e6a750e8fec
last_write_checksum: sha1:b4ccb3514075bcb1b67df61bdbe52154c46e21b2
@@ -4756,8 +4865,8 @@ trackedFiles:
pristine_git_object: 571de419ea3321d79acec4bddbb46b1580007115
src/sdk/billing.ts:
id: 10905058c4ad
- last_write_checksum: sha1:876dd93560d0019479f17af07c3874f6af8e8e29
- pristine_git_object: cd86ce3ce51049a487cb0aacdc3bd7bc6a850f9e
+ last_write_checksum: sha1:9e9b653cd84c96cbd9b80540a2800b113025d4d4
+ pristine_git_object: 199d000c7899a69863745c75661b6137acfbf9fd
src/sdk/customers.ts:
id: d33e193e0c00
last_write_checksum: sha1:8d64f03efa17b4ef45a6d67a44d23e2943f1cd8b
@@ -4782,6 +4891,10 @@ trackedFiles:
id: c0cb8188cdc1
last_write_checksum: sha1:62ee50f030050dae85c77a2f877b2471970f29d6
pristine_git_object: 521774c3587bff53d05bf80accc45eb9ccb926ac
+ src/sdk/platform.ts:
+ id: 86d659f7229d
+ last_write_checksum: sha1:831299efdeefe09a98910994e31c0ff992f3915f
+ pristine_git_object: 43d8c9737a11477ceff5f0951fb3e5d60670d55f
src/sdk/referrals.ts:
id: bf164167845c
last_write_checksum: sha1:b73c1db6a419f5c7f6643382d5bc399204e95150
@@ -4792,8 +4905,8 @@ trackedFiles:
pristine_git_object: f6a3928ecbf5b6a9907bc7d405808d0839848a04
src/sdk/sdk.ts:
id: 784571af2f69
- last_write_checksum: sha1:91b52ea99e9a7b641d6c66d525961a174bd258ed
- pristine_git_object: 2b0197cb19c31124a3b1cad3a38dec70d3856159
+ last_write_checksum: sha1:33274a26041219427b3477c52c8bd100f08ba630
+ pristine_git_object: da01a652b7e0ec3221579c07c66bab56ff49016b
src/types/async.ts:
id: fac8da972f86
last_write_checksum: sha1:3ff07b3feaf390ec1aeb18ff938e139c6c4a9585
@@ -5706,4 +5819,34 @@ examples:
responses:
"202":
application/json: {"success": true}
+ linkRevenueCat:
+ speakeasy-default-link-revenue-cat:
+ parameters:
+ header:
+ x-api-version: "2.3.0"
+ requestBody:
+ application/json: {"organization_slug": "acme", "env": "test", "project_name": "acme-mobile", "redirect_url": "https://dashboard.useautumn.com/dev?tab=revenuecat"}
+ responses:
+ "200":
+ application/json: {"oauth_url": "https://api.revenuecat.com/oauth2/authorize?client_id=...&redirect_uri=...&response_type=code&scope=project.read+project.write"}
+ syncRevenueCat:
+ speakeasy-default-sync-revenue-cat:
+ parameters:
+ header:
+ x-api-version: "2.3.0"
+ requestBody:
+ application/json: {"organization_slug": "acme", "env": "test", "product_ids": ["pro", "premium"]}
+ responses:
+ "200":
+ application/json: {"results": [{"plan_id": "pro", "status": "synced", "store_identifier": "autumn.sandbox.org_123.pro", "apps": [{"app_id": "app_test", "app_type": "test_store", "product": "created", "store_push": "skipped", "price": "set"}]}]}
+ getRevenueCatKeys:
+ speakeasy-default-get-revenue-cat-keys:
+ parameters:
+ header:
+ x-api-version: "2.3.0"
+ requestBody:
+ application/json: {"organization_slug": "acme", "env": "test"}
+ responses:
+ "200":
+ application/json: {"apps": [{"app_id": "app1a2b3c4d", "app_type": "test_store", "name": "Acme (Test Store)", "api_keys": [{"id": "apikey12345", "key": "test_aBcDeFgHiJkLmNoPqRsTuVwXyZ", "environment": "production", "app_id": "app1a2b3c4"}]}], "oauth_access_token": "atk_aBcDeFgHiJkLmNoPqRsTuVwXyZ"}
examplesVersion: 1.0.2
diff --git a/packages/sdk/.speakeasy/out.openapi.yaml b/packages/sdk/.speakeasy/out.openapi.yaml
index dcb366307..fa093fefe 100644
--- a/packages/sdk/.speakeasy/out.openapi.yaml
+++ b/packages/sdk/.speakeasy/out.openapi.yaml
@@ -8074,7 +8074,7 @@ paths:
@example
```typescript
// Schedule a transition from a trial plan to a paid plan
- const response = await client.billing.createSchedule({ customerId: "cus_123", phases: [{"startsAt":1780512803523,"plans":[{"planId":"trial_plan"}]},{"startsAt":1781722403523,"plans":[{"planId":"pro_plan"}]}] });
+ const response = await client.billing.createSchedule({ customerId: "cus_123", phases: [{"startsAt":1780584084429,"plans":[{"planId":"trial_plan"}]},{"startsAt":1781793684429,"plans":[{"planId":"pro_plan"}]}] });
```
@param customerId - The ID of the customer to create the schedule for.
@@ -17987,6 +17987,282 @@ paths:
x-speakeasy-name-override: redeemCode
parameters:
- *a1
+ /v1/platform.link_revenuecat:
+ post:
+ operationId: linkRevenueCat
+ description: Generate a RevenueCat OAuth URL for linking a project to an organization.
+ tags:
+ - platform
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ organization_slug:
+ type: string
+ minLength: 1
+ env:
+ enum:
+ - test
+ - live
+ type: string
+ project_name:
+ type: string
+ minLength: 1
+ maxLength: 255
+ redirect_url:
+ type: string
+ format: uri
+ required:
+ - organization_slug
+ - env
+ - project_name
+ - redirect_url
+ title: LinkRevenueCatParams
+ examples:
+ - organization_slug: acme
+ env: test
+ project_name: acme-mobile
+ redirect_url: https://dashboard.useautumn.com/dev?tab=revenuecat
+ responses:
+ "200":
+ description: OK
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ oauth_url:
+ type: string
+ required:
+ - oauth_url
+ title: LinkRevenueCatResponse
+ examples:
+ - oauth_url: https://api.revenuecat.com/oauth2/authorize?client_id=...&redirect_uri=...&response_type=code&scope=project.read+project.write
+ x-speakeasy-name-override: linkRevenueCat
+ parameters:
+ - *a1
+ /v1/platform.sync_revenuecat:
+ post:
+ operationId: syncRevenueCat
+ description: Push an organization's plans into RevenueCat as products (creating or renaming them across the project's apps) and set test-store prices from each plan's price. Requires the org to have linked RevenueCat via OAuth.
+ tags:
+ - platform
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ organization_slug:
+ type: string
+ minLength: 1
+ env:
+ enum:
+ - test
+ - sandbox
+ - live
+ type: string
+ description: '"test" and "sandbox" both target the sandbox environment'
+ product_ids:
+ type: array
+ items:
+ type: string
+ description: Plans to push. Omit to sync every plan in the org/env.
+ required:
+ - organization_slug
+ - env
+ title: SyncRevenueCatParams
+ examples:
+ - organization_slug: acme
+ env: test
+ product_ids:
+ - pro
+ - premium
+ responses:
+ "200":
+ description: OK
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ results:
+ type: array
+ items:
+ type: object
+ properties:
+ plan_id:
+ type: string
+ status:
+ enum:
+ - synced
+ - skipped
+ - error
+ type: string
+ store_identifier:
+ type: string
+ apps:
+ type: array
+ items:
+ type: object
+ properties:
+ app_id:
+ type: string
+ app_type:
+ type: string
+ product:
+ enum:
+ - created
+ - updated
+ - exists
+ type: string
+ store_push:
+ enum:
+ - pushed
+ - failed
+ - skipped
+ type: string
+ price:
+ enum:
+ - set
+ - skipped
+ - failed
+ type: string
+ message:
+ type: string
+ required:
+ - app_id
+ - app_type
+ - product
+ message:
+ type: string
+ required:
+ - plan_id
+ - status
+ required:
+ - results
+ title: SyncRevenueCatResponse
+ examples:
+ - results:
+ - plan_id: pro
+ status: synced
+ store_identifier: autumn.sandbox.org_123.pro
+ apps:
+ - app_id: app_test
+ app_type: test_store
+ product: created
+ store_push: skipped
+ price: set
+ x-speakeasy-name-override: syncRevenueCat
+ parameters:
+ - *a1
+ /v1/platform.get_revenuecat_keys:
+ post:
+ operationId: getRevenueCatKeys
+ description: Retrieve a managed organization's RevenueCat public (SDK) API keys, grouped by app — for the test store, App Store, and Google Play Store. Use these to configure the RevenueCat SDK in the org's mobile app.
+ tags:
+ - platform
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ organization_slug:
+ type: string
+ minLength: 1
+ env:
+ enum:
+ - test
+ - sandbox
+ - live
+ type: string
+ description: '"test" and "sandbox" both target the sandbox environment'
+ required:
+ - organization_slug
+ - env
+ title: GetRevenueCatKeysParams
+ examples:
+ - organization_slug: acme
+ env: test
+ responses:
+ "200":
+ description: OK
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ apps:
+ type: array
+ items:
+ type: object
+ properties:
+ app_id:
+ type: string
+ app_type:
+ type: string
+ description: RevenueCat store type, e.g. test_store / app_store / play_store
+ name:
+ type: string
+ api_keys:
+ type: array
+ items:
+ type: object
+ properties:
+ id:
+ type: string
+ key:
+ type: string
+ description: The public SDK API key value
+ environment:
+ anyOf:
+ - type: string
+ - type: "null"
+ description: e.g. "production" / "sandbox"
+ app_id:
+ anyOf:
+ - type: string
+ - type: "null"
+ created_at:
+ type: number
+ required:
+ - id
+ - key
+ additionalProperties: {}
+ required:
+ - app_id
+ - app_type
+ - name
+ - api_keys
+ oauth_access_token:
+ anyOf:
+ - type: string
+ - type: "null"
+ description: Freshly-refreshed RevenueCat OAuth access token for the org (null for api-key orgs). The refresh token is never exposed — call this endpoint again for a new access token.
+ required:
+ - apps
+ - oauth_access_token
+ title: GetRevenueCatKeysResponse
+ examples:
+ - apps:
+ - app_id: app1a2b3c4d
+ app_type: test_store
+ name: Acme (Test Store)
+ api_keys:
+ - id: apikey12345
+ key: test_aBcDeFgHiJkLmNoPqRsTuVwXyZ
+ environment: production
+ app_id: app1a2b3c4
+ oauth_access_token: atk_aBcDeFgHiJkLmNoPqRsTuVwXyZ
+ x-speakeasy-name-override: getRevenueCatKeys
+ parameters:
+ - *a1
security:
- secretKey: []
x-speakeasy-globals:
diff --git a/packages/sdk/README.md b/packages/sdk/README.md
index 3a5f2c233..08cb82530 100644
--- a/packages/sdk/README.md
+++ b/packages/sdk/README.md
@@ -274,7 +274,7 @@ Use this endpoint to schedule future plan changes (e.g. switch from a trial plan
@example
```typescript
// Schedule a transition from a trial plan to a paid plan
-const response = await client.billing.createSchedule({ customerId: "cus_123", phases: [{"startsAt":1780512803523,"plans":[{"planId":"trial_plan"}]},{"startsAt":1781722403523,"plans":[{"planId":"pro_plan"}]}] });
+const response = await client.billing.createSchedule({ customerId: "cus_123", phases: [{"startsAt":1780584084429,"plans":[{"planId":"trial_plan"}]},{"startsAt":1781793684429,"plans":[{"planId":"pro_plan"}]}] });
```
@param customerId - The ID of the customer to create the schedule for.
@@ -705,6 +705,12 @@ const response = await client.features.delete({ featureId: "old-feature" });
* [update](docs/sdks/plans/README.md#update) - Update a plan
* [delete](docs/sdks/plans/README.md#delete) - Delete a plan
+### [Platform](docs/sdks/platform/README.md)
+
+* [linkRevenueCat](docs/sdks/platform/README.md#linkrevenuecat) - Generate a RevenueCat OAuth URL for linking a project to an organization.
+* [syncRevenueCat](docs/sdks/platform/README.md#syncrevenuecat) - Push an organization's plans into RevenueCat as products (creating or renaming them across the project's apps) and set test-store prices from each plan's price. Requires the org to have linked RevenueCat via OAuth.
+* [getRevenueCatKeys](docs/sdks/platform/README.md#getrevenuecatkeys) - Retrieve a managed organization's RevenueCat public (SDK) API keys, grouped by app — for the test store, App Store, and Google Play Store. Use these to configure the RevenueCat SDK in the org's mobile app.
+
### [Referrals](docs/sdks/referrals/README.md)
* [createCode](docs/sdks/referrals/README.md#createcode) - Create or fetch a referral code for a customer in a referral program.
@@ -794,7 +800,7 @@ Use this endpoint to schedule future plan changes (e.g. switch from a trial plan
@example
```typescript
// Schedule a transition from a trial plan to a paid plan
-const response = await client.billing.createSchedule({ customerId: "cus_123", phases: [{"startsAt":1780512803523,"plans":[{"planId":"trial_plan"}]},{"startsAt":1781722403523,"plans":[{"planId":"pro_plan"}]}] });
+const response = await client.billing.createSchedule({ customerId: "cus_123", phases: [{"startsAt":1780584084429,"plans":[{"planId":"trial_plan"}]},{"startsAt":1781793684429,"plans":[{"planId":"pro_plan"}]}] });
```
@param customerId - The ID of the customer to create the schedule for.
@@ -1241,6 +1247,9 @@ const response = await client.features.update({ featureId: "deprecated-feature",
- [`plansGet`](docs/sdks/plans/README.md#get) - Get a plan
- [`plansList`](docs/sdks/plans/README.md#list) - List all plans
- [`plansUpdate`](docs/sdks/plans/README.md#update) - Update a plan
+- [`platformGetRevenueCatKeys`](docs/sdks/platform/README.md#getrevenuecatkeys) - Retrieve a managed organization's RevenueCat public (SDK) API keys, grouped by app — for the test store, App Store, and Google Play Store. Use these to configure the RevenueCat SDK in the org's mobile app.
+- [`platformLinkRevenueCat`](docs/sdks/platform/README.md#linkrevenuecat) - Generate a RevenueCat OAuth URL for linking a project to an organization.
+- [`platformSyncRevenueCat`](docs/sdks/platform/README.md#syncrevenuecat) - Push an organization's plans into RevenueCat as products (creating or renaming them across the project's apps) and set test-store prices from each plan's price. Requires the org to have linked RevenueCat via OAuth.
- [`referralsCreateCode`](docs/sdks/referrals/README.md#createcode) - Create or fetch a referral code for a customer in a referral program.
- [`referralsRedeemCode`](docs/sdks/referrals/README.md#redeemcode) - Redeem a referral code for a customer.
- [`rewardsRedeemCode`](docs/sdks/rewards/README.md#redeemcode) - Redeem a reward promo code for a customer.
diff --git a/packages/sdk/src/funcs/platform-get-revenue-cat-keys.ts b/packages/sdk/src/funcs/platform-get-revenue-cat-keys.ts
new file mode 100644
index 000000000..7cbda7508
--- /dev/null
+++ b/packages/sdk/src/funcs/platform-get-revenue-cat-keys.ts
@@ -0,0 +1,165 @@
+/*
+ * Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.
+ */
+
+import * as z from "zod/v4-mini";
+import { AutumnCore } from "../core.js";
+import { encodeJSON, encodeSimple } from "../lib/encodings.js";
+import { matchStatusCode } from "../lib/http.js";
+import * as M from "../lib/matchers.js";
+import { compactMap } from "../lib/primitives.js";
+import { safeParse } from "../lib/schemas.js";
+import { RequestOptions } from "../lib/sdks.js";
+import { extractSecurity, resolveGlobalSecurity } from "../lib/security.js";
+import { pathToFunc } from "../lib/url.js";
+import { AutumnError } from "../models/autumn-error.js";
+import {
+ ConnectionError,
+ InvalidRequestError,
+ RequestAbortedError,
+ RequestTimeoutError,
+ UnexpectedClientError,
+} from "../models/http-client-errors.js";
+import * as models from "../models/index.js";
+import { ResponseValidationError } from "../models/response-validation-error.js";
+import { SDKValidationError } from "../models/sdk-validation-error.js";
+import { APICall, APIPromise } from "../types/async.js";
+import { Result } from "../types/fp.js";
+
+/**
+ * Retrieve a managed organization's RevenueCat public (SDK) API keys, grouped by app — for the test store, App Store, and Google Play Store. Use these to configure the RevenueCat SDK in the org's mobile app.
+ */
+export function platformGetRevenueCatKeys(
+ client: AutumnCore,
+ request: models.GetRevenueCatKeysParams,
+ options?: RequestOptions,
+): APIPromise<
+ Result<
+ models.GetRevenueCatKeysResponse,
+ | AutumnError
+ | ResponseValidationError
+ | ConnectionError
+ | RequestAbortedError
+ | RequestTimeoutError
+ | InvalidRequestError
+ | UnexpectedClientError
+ | SDKValidationError
+ >
+> {
+ return new APIPromise($do(
+ client,
+ request,
+ options,
+ ));
+}
+
+async function $do(
+ client: AutumnCore,
+ request: models.GetRevenueCatKeysParams,
+ options?: RequestOptions,
+): Promise<
+ [
+ Result<
+ models.GetRevenueCatKeysResponse,
+ | AutumnError
+ | ResponseValidationError
+ | ConnectionError
+ | RequestAbortedError
+ | RequestTimeoutError
+ | InvalidRequestError
+ | UnexpectedClientError
+ | SDKValidationError
+ >,
+ APICall,
+ ]
+> {
+ const parsed = safeParse(
+ request,
+ (value) => z.parse(models.GetRevenueCatKeysParams$outboundSchema, value),
+ "Input validation failed",
+ );
+ if (!parsed.ok) {
+ return [parsed, { status: "invalid" }];
+ }
+ const payload = parsed.value;
+ const body = encodeJSON("body", payload, { explode: true });
+
+ const path = pathToFunc("/v1/platform.get_revenuecat_keys")();
+
+ const headers = new Headers(compactMap({
+ "Content-Type": "application/json",
+ Accept: "application/json",
+ "x-api-version": encodeSimple(
+ "x-api-version",
+ client._options.xApiVersion,
+ { explode: false, charEncoding: "none" },
+ ),
+ }));
+
+ const secConfig = await extractSecurity(client._options.secretKey);
+ const securityInput = secConfig == null ? {} : { secretKey: secConfig };
+ const requestSecurity = resolveGlobalSecurity(securityInput);
+
+ const context = {
+ options: client._options,
+ baseURL: options?.serverURL ?? client._baseURL ?? "",
+ operationID: "getRevenueCatKeys",
+ oAuth2Scopes: null,
+
+ resolvedSecurity: requestSecurity,
+
+ securitySource: client._options.secretKey,
+ retryConfig: options?.retries
+ || client._options.retryConfig
+ || { strategy: "none" },
+ retryCodes: options?.retryCodes || ["429", "500", "502", "503", "504"],
+ };
+
+ const requestRes = client._createRequest(context, {
+ security: requestSecurity,
+ method: "POST",
+ baseURL: options?.serverURL,
+ path: path,
+ headers: headers,
+ body: body,
+ userAgent: client._options.userAgent,
+ timeoutMs: options?.timeoutMs || client._options.timeoutMs || -1,
+ }, options);
+ if (!requestRes.ok) {
+ return [requestRes, { status: "invalid" }];
+ }
+ const req = requestRes.value;
+
+ const doResult = await client._do(req, {
+ context,
+ isErrorStatusCode: (statusCode: number) =>
+ matchStatusCode({ status: statusCode } as Response, ["4XX", "5XX"]),
+ retryConfig: context.retryConfig,
+ retryCodes: context.retryCodes,
+ });
+ if (!doResult.ok) {
+ return [doResult, { status: "request-error", request: req }];
+ }
+ const response = doResult.value;
+
+ const [result] = await M.match<
+ models.GetRevenueCatKeysResponse,
+ | AutumnError
+ | ResponseValidationError
+ | ConnectionError
+ | RequestAbortedError
+ | RequestTimeoutError
+ | InvalidRequestError
+ | UnexpectedClientError
+ | SDKValidationError
+ >(
+ M.json(200, models.GetRevenueCatKeysResponse$inboundSchema),
+ M.fail("4XX"),
+ M.fail("5XX"),
+ )(response, req);
+ if (!result.ok) {
+ return [result, { status: "complete", request: req, response }];
+ }
+
+ return [result, { status: "complete", request: req, response }];
+}
diff --git a/packages/sdk/src/funcs/platform-link-revenue-cat.ts b/packages/sdk/src/funcs/platform-link-revenue-cat.ts
new file mode 100644
index 000000000..831bad458
--- /dev/null
+++ b/packages/sdk/src/funcs/platform-link-revenue-cat.ts
@@ -0,0 +1,165 @@
+/*
+ * Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.
+ */
+
+import * as z from "zod/v4-mini";
+import { AutumnCore } from "../core.js";
+import { encodeJSON, encodeSimple } from "../lib/encodings.js";
+import { matchStatusCode } from "../lib/http.js";
+import * as M from "../lib/matchers.js";
+import { compactMap } from "../lib/primitives.js";
+import { safeParse } from "../lib/schemas.js";
+import { RequestOptions } from "../lib/sdks.js";
+import { extractSecurity, resolveGlobalSecurity } from "../lib/security.js";
+import { pathToFunc } from "../lib/url.js";
+import { AutumnError } from "../models/autumn-error.js";
+import {
+ ConnectionError,
+ InvalidRequestError,
+ RequestAbortedError,
+ RequestTimeoutError,
+ UnexpectedClientError,
+} from "../models/http-client-errors.js";
+import * as models from "../models/index.js";
+import { ResponseValidationError } from "../models/response-validation-error.js";
+import { SDKValidationError } from "../models/sdk-validation-error.js";
+import { APICall, APIPromise } from "../types/async.js";
+import { Result } from "../types/fp.js";
+
+/**
+ * Generate a RevenueCat OAuth URL for linking a project to an organization.
+ */
+export function platformLinkRevenueCat(
+ client: AutumnCore,
+ request: models.LinkRevenueCatParams,
+ options?: RequestOptions,
+): APIPromise<
+ Result<
+ models.LinkRevenueCatResponse,
+ | AutumnError
+ | ResponseValidationError
+ | ConnectionError
+ | RequestAbortedError
+ | RequestTimeoutError
+ | InvalidRequestError
+ | UnexpectedClientError
+ | SDKValidationError
+ >
+> {
+ return new APIPromise($do(
+ client,
+ request,
+ options,
+ ));
+}
+
+async function $do(
+ client: AutumnCore,
+ request: models.LinkRevenueCatParams,
+ options?: RequestOptions,
+): Promise<
+ [
+ Result<
+ models.LinkRevenueCatResponse,
+ | AutumnError
+ | ResponseValidationError
+ | ConnectionError
+ | RequestAbortedError
+ | RequestTimeoutError
+ | InvalidRequestError
+ | UnexpectedClientError
+ | SDKValidationError
+ >,
+ APICall,
+ ]
+> {
+ const parsed = safeParse(
+ request,
+ (value) => z.parse(models.LinkRevenueCatParams$outboundSchema, value),
+ "Input validation failed",
+ );
+ if (!parsed.ok) {
+ return [parsed, { status: "invalid" }];
+ }
+ const payload = parsed.value;
+ const body = encodeJSON("body", payload, { explode: true });
+
+ const path = pathToFunc("/v1/platform.link_revenuecat")();
+
+ const headers = new Headers(compactMap({
+ "Content-Type": "application/json",
+ Accept: "application/json",
+ "x-api-version": encodeSimple(
+ "x-api-version",
+ client._options.xApiVersion,
+ { explode: false, charEncoding: "none" },
+ ),
+ }));
+
+ const secConfig = await extractSecurity(client._options.secretKey);
+ const securityInput = secConfig == null ? {} : { secretKey: secConfig };
+ const requestSecurity = resolveGlobalSecurity(securityInput);
+
+ const context = {
+ options: client._options,
+ baseURL: options?.serverURL ?? client._baseURL ?? "",
+ operationID: "linkRevenueCat",
+ oAuth2Scopes: null,
+
+ resolvedSecurity: requestSecurity,
+
+ securitySource: client._options.secretKey,
+ retryConfig: options?.retries
+ || client._options.retryConfig
+ || { strategy: "none" },
+ retryCodes: options?.retryCodes || ["429", "500", "502", "503", "504"],
+ };
+
+ const requestRes = client._createRequest(context, {
+ security: requestSecurity,
+ method: "POST",
+ baseURL: options?.serverURL,
+ path: path,
+ headers: headers,
+ body: body,
+ userAgent: client._options.userAgent,
+ timeoutMs: options?.timeoutMs || client._options.timeoutMs || -1,
+ }, options);
+ if (!requestRes.ok) {
+ return [requestRes, { status: "invalid" }];
+ }
+ const req = requestRes.value;
+
+ const doResult = await client._do(req, {
+ context,
+ isErrorStatusCode: (statusCode: number) =>
+ matchStatusCode({ status: statusCode } as Response, ["4XX", "5XX"]),
+ retryConfig: context.retryConfig,
+ retryCodes: context.retryCodes,
+ });
+ if (!doResult.ok) {
+ return [doResult, { status: "request-error", request: req }];
+ }
+ const response = doResult.value;
+
+ const [result] = await M.match<
+ models.LinkRevenueCatResponse,
+ | AutumnError
+ | ResponseValidationError
+ | ConnectionError
+ | RequestAbortedError
+ | RequestTimeoutError
+ | InvalidRequestError
+ | UnexpectedClientError
+ | SDKValidationError
+ >(
+ M.json(200, models.LinkRevenueCatResponse$inboundSchema),
+ M.fail("4XX"),
+ M.fail("5XX"),
+ )(response, req);
+ if (!result.ok) {
+ return [result, { status: "complete", request: req, response }];
+ }
+
+ return [result, { status: "complete", request: req, response }];
+}
diff --git a/packages/sdk/src/funcs/platform-sync-revenue-cat.ts b/packages/sdk/src/funcs/platform-sync-revenue-cat.ts
new file mode 100644
index 000000000..cbf471baa
--- /dev/null
+++ b/packages/sdk/src/funcs/platform-sync-revenue-cat.ts
@@ -0,0 +1,165 @@
+/*
+ * Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.
+ */
+
+import * as z from "zod/v4-mini";
+import { AutumnCore } from "../core.js";
+import { encodeJSON, encodeSimple } from "../lib/encodings.js";
+import { matchStatusCode } from "../lib/http.js";
+import * as M from "../lib/matchers.js";
+import { compactMap } from "../lib/primitives.js";
+import { safeParse } from "../lib/schemas.js";
+import { RequestOptions } from "../lib/sdks.js";
+import { extractSecurity, resolveGlobalSecurity } from "../lib/security.js";
+import { pathToFunc } from "../lib/url.js";
+import { AutumnError } from "../models/autumn-error.js";
+import {
+ ConnectionError,
+ InvalidRequestError,
+ RequestAbortedError,
+ RequestTimeoutError,
+ UnexpectedClientError,
+} from "../models/http-client-errors.js";
+import * as models from "../models/index.js";
+import { ResponseValidationError } from "../models/response-validation-error.js";
+import { SDKValidationError } from "../models/sdk-validation-error.js";
+import { APICall, APIPromise } from "../types/async.js";
+import { Result } from "../types/fp.js";
+
+/**
+ * Push an organization's plans into RevenueCat as products (creating or renaming them across the project's apps) and set test-store prices from each plan's price. Requires the org to have linked RevenueCat via OAuth.
+ */
+export function platformSyncRevenueCat(
+ client: AutumnCore,
+ request: models.SyncRevenueCatParams,
+ options?: RequestOptions,
+): APIPromise<
+ Result<
+ models.SyncRevenueCatResponse,
+ | AutumnError
+ | ResponseValidationError
+ | ConnectionError
+ | RequestAbortedError
+ | RequestTimeoutError
+ | InvalidRequestError
+ | UnexpectedClientError
+ | SDKValidationError
+ >
+> {
+ return new APIPromise($do(
+ client,
+ request,
+ options,
+ ));
+}
+
+async function $do(
+ client: AutumnCore,
+ request: models.SyncRevenueCatParams,
+ options?: RequestOptions,
+): Promise<
+ [
+ Result<
+ models.SyncRevenueCatResponse,
+ | AutumnError
+ | ResponseValidationError
+ | ConnectionError
+ | RequestAbortedError
+ | RequestTimeoutError
+ | InvalidRequestError
+ | UnexpectedClientError
+ | SDKValidationError
+ >,
+ APICall,
+ ]
+> {
+ const parsed = safeParse(
+ request,
+ (value) => z.parse(models.SyncRevenueCatParams$outboundSchema, value),
+ "Input validation failed",
+ );
+ if (!parsed.ok) {
+ return [parsed, { status: "invalid" }];
+ }
+ const payload = parsed.value;
+ const body = encodeJSON("body", payload, { explode: true });
+
+ const path = pathToFunc("/v1/platform.sync_revenuecat")();
+
+ const headers = new Headers(compactMap({
+ "Content-Type": "application/json",
+ Accept: "application/json",
+ "x-api-version": encodeSimple(
+ "x-api-version",
+ client._options.xApiVersion,
+ { explode: false, charEncoding: "none" },
+ ),
+ }));
+
+ const secConfig = await extractSecurity(client._options.secretKey);
+ const securityInput = secConfig == null ? {} : { secretKey: secConfig };
+ const requestSecurity = resolveGlobalSecurity(securityInput);
+
+ const context = {
+ options: client._options,
+ baseURL: options?.serverURL ?? client._baseURL ?? "",
+ operationID: "syncRevenueCat",
+ oAuth2Scopes: null,
+
+ resolvedSecurity: requestSecurity,
+
+ securitySource: client._options.secretKey,
+ retryConfig: options?.retries
+ || client._options.retryConfig
+ || { strategy: "none" },
+ retryCodes: options?.retryCodes || ["429", "500", "502", "503", "504"],
+ };
+
+ const requestRes = client._createRequest(context, {
+ security: requestSecurity,
+ method: "POST",
+ baseURL: options?.serverURL,
+ path: path,
+ headers: headers,
+ body: body,
+ userAgent: client._options.userAgent,
+ timeoutMs: options?.timeoutMs || client._options.timeoutMs || -1,
+ }, options);
+ if (!requestRes.ok) {
+ return [requestRes, { status: "invalid" }];
+ }
+ const req = requestRes.value;
+
+ const doResult = await client._do(req, {
+ context,
+ isErrorStatusCode: (statusCode: number) =>
+ matchStatusCode({ status: statusCode } as Response, ["4XX", "5XX"]),
+ retryConfig: context.retryConfig,
+ retryCodes: context.retryCodes,
+ });
+ if (!doResult.ok) {
+ return [doResult, { status: "request-error", request: req }];
+ }
+ const response = doResult.value;
+
+ const [result] = await M.match<
+ models.SyncRevenueCatResponse,
+ | AutumnError
+ | ResponseValidationError
+ | ConnectionError
+ | RequestAbortedError
+ | RequestTimeoutError
+ | InvalidRequestError
+ | UnexpectedClientError
+ | SDKValidationError
+ >(
+ M.json(200, models.SyncRevenueCatResponse$inboundSchema),
+ M.fail("4XX"),
+ M.fail("5XX"),
+ )(response, req);
+ if (!result.ok) {
+ return [result, { status: "complete", request: req, response }];
+ }
+
+ return [result, { status: "complete", request: req, response }];
+}
diff --git a/packages/sdk/src/models/check-op.ts b/packages/sdk/src/models/check-op.ts
index 4826a7e94..b6c962e14 100644
--- a/packages/sdk/src/models/check-op.ts
+++ b/packages/sdk/src/models/check-op.ts
@@ -438,7 +438,7 @@ export type Properties2 = {
updateable?: boolean | null | undefined;
};
-export type Product2 = {
+export type CheckProduct2 = {
/**
* The ID of the product you set when creating the product
*/
@@ -521,7 +521,7 @@ export type Preview2 = {
/**
* Products that would grant access to this feature. Use to display upgrade options.
*/
- products: Array;
+ products: Array;
};
/**
@@ -930,7 +930,7 @@ export type Properties1 = {
updateable?: boolean | null | undefined;
};
-export type Product1 = {
+export type CheckProduct1 = {
/**
* The ID of the product you set when creating the product
*/
@@ -1013,7 +1013,7 @@ export type Preview1 = {
/**
* Products that would grant access to this feature. Use to display upgrade options.
*/
- products: Array;
+ products: Array;
};
/**
@@ -1520,7 +1520,10 @@ export function properties2FromJSON(
}
/** @internal */
-export const Product2$inboundSchema: z.ZodMiniType = z.pipe(
+export const CheckProduct2$inboundSchema: z.ZodMiniType<
+ CheckProduct2,
+ unknown
+> = z.pipe(
z.object({
id: types.string(),
name: types.string(),
@@ -1548,13 +1551,13 @@ export const Product2$inboundSchema: z.ZodMiniType = z.pipe(
}),
);
-export function product2FromJSON(
+export function checkProduct2FromJSON(
jsonString: string,
-): SafeParseResult {
+): SafeParseResult {
return safeParse(
jsonString,
- (x) => Product2$inboundSchema.parse(JSON.parse(x)),
- `Failed to parse 'Product2' from JSON`,
+ (x) => CheckProduct2$inboundSchema.parse(JSON.parse(x)),
+ `Failed to parse 'CheckProduct2' from JSON`,
);
}
@@ -1566,7 +1569,7 @@ export const Preview2$inboundSchema: z.ZodMiniType = z.pipe(
message: types.string(),
feature_id: types.string(),
feature_name: types.string(),
- products: z.array(z.lazy(() => Product2$inboundSchema)),
+ products: z.array(z.lazy(() => CheckProduct2$inboundSchema)),
}),
z.transform((v) => {
return remap$(v, {
@@ -2014,7 +2017,10 @@ export function properties1FromJSON(
}
/** @internal */
-export const Product1$inboundSchema: z.ZodMiniType = z.pipe(
+export const CheckProduct1$inboundSchema: z.ZodMiniType<
+ CheckProduct1,
+ unknown
+> = z.pipe(
z.object({
id: types.string(),
name: types.string(),
@@ -2042,13 +2048,13 @@ export const Product1$inboundSchema: z.ZodMiniType = z.pipe(
}),
);
-export function product1FromJSON(
+export function checkProduct1FromJSON(
jsonString: string,
-): SafeParseResult {
+): SafeParseResult {
return safeParse(
jsonString,
- (x) => Product1$inboundSchema.parse(JSON.parse(x)),
- `Failed to parse 'Product1' from JSON`,
+ (x) => CheckProduct1$inboundSchema.parse(JSON.parse(x)),
+ `Failed to parse 'CheckProduct1' from JSON`,
);
}
@@ -2060,7 +2066,7 @@ export const Preview1$inboundSchema: z.ZodMiniType = z.pipe(
message: types.string(),
feature_id: types.string(),
feature_name: types.string(),
- products: z.array(z.lazy(() => Product1$inboundSchema)),
+ products: z.array(z.lazy(() => CheckProduct1$inboundSchema)),
}),
z.transform((v) => {
return remap$(v, {
diff --git a/packages/sdk/src/models/get-revenue-cat-keys-op.ts b/packages/sdk/src/models/get-revenue-cat-keys-op.ts
new file mode 100644
index 000000000..e52b5aaf5
--- /dev/null
+++ b/packages/sdk/src/models/get-revenue-cat-keys-op.ts
@@ -0,0 +1,193 @@
+/*
+ * Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.
+ */
+
+import * as z from "zod/v4-mini";
+import { remap as remap$ } from "../lib/primitives.js";
+import { safeParse } from "../lib/schemas.js";
+import { ClosedEnum } from "../types/enums.js";
+import { Result as SafeParseResult } from "../types/fp.js";
+import * as types from "../types/primitives.js";
+import { SDKValidationError } from "./sdk-validation-error.js";
+
+export type GetRevenueCatKeysGlobals = {
+ xApiVersion?: string | undefined;
+};
+
+/**
+ * "test" and "sandbox" both target the sandbox environment
+ */
+export const GetRevenueCatKeysEnv = {
+ Test: "test",
+ Sandbox: "sandbox",
+ Live: "live",
+} as const;
+/**
+ * "test" and "sandbox" both target the sandbox environment
+ */
+export type GetRevenueCatKeysEnv = ClosedEnum;
+
+export type GetRevenueCatKeysParams = {
+ organizationSlug: string;
+ /**
+ * "test" and "sandbox" both target the sandbox environment
+ */
+ env: GetRevenueCatKeysEnv;
+};
+
+export type ApiKey = {
+ id: string;
+ /**
+ * The public SDK API key value
+ */
+ key: string;
+ /**
+ * e.g. "production" / "sandbox"
+ */
+ environment?: string | null | undefined;
+ appId?: string | null | undefined;
+ createdAt?: number | undefined;
+ [additionalProperties: string]: unknown;
+};
+
+export type GetRevenueCatKeysApp = {
+ appId: string;
+ /**
+ * RevenueCat store type, e.g. test_store / app_store / play_store
+ */
+ appType: string;
+ name: string;
+ apiKeys: Array;
+};
+
+/**
+ * OK
+ */
+export type GetRevenueCatKeysResponse = {
+ apps: Array;
+ /**
+ * Freshly-refreshed RevenueCat OAuth access token for the org (null for api-key orgs). The refresh token is never exposed — call this endpoint again for a new access token.
+ */
+ oauthAccessToken: string | null;
+};
+
+/** @internal */
+export const GetRevenueCatKeysEnv$outboundSchema: z.ZodMiniEnum<
+ typeof GetRevenueCatKeysEnv
+> = z.enum(GetRevenueCatKeysEnv);
+
+/** @internal */
+export type GetRevenueCatKeysParams$Outbound = {
+ organization_slug: string;
+ env: string;
+};
+
+/** @internal */
+export const GetRevenueCatKeysParams$outboundSchema: z.ZodMiniType<
+ GetRevenueCatKeysParams$Outbound,
+ GetRevenueCatKeysParams
+> = z.pipe(
+ z.object({
+ organizationSlug: z.string(),
+ env: GetRevenueCatKeysEnv$outboundSchema,
+ }),
+ z.transform((v) => {
+ return remap$(v, {
+ organizationSlug: "organization_slug",
+ });
+ }),
+);
+
+export function getRevenueCatKeysParamsToJSON(
+ getRevenueCatKeysParams: GetRevenueCatKeysParams,
+): string {
+ return JSON.stringify(
+ GetRevenueCatKeysParams$outboundSchema.parse(getRevenueCatKeysParams),
+ );
+}
+
+/** @internal */
+export const ApiKey$inboundSchema: z.ZodMiniType = z.pipe(
+ z.catchall(
+ z.object({
+ id: types.string(),
+ key: types.string(),
+ environment: z.optional(z.nullable(types.string())),
+ app_id: z.optional(z.nullable(types.string())),
+ created_at: types.optional(types.number()),
+ }),
+ z.any(),
+ ),
+ z.transform((v) => {
+ return remap$(v, {
+ "app_id": "appId",
+ "created_at": "createdAt",
+ });
+ }),
+);
+
+export function apiKeyFromJSON(
+ jsonString: string,
+): SafeParseResult {
+ return safeParse(
+ jsonString,
+ (x) => ApiKey$inboundSchema.parse(JSON.parse(x)),
+ `Failed to parse 'ApiKey' from JSON`,
+ );
+}
+
+/** @internal */
+export const GetRevenueCatKeysApp$inboundSchema: z.ZodMiniType<
+ GetRevenueCatKeysApp,
+ unknown
+> = z.pipe(
+ z.object({
+ app_id: types.string(),
+ app_type: types.string(),
+ name: types.string(),
+ api_keys: z.array(z.lazy(() => ApiKey$inboundSchema)),
+ }),
+ z.transform((v) => {
+ return remap$(v, {
+ "app_id": "appId",
+ "app_type": "appType",
+ "api_keys": "apiKeys",
+ });
+ }),
+);
+
+export function getRevenueCatKeysAppFromJSON(
+ jsonString: string,
+): SafeParseResult {
+ return safeParse(
+ jsonString,
+ (x) => GetRevenueCatKeysApp$inboundSchema.parse(JSON.parse(x)),
+ `Failed to parse 'GetRevenueCatKeysApp' from JSON`,
+ );
+}
+
+/** @internal */
+export const GetRevenueCatKeysResponse$inboundSchema: z.ZodMiniType<
+ GetRevenueCatKeysResponse,
+ unknown
+> = z.pipe(
+ z.object({
+ apps: z.array(z.lazy(() => GetRevenueCatKeysApp$inboundSchema)),
+ oauth_access_token: types.nullable(types.string()),
+ }),
+ z.transform((v) => {
+ return remap$(v, {
+ "oauth_access_token": "oauthAccessToken",
+ });
+ }),
+);
+
+export function getRevenueCatKeysResponseFromJSON(
+ jsonString: string,
+): SafeParseResult {
+ return safeParse(
+ jsonString,
+ (x) => GetRevenueCatKeysResponse$inboundSchema.parse(JSON.parse(x)),
+ `Failed to parse 'GetRevenueCatKeysResponse' from JSON`,
+ );
+}
diff --git a/packages/sdk/src/models/index.ts b/packages/sdk/src/models/index.ts
index e63734a48..9c3a0afcd 100644
--- a/packages/sdk/src/models/index.ts
+++ b/packages/sdk/src/models/index.ts
@@ -30,7 +30,9 @@ export * from "./get-entity-op.js";
export * from "./get-feature-op.js";
export * from "./get-or-create-customer-op.js";
export * from "./get-plan-op.js";
+export * from "./get-revenue-cat-keys-op.js";
export * from "./http-client-errors.js";
+export * from "./link-revenue-cat-op.js";
export * from "./list-customers-op.js";
export * from "./list-entities-op.js";
export * from "./list-events-op.js";
@@ -48,6 +50,7 @@ export * from "./response-validation-error.js";
export * from "./sdk-validation-error.js";
export * from "./security.js";
export * from "./setup-payment-op.js";
+export * from "./sync-revenue-cat-op.js";
export * from "./track-op.js";
export * from "./update-balance-op.js";
export * from "./update-customer-op.js";
diff --git a/packages/sdk/src/models/link-revenue-cat-op.ts b/packages/sdk/src/models/link-revenue-cat-op.ts
new file mode 100644
index 000000000..2f5338db9
--- /dev/null
+++ b/packages/sdk/src/models/link-revenue-cat-op.ts
@@ -0,0 +1,101 @@
+/*
+ * Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.
+ */
+
+import * as z from "zod/v4-mini";
+import { remap as remap$ } from "../lib/primitives.js";
+import { safeParse } from "../lib/schemas.js";
+import { ClosedEnum } from "../types/enums.js";
+import { Result as SafeParseResult } from "../types/fp.js";
+import * as types from "../types/primitives.js";
+import { SDKValidationError } from "./sdk-validation-error.js";
+
+export type LinkRevenueCatGlobals = {
+ xApiVersion?: string | undefined;
+};
+
+export const LinkRevenueCatEnv = {
+ Test: "test",
+ Live: "live",
+} as const;
+export type LinkRevenueCatEnv = ClosedEnum;
+
+export type LinkRevenueCatParams = {
+ organizationSlug: string;
+ env: LinkRevenueCatEnv;
+ projectName: string;
+ redirectUrl: string;
+};
+
+/**
+ * OK
+ */
+export type LinkRevenueCatResponse = {
+ oauthUrl: string;
+};
+
+/** @internal */
+export const LinkRevenueCatEnv$outboundSchema: z.ZodMiniEnum<
+ typeof LinkRevenueCatEnv
+> = z.enum(LinkRevenueCatEnv);
+
+/** @internal */
+export type LinkRevenueCatParams$Outbound = {
+ organization_slug: string;
+ env: string;
+ project_name: string;
+ redirect_url: string;
+};
+
+/** @internal */
+export const LinkRevenueCatParams$outboundSchema: z.ZodMiniType<
+ LinkRevenueCatParams$Outbound,
+ LinkRevenueCatParams
+> = z.pipe(
+ z.object({
+ organizationSlug: z.string(),
+ env: LinkRevenueCatEnv$outboundSchema,
+ projectName: z.string(),
+ redirectUrl: z.string(),
+ }),
+ z.transform((v) => {
+ return remap$(v, {
+ organizationSlug: "organization_slug",
+ projectName: "project_name",
+ redirectUrl: "redirect_url",
+ });
+ }),
+);
+
+export function linkRevenueCatParamsToJSON(
+ linkRevenueCatParams: LinkRevenueCatParams,
+): string {
+ return JSON.stringify(
+ LinkRevenueCatParams$outboundSchema.parse(linkRevenueCatParams),
+ );
+}
+
+/** @internal */
+export const LinkRevenueCatResponse$inboundSchema: z.ZodMiniType<
+ LinkRevenueCatResponse,
+ unknown
+> = z.pipe(
+ z.object({
+ oauth_url: types.string(),
+ }),
+ z.transform((v) => {
+ return remap$(v, {
+ "oauth_url": "oauthUrl",
+ });
+ }),
+);
+
+export function linkRevenueCatResponseFromJSON(
+ jsonString: string,
+): SafeParseResult {
+ return safeParse(
+ jsonString,
+ (x) => LinkRevenueCatResponse$inboundSchema.parse(JSON.parse(x)),
+ `Failed to parse 'LinkRevenueCatResponse' from JSON`,
+ );
+}
diff --git a/packages/sdk/src/models/sync-revenue-cat-op.ts b/packages/sdk/src/models/sync-revenue-cat-op.ts
new file mode 100644
index 000000000..922cf35b3
--- /dev/null
+++ b/packages/sdk/src/models/sync-revenue-cat-op.ts
@@ -0,0 +1,232 @@
+/*
+ * Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.
+ */
+
+import * as z from "zod/v4-mini";
+import { remap as remap$ } from "../lib/primitives.js";
+import { safeParse } from "../lib/schemas.js";
+import * as openEnums from "../types/enums.js";
+import { ClosedEnum, OpenEnum } from "../types/enums.js";
+import { Result as SafeParseResult } from "../types/fp.js";
+import * as types from "../types/primitives.js";
+import { SDKValidationError } from "./sdk-validation-error.js";
+
+export type SyncRevenueCatGlobals = {
+ xApiVersion?: string | undefined;
+};
+
+/**
+ * "test" and "sandbox" both target the sandbox environment
+ */
+export const SyncRevenueCatEnv = {
+ Test: "test",
+ Sandbox: "sandbox",
+ Live: "live",
+} as const;
+/**
+ * "test" and "sandbox" both target the sandbox environment
+ */
+export type SyncRevenueCatEnv = ClosedEnum;
+
+export type SyncRevenueCatParams = {
+ organizationSlug: string;
+ /**
+ * "test" and "sandbox" both target the sandbox environment
+ */
+ env: SyncRevenueCatEnv;
+ /**
+ * Plans to push. Omit to sync every plan in the org/env.
+ */
+ productIds?: Array | undefined;
+};
+
+export const SyncRevenueCatStatus = {
+ Synced: "synced",
+ Skipped: "skipped",
+ Error: "error",
+} as const;
+export type SyncRevenueCatStatus = OpenEnum;
+
+export const SyncRevenueCatProduct = {
+ Created: "created",
+ Updated: "updated",
+ Exists: "exists",
+} as const;
+export type SyncRevenueCatProduct = OpenEnum;
+
+export const StorePush = {
+ Pushed: "pushed",
+ Failed: "failed",
+ Skipped: "skipped",
+} as const;
+export type StorePush = OpenEnum;
+
+export const SyncRevenueCatPrice = {
+ Set: "set",
+ Skipped: "skipped",
+ Failed: "failed",
+} as const;
+export type SyncRevenueCatPrice = OpenEnum;
+
+export type SyncRevenueCatApp = {
+ appId: string;
+ appType: string;
+ product: SyncRevenueCatProduct;
+ storePush?: StorePush | undefined;
+ price?: SyncRevenueCatPrice | undefined;
+ message?: string | undefined;
+};
+
+export type Result = {
+ planId: string;
+ status: SyncRevenueCatStatus;
+ storeIdentifier?: string | undefined;
+ apps?: Array | undefined;
+ message?: string | undefined;
+};
+
+/**
+ * OK
+ */
+export type SyncRevenueCatResponse = {
+ results: Array;
+};
+
+/** @internal */
+export const SyncRevenueCatEnv$outboundSchema: z.ZodMiniEnum<
+ typeof SyncRevenueCatEnv
+> = z.enum(SyncRevenueCatEnv);
+
+/** @internal */
+export type SyncRevenueCatParams$Outbound = {
+ organization_slug: string;
+ env: string;
+ product_ids?: Array | undefined;
+};
+
+/** @internal */
+export const SyncRevenueCatParams$outboundSchema: z.ZodMiniType<
+ SyncRevenueCatParams$Outbound,
+ SyncRevenueCatParams
+> = z.pipe(
+ z.object({
+ organizationSlug: z.string(),
+ env: SyncRevenueCatEnv$outboundSchema,
+ productIds: z.optional(z.array(z.string())),
+ }),
+ z.transform((v) => {
+ return remap$(v, {
+ organizationSlug: "organization_slug",
+ productIds: "product_ids",
+ });
+ }),
+);
+
+export function syncRevenueCatParamsToJSON(
+ syncRevenueCatParams: SyncRevenueCatParams,
+): string {
+ return JSON.stringify(
+ SyncRevenueCatParams$outboundSchema.parse(syncRevenueCatParams),
+ );
+}
+
+/** @internal */
+export const SyncRevenueCatStatus$inboundSchema: z.ZodMiniType<
+ SyncRevenueCatStatus,
+ unknown
+> = openEnums.inboundSchema(SyncRevenueCatStatus);
+
+/** @internal */
+export const SyncRevenueCatProduct$inboundSchema: z.ZodMiniType<
+ SyncRevenueCatProduct,
+ unknown
+> = openEnums.inboundSchema(SyncRevenueCatProduct);
+
+/** @internal */
+export const StorePush$inboundSchema: z.ZodMiniType =
+ openEnums.inboundSchema(StorePush);
+
+/** @internal */
+export const SyncRevenueCatPrice$inboundSchema: z.ZodMiniType<
+ SyncRevenueCatPrice,
+ unknown
+> = openEnums.inboundSchema(SyncRevenueCatPrice);
+
+/** @internal */
+export const SyncRevenueCatApp$inboundSchema: z.ZodMiniType<
+ SyncRevenueCatApp,
+ unknown
+> = z.pipe(
+ z.object({
+ app_id: types.string(),
+ app_type: types.string(),
+ product: SyncRevenueCatProduct$inboundSchema,
+ store_push: types.optional(StorePush$inboundSchema),
+ price: types.optional(SyncRevenueCatPrice$inboundSchema),
+ message: types.optional(types.string()),
+ }),
+ z.transform((v) => {
+ return remap$(v, {
+ "app_id": "appId",
+ "app_type": "appType",
+ "store_push": "storePush",
+ });
+ }),
+);
+
+export function syncRevenueCatAppFromJSON(
+ jsonString: string,
+): SafeParseResult {
+ return safeParse(
+ jsonString,
+ (x) => SyncRevenueCatApp$inboundSchema.parse(JSON.parse(x)),
+ `Failed to parse 'SyncRevenueCatApp' from JSON`,
+ );
+}
+
+/** @internal */
+export const Result$inboundSchema: z.ZodMiniType = z.pipe(
+ z.object({
+ plan_id: types.string(),
+ status: SyncRevenueCatStatus$inboundSchema,
+ store_identifier: types.optional(types.string()),
+ apps: types.optional(
+ z.array(z.lazy(() => SyncRevenueCatApp$inboundSchema)),
+ ),
+ message: types.optional(types.string()),
+ }),
+ z.transform((v) => {
+ return remap$(v, {
+ "plan_id": "planId",
+ "store_identifier": "storeIdentifier",
+ });
+ }),
+);
+
+export function resultFromJSON(
+ jsonString: string,
+): SafeParseResult {
+ return safeParse(
+ jsonString,
+ (x) => Result$inboundSchema.parse(JSON.parse(x)),
+ `Failed to parse 'Result' from JSON`,
+ );
+}
+
+/** @internal */
+export const SyncRevenueCatResponse$inboundSchema: z.ZodMiniType<
+ SyncRevenueCatResponse,
+ unknown
+> = z.object({
+ results: z.array(z.lazy(() => Result$inboundSchema)),
+});
+
+export function syncRevenueCatResponseFromJSON(
+ jsonString: string,
+): SafeParseResult {
+ return safeParse(
+ jsonString,
+ (x) => SyncRevenueCatResponse$inboundSchema.parse(JSON.parse(x)),
+ `Failed to parse 'SyncRevenueCatResponse' from JSON`,
+ );
+}
diff --git a/packages/sdk/src/sdk/platform.ts b/packages/sdk/src/sdk/platform.ts
new file mode 100644
index 000000000..43d8c9737
--- /dev/null
+++ b/packages/sdk/src/sdk/platform.ts
@@ -0,0 +1,54 @@
+/*
+ * Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.
+ */
+
+import { platformGetRevenueCatKeys } from "../funcs/platform-get-revenue-cat-keys.js";
+import { platformLinkRevenueCat } from "../funcs/platform-link-revenue-cat.js";
+import { platformSyncRevenueCat } from "../funcs/platform-sync-revenue-cat.js";
+import { ClientSDK, RequestOptions } from "../lib/sdks.js";
+import * as models from "../models/index.js";
+import { unwrapAsync } from "../types/fp.js";
+
+export class Platform extends ClientSDK {
+ /**
+ * Generate a RevenueCat OAuth URL for linking a project to an organization.
+ */
+ async linkRevenueCat(
+ request: models.LinkRevenueCatParams,
+ options?: RequestOptions,
+ ): Promise {
+ return unwrapAsync(platformLinkRevenueCat(
+ this,
+ request,
+ options,
+ ));
+ }
+
+ /**
+ * Push an organization's plans into RevenueCat as products (creating or renaming them across the project's apps) and set test-store prices from each plan's price. Requires the org to have linked RevenueCat via OAuth.
+ */
+ async syncRevenueCat(
+ request: models.SyncRevenueCatParams,
+ options?: RequestOptions,
+ ): Promise {
+ return unwrapAsync(platformSyncRevenueCat(
+ this,
+ request,
+ options,
+ ));
+ }
+
+ /**
+ * Retrieve a managed organization's RevenueCat public (SDK) API keys, grouped by app — for the test store, App Store, and Google Play Store. Use these to configure the RevenueCat SDK in the org's mobile app.
+ */
+ async getRevenueCatKeys(
+ request: models.GetRevenueCatKeysParams,
+ options?: RequestOptions,
+ ): Promise {
+ return unwrapAsync(platformGetRevenueCatKeys(
+ this,
+ request,
+ options,
+ ));
+ }
+}
diff --git a/packages/sdk/src/sdk/sdk.ts b/packages/sdk/src/sdk/sdk.ts
index 2b0197cb1..da01a652b 100644
--- a/packages/sdk/src/sdk/sdk.ts
+++ b/packages/sdk/src/sdk/sdk.ts
@@ -15,6 +15,7 @@ import { Entities } from "./entities.js";
import { Events } from "./events.js";
import { Features } from "./features.js";
import { Plans } from "./plans.js";
+import { Platform } from "./platform.js";
import { Referrals } from "./referrals.js";
import { Rewards } from "./rewards.js";
@@ -64,6 +65,11 @@ export class Autumn extends ClientSDK {
return (this._rewards ??= new Rewards(this._options));
}
+ private _platform?: Platform;
+ get platform(): Platform {
+ return (this._platform ??= new Platform(this._options));
+ }
+
/**
* Checks whether a customer currently has enough balance to use a feature.
*
diff --git a/scripts/axiom/cli.ts b/scripts/axiom/cli.ts
new file mode 100644
index 000000000..4e274a1f0
--- /dev/null
+++ b/scripts/axiom/cli.ts
@@ -0,0 +1,45 @@
+/**
+ * Axiom provisioning CLI. Secrets (AXIOM_ADMIN_TOKEN) are injected by infisical
+ * via the package.json scripts:
+ *
+ * bun axiom # dev (infisical --env=dev)
+ * bun axiom:prod # prod (infisical --env=prod)
+ *
+ * Add a new action by registering it in the `actions` map below.
+ */
+import "dotenv/config";
+import { createLeafDataset } from "./createLeafDataset.js";
+
+const actions = {
+ "create-leaf": createLeafDataset,
+} satisfies Record Promise>;
+
+type Action = keyof typeof actions;
+
+const isAction = (value: string | undefined): value is Action =>
+ value !== undefined && Object.hasOwn(actions, value);
+
+const usage = () =>
+ [
+ "Usage: bun axiom (or bun axiom:prod )",
+ "",
+ "Actions:",
+ ...Object.keys(actions).map((action) => ` - ${action}`),
+ ].join("\n");
+
+const main = async () => {
+ const action = process.argv[2];
+ if (!isAction(action)) {
+ console.error(usage());
+ process.exit(1);
+ }
+
+ try {
+ await actions[action]();
+ } catch (error) {
+ console.error(error instanceof Error ? error.message : error);
+ process.exit(1);
+ }
+};
+
+await main();
diff --git a/scripts/axiom/createLeafDataset.ts b/scripts/axiom/createLeafDataset.ts
new file mode 100644
index 000000000..c791c9523
--- /dev/null
+++ b/scripts/axiom/createLeafDataset.ts
@@ -0,0 +1,149 @@
+/**
+ * Idempotently provisions the Axiom `leaf` dataset used for Leaf runtime logs
+ * and MCP usage analytics, and configures its map fields.
+ *
+ * Map fields ("vacuum" the unpredictable nested payloads into a single column):
+ * Tool payloads, req/res bodies, and per-log details have open-ended shape.
+ * Every distinct top-level key would otherwise become its own mapped field and
+ * quickly blow Axiom's per-dataset field limit. These map fields keep nested
+ * keys inside one field each while staying queryable.
+ *
+ * Run via the Axiom CLI (resolves AXIOM_ADMIN_TOKEN from infisical):
+ * bun axiom create-leaf # dev
+ * bun axiom:prod create-leaf # prod
+ *
+ * Notes:
+ * - AXIOM_ADMIN_TOKEN must be a personal API token with dataset create/update
+ * scope, NOT the `xaat-` ingest token used at runtime.
+ * - Safe to re-run: dataset creation tolerates "already exists", and existing
+ * map fields are read before missing fields are created.
+ */
+
+const AXIOM_BASE = "https://api.axiom.co/v2";
+const DATASET = "leaf";
+const DATASET_DESCRIPTION = "Leaf runtime logs and MCP usage analytics";
+
+// Nested, open-ended payloads stored as map fields to stay under the field
+// limit. Keep this list minimal — only genuinely high-cardinality objects.
+const MAP_FIELDS = [
+ "context",
+ "data",
+ "extras",
+ "input",
+ "output",
+ "req",
+ "res",
+];
+
+const authHeaders = (token: string) => ({
+ Authorization: `Bearer ${token}`,
+ "Content-Type": "application/json",
+});
+
+const createDataset = async (token: string) => {
+ const res = await fetch(`${AXIOM_BASE}/datasets`, {
+ method: "POST",
+ headers: authHeaders(token),
+ body: JSON.stringify({
+ name: DATASET,
+ description: DATASET_DESCRIPTION,
+ }),
+ });
+
+ if (res.ok) {
+ console.log(` + created dataset \`${DATASET}\``);
+ return;
+ }
+
+ // 409 (or a 400 mentioning existence) means it's already there — fine.
+ const text = await res.text();
+ if (res.status === 409 || /exist/i.test(text)) {
+ console.log(` = dataset \`${DATASET}\` already exists`);
+ return;
+ }
+
+ throw new Error(`Failed to create dataset: ${res.status} ${text}`);
+};
+
+const getMapFields = async (token: string) => {
+ const res = await fetch(
+ `${AXIOM_BASE}/datasets/${encodeURIComponent(DATASET)}/mapfields`,
+ {
+ method: "GET",
+ headers: authHeaders(token),
+ },
+ );
+
+ const text = await res.text();
+ if (!res.ok) {
+ throw new Error(`Failed to list map fields: ${res.status} ${text}`);
+ }
+
+ const parsed = JSON.parse(text) as unknown;
+ if (!Array.isArray(parsed) || parsed.some((name) => typeof name !== "string")) {
+ throw new Error(`Unexpected map fields response: ${text}`);
+ }
+
+ return new Set(parsed);
+};
+
+const setMapField = async ({
+ existing,
+ name,
+ token,
+}: {
+ existing: Set;
+ name: string;
+ token: string;
+}) => {
+ if (existing.has(name)) {
+ console.log(` = map field: ${name} (already set)`);
+ return;
+ }
+
+ const res = await fetch(
+ `${AXIOM_BASE}/datasets/${encodeURIComponent(DATASET)}/mapfields`,
+ {
+ method: "POST",
+ headers: authHeaders(token),
+ body: JSON.stringify({ name }),
+ },
+ );
+
+ const text = await res.text();
+ if (res.ok) {
+ existing.add(name);
+ console.log(` + map field: ${name}`);
+ return;
+ }
+
+ if (/exist/i.test(text)) {
+ existing.add(name);
+ console.log(` = map field: ${name} (already set)`);
+ return;
+ }
+
+ throw new Error(`Failed to set map field "${name}": ${res.status} ${text}`);
+};
+
+const setMapFields = async (token: string) => {
+ const existing = await getMapFields(token);
+ for (const name of MAP_FIELDS) {
+ await setMapField({ existing, name, token });
+ }
+};
+
+/** Provisions the `leaf` dataset and its map fields. */
+export const createLeafDataset = async () => {
+ const token = process.env.AXIOM_ADMIN_TOKEN;
+ if (!token) {
+ throw new Error(
+ "AXIOM_ADMIN_TOKEN env var is required (personal API token, not xaat-* ingest token)",
+ );
+ }
+
+ console.log(`Provisioning Axiom dataset \`${DATASET}\`...`);
+ await createDataset(token);
+ await setMapFields(token);
+ console.log("\nDone.");
+};
diff --git a/scripts/axiom/setOtelVirtualFields.ts b/scripts/axiom/setOtelVirtualFields.ts
index 051a4ed5e..8c4334f7e 100644
--- a/scripts/axiom/setOtelVirtualFields.ts
+++ b/scripts/axiom/setOtelVirtualFields.ts
@@ -4,10 +4,10 @@
* (`req.url`, `context.org_slug`, `statusCode`, etc.).
*
* Usage:
- * AXIOM_API_TOKEN= bun scripts/axiom/setOtelVirtualFields.ts
+ * AXIOM_ADMIN_TOKEN= bun scripts/axiom/setOtelVirtualFields.ts
*
* Notes:
- * - AXIOM_API_TOKEN must be a personal API token with dataset-write scope,
+ * - AXIOM_ADMIN_TOKEN must be a personal API token with dataset-write scope,
* NOT the `xaat-` ingest token used by the server.
* - Safe to re-run; existing fields with matching names are updated in place.
*/
@@ -149,10 +149,10 @@ type ExistingVField = {
dataset: string;
};
-const token = process.env.AXIOM_API_TOKEN;
+const token = process.env.AXIOM_ADMIN_TOKEN;
if (!token) {
console.error(
- "AXIOM_API_TOKEN env var is required (personal API token, not xaat-* ingest token)",
+ "AXIOM_ADMIN_TOKEN env var is required (personal API token, not xaat-* ingest token)",
);
process.exit(1);
}
diff --git a/scripts/dev.ts b/scripts/dev.ts
index 9b31e7427..e3753bcf5 100644
--- a/scripts/dev.ts
+++ b/scripts/dev.ts
@@ -24,6 +24,9 @@ const CHAT_PORT = process.env.CHAT_PORT
const LOCAL_CLIENT_URL = `http://localhost:${VITE_PORT}`;
const LOCAL_SERVER_URL = `http://localhost:${SERVER_PORT}`;
const LOCAL_CHAT_URL = `http://localhost:${CHAT_PORT}`;
+const publicTunnelUrl = process.env.NGROK_URL?.replace(/\/$/, "");
+const CHAT_URL = process.env.CHAT_URL ?? publicTunnelUrl ?? LOCAL_CHAT_URL;
+const SLACK_BOT_URL = process.env.SLACK_BOT_URL ?? publicTunnelUrl ?? CHAT_URL;
const skipWorkers = false;
const isProductionMode = process.argv.includes("--production");
@@ -36,6 +39,12 @@ const viteAppEnv = envFile.includes(".env.prod")
const useLocalAuthUrls = viteAppEnv === "dev" && !isProductionMode;
const localUrl = (value: string | undefined, fallback: string) =>
value && !value.includes(".useautumn.com") ? value : fallback;
+const SLACK_REDIRECT_URI = useLocalAuthUrls
+ ? localUrl(
+ process.env.SLACK_REDIRECT_URI,
+ `${SLACK_BOT_URL}/slack/oauth/callback`,
+ )
+ : (process.env.SLACK_REDIRECT_URI ?? `${SLACK_BOT_URL}/slack/oauth/callback`);
/**
* Read environment variable from .env file
@@ -295,8 +304,9 @@ async function startDev() {
MCP_RESOURCE_URLS:
process.env.MCP_RESOURCE_URLS ?? `http://localhost:${CHAT_PORT}/mcp`,
AUTUMN_API_URL: process.env.AUTUMN_API_URL ?? LOCAL_SERVER_URL,
- CHAT_URL: process.env.CHAT_URL ?? LOCAL_CHAT_URL,
- SLACK_BOT_URL: process.env.SLACK_BOT_URL ?? LOCAL_CHAT_URL,
+ CHAT_URL,
+ SLACK_BOT_URL,
+ SLACK_REDIRECT_URI,
DISCORD_BOT_URL: process.env.DISCORD_BOT_URL ?? LOCAL_CHAT_URL,
VITE_APP_ENV: viteAppEnv,
...(useLocalAuthUrls && {
diff --git a/scripts/devServices/index.ts b/scripts/devServices/index.ts
index 85492dd47..3b448831c 100644
--- a/scripts/devServices/index.ts
+++ b/scripts/devServices/index.ts
@@ -11,6 +11,7 @@ const localConfig = {
redisStackPort: 6379,
dragonflyPort: 6380,
databaseUrl: "postgresql://postgres:postgres@localhost:5432/autumn",
+ chatStateDatabaseUrl: "postgresql://postgres:postgres@localhost:5432/chat",
cacheUrl: "redis://localhost:6379",
dragonflyUrl: "redis://localhost:6380",
};
@@ -166,6 +167,32 @@ const doctor = async () => {
if (results.some((result) => !result)) process.exit(1);
};
+const psql = ({ args, quiet = false }: { args: string[]; quiet?: boolean }) =>
+ dockerCompose({
+ args: ["exec", "-T", "postgres", "psql", "-U", "postgres", ...args],
+ quiet,
+ });
+
+const ensureChatDatabase = () => {
+ const result = psql({
+ args: [
+ "-d",
+ "postgres",
+ "-tAc",
+ "SELECT 1 FROM pg_database WHERE datname = 'chat'",
+ ],
+ quiet: true,
+ });
+ const exists = new TextDecoder().decode(result.stdout).trim() === "1";
+ if (exists) {
+ log("chat database already exists");
+ return;
+ }
+
+ log("creating chat database");
+ psql({ args: ["-d", "postgres", "-c", "CREATE DATABASE chat"] });
+};
+
const up = async () => {
log("starting Docker services");
dockerCompose({ args: ["up", "-d", "--remove-orphans"] });
@@ -176,6 +203,7 @@ const up = async () => {
waitForTcp({ port: localConfig.dragonflyPort, label: "Dragonfly" }),
]);
+ ensureChatDatabase();
await doctor();
};
@@ -219,6 +247,7 @@ Commands:
Local service values:
DATABASE_URL=${localConfig.databaseUrl}
+ CHAT_STATE_DATABASE_URL=${localConfig.chatStateDatabaseUrl}
CACHE_URL=${localConfig.cacheUrl}
CACHE_URL_US_EAST=${localConfig.cacheUrl}
CACHE_V2_DRAGONFLY_URL=${localConfig.dragonflyUrl}
diff --git a/scripts/mcp/addMcp.ts b/scripts/mcp/addMcp.ts
new file mode 100644
index 000000000..3b76d3e41
--- /dev/null
+++ b/scripts/mcp/addMcp.ts
@@ -0,0 +1,91 @@
+/**
+ * Registers the Autumn MCP server with local AI CLIs (Claude Code + Codex).
+ *
+ * Usage:
+ * bun add-mcp # autumn-dev -> http://localhost:3099/mcp
+ * bun add-mcp # custom name / url
+ *
+ * Only CLIs that are actually installed are touched; the rest are skipped.
+ * The server uses OAuth, so you authenticate on first connect (Claude prompts
+ * automatically; for Codex run `codex mcp login `).
+ */
+
+const DEFAULT_NAME = "autumn-dev";
+const DEFAULT_URL = "http://localhost:3099/mcp";
+
+type Client = {
+ label: string;
+ bin: string;
+ /** Args to remove an existing server of this name (best-effort, ignored). */
+ removeArgs: (name: string) => string[];
+ /** Args to add the streamable-HTTP server. */
+ addArgs: (name: string, url: string) => string[];
+ /** Follow-up the user must run/do (e.g. OAuth login). */
+ next: (name: string) => string;
+};
+
+const clients: Client[] = [
+ {
+ label: "Claude Code",
+ bin: "claude",
+ removeArgs: (name) => ["mcp", "remove", name],
+ addArgs: (name, url) => ["mcp", "add", "--transport", "http", name, url],
+ next: () => "Claude prompts for OAuth automatically on first use.",
+ },
+ {
+ label: "Codex",
+ bin: "codex",
+ removeArgs: (name) => ["mcp", "remove", name],
+ addArgs: (name, url) => ["mcp", "add", name, "--url", url],
+ next: (name) =>
+ `Run \`codex mcp login ${name}\` to authenticate (OAuth). If the handshake fails, retry with \`-c experimental_use_rmcp_client=true\`.`,
+ },
+];
+
+const run = (bin: string, args: string[]) => {
+ const proc = Bun.spawnSync([bin, ...args], {
+ stdout: "pipe",
+ stderr: "pipe",
+ });
+ const output = `${proc.stdout.toString()}${proc.stderr.toString()}`.trim();
+ return { ok: proc.exitCode === 0, output };
+};
+
+const addToClient = (client: Client, name: string, url: string) => {
+ if (!Bun.which(client.bin)) {
+ console.log(`- ${client.label}: skipped (\`${client.bin}\` not found)`);
+ return;
+ }
+
+ // Remove any existing entry first so re-running converges cleanly.
+ run(client.bin, client.removeArgs(name));
+
+ const { ok, output } = run(client.bin, client.addArgs(name, url));
+ if (ok) {
+ console.log(`+ ${client.label}: added \`${name}\` -> ${url}`);
+ console.log(` next: ${client.next(name)}`);
+ return;
+ }
+
+ console.log(`! ${client.label}: failed to add \`${name}\``);
+ if (output) console.log(` ${output.replaceAll("\n", "\n ")}`);
+};
+
+const main = () => {
+ const [, , nameArg, urlArg] = process.argv;
+ if (nameArg === "--help" || nameArg === "-h") {
+ console.log("Usage: bun add-mcp [name] [url]");
+ console.log(`Defaults: ${DEFAULT_NAME} ${DEFAULT_URL}`);
+ return;
+ }
+
+ const name = nameArg ?? DEFAULT_NAME;
+ const url = urlArg ?? DEFAULT_URL;
+
+ console.log(`Registering MCP server \`${name}\` (${url})\n`);
+ for (const client of clients) {
+ addToClient(client, name, url);
+ }
+};
+
+main();
diff --git a/server/package.json b/server/package.json
index 2271aabe3..d035d7f18 100644
--- a/server/package.json
+++ b/server/package.json
@@ -30,7 +30,7 @@
"clear-master": "ENV_FILE=.env infisical run --env=dev --recursive -- bun tests/clearMasterOrg.ts",
"cm": "ENV_FILE=.env infisical run --env=dev --recursive -- bun tests/clearMaster.ts",
"ts": "bunx tsgo --build --noEmit",
- "test:unit": "ENV_FILE=.env infisical run --env=dev --recursive -- bun test tests/unit",
+ "test:unit": "ENV_FILE=.env infisical run --env=dev --recursive -- bun test --isolate tests/unit",
"test:integration": "ENV_FILE=.env infisical run --env=dev --recursive -- bun test --timeout 0 --preload ./tests/setup-integration-tests.ts",
"loadtest": "ENV_FILE=.env infisical run --env=dev --recursive -- npx artillery run perf/load-test/artillery.yml",
"loadtest:leak": "ENV_FILE=.env infisical run --env=dev --recursive -- bun perf/load-test/runLeakTest.ts",
@@ -47,6 +47,7 @@
"dependencies": {
"@ai-sdk/anthropic": "^3.0.9",
"@anthropic-ai/sdk": "^0.32.1",
+ "@autumn/auth": "workspace:*",
"@autumn/ksuid": "workspace:*",
"@autumn/shared": "workspace:*",
"@autumn/stripe-sync": "workspace:*",
diff --git a/server/src/db/pgPoolMonitor.ts b/server/src/db/pgPoolMonitor.ts
index 2d59eec78..21aed6622 100644
--- a/server/src/db/pgPoolMonitor.ts
+++ b/server/src/db/pgPoolMonitor.ts
@@ -49,23 +49,23 @@ export const attachPoolErrorHandlers = ({
};
const emitSnapshot = (): void => {
- const role = getRole();
- for (const { pool, name, max } of registry.values()) {
- const totalCount = pool.totalCount;
- const idleCount = pool.idleCount;
- const waitingCount = pool.waitingCount;
- logger.debug("pg_pool_stats", {
- type: "pg_pool_stats",
- pool: name,
- pid: process.pid,
- role,
- totalCount,
- idleCount,
- waitingCount,
- max,
- utilization: max > 0 ? totalCount / max : 0,
- });
- }
+ // const role = getRole();
+ // for (const { pool, name, max } of registry.values()) {
+ // const totalCount = pool.totalCount;
+ // const idleCount = pool.idleCount;
+ // const waitingCount = pool.waitingCount;
+ // logger.debug("pg_pool_stats", {
+ // type: "pg_pool_stats",
+ // pool: name,
+ // pid: process.pid,
+ // role,
+ // totalCount,
+ // idleCount,
+ // waitingCount,
+ // max,
+ // utilization: max > 0 ? totalCount / max : 0,
+ // });
+ // }
};
export const startPgPoolMonitor = (intervalMs = 30_000): void => {
diff --git a/server/src/external/revenueCat/handlers/handleGetRevenuecatProducts.ts b/server/src/external/revenueCat/handlers/handleGetRevenuecatProducts.ts
index 116e7e92c..1401b3f14 100644
--- a/server/src/external/revenueCat/handlers/handleGetRevenuecatProducts.ts
+++ b/server/src/external/revenueCat/handlers/handleGetRevenuecatProducts.ts
@@ -1,32 +1,30 @@
import { AppEnv } from "@shared/index";
import { Scopes } from "@autumn/shared";
import { createRoute } from "@/honoMiddlewares/routeHandler";
+import {
+ getRevenuecatAccessToken,
+ getRevenuecatProjectId,
+} from "../misc/getRevenuecatAccessToken";
import { initRevenuecatCli } from "../misc/initRevenuecatCli";
export const handleGetRevenueCatProducts = createRoute({
scopes: [Scopes.Organisation.Read],
handler: async (c) => {
- const { org, env } = c.get("ctx");
+ const { db, org, env } = c.get("ctx");
const revenueCatConfig = org.processor_configs?.revenuecat;
if (!revenueCatConfig) {
return c.json({ products: [] }, 404);
}
- const projectId =
- env === AppEnv.Live
- ? revenueCatConfig.project_id
- : revenueCatConfig.sandbox_project_id;
- const apiKey =
- env === AppEnv.Live
- ? revenueCatConfig.api_key
- : revenueCatConfig.sandbox_api_key;
+ const projectId = getRevenuecatProjectId({ revenueCatConfig, env });
+ const accessToken = await getRevenuecatAccessToken({ db, org, env });
- if (!projectId || !apiKey) {
+ if (!projectId || !accessToken) {
return c.json({ products: [] }, 404);
}
- const rcCli = initRevenuecatCli({ projectId, apiKey });
+ const rcCli = initRevenuecatCli({ projectId, accessToken });
const products = await rcCli.listProducts();
return c.json(products);
diff --git a/server/src/external/revenueCat/handlers/handleGetRevenuecatProjects.ts b/server/src/external/revenueCat/handlers/handleGetRevenuecatProjects.ts
new file mode 100644
index 000000000..fae9585a1
--- /dev/null
+++ b/server/src/external/revenueCat/handlers/handleGetRevenuecatProjects.ts
@@ -0,0 +1,52 @@
+import { ErrCode, RecaseError, Scopes } from "@autumn/shared";
+import { StatusCodes } from "http-status-codes";
+import { z } from "zod/v4";
+import { createRoute } from "@/honoMiddlewares/routeHandler";
+import { getRevenuecatAccessToken } from "../misc/getRevenuecatAccessToken";
+import { initRevenuecatCli } from "../misc/initRevenuecatCli";
+
+export const handleGetRevenueCatProjects = createRoute({
+ scopes: [Scopes.Organisation.Read],
+ handler: async (c) => {
+ const { db, org, env } = c.get("ctx");
+ const revenueCatConfig = org.processor_configs?.revenuecat;
+
+ if (!revenueCatConfig) {
+ return c.json({ projects: [] }, 404);
+ }
+
+ const accessToken = await getRevenuecatAccessToken({ db, org, env });
+
+ if (!accessToken) {
+ return c.json({ projects: [] }, 404);
+ }
+
+ const rcCli = initRevenuecatCli({ accessToken });
+ const projects = await rcCli.listProjects();
+
+ return c.json(projects);
+ },
+});
+
+export const handleCreateRevenueCatProject = createRoute({
+ scopes: [Scopes.Organisation.Write],
+ body: z.object({ name: z.string().min(1).max(255) }),
+ handler: async (c) => {
+ const { db, org, env } = c.get("ctx");
+ const { name } = c.req.valid("json");
+
+ const accessToken = await getRevenuecatAccessToken({ db, org, env });
+ if (!accessToken) {
+ throw new RecaseError({
+ message: "Connect RevenueCat via OAuth before creating a project",
+ code: ErrCode.InvalidRequest,
+ statusCode: StatusCodes.BAD_REQUEST,
+ });
+ }
+
+ const rcCli = initRevenuecatCli({ accessToken });
+ const project = await rcCli.createProject({ name });
+
+ return c.json({ id: project.id, name: project.name });
+ },
+});
diff --git a/server/src/external/revenueCat/handlers/handleListRevenueCatMappings.ts b/server/src/external/revenueCat/handlers/handleListRevenueCatMappings.ts
new file mode 100644
index 000000000..0d3019946
--- /dev/null
+++ b/server/src/external/revenueCat/handlers/handleListRevenueCatMappings.ts
@@ -0,0 +1,24 @@
+import { Scopes } from "@autumn/shared";
+import { createRoute } from "@/honoMiddlewares/routeHandler.js";
+import { RCMappingService } from "../misc/RCMappingService.js";
+
+/**
+ * POST /v1/plans.revenuecat_mappings — returns each Autumn plan's RevenueCat store
+ * product identifier(s) for the calling org/env, so SDK implementers can map a plan
+ * to the product to purchase without reconstructing the identifier themselves.
+ */
+export const handleListRevenueCatMappings = createRoute({
+ scopes: [Scopes.Plans.Read],
+ handler: async (c) => {
+ const { db, org, env } = c.get("ctx");
+
+ const rows = await RCMappingService.getAll({ db, orgId: org.id, env });
+
+ return c.json({
+ mappings: rows.map((row) => ({
+ autumn_product_id: row.autumn_product_id,
+ revenuecat_product_ids: row.revenuecat_product_ids,
+ })),
+ });
+ },
+});
diff --git a/server/src/external/revenueCat/handlers/handlePreflightRevenueCatSync.ts b/server/src/external/revenueCat/handlers/handlePreflightRevenueCatSync.ts
new file mode 100644
index 000000000..5b0102c80
--- /dev/null
+++ b/server/src/external/revenueCat/handlers/handlePreflightRevenueCatSync.ts
@@ -0,0 +1,126 @@
+import {
+ type AppEnv,
+ type FullProduct,
+ type Organization,
+ Scopes,
+} from "@autumn/shared";
+import { createRoute } from "@/honoMiddlewares/routeHandler";
+import { ProductService } from "@/internal/products/ProductService";
+import {
+ getRevenuecatAccessToken,
+ getRevenuecatProjectId,
+} from "../misc/getRevenuecatAccessToken";
+import { initRevenuecatCli } from "../misc/initRevenuecatCli";
+import type { RevenueCatPrice, RevenueCatProduct } from "../revenuecatTypes";
+import {
+ getRcBasePrice,
+ getRcStoreIdentifier,
+} from "../sync/revenuecatProductSyncUtils";
+
+type PreflightPrice = { amount_micros: number; currency: string };
+
+export type PreflightItem = {
+ plan_id: string;
+ autumn_name: string;
+ autumn_price: PreflightPrice | null;
+ rc_exists: boolean;
+ rc_name: string | null;
+ rc_price: PreflightPrice | null;
+};
+
+/**
+ * Pure assembly of the preflight diff: match each plan to its minted RC product and
+ * surface Autumn's base price alongside RC's. `listPrices` is injected so this stays
+ * free of network/DB — the handler wires in `rcCli.listProductPrices`.
+ */
+export const buildRcPreflightItems = async ({
+ products,
+ rcProducts,
+ org,
+ env,
+ listPrices,
+}: {
+ products: FullProduct[];
+ rcProducts: RevenueCatProduct[];
+ org: Organization;
+ env: AppEnv;
+ listPrices: (rcProductId: string) => Promise;
+}): Promise => {
+ // One RC product per store_identifier is enough to read the name + price.
+ const rcByStoreId = new Map();
+ for (const rcProduct of rcProducts) {
+ if (!rcByStoreId.has(rcProduct.store_identifier)) {
+ rcByStoreId.set(rcProduct.store_identifier, rcProduct);
+ }
+ }
+
+ return Promise.all(
+ products.map(async (product) => {
+ const storeId = getRcStoreIdentifier({
+ env,
+ orgId: org.id,
+ planId: product.id,
+ });
+ const base = getRcBasePrice({ product, org });
+ const autumn_price = base
+ ? { amount_micros: base.amountMicros, currency: base.currency }
+ : null;
+
+ const rcProduct = rcByStoreId.get(storeId);
+ if (!rcProduct) {
+ return {
+ plan_id: product.id,
+ autumn_name: product.name || product.id,
+ autumn_price,
+ rc_exists: false,
+ rc_name: null,
+ rc_price: null,
+ };
+ }
+
+ const prices = await listPrices(rcProduct.id);
+ const rc_price = prices[0]
+ ? { amount_micros: prices[0].amount_micros, currency: prices[0].currency }
+ : null;
+
+ return {
+ plan_id: product.id,
+ autumn_name: product.name || product.id,
+ autumn_price,
+ rc_exists: true,
+ rc_name: rcProduct.display_name,
+ rc_price,
+ };
+ }),
+ );
+};
+
+/** Read-only preview of what a sync would do per plan (create/rename) + Autumn-vs-RC price divergence. */
+export const handlePreflightRevenueCatSync = createRoute({
+ scopes: [Scopes.Organisation.Read],
+ handler: async (c) => {
+ const { db, org, env } = c.get("ctx");
+ const revenueCatConfig = org.processor_configs?.revenuecat;
+ if (!revenueCatConfig) return c.json({ items: [] });
+
+ const projectId = getRevenuecatProjectId({ revenueCatConfig, env });
+ const accessToken = await getRevenuecatAccessToken({ db, org, env });
+ if (!projectId || !accessToken) return c.json({ items: [] });
+
+ const rcCli = initRevenuecatCli({ projectId, accessToken });
+ const [products, rcProducts] = await Promise.all([
+ ProductService.listFull({ db, orgId: org.id, env }),
+ rcCli.listAllProducts(),
+ ]);
+
+ const items = await buildRcPreflightItems({
+ products,
+ rcProducts,
+ org,
+ env,
+ listPrices: (id) => rcCli.listProductPrices(id),
+ });
+
+ return c.json({ items });
+ },
+});
diff --git a/server/src/external/revenueCat/handlers/handleSyncRevenueCatProducts.ts b/server/src/external/revenueCat/handlers/handleSyncRevenueCatProducts.ts
new file mode 100644
index 000000000..3d37582ee
--- /dev/null
+++ b/server/src/external/revenueCat/handlers/handleSyncRevenueCatProducts.ts
@@ -0,0 +1,21 @@
+import { Scopes } from "@autumn/shared";
+import { z } from "zod/v4";
+import { createRoute } from "@/honoMiddlewares/routeHandler.js";
+import { syncProductsToRevenueCat } from "../sync/syncRevenueCatProducts.js";
+
+/** POST /v1/organization/revenuecat/sync — push selected Autumn plans into RevenueCat. */
+export const handleSyncRevenueCatProducts = createRoute({
+ scopes: [Scopes.Organisation.Write],
+ body: z.object({ product_ids: z.array(z.string()).min(1) }),
+ handler: async (c) => {
+ const ctx = c.get("ctx");
+ const { product_ids } = c.req.valid("json");
+
+ const results = await syncProductsToRevenueCat({
+ ctx,
+ productIds: product_ids,
+ });
+
+ return c.json({ results });
+ },
+});
diff --git a/server/src/external/revenueCat/misc/getRevenuecatAccessToken.ts b/server/src/external/revenueCat/misc/getRevenuecatAccessToken.ts
new file mode 100644
index 000000000..49331fed4
--- /dev/null
+++ b/server/src/external/revenueCat/misc/getRevenuecatAccessToken.ts
@@ -0,0 +1,152 @@
+import {
+ AppEnv,
+ type Organization,
+ type RevenueCatOAuthConfig,
+ type RevenueCatProcessorConfig,
+} from "@autumn/shared";
+import type { DrizzleCli } from "@/db/initDrizzle.js";
+import { refreshRcTokens } from "@/external/revenueCat/misc/revenuecatOAuth.js";
+import { OrgService } from "@/internal/orgs/OrgService.js";
+import { decryptData, encryptData } from "@/utils/encryptUtils.js";
+
+const TOKEN_EXPIRY_SKEW_MS = 60_000;
+
+const getOAuthConfigForEnv = ({
+ revenueCatConfig,
+ env,
+}: {
+ revenueCatConfig: RevenueCatProcessorConfig;
+ env: AppEnv;
+}): RevenueCatOAuthConfig | undefined =>
+ env === AppEnv.Live ? revenueCatConfig.oauth : revenueCatConfig.sandbox_oauth;
+
+const persistOAuthTokens = async ({
+ db,
+ org,
+ env,
+ oauthConfig,
+}: {
+ db: DrizzleCli;
+ org: Organization;
+ env: AppEnv;
+ oauthConfig: RevenueCatOAuthConfig;
+}) => {
+ const existing = org.processor_configs?.revenuecat || {};
+
+ await OrgService.update({
+ db,
+ orgId: org.id,
+ updates: {
+ processor_configs: {
+ ...org.processor_configs,
+ revenuecat: {
+ ...existing,
+ ...(env === AppEnv.Live
+ ? { oauth: oauthConfig }
+ : { sandbox_oauth: oauthConfig }),
+ },
+ },
+ },
+ });
+};
+
+const isOAuthAccessTokenValid = (oauthConfig: RevenueCatOAuthConfig) =>
+ oauthConfig.expires_at - TOKEN_EXPIRY_SKEW_MS > Date.now();
+
+/** Rotate the OAuth tokens, persist the new pair, and return the fresh access token. */
+const refreshAndPersistTokens = async ({
+ db,
+ org,
+ env,
+ oauthConfig,
+}: {
+ db: DrizzleCli;
+ org: Organization;
+ env: AppEnv;
+ oauthConfig: RevenueCatOAuthConfig;
+}): Promise => {
+ const refreshToken = decryptData(oauthConfig.refresh_token);
+ const tokens = await refreshRcTokens({ refreshToken });
+
+ const refreshedOAuthConfig: RevenueCatOAuthConfig = {
+ ...oauthConfig,
+ access_token: encryptData(tokens.accessToken()),
+ refresh_token: encryptData(tokens.refreshToken()),
+ expires_at: tokens.accessTokenExpiresAt().getTime(),
+ ...(tokens.hasScopes() ? { scope: tokens.scopes().join(" ") } : {}),
+ };
+
+ await persistOAuthTokens({ db, org, env, oauthConfig: refreshedOAuthConfig });
+ return tokens.accessToken();
+};
+
+/**
+ * Force-refresh the env's OAuth access token, persisting the rotated refresh token for us.
+ * Returns the fresh access token, or null if the org isn't OAuth-connected for this env.
+ * Used to hand a platform master a usable access token WITHOUT exposing the refresh token —
+ * so they can't rotate it and lock Autumn out.
+ */
+export const refreshRevenuecatOAuthAccessToken = async ({
+ db,
+ org,
+ env,
+}: {
+ db: DrizzleCli;
+ org: Organization;
+ env: AppEnv;
+}): Promise => {
+ const oauthConfig = getOAuthConfigForEnv({
+ revenueCatConfig: org.processor_configs?.revenuecat ?? {},
+ env,
+ });
+ if (!oauthConfig) return null;
+ return refreshAndPersistTokens({ db, org, env, oauthConfig });
+};
+
+export const getRevenuecatAccessToken = async ({
+ db,
+ org,
+ env,
+}: {
+ db: DrizzleCli;
+ org: Organization;
+ env: AppEnv;
+}): Promise => {
+ const revenueCatConfig = org.processor_configs?.revenuecat;
+ if (!revenueCatConfig) return null;
+
+ const oauthConfig = getOAuthConfigForEnv({ revenueCatConfig, env });
+
+ if (oauthConfig) {
+ if (isOAuthAccessTokenValid(oauthConfig)) {
+ return decryptData(oauthConfig.access_token);
+ }
+
+ return refreshAndPersistTokens({ db, org, env, oauthConfig });
+ }
+
+ const apiKey =
+ env === AppEnv.Live
+ ? revenueCatConfig.api_key
+ : revenueCatConfig.sandbox_api_key;
+
+ return apiKey ? decryptData(apiKey) : null;
+};
+
+export const getRevenuecatProjectId = ({
+ revenueCatConfig,
+ env,
+}: {
+ revenueCatConfig: RevenueCatProcessorConfig;
+ env: AppEnv;
+}): string | undefined => {
+ const oauthConfig = getOAuthConfigForEnv({ revenueCatConfig, env });
+
+ if (oauthConfig?.project_id) {
+ return oauthConfig.project_id;
+ }
+
+ return env === AppEnv.Live
+ ? revenueCatConfig.project_id
+ : revenueCatConfig.sandbox_project_id;
+};
diff --git a/server/src/external/revenueCat/misc/getRevenuecatWebhookSecret.ts b/server/src/external/revenueCat/misc/getRevenuecatWebhookSecret.ts
index 45e504c66..66e167935 100644
--- a/server/src/external/revenueCat/misc/getRevenuecatWebhookSecret.ts
+++ b/server/src/external/revenueCat/misc/getRevenuecatWebhookSecret.ts
@@ -1,5 +1,17 @@
import { AppEnv, type Organization } from "@autumn/shared";
+/** Random 64-char alphanumeric secret RevenueCat echoes back in the Authorization header. */
+export const generateRevenuecatWebhookSecret = (): string => {
+ const chars =
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
+ let result = "";
+ const randomBytes = crypto.getRandomValues(new Uint8Array(64));
+ for (let i = 0; i < 64; i++) {
+ result += chars[randomBytes[i] % chars.length];
+ }
+ return result;
+};
+
export const getRevenuecatWebhookSecret = ({
org,
env,
diff --git a/server/src/external/revenueCat/misc/initRevenuecatCli.ts b/server/src/external/revenueCat/misc/initRevenuecatCli.ts
index 6067d3314..cf5832c1a 100644
--- a/server/src/external/revenueCat/misc/initRevenuecatCli.ts
+++ b/server/src/external/revenueCat/misc/initRevenuecatCli.ts
@@ -1,34 +1,267 @@
import { decryptData } from "@server/utils/encryptUtils.js";
-import type { RevenueCatProductsResponse } from "../revenuecatTypes";
+import { callRcMcpTool } from "./revenuecatMcp.js";
+import type {
+ RevenueCatApp,
+ RevenueCatAppsResponse,
+ RevenueCatCreateInStoreBody,
+ RevenueCatCreateProductBody,
+ RevenueCatCreateProjectBody,
+ RevenueCatPrice,
+ RevenueCatProduct,
+ RevenueCatProductsResponse,
+ RevenueCatProject,
+ RevenueCatProjectsResponse,
+ RevenueCatCreateWebhookBody,
+ RevenueCatPublicApiKey,
+ RevenueCatPublicApiKeysResponse,
+ RevenueCatUpdateProductBody,
+ RevenueCatWebhookIntegration,
+ RevenueCatWebhooksResponse,
+} from "../revenuecatTypes";
type ListRevenuecatProductsResponse = {
products: { id: string; name: string }[];
};
+type ListRevenuecatProjectsResponse = {
+ projects: { id: string; name: string }[];
+};
+
export const initRevenuecatCli = ({
projectId,
apiKey,
+ accessToken,
+ // Injected so unit tests can supply a fake transport instead of touching global fetch.
+ fetchImpl = fetch,
}: {
- projectId: string;
- apiKey: string;
+ projectId?: string;
+ apiKey?: string;
+ accessToken?: string;
+ fetchImpl?: typeof fetch;
}) => {
- let resolvedApiKey = apiKey;
+ const resolvedAccessToken =
+ accessToken ?? (apiKey ? decryptData(apiKey) : undefined);
- resolvedApiKey = decryptData(apiKey);
+ if (!resolvedAccessToken) {
+ throw new Error("RevenueCat access token or API key is required");
+ }
+
+ const authHeaders = {
+ Authorization: `Bearer ${resolvedAccessToken}`,
+ "Content-Type": "application/json",
+ };
+
+ const checkOk = async (response: Response) => {
+ if (!response.ok) {
+ let message: string;
+ try {
+ const body = await response.json();
+ message = JSON.stringify(body);
+ } catch {
+ message = response.statusText;
+ }
+ const error = new Error(
+ `RevenueCat error (${response.status}): ${message}`,
+ ) as Error & { status: number };
+ error.status = response.status;
+ throw error;
+ }
+ };
return {
+ createProject: async ({ name }: RevenueCatCreateProjectBody) => {
+ const url = new URL("https://api.revenuecat.com/v2/projects");
+ const response = await fetchImpl(url, {
+ method: "POST",
+ headers: authHeaders,
+ body: JSON.stringify({ name }),
+ });
+ await checkOk(response);
+ return (await response.json()) as RevenueCatProject;
+ },
+
+ listAppPublicApiKeys: async (
+ appId: string,
+ ): Promise => {
+ const url = new URL(
+ `https://api.revenuecat.com/v2/projects/${projectId}/apps/${appId}/public_api_keys`,
+ );
+ const response = await fetchImpl(url, { headers: authHeaders });
+ await checkOk(response);
+ const data = (await response.json()) as
+ | RevenueCatPublicApiKeysResponse
+ | RevenueCatPublicApiKey[];
+ return Array.isArray(data) ? data : (data.items ?? []);
+ },
+
+ listApps: async (): Promise => {
+ const url = new URL(
+ `https://api.revenuecat.com/v2/projects/${projectId}/apps`,
+ );
+ url.searchParams.set("limit", "50");
+
+ const response = await fetchImpl(url, { headers: authHeaders });
+ await checkOk(response);
+
+ const data = (await response.json()) as RevenueCatAppsResponse;
+ return data.items ?? [];
+ },
+
+ createProduct: async (body: RevenueCatCreateProductBody) => {
+ const url = new URL(
+ `https://api.revenuecat.com/v2/projects/${projectId}/products`,
+ );
+ const response = await fetchImpl(url, {
+ method: "POST",
+ headers: authHeaders,
+ body: JSON.stringify(body),
+ });
+ await checkOk(response);
+ return (await response.json()) as RevenueCatProduct;
+ },
+
+ findProductByStoreIdentifier: async ({
+ appId,
+ storeIdentifier,
+ }: {
+ appId: string;
+ storeIdentifier: string;
+ }): Promise => {
+ let nextPage:
+ | string
+ | null = `/v2/projects/${projectId}/products?limit=100`;
+
+ while (nextPage) {
+ const response = await fetchImpl(
+ new URL(`https://api.revenuecat.com${nextPage}`),
+ { headers: authHeaders },
+ );
+ await checkOk(response);
+ const data = (await response.json()) as RevenueCatProductsResponse;
+
+ const match = data.items.find(
+ (p) =>
+ p.app_id === appId && p.store_identifier === storeIdentifier,
+ );
+ if (match) return match;
+
+ nextPage = data.next_page;
+ }
+
+ return null;
+ },
+
+ listAllProducts: async (): Promise => {
+ const items: RevenueCatProduct[] = [];
+ let nextPage:
+ | string
+ | null = `/v2/projects/${projectId}/products?limit=100`;
+
+ while (nextPage) {
+ const response = await fetchImpl(
+ new URL(`https://api.revenuecat.com${nextPage}`),
+ { headers: authHeaders },
+ );
+ await checkOk(response);
+ const data = (await response.json()) as RevenueCatProductsResponse;
+ items.push(...data.items);
+ nextPage = data.next_page;
+ }
+
+ return items;
+ },
+
+ listProductPrices: async (
+ revenuecatProductId: string,
+ ): Promise => {
+ const url = new URL(
+ `https://api.revenuecat.com/v2/projects/${projectId}/products/${revenuecatProductId}/prices`,
+ );
+ const response = await fetchImpl(url, { headers: authHeaders });
+ await checkOk(response);
+ // RC returns a bare array here, not the usual { items } envelope.
+ const data = (await response.json()) as
+ | RevenueCatPrice[]
+ | { items?: RevenueCatPrice[] };
+ return Array.isArray(data) ? data : (data.items ?? []);
+ },
+
+ // Test-store prices can't be set over the REST API — only via RC's MCP server.
+ setTestStoreProductPrice: async (
+ revenuecatProductId: string,
+ { amountMicros, currency }: { amountMicros: number; currency: string },
+ ) =>
+ callRcMcpTool({
+ accessToken: resolvedAccessToken,
+ name: "create-product-prices",
+ arguments: {
+ project_id: projectId,
+ product_id: revenuecatProductId,
+ prices: [{ amount_micros: amountMicros, currency }],
+ },
+ fetchImpl,
+ }),
+
+ listProductStoreIdentifiers: async (): Promise> => {
+ const ids = new Set();
+ let nextPage:
+ | string
+ | null = `/v2/projects/${projectId}/products?limit=100`;
+
+ while (nextPage) {
+ const response = await fetchImpl(
+ new URL(`https://api.revenuecat.com${nextPage}`),
+ { headers: authHeaders },
+ );
+ await checkOk(response);
+ const data = (await response.json()) as RevenueCatProductsResponse;
+ for (const product of data.items) ids.add(product.store_identifier);
+ nextPage = data.next_page;
+ }
+
+ return ids;
+ },
+
+ updateProduct: async (
+ revenuecatProductId: string,
+ body: RevenueCatUpdateProductBody,
+ ) => {
+ const url = new URL(
+ `https://api.revenuecat.com/v2/projects/${projectId}/products/${revenuecatProductId}`,
+ );
+ const response = await fetchImpl(url, {
+ method: "POST",
+ headers: authHeaders,
+ body: JSON.stringify(body),
+ });
+ await checkOk(response);
+ return (await response.json()) as RevenueCatProduct;
+ },
+
+ createInStore: async (
+ revenuecatProductId: string,
+ body?: RevenueCatCreateInStoreBody,
+ ) => {
+ const url = new URL(
+ `https://api.revenuecat.com/v2/projects/${projectId}/products/${revenuecatProductId}/create_in_store`,
+ );
+ const response = await fetchImpl(url, {
+ method: "POST",
+ headers: authHeaders,
+ body: JSON.stringify(body ?? {}),
+ });
+ await checkOk(response);
+ return await response.json();
+ },
+
listProducts: async () => {
const url = new URL(
`https://api.revenuecat.com/v2/projects/${projectId}/products`,
);
url.searchParams.set("limit", "20");
- const response = await fetch(url, {
- headers: {
- Authorization: `Bearer ${resolvedApiKey}`,
- "Content-Type": "application/json",
- },
- });
+ const response = await fetchImpl(url, { headers: authHeaders });
+ await checkOk(response);
const data = (await response.json()) as RevenueCatProductsResponse;
@@ -50,5 +283,59 @@ export const initRevenuecatCli = ({
})),
} satisfies ListRevenuecatProductsResponse;
},
+
+ listWebhookIntegrations: async (): Promise<
+ RevenueCatWebhookIntegration[]
+ > => {
+ const items: RevenueCatWebhookIntegration[] = [];
+ let nextPage:
+ | string
+ | null = `/v2/projects/${projectId}/integrations/webhooks?limit=100`;
+
+ while (nextPage) {
+ const response = await fetchImpl(
+ new URL(`https://api.revenuecat.com${nextPage}`),
+ { headers: authHeaders },
+ );
+ await checkOk(response);
+ const data = (await response.json()) as RevenueCatWebhooksResponse;
+ items.push(...data.items);
+ nextPage = data.next_page;
+ }
+
+ return items;
+ },
+
+ createWebhookIntegration: async (
+ body: RevenueCatCreateWebhookBody,
+ ): Promise => {
+ const url = new URL(
+ `https://api.revenuecat.com/v2/projects/${projectId}/integrations/webhooks`,
+ );
+ const response = await fetchImpl(url, {
+ method: "POST",
+ headers: authHeaders,
+ body: JSON.stringify(body),
+ });
+ await checkOk(response);
+ return (await response.json()) as RevenueCatWebhookIntegration;
+ },
+
+ listProjects: async () => {
+ const url = new URL("https://api.revenuecat.com/v2/projects");
+ url.searchParams.set("limit", "100");
+
+ const response = await fetchImpl(url, { headers: authHeaders });
+ await checkOk(response);
+
+ const data = (await response.json()) as RevenueCatProjectsResponse;
+
+ return {
+ projects: (data.items ?? []).map((project) => ({
+ id: project.id,
+ name: project.name,
+ })),
+ } satisfies ListRevenuecatProjectsResponse;
+ },
};
};
diff --git a/server/src/external/revenueCat/misc/provisionRevenueCatCusProduct.ts b/server/src/external/revenueCat/misc/provisionRevenueCatCusProduct.ts
new file mode 100644
index 000000000..0d726f283
--- /dev/null
+++ b/server/src/external/revenueCat/misc/provisionRevenueCatCusProduct.ts
@@ -0,0 +1,94 @@
+import {
+ type BillingContextOverride,
+ ErrCode,
+ type FullCusProduct,
+ type FullCustomer,
+ type FullProduct,
+ ProcessorType,
+ RecaseError,
+} from "@autumn/shared";
+import { StatusCodes } from "http-status-codes";
+import type { AutumnContext } from "@/honoUtils/HonoEnv";
+import { attach } from "@/internal/billing/v2/actions/attach/attach";
+import { customerProductRepo } from "@/internal/customers/cusProducts/repos";
+
+/**
+ * Provisions a RevenueCat customer product via V2 attach.
+ *
+ * RC payments happen on App Store / Play Store / etc., so Autumn never reads
+ * or writes Stripe state for these flows. We funnel through V2 `attach()` so
+ * the new cus_product, entitlements, prices, line items, webhooks, and rollover
+ * carry-overs all run through the same pipeline as Stripe/Vercel, just with
+ * the Stripe and external-PSP guards disabled.
+ *
+ * Handles new / upgrade / downgrade scenarios via `computeAttachPlan`'s
+ * transition logic — the caller does not need to expire the outgoing
+ * cus_product manually. Transitions are forced immediate (`plan_schedule`)
+ * since RC is the payment source-of-truth; we don't schedule downgrades
+ * end-of-cycle the way a Stripe-billed attach would.
+ */
+export const provisionRevenueCatCusProduct = async ({
+ ctx,
+ customer,
+ product,
+ revenuecatMetadata,
+}: {
+ ctx: AutumnContext;
+ customer: FullCustomer;
+ product: FullProduct;
+ revenuecatMetadata?: Record;
+}): Promise<{ cusProduct: FullCusProduct; product: FullProduct }> => {
+ const { db, org, env } = ctx;
+
+ // `resolveRevenuecatResources` loads `customer` with `withEntities: true`, which
+ // is what `setupFullCustomerContext` would do anyway. Passing it as an override
+ // skips a redundant DB fetch.
+ const contextOverride: BillingContextOverride = {
+ fullCustomer: customer,
+ productContext: { fullProduct: product },
+ skipBillingFetching: true,
+ skipExternalPSPGuard: true,
+ processorTypeOverride: ProcessorType.RevenueCat,
+ };
+
+ await attach({
+ ctx,
+ params: {
+ customer_id: customer.id || customer.internal_id,
+ plan_id: product.id,
+ redirect_mode: "if_required",
+ no_billing_changes: true,
+ enable_plan_immediately: true,
+ // RC payments are source-of-truth: apply product changes now rather
+ // than scheduling downgrades end-of-cycle like Stripe-style attaches.
+ plan_schedule: "immediate",
+ ...(revenuecatMetadata ? { metadata: revenuecatMetadata } : {}),
+ },
+ contextOverride,
+ skipAutumnCheckout: true,
+ });
+
+ const cusProducts = await customerProductRepo.getByCustomerAndProduct({
+ db,
+ internalCustomerId: customer.internal_id,
+ internalProductId: product.internal_id,
+ orgId: org.id,
+ env,
+ inStatuses: ["active", "trialing", "scheduled"],
+ });
+
+ const cusProduct = cusProducts.find(
+ (cp) => cp.processor?.type === ProcessorType.RevenueCat,
+ );
+
+ if (!cusProduct) {
+ throw new RecaseError({
+ message:
+ "Failed to find newly-provisioned RevenueCat customer product after attach",
+ code: ErrCode.CusProductNotFound,
+ statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
+ });
+ }
+
+ return { cusProduct, product };
+};
diff --git a/server/src/external/revenueCat/misc/registerRevenuecatWebhook.ts b/server/src/external/revenueCat/misc/registerRevenuecatWebhook.ts
new file mode 100644
index 000000000..76328996d
--- /dev/null
+++ b/server/src/external/revenueCat/misc/registerRevenuecatWebhook.ts
@@ -0,0 +1,57 @@
+import { AppEnv } from "@autumn/shared";
+import type { initRevenuecatCli } from "./initRevenuecatCli.js";
+
+type RcCli = ReturnType;
+
+/**
+ * Outbound base URL for our webhook receiver. Dev/staging use NGROK_URL (so RevenueCat
+ * can reach a local tunnel); production uses BETTER_AUTH_URL.
+ */
+const getServerBaseUrl = (): string | undefined =>
+ process.env.NODE_ENV !== "production"
+ ? process.env.NGROK_URL
+ : process.env.BETTER_AUTH_URL;
+
+export const getRevenuecatWebhookUrl = ({
+ orgId,
+ env,
+}: {
+ orgId: string;
+ env: AppEnv;
+}): string | null => {
+ const base = getServerBaseUrl();
+ if (!base) return null;
+ // `:env` segment is the AppEnv value ("sandbox"/"live") — revenueCatMiddleware reads it verbatim.
+ return `${base.replace(/\/$/, "")}/webhooks/revenuecat/${orgId}/${env}`;
+};
+
+/**
+ * Idempotently register the org's RevenueCat webhook for an env: one integration per
+ * environment, matched by URL, with the org's webhook secret as the Authorization header.
+ */
+export const registerRevenuecatWebhook = async ({
+ rcCli,
+ orgId,
+ env,
+ secret,
+}: {
+ rcCli: RcCli;
+ orgId: string;
+ env: AppEnv;
+ secret: string;
+}): Promise<"exists" | "created" | "skipped"> => {
+ const url = getRevenuecatWebhookUrl({ orgId, env });
+ if (!url) return "skipped";
+
+ const existing = await rcCli.listWebhookIntegrations();
+ if (existing.some((webhook) => webhook.url === url)) return "exists";
+
+ await rcCli.createWebhookIntegration({
+ name: `Autumn (${env})`,
+ url,
+ authorization_header: secret,
+ environment: env === AppEnv.Live ? "production" : "sandbox",
+ // no event_types / app_id → all events, all apps for this environment
+ });
+ return "created";
+};
diff --git a/server/src/external/revenueCat/misc/resolveRevenuecatResources.ts b/server/src/external/revenueCat/misc/resolveRevenuecatResources.ts
index 43f5919ef..0c1264f25 100644
--- a/server/src/external/revenueCat/misc/resolveRevenuecatResources.ts
+++ b/server/src/external/revenueCat/misc/resolveRevenuecatResources.ts
@@ -65,6 +65,7 @@ export const resolveRevenuecatResources = async ({
? getOrCreateCustomer({
ctx,
customerId,
+ withEntities: true,
})
: CusService.getFull({
ctx,
diff --git a/server/src/external/revenueCat/misc/revenuecatMcp.ts b/server/src/external/revenueCat/misc/revenuecatMcp.ts
new file mode 100644
index 000000000..1dad9fa74
--- /dev/null
+++ b/server/src/external/revenueCat/misc/revenuecatMcp.ts
@@ -0,0 +1,81 @@
+const RC_MCP_URL = "https://mcp.revenuecat.ai/mcp";
+
+type JsonRpcResult = {
+ result?: { isError?: boolean; content?: unknown };
+ error?: { message?: string };
+};
+
+/**
+ * Call a tool on RevenueCat's hosted MCP server (the only supported way to write
+ * test-store prices). Auth is the org's RC token — OAuth (`atk_`) or secret (`sk_`).
+ */
+export const callRcMcpTool = async ({
+ accessToken,
+ name,
+ arguments: args,
+ fetchImpl = fetch,
+}: {
+ accessToken: string;
+ name: string;
+ arguments: Record;
+ fetchImpl?: typeof fetch;
+}): Promise => {
+ const response = await fetchImpl(RC_MCP_URL, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ "Content-Type": "application/json",
+ Accept: "application/json, text/event-stream",
+ },
+ body: JSON.stringify({
+ jsonrpc: "2.0",
+ id: 1,
+ method: "tools/call",
+ params: { name, arguments: args },
+ }),
+ });
+
+ if (!response.ok) {
+ throw new Error(`RevenueCat MCP error (${response.status})`);
+ }
+
+ // Streamable-HTTP MCP replies as SSE and may emit preamble frames (ping,
+ // progress) before the result — pick the frame that carries result/error.
+ const text = await response.text();
+ const candidates = text
+ .split("\n")
+ .map((line) => line.trim())
+ .filter((line) => line.startsWith("data:") || line.startsWith("{"))
+ .map((line) => line.replace(/^data:\s*/, ""));
+
+ let parsed: JsonRpcResult | undefined;
+ for (const candidate of candidates) {
+ let frame: JsonRpcResult;
+ try {
+ frame = JSON.parse(candidate) as JsonRpcResult;
+ } catch {
+ continue;
+ }
+ if (frame.result !== undefined || frame.error !== undefined) {
+ parsed = frame;
+ break;
+ }
+ }
+
+ if (!parsed) {
+ throw new Error(
+ `RevenueCat MCP returned no result frame: ${text.slice(0, 200)}`,
+ );
+ }
+
+ if (parsed.error) {
+ throw new Error(`RevenueCat MCP tool error: ${parsed.error.message ?? "unknown"}`);
+ }
+ if (parsed.result?.isError) {
+ throw new Error(
+ `RevenueCat MCP tool "${name}" failed: ${JSON.stringify(parsed.result.content).slice(0, 300)}`,
+ );
+ }
+
+ return parsed.result;
+};
diff --git a/server/src/external/revenueCat/misc/revenuecatOAuth.ts b/server/src/external/revenueCat/misc/revenuecatOAuth.ts
new file mode 100644
index 000000000..b2427bc6b
--- /dev/null
+++ b/server/src/external/revenueCat/misc/revenuecatOAuth.ts
@@ -0,0 +1,114 @@
+import {
+ CodeChallengeMethod,
+ generateCodeVerifier,
+ OAuth2Client,
+} from "arctic";
+
+const RC_AUTHORIZE_URL = "https://api.revenuecat.com/oauth2/authorize";
+const RC_TOKEN_URL = "https://api.revenuecat.com/oauth2/token";
+
+export const RC_OAUTH_SCOPES = [
+ "project_configuration:projects:read_write",
+ "project_configuration:apps:read_write",
+ "project_configuration:entitlements:read_write",
+ "project_configuration:offerings:read_write",
+ "project_configuration:packages:read_write",
+ "project_configuration:products:read_write",
+ "project_configuration:integrations:read_write",
+ "project_configuration:virtual_currencies:read_write",
+ "customer_information:customers:read_write",
+ "customer_information:subscriptions:read_write",
+ "customer_information:purchases:read_write",
+ "customer_information:invoices:read",
+ "charts_metrics:overview:read",
+ "charts_metrics:charts:read",
+];
+
+const parseScope = (scope: string) => {
+ const [domain, resource, access] = scope.split(":");
+ return { domain, resource, access };
+};
+
+// RevenueCat collapses broad grants into wildcards (e.g. "*:*:read_write");
+// read_write also satisfies a read requirement.
+const grantSatisfies = (granted: string, required: string): boolean => {
+ const g = parseScope(granted);
+ const r = parseScope(required);
+ const domainOk = g.domain === "*" || g.domain === r.domain;
+ const resourceOk = g.resource === "*" || g.resource === r.resource;
+ const accessOk = g.access === "read_write" || g.access === r.access;
+ return domainOk && resourceOk && accessOk;
+};
+
+export const findMissingRcScopes = (grantedScopes: string[]): string[] => {
+ return RC_OAUTH_SCOPES.filter(
+ (required) =>
+ !grantedScopes.some((granted) => grantSatisfies(granted, required)),
+ );
+};
+
+const getRcOAuthClient = () => {
+ const clientId = process.env.REVENUECAT_OAUTH_CLIENT_ID;
+ const clientSecret = process.env.REVENUECAT_OAUTH_CLIENT_SECRET;
+
+ if (!clientId || !clientSecret) {
+ throw new Error("RevenueCat OAuth client credentials not configured");
+ }
+
+ return new OAuth2Client(clientId, clientSecret, getRcOAuthRedirectUri());
+};
+
+export const getRcOAuthRedirectUri = () => {
+ let serverUrl = process.env.BETTER_AUTH_URL;
+
+ if (process.env.NGROK_URL) {
+ serverUrl = process.env.NGROK_URL;
+ }
+
+ return `${(serverUrl ?? "").replace(/\/+$/, "")}/revenuecat/oauth_callback`;
+};
+
+export const createRcAuthorizationUrl = ({
+ state,
+ codeVerifier,
+ scopes = RC_OAUTH_SCOPES,
+}: {
+ state: string;
+ codeVerifier: string;
+ scopes?: string[];
+}) => {
+ const client = getRcOAuthClient();
+ return client.createAuthorizationURLWithPKCE(
+ RC_AUTHORIZE_URL,
+ state,
+ CodeChallengeMethod.S256,
+ codeVerifier,
+ scopes,
+ );
+};
+
+export const exchangeRcCode = async ({
+ code,
+ codeVerifier,
+}: {
+ code: string;
+ codeVerifier: string;
+}) => {
+ const client = getRcOAuthClient();
+ return client.validateAuthorizationCode(RC_TOKEN_URL, code, codeVerifier);
+};
+
+export const refreshRcTokens = async ({
+ refreshToken,
+ // Omit scopes on refresh — re-requesting the full set triggers RC `invalid_scope`.
+ // An empty list reuses the originally-granted scopes (OAuth2 §6).
+ scopes = [],
+}: {
+ refreshToken: string;
+ scopes?: string[];
+}) => {
+ const client = getRcOAuthClient();
+ return client.refreshAccessToken(RC_TOKEN_URL, refreshToken, scopes);
+};
+
+export { generateCodeVerifier };
diff --git a/server/src/external/revenueCat/revenuecatTypes.ts b/server/src/external/revenueCat/revenuecatTypes.ts
index 0277b8657..b5d440533 100644
--- a/server/src/external/revenueCat/revenuecatTypes.ts
+++ b/server/src/external/revenueCat/revenuecatTypes.ts
@@ -146,6 +146,7 @@ export type RevenueCatProduct = {
created_at: number;
app_id: string;
display_name: string;
+ state?: string;
};
export type RevenueCatProductsResponse = {
@@ -154,3 +155,135 @@ export type RevenueCatProductsResponse = {
next_page: string | null;
url: string;
};
+
+export type RevenueCatPrice = {
+ id: string;
+ amount_micros: number;
+ currency: string;
+};
+
+export type RevenueCatPublicApiKey = {
+ object?: string;
+ id: string;
+ key: string;
+ environment?: string;
+ app_id?: string;
+ created_at?: number;
+};
+
+export type RevenueCatPublicApiKeysResponse = {
+ object: "list";
+ items: RevenueCatPublicApiKey[];
+ next_page: string | null;
+ url: string;
+};
+
+export type RevenueCatWebhookEnvironment = "production" | "sandbox";
+
+export type RevenueCatWebhookIntegration = {
+ object?: string;
+ id: string;
+ project_id?: string;
+ name: string;
+ url: string;
+ environment?: RevenueCatWebhookEnvironment | null;
+ event_types?: string[] | null;
+ app_id?: string | null;
+ created_at?: number;
+};
+
+export type RevenueCatCreateWebhookBody = {
+ name: string;
+ url: string;
+ authorization_header?: string;
+ environment?: RevenueCatWebhookEnvironment | null;
+ event_types?: string[] | null;
+ app_id?: string | null;
+};
+
+export type RevenueCatWebhooksResponse = {
+ object: "list";
+ items: RevenueCatWebhookIntegration[];
+ next_page: string | null;
+ url: string;
+};
+
+export type RevenueCatProductType = "subscription" | "one_time";
+
+export type RevenueCatCreateProductBody = {
+ store_identifier: string;
+ app_id: string;
+ type: RevenueCatProductType;
+ display_name: string;
+ // Required by Test Store apps ("user-facing title"); harmless for store apps.
+ title?: string;
+ // ISO-8601 duration (e.g. "P1M", "P1Y"). Required when type is "subscription".
+ subscription?: { duration: string };
+ one_time?: { is_consumable?: boolean };
+};
+
+export type RevenueCatUpdateProductBody = {
+ display_name?: string;
+};
+
+// create_in_store uses an enum duration (NOT the ISO-8601 one createProduct uses).
+export type RevenueCatStoreDuration =
+ | "ONE_WEEK"
+ | "ONE_MONTH"
+ | "TWO_MONTHS"
+ | "THREE_MONTHS"
+ | "SIX_MONTHS"
+ | "ONE_YEAR";
+
+export type RevenueCatCreateInStoreBody = {
+ store_information?: {
+ duration: RevenueCatStoreDuration;
+ subscription_group_name: string;
+ subscription_group_id?: string;
+ };
+};
+
+export type RevenueCatAppStoreType =
+ | "app_store"
+ | "mac_app_store"
+ | "play_store"
+ | "amazon"
+ | "roku"
+ | "stripe"
+ | "paddle"
+ | "rc_billing"
+ | "test_store";
+
+export type RevenueCatApp = {
+ object: "app";
+ id: string;
+ name: string;
+ type: RevenueCatAppStoreType;
+ project_id: string;
+ created_at: number;
+};
+
+export type RevenueCatAppsResponse = {
+ object: "list";
+ items: RevenueCatApp[];
+ next_page: string | null;
+ url: string;
+};
+
+export type RevenueCatProject = {
+ object: "project";
+ id: string;
+ name: string;
+ created_at: number;
+};
+
+export type RevenueCatCreateProjectBody = {
+ name: string;
+};
+
+export type RevenueCatProjectsResponse = {
+ object: "list";
+ items: RevenueCatProject[];
+ next_page: string | null;
+ url: string;
+};
diff --git a/server/src/external/revenueCat/revenuecatWebhookRouter.ts b/server/src/external/revenueCat/revenuecatWebhookRouter.ts
index d7f050767..7cdb3d8dc 100644
--- a/server/src/external/revenueCat/revenuecatWebhookRouter.ts
+++ b/server/src/external/revenueCat/revenuecatWebhookRouter.ts
@@ -42,10 +42,12 @@ revenuecatWebhookRouter.post(
try {
const webhookSecret = getRevenuecatWebhookSecret({ org, env });
- if (Authorization !== webhookSecret) {
+ // Missing secret must fail closed — otherwise an unauthenticated
+ // request (no header) matches an unconfigured secret (both undefined).
+ if (!webhookSecret || Authorization !== webhookSecret) {
logger.error("Invalid authorization for RevenueCat webhook", {
- Authorization,
- webhookSecret,
+ secretConfigured: Boolean(webhookSecret),
+ authorizationProvided: Boolean(Authorization),
});
return c.json({ error: "Unauthorized" }, 401);
}
diff --git a/server/src/external/revenueCat/sync/revenuecatProductSyncUtils.ts b/server/src/external/revenueCat/sync/revenuecatProductSyncUtils.ts
new file mode 100644
index 000000000..05a8cc143
--- /dev/null
+++ b/server/src/external/revenueCat/sync/revenuecatProductSyncUtils.ts
@@ -0,0 +1,114 @@
+import {
+ AppEnv,
+ BillingInterval,
+ type FullProduct,
+ type Organization,
+ isFixedPrice,
+ orgToCurrency,
+ type RevenueCatProcessorConfig,
+} from "@autumn/shared";
+import type { RevenueCatStoreDuration } from "../revenuecatTypes.js";
+
+/** Autumn's base (flat) price for a plan, as RevenueCat micros + currency. Null when free/usage-only. */
+export const getRcBasePrice = ({
+ product,
+ org,
+}: {
+ product: FullProduct;
+ org: Organization;
+}): { amountMicros: number; currency: string } | null => {
+ const base = product.prices.find(isFixedPrice);
+ const amount = base?.config && "amount" in base.config ? base.config.amount : 0;
+ if (!amount || amount <= 0) return null;
+ return {
+ amountMicros: Math.round(amount * 1_000_000),
+ currency: orgToCurrency({ org }).toUpperCase(),
+ };
+};
+
+/** Push is available once the org connected RevenueCat via OAuth for this env. */
+export const isRevenueCatPushEnabled = ({
+ revenueCatConfig,
+ env,
+}: {
+ revenueCatConfig: RevenueCatProcessorConfig;
+ env: AppEnv;
+}): boolean =>
+ env === AppEnv.Live
+ ? !!revenueCatConfig.oauth
+ : !!revenueCatConfig.sandbox_oauth;
+
+/** Version-stable, env-scoped store identifier Autumn mints for a pushed plan. */
+export const getRcStoreIdentifier = ({
+ env,
+ orgId,
+ planId,
+}: {
+ env: AppEnv;
+ orgId: string;
+ planId: string;
+}): string => `autumn.${env}.${orgId}.${planId}`;
+
+/** Apple subscription group name for create_in_store. */
+export const getSubscriptionGroupName = (group?: string | null): string =>
+ group && group.length > 0 ? `Autumn - ${group} Group` : "Autumn - Default Group";
+
+/** ISO-8601 duration for createProduct. Null = RC can't represent it (lossy). */
+export const autumnIntervalToRcDuration = ({
+ interval,
+ intervalCount,
+}: {
+ interval: BillingInterval;
+ intervalCount: number;
+}): string | null => {
+ const count = intervalCount || 1;
+ switch (interval) {
+ case BillingInterval.Week:
+ return count === 1 ? "P1W" : null;
+ case BillingInterval.Month:
+ if (count === 1) return "P1M";
+ if (count === 2) return "P2M";
+ if (count === 3) return "P3M";
+ if (count === 6) return "P6M";
+ if (count === 12) return "P1Y";
+ return null;
+ case BillingInterval.Quarter:
+ return count === 1 ? "P3M" : null;
+ case BillingInterval.SemiAnnual:
+ return count === 1 ? "P6M" : null;
+ case BillingInterval.Year:
+ return count === 1 ? "P1Y" : null;
+ default:
+ return null;
+ }
+};
+
+/** Enum duration for create_in_store (different format than createProduct). */
+export const autumnIntervalToStoreDuration = ({
+ interval,
+ intervalCount,
+}: {
+ interval: BillingInterval;
+ intervalCount: number;
+}): RevenueCatStoreDuration | null => {
+ const count = intervalCount || 1;
+ switch (interval) {
+ case BillingInterval.Week:
+ return count === 1 ? "ONE_WEEK" : null;
+ case BillingInterval.Month:
+ if (count === 1) return "ONE_MONTH";
+ if (count === 2) return "TWO_MONTHS";
+ if (count === 3) return "THREE_MONTHS";
+ if (count === 6) return "SIX_MONTHS";
+ if (count === 12) return "ONE_YEAR";
+ return null;
+ case BillingInterval.Quarter:
+ return count === 1 ? "THREE_MONTHS" : null;
+ case BillingInterval.SemiAnnual:
+ return count === 1 ? "SIX_MONTHS" : null;
+ case BillingInterval.Year:
+ return count === 1 ? "ONE_YEAR" : null;
+ default:
+ return null;
+ }
+};
diff --git a/server/src/external/revenueCat/sync/syncRevenueCatProducts.ts b/server/src/external/revenueCat/sync/syncRevenueCatProducts.ts
new file mode 100644
index 000000000..eaafffae1
--- /dev/null
+++ b/server/src/external/revenueCat/sync/syncRevenueCatProducts.ts
@@ -0,0 +1,333 @@
+import {
+ AppEnv,
+ ErrCode,
+ type FullProduct,
+ RecaseError,
+} from "@autumn/shared";
+import { StatusCodes } from "http-status-codes";
+import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
+import {
+ getBillingInterval,
+ pricesOnlyOneOff,
+} from "@/internal/products/prices/priceUtils.js";
+import { ProductService } from "@/internal/products/ProductService.js";
+import {
+ getRevenuecatAccessToken,
+ getRevenuecatProjectId,
+} from "../misc/getRevenuecatAccessToken.js";
+import { initRevenuecatCli } from "../misc/initRevenuecatCli.js";
+import { RCMappingService } from "../misc/RCMappingService.js";
+import type { RevenueCatApp, RevenueCatProductType } from "../revenuecatTypes.js";
+import {
+ autumnIntervalToRcDuration,
+ autumnIntervalToStoreDuration,
+ getRcBasePrice,
+ getRcStoreIdentifier,
+ getSubscriptionGroupName,
+ isRevenueCatPushEnabled,
+} from "./revenuecatProductSyncUtils.js";
+
+type RcCli = ReturnType;
+
+type AppResult = {
+ app_id: string;
+ app_type: string;
+ product: "created" | "updated" | "exists";
+ store_push?: "pushed" | "failed" | "skipped";
+ price?: "set" | "skipped" | "failed";
+ message?: string;
+};
+
+export type ProductSyncResult = {
+ plan_id: string;
+ status: "synced" | "skipped" | "error";
+ store_identifier?: string;
+ apps?: AppResult[];
+ message?: string;
+};
+
+/**
+ * Push a single Autumn product into RevenueCat across every app: create the RC
+ * product if missing (adopt on 409 via find), else patch its name; then (live only)
+ * push it into the store via create_in_store. The minted store id is unioned into the
+ * plan's revenuecat_mappings row — never replacing existing ids.
+ */
+export const syncProductToRevenueCat = async ({
+ ctx,
+ rcCli,
+ apps,
+ isLive,
+ projectId,
+ product,
+}: {
+ ctx: AutumnContext;
+ rcCli: RcCli;
+ apps: RevenueCatApp[];
+ isLive: boolean;
+ projectId: string;
+ product: FullProduct;
+}): Promise => {
+ const { db, org, env, logger } = ctx;
+
+ if (product.prices.length === 0) {
+ return {
+ plan_id: product.id,
+ status: "skipped",
+ message: "Free plan (no price) — nothing to sell in the store",
+ };
+ }
+
+ let type: RevenueCatProductType;
+ let isoDuration: string | null = null;
+ let storeDuration = null as ReturnType;
+
+ if (pricesOnlyOneOff(product.prices)) {
+ type = "one_time";
+ } else {
+ type = "subscription";
+ const { interval, intervalCount } = getBillingInterval(product.prices);
+ isoDuration = autumnIntervalToRcDuration({ interval, intervalCount });
+ storeDuration = autumnIntervalToStoreDuration({ interval, intervalCount });
+ if (!isoDuration) {
+ return {
+ plan_id: product.id,
+ status: "skipped",
+ message: `Unsupported billing interval (${interval} x${intervalCount}) for RevenueCat`,
+ };
+ }
+ }
+
+ const storeIdentifier = getRcStoreIdentifier({
+ env,
+ orgId: org.id,
+ planId: product.id,
+ });
+ const displayName = product.name || product.id;
+ const appResults: AppResult[] = [];
+ let syncedAnyApp = false;
+
+ for (const app of apps) {
+ try {
+ let rcProductId: string;
+ let productAction: AppResult["product"];
+
+ // RC only accepts subscription params on create for the simulated test store;
+ // real store apps get a bare product, and duration is set via create_in_store.
+ const isTestStore = app.type === "test_store";
+
+ const existing = await rcCli.findProductByStoreIdentifier({
+ appId: app.id,
+ storeIdentifier,
+ });
+
+ if (existing) {
+ rcProductId = existing.id;
+ if (existing.display_name !== displayName) {
+ await rcCli.updateProduct(existing.id, { display_name: displayName });
+ productAction = "updated";
+ } else {
+ productAction = "exists";
+ }
+ } else {
+ const created = await rcCli.createProduct({
+ app_id: app.id,
+ store_identifier: storeIdentifier,
+ type,
+ display_name: displayName,
+ title: displayName,
+ ...(type === "subscription" && isTestStore
+ ? { subscription: { duration: isoDuration as string } }
+ : {}),
+ ...(type === "one_time" ? { one_time: {} } : {}),
+ });
+ rcProductId = created.id;
+ productAction = "created";
+ }
+
+ const appResult: AppResult = {
+ app_id: app.id,
+ app_type: app.type,
+ product: productAction,
+ };
+
+ // Test-store products are already usable; only push real store apps (live).
+ if (isLive && !isTestStore) {
+ try {
+ await rcCli.createInStore(
+ rcProductId,
+ type === "subscription" && storeDuration
+ ? {
+ store_information: {
+ duration: storeDuration,
+ subscription_group_name: getSubscriptionGroupName(
+ product.group,
+ ),
+ },
+ }
+ : undefined,
+ );
+ appResult.store_push = "pushed";
+ } catch (storeError) {
+ appResult.store_push = "failed";
+ appResult.message = `${storeError}. Check the app's store credentials at https://app.revenuecat.com/projects/${projectId}/apps/${app.id}`;
+ logger.warn(
+ `[RC sync] create_in_store failed for ${product.id} / app ${app.id}: ${storeError}`,
+ );
+ }
+ } else {
+ appResult.store_push = "skipped";
+ }
+
+ // Real-store prices come from Apple/Google. Only the test store needs an
+ // explicit price, set via RC's MCP server (no REST endpoint for it).
+ if (isTestStore) {
+ const basePrice = getRcBasePrice({ product, org });
+ if (basePrice) {
+ try {
+ await rcCli.setTestStoreProductPrice(rcProductId, basePrice);
+ appResult.price = "set";
+ } catch (priceError) {
+ appResult.price = "failed";
+ appResult.message = `Price not set: ${priceError}`;
+ logger.warn(
+ `[RC sync] set test-store price failed for ${product.id} / app ${app.id}: ${priceError}`,
+ );
+ }
+ } else {
+ appResult.price = "skipped";
+ }
+ }
+
+ syncedAnyApp = true;
+ appResults.push(appResult);
+ } catch (error) {
+ appResults.push({
+ app_id: app.id,
+ app_type: app.type,
+ product: "exists",
+ store_push: "failed",
+ message: `${error}`,
+ });
+ logger.error(
+ `[RC sync] Failed to sync ${product.id} for app ${app.id}: ${error}`,
+ { error },
+ );
+ }
+ }
+
+ // Every app failed: don't persist a mapping or report "synced", else the plan
+ // is marked connected to an RC product that was never created.
+ if (!syncedAnyApp) {
+ return {
+ plan_id: product.id,
+ status: "error",
+ store_identifier: storeIdentifier,
+ apps: appResults,
+ message: "RevenueCat product sync failed for every app",
+ };
+ }
+
+ // Union the minted id into the mapping — never clobber existing manual ids.
+ const existingRows = await RCMappingService.get({
+ db,
+ orgId: org.id,
+ env,
+ autumnProductId: product.id,
+ });
+ const currentIds = existingRows[0]?.revenuecat_product_ids ?? [];
+ const revenuecat_product_ids = currentIds.includes(storeIdentifier)
+ ? currentIds
+ : [...currentIds, storeIdentifier];
+
+ await RCMappingService.upsert({
+ db,
+ data: {
+ org_id: org.id,
+ env,
+ autumn_product_id: product.id,
+ revenuecat_product_ids,
+ },
+ });
+
+ return {
+ plan_id: product.id,
+ status: "synced",
+ store_identifier: storeIdentifier,
+ apps: appResults,
+ };
+};
+
+/**
+ * On-demand push of selected Autumn plans into RevenueCat. Throws only if
+ * RevenueCat isn't connected / has no apps; per-product issues are collected.
+ */
+export const syncProductsToRevenueCat = async ({
+ ctx,
+ productIds,
+}: {
+ ctx: AutumnContext;
+ productIds: string[];
+}): Promise => {
+ const { db, org, env } = ctx;
+
+ const revenueCatConfig = org.processor_configs?.revenuecat;
+ if (!revenueCatConfig || !isRevenueCatPushEnabled({ revenueCatConfig, env })) {
+ throw new RecaseError({
+ message: "Connect RevenueCat via OAuth for this environment before syncing",
+ code: ErrCode.InvalidRequest,
+ statusCode: StatusCodes.BAD_REQUEST,
+ });
+ }
+
+ const projectId = getRevenuecatProjectId({ revenueCatConfig, env });
+ const accessToken = await getRevenuecatAccessToken({ db, org, env });
+ if (!projectId || !accessToken) {
+ throw new RecaseError({
+ message: "RevenueCat is not fully configured (missing project or token)",
+ code: ErrCode.InvalidRequest,
+ statusCode: StatusCodes.BAD_REQUEST,
+ });
+ }
+
+ const rcCli = initRevenuecatCli({ projectId, accessToken });
+ const apps = await rcCli.listApps();
+ if (apps.length === 0) {
+ throw new RecaseError({
+ message:
+ "No apps configured in this RevenueCat project. Add one in the RevenueCat dashboard first.",
+ code: ErrCode.InvalidRequest,
+ statusCode: StatusCodes.BAD_REQUEST,
+ });
+ }
+
+ const isLive = env === AppEnv.Live;
+ const results: ProductSyncResult[] = [];
+
+ for (const planId of productIds) {
+ const product = await ProductService.getFull({
+ db,
+ idOrInternalId: planId,
+ orgId: org.id,
+ env,
+ allowNotFound: true,
+ });
+
+ if (!product) {
+ results.push({ plan_id: planId, status: "error", message: "Plan not found" });
+ continue;
+ }
+
+ results.push(
+ await syncProductToRevenueCat({
+ ctx,
+ rcCli,
+ apps,
+ isLive,
+ projectId,
+ product,
+ }),
+ );
+ }
+
+ return results;
+};
diff --git a/server/src/external/revenueCat/utils/recordRevenueCatInvoice.ts b/server/src/external/revenueCat/utils/recordRevenueCatInvoice.ts
index d1b33039d..e8c0f92a5 100644
--- a/server/src/external/revenueCat/utils/recordRevenueCatInvoice.ts
+++ b/server/src/external/revenueCat/utils/recordRevenueCatInvoice.ts
@@ -13,8 +13,10 @@ import { generateId } from "@/utils/genUtils";
type RecordableEvent = {
transaction_id?: string | null;
original_transaction_id?: string | null;
+ // RevenueCat's `price` is always normalized to USD; `currency` describes
+ // `price_in_purchased_currency`, NOT `price`. We record `price`, so the
+ // invoice currency is always USD.
price?: number | null;
- currency?: string | null;
purchased_at_ms?: number | null;
event_timestamp_ms?: number | null;
};
@@ -51,7 +53,9 @@ export const recordRevenueCatInvoice = async ({
}
const total = event.price ?? 0;
- const currency = event.currency ?? "usd";
+ // `event.price` is normalized to USD by RevenueCat regardless of the
+ // purchase currency, so the recorded invoice is always denominated in USD.
+ const currency = "usd";
const createdAt =
event.purchased_at_ms ?? event.event_timestamp_ms ?? Date.now();
diff --git a/server/src/external/revenueCat/webhookHandlers/handleRevenucatRenewal.ts b/server/src/external/revenueCat/webhookHandlers/handleRevenucatRenewal.ts
index 9d98157a9..2d0fe4374 100644
--- a/server/src/external/revenueCat/webhookHandlers/handleRevenucatRenewal.ts
+++ b/server/src/external/revenueCat/webhookHandlers/handleRevenucatRenewal.ts
@@ -3,22 +3,14 @@ import {
ACTIVE_STATUSES,
AttachScenario,
CusProductStatus,
- cusProductToPrices,
- ProcessorType,
} from "@shared/index";
-import { createStripeCli } from "@/external/connect/createStripeCli";
+import { provisionRevenueCatCusProduct } from "@/external/revenueCat/misc/provisionRevenueCatCusProduct";
import { resolveRevenuecatResources } from "@/external/revenueCat/misc/resolveRevenuecatResources";
import { recordRevenueCatInvoice } from "@/external/revenueCat/utils/recordRevenueCatInvoice";
import type { RevenueCatWebhookContext } from "@/external/revenueCat/webhookMiddlewares/revenuecatWebhookContext";
import { addProductsUpdatedWebhookTask } from "@/internal/analytics/handlers/handleProductsUpdated";
-import { createFullCusProduct } from "@/internal/customers/add-product/createFullCusProduct";
-import { CusProductService } from "@/internal/customers/cusProducts/CusProductService";
+import { customerProductActions } from "@/internal/customers/cusProducts/actions";
import { getExistingCusProducts } from "@/internal/customers/cusProducts/cusProductUtils/getExistingCusProducts";
-import {
- attachToInsertParams,
- isProductUpgrade,
-} from "@/internal/products/productUtils";
-import { isMainProduct } from "@/internal/products/productUtils/classifyProduct";
export const handleRenewal = async ({
event,
@@ -27,7 +19,7 @@ export const handleRenewal = async ({
event: WebhookRenewal;
ctx: RevenueCatWebhookContext;
}) => {
- const { db, org, env, logger, features } = ctx;
+ const { org, env, logger } = ctx;
const { product_id, app_user_id } = event;
const {
@@ -41,20 +33,18 @@ export const handleRenewal = async ({
customerId: app_user_id,
});
- const { curSameProduct, curMainProduct } = getExistingCusProducts({
+ const { curSameProduct } = getExistingCusProducts({
product,
cusProducts,
});
- const now = Date.now();
-
- // If same product exists and is active, this is just a renewal - send webhook only
+ // Same active product: pure side-effect (webhook + invoice record). No DB
+ // mutation on the cusProduct; the cycle anchor is owned by the app store.
if (curSameProduct && ACTIVE_STATUSES.includes(curSameProduct.status)) {
logger.info(
`Renewal for existing active product ${product.id}, sending webhook`,
);
- // Send webhook for simple renewal (no state change)
await addProductsUpdatedWebhookTask({
ctx: customerCtx,
internalCustomerId: curSameProduct.internal_customer_id,
@@ -73,30 +63,19 @@ export const handleRenewal = async ({
});
return { success: true };
- } else if (
- curSameProduct &&
- curSameProduct.status === CusProductStatus.PastDue
- ) {
+ }
+
+ // Past-due → active recovery.
+ if (curSameProduct && curSameProduct.status === CusProductStatus.PastDue) {
logger.info(
`Renewal for existing past due product ${product.id}, marking as active`,
);
- await CusProductService.update({
- ctx: customerCtx,
- cusProductId: curSameProduct.id,
- updates: {
- status: CusProductStatus.Active,
- },
- });
- // Send webhook for past_due → active recovery
- await addProductsUpdatedWebhookTask({
+ await customerProductActions.markActive({
ctx: customerCtx,
- internalCustomerId: curSameProduct.internal_customer_id,
- org,
- env,
- customerId: customer.id || "",
- scenario: AttachScenario.Renew,
- cusProduct: curSameProduct,
+ customerProduct: curSameProduct,
+ fullCustomer: customer,
+ sendWebhook: true,
});
logger.info(`Marked past due product as active: ${curSameProduct.id}`);
@@ -111,69 +90,12 @@ export const handleRenewal = async ({
return { success: true };
}
- // Check if this is an upgrade (renewing to a different/better product)
- const isNewProductMain = isMainProduct({ product, prices: product.prices });
- let scenario = AttachScenario.New;
-
- if (curMainProduct && isNewProductMain) {
- const curPrices = cusProductToPrices({ cusProduct: curMainProduct });
- const newPrices = product.prices;
-
- const isUpgrade = isProductUpgrade({
- prices1: curPrices,
- prices2: newPrices,
- });
-
- scenario = isUpgrade ? AttachScenario.Upgrade : AttachScenario.Downgrade;
-
- logger.info(
- `Renewal with ${isUpgrade ? "upgrade" : "downgrade"}: ${curMainProduct.product.id} -> ${product.id}`,
- );
-
- // Expire old cus_product
- await CusProductService.update({
+ // Reactivate same product (expired/canceled → active).
+ if (curSameProduct) {
+ await customerProductActions.uncancel({
ctx: customerCtx,
- cusProductId: curMainProduct.id,
- updates: {
- status: CusProductStatus.Expired,
- ended_at: now,
- },
- });
-
- // Send webhook for the expired product
- await addProductsUpdatedWebhookTask({
- ctx: customerCtx,
- internalCustomerId: curMainProduct.internal_customer_id,
- org,
- env,
- customerId: customer.id || "",
- scenario: AttachScenario.Expired,
- cusProduct: curMainProduct,
- });
-
- logger.info(`Expired old cus_product: ${curMainProduct.id}`);
- } else if (curSameProduct) {
- // Reactivate the same product if it was expired/cancelled
- await CusProductService.update({
- ctx: customerCtx,
- cusProductId: curSameProduct.id,
- updates: {
- status: CusProductStatus.Active,
- canceled_at: null,
- ended_at: null,
- canceled: false,
- },
- });
-
- // Send webhook for reactivation
- await addProductsUpdatedWebhookTask({
- ctx: customerCtx,
- internalCustomerId: curSameProduct.internal_customer_id,
- org,
- env,
- customerId: customer.id || "",
- scenario: AttachScenario.Renew,
- cusProduct: curSameProduct,
+ customerProduct: curSameProduct,
+ fullCustomer: customer,
});
logger.info(`Reactivated cus_product: ${curSameProduct.id}`);
@@ -188,37 +110,15 @@ export const handleRenewal = async ({
return { success: true };
}
- // Create new cus_product for upgrade or new product
- await createFullCusProduct({
- db,
- logger,
- scenario,
- processorType: ProcessorType.RevenueCat,
- attachParams: attachToInsertParams(
- {
- customer,
- products: [product],
- prices: product.prices,
- entitlements: product.entitlements,
- entities: customer.entities || [],
- org,
- stripeCli: createStripeCli({ org, env }),
- now,
- paymentMethod: null,
- freeTrial: null,
- optionsList: [],
- cusProducts,
- replaceables: [],
- features,
- },
- product,
- ),
- sendWebhook: true,
+ // Different product (upgrade or downgrade). V2 attach handles expiring the
+ // outgoing cusProduct via computeAttachPlan's transition logic.
+ await provisionRevenueCatCusProduct({
+ ctx: customerCtx,
+ customer,
+ product,
});
- logger.info(
- `Created cus_product for ${product.id} with scenario: ${scenario} (renewal)`,
- );
+ logger.info(`Created RC cus_product for ${product.id} (renewal transition)`);
await recordRevenueCatInvoice({ ctx: customerCtx, event, customer, product });
diff --git a/server/src/external/revenueCat/webhookHandlers/handleRevenuecatInitialPurchase.ts b/server/src/external/revenueCat/webhookHandlers/handleRevenuecatInitialPurchase.ts
index 06d3e25e1..e56d9329b 100644
--- a/server/src/external/revenueCat/webhookHandlers/handleRevenuecatInitialPurchase.ts
+++ b/server/src/external/revenueCat/webhookHandlers/handleRevenuecatInitialPurchase.ts
@@ -1,24 +1,10 @@
import type { WebhookInitialPurchase } from "@puzzmo/revenue-cat-webhook-types";
-import {
- AttachScenario,
- CusProductStatus,
- cusProductToPrices,
- ErrCode,
- ProcessorType,
- RecaseError,
-} from "@shared/index";
-import { createStripeCli } from "@/external/connect/createStripeCli";
+import { ErrCode, RecaseError } from "@shared/index";
+import { provisionRevenueCatCusProduct } from "@/external/revenueCat/misc/provisionRevenueCatCusProduct";
import { resolveRevenuecatResources } from "@/external/revenueCat/misc/resolveRevenuecatResources";
import { recordRevenueCatInvoice } from "@/external/revenueCat/utils/recordRevenueCatInvoice";
import type { RevenueCatWebhookContext } from "@/external/revenueCat/webhookMiddlewares/revenuecatWebhookContext";
-import { createFullCusProduct } from "@/internal/customers/add-product/createFullCusProduct";
-import { CusProductService } from "@/internal/customers/cusProducts/CusProductService";
import { getExistingCusProducts } from "@/internal/customers/cusProducts/cusProductUtils/getExistingCusProducts";
-import {
- attachToInsertParams,
- isProductUpgrade,
-} from "@/internal/products/productUtils";
-import { isMainProduct } from "@/internal/products/productUtils/classifyProduct";
export const handleInitialPurchase = async ({
event,
@@ -27,7 +13,7 @@ export const handleInitialPurchase = async ({
event: WebhookInitialPurchase;
ctx: RevenueCatWebhookContext;
}) => {
- const { db, org, env, logger, features } = ctx;
+ const { logger } = ctx;
const { product_id, app_user_id } = event;
const {
@@ -42,12 +28,13 @@ export const handleInitialPurchase = async ({
autoCreateCustomer: true,
});
- const { curSameProduct, curMainProduct } = getExistingCusProducts({
+ const { curSameProduct } = getExistingCusProducts({
product,
cusProducts,
});
- // If same product already exists, skip
+ // Guard the same-product attach explicitly so RC consumers get the canonical
+ // CustomerAlreadyHasProduct error rather than V2 attach's PlanAlreadyAttached.
if (curSameProduct) {
throw new RecaseError({
message: `[handleInitialPurchase] Customer ${customer.id} already has product ${product.id}`,
@@ -56,71 +43,13 @@ export const handleInitialPurchase = async ({
});
}
- const now = Date.now();
- let scenario = AttachScenario.New;
-
- // Handle upgrade/downgrade (only when both are main products)
- const isNewProductMain = isMainProduct({ product, prices: product.prices });
-
- if (curMainProduct && isNewProductMain) {
- const curPrices = cusProductToPrices({ cusProduct: curMainProduct });
- const newPrices = product.prices;
-
- const isUpgrade = isProductUpgrade({
- prices1: curPrices,
- prices2: newPrices,
- });
-
- scenario = isUpgrade ? AttachScenario.Upgrade : AttachScenario.Downgrade;
-
- logger.info(
- `${isUpgrade ? "Upgrade" : "Downgrade"} detected: ${curMainProduct.product.id} -> ${product.id}`,
- );
-
- // Expire old cus_product
- await CusProductService.update({
- ctx: customerCtx,
- cusProductId: curMainProduct.id,
- updates: {
- status: CusProductStatus.Expired,
- ended_at: now,
- },
- });
-
- logger.info(`Expired old cus_product: ${curMainProduct.id}`);
- }
-
- // Create new cus_product
- await createFullCusProduct({
- db,
- logger,
- scenario,
- processorType: ProcessorType.RevenueCat,
- attachParams: attachToInsertParams(
- {
- customer,
- products: [product],
- prices: product.prices,
- entitlements: product.entitlements,
- entities: customer.entities || [],
- org,
- stripeCli: createStripeCli({ org, env }),
- now,
- paymentMethod: null,
- freeTrial: null,
- optionsList: [],
- cusProducts,
- replaceables: [],
- features,
- },
- product,
- ),
- sendWebhook: true,
+ await provisionRevenueCatCusProduct({
+ ctx: customerCtx,
+ customer,
+ product,
});
- logger.info(
- `Created cus_product for ${product.id} with scenario: ${scenario}`,
- );
+ logger.info(`Created RC cus_product for ${product.id} (initial purchase)`);
await recordRevenueCatInvoice({ ctx: customerCtx, event, customer, product });
};
diff --git a/server/src/external/revenueCat/webhookHandlers/handleRevenuecatNonRenewingPurchase.ts b/server/src/external/revenueCat/webhookHandlers/handleRevenuecatNonRenewingPurchase.ts
index 1b08a5127..646f34b25 100644
--- a/server/src/external/revenueCat/webhookHandlers/handleRevenuecatNonRenewingPurchase.ts
+++ b/server/src/external/revenueCat/webhookHandlers/handleRevenuecatNonRenewingPurchase.ts
@@ -1,16 +1,9 @@
import type { WebhookNonRenewingPurchase } from "@puzzmo/revenue-cat-webhook-types";
-import {
- AttachScenario,
- ErrCode,
- ProcessorType,
- RecaseError,
-} from "@shared/index";
-import { createStripeCli } from "@/external/connect/createStripeCli";
+import { ErrCode, RecaseError } from "@shared/index";
+import { provisionRevenueCatCusProduct } from "@/external/revenueCat/misc/provisionRevenueCatCusProduct";
import { resolveRevenuecatResources } from "@/external/revenueCat/misc/resolveRevenuecatResources";
import { recordRevenueCatInvoice } from "@/external/revenueCat/utils/recordRevenueCatInvoice";
import type { RevenueCatWebhookContext } from "@/external/revenueCat/webhookMiddlewares/revenuecatWebhookContext";
-import { createFullCusProduct } from "@/internal/customers/add-product/createFullCusProduct";
-import { attachToInsertParams } from "@/internal/products/productUtils";
import { oneOffOrAddOn } from "@/internal/products/productUtils/classifyProduct";
export const handleNonRenewingPurchase = async ({
@@ -20,13 +13,12 @@ export const handleNonRenewingPurchase = async ({
event: WebhookNonRenewingPurchase;
ctx: RevenueCatWebhookContext;
}) => {
- const { db, org, env, logger, features } = ctx;
+ const { logger } = ctx;
const {
ctx: customerCtx,
product,
customer,
- cusProducts,
} = await resolveRevenuecatResources({
ctx,
revenuecatProductId: event.product_id,
@@ -41,40 +33,13 @@ export const handleNonRenewingPurchase = async ({
});
}
- const now = Date.now();
- const scenario = AttachScenario.New;
-
- // Create new cus_product
- await createFullCusProduct({
- db,
- logger,
- scenario,
- processorType: ProcessorType.RevenueCat,
- attachParams: attachToInsertParams(
- {
- customer,
- products: [product],
- prices: product.prices,
- entitlements: product.entitlements,
- entities: customer.entities || [],
- org,
- stripeCli: createStripeCli({ org, env }),
- now,
- paymentMethod: null,
- freeTrial: null,
- optionsList: [],
- cusProducts,
- replaceables: [],
- features,
- },
- product,
- ),
- sendWebhook: true,
+ await provisionRevenueCatCusProduct({
+ ctx: customerCtx,
+ customer,
+ product,
});
- logger.info(
- `Created cus_product for ${product.id} with scenario: ${scenario}`,
- );
+ logger.info(`Created RC cus_product for ${product.id} (non-renewing purchase)`);
await recordRevenueCatInvoice({ ctx: customerCtx, event, customer, product });
};
diff --git a/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handlePrepaidPrices.ts b/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handlePrepaidPrices.ts
index 8565fc0cd..d909817f4 100644
--- a/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handlePrepaidPrices.ts
+++ b/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handlePrepaidPrices.ts
@@ -79,7 +79,7 @@ export const handlePrepaidPrices = async ({
const rolloverUpdate = getRolloverUpdates({
cusEnt,
- nextResetAt: end * 1000,
+ nextResetAt: start * 1000,
});
if (notNullish(options?.upcoming_quantity)) {
diff --git a/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handleUsagePrices.ts b/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handleUsagePrices.ts
index c2c46a484..da2a9c936 100644
--- a/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handleUsagePrices.ts
+++ b/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handleUsagePrices.ts
@@ -149,7 +149,7 @@ export const handleUsagePrices = async ({
allowance: ent.interval === EntInterval.Lifetime ? 0 : ent.allowance!,
});
- const { end } = subToPeriodStartEnd({ sub: usageSub });
+ const { start, end } = subToPeriodStartEnd({ sub: usageSub });
await CusEntService.update({
ctx,
id: relatedCusEnt.id,
@@ -162,7 +162,7 @@ export const handleUsagePrices = async ({
const rolloverUpdate = getRolloverUpdates({
cusEnt: relatedCusEnt,
- nextResetAt: end * 1000,
+ nextResetAt: start * 1000,
});
if (rolloverUpdate?.toInsert && rolloverUpdate.toInsert.length > 0) {
diff --git a/server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/handleStripeInvoiceCreated.ts b/server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/handleStripeInvoiceCreated.ts
index 545b95061..8ec5d6ae3 100644
--- a/server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/handleStripeInvoiceCreated.ts
+++ b/server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/handleStripeInvoiceCreated.ts
@@ -36,13 +36,17 @@ export const handleStripeInvoiceCreated = async ({
await processPrepaidPricesForInvoiceCreated({ ctx, eventContext });
await processAllocatedPricesForInvoiceCreated({ ctx, eventContext });
+ const shouldStoreScheduleProrationInvoice =
+ eventContext.stripeInvoice.billing_reason === "subscription_update" &&
+ !!eventContext.stripeSubscription.schedule;
+
// Upsert Autumn invoice record
const autumnInvoice = await upsertAutumnInvoice({
ctx,
stripeInvoice: eventContext.stripeInvoice,
stripeSubscription: eventContext.stripeSubscription,
customerProducts: eventContext.customerProducts,
- options: { skipNonCycleInvoices: true },
+ options: { skipNonCycleInvoices: !shouldStoreScheduleProrationInvoice },
});
// Store invoice line items (async via SQS workflow)
diff --git a/server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/tasks/processConsumablePricesForInvoiceCreated.ts b/server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/tasks/processConsumablePricesForInvoiceCreated.ts
index cfeda7de5..16f5abfde 100644
--- a/server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/tasks/processConsumablePricesForInvoiceCreated.ts
+++ b/server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/tasks/processConsumablePricesForInvoiceCreated.ts
@@ -9,9 +9,9 @@ import { eventContextToArrearLineItems } from "@/external/stripe/webhookHandlers
import { lineItemsToCreateInvoiceItemsParams } from "@/internal/billing/v2/providers/stripe/utils/invoiceLines/lineItemsToCreateInvoiceItemsParams";
import { createStripeInvoiceItems } from "@/internal/billing/v2/providers/stripe/utils/invoices/stripeInvoiceOps";
import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService";
-import { deleteCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/deleteCachedFullCustomer";
import { RolloverService } from "@/internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService";
import { getRolloverUpdates } from "@/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverUtils";
+import { deleteCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/deleteCachedFullCustomer";
import { parseSkipOverageSubmissionFlag } from "@/internal/misc/featureFlags/parseSkipOverageSubmission";
import type { StripeWebhookContext } from "../../../webhookMiddlewares/stripeWebhookContext";
import type { InvoiceCreatedContext } from "../setupInvoiceCreatedContext";
@@ -116,7 +116,7 @@ export const processConsumablePricesForInvoiceCreated = async ({
updateCustomerEntitlements.forEach(async (update) => {
const rolloverUpdates = getRolloverUpdates({
cusEnt: update.customerEntitlement,
- nextResetAt: Date.now(),
+ nextResetAt: invoicePeriodEndMs,
});
const fullCusEnt: FullCusEntWithProduct = {
diff --git a/server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/tasks/processPrepaidPricesForInvoiceCreated.ts b/server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/tasks/processPrepaidPricesForInvoiceCreated.ts
index 5384211f5..5e2db5331 100644
--- a/server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/tasks/processPrepaidPricesForInvoiceCreated.ts
+++ b/server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/tasks/processPrepaidPricesForInvoiceCreated.ts
@@ -41,8 +41,7 @@ const processPrepaidPrice = async ({
const customerProduct = customerEntitlement.customer_product;
- const { stripeSubscription, fullCustomer } = eventContext;
- const { db } = ctx;
+ const { stripeSubscription } = eventContext;
if (!options) return;
const previousQuantity = options?.quantity ?? 0;
@@ -60,11 +59,11 @@ const processPrepaidPrice = async ({
const ent = customerEntitlement.entitlement;
- const { end } = subToPeriodStartEnd({ sub: stripeSubscription });
+ const { start, end } = subToPeriodStartEnd({ sub: stripeSubscription });
const rolloverUpdate = getRolloverUpdates({
cusEnt: customerEntitlement,
- nextResetAt: end * 1000,
+ nextResetAt: start * 1000,
});
if (notNullish(options?.upcoming_quantity) && customerProduct) {
diff --git a/server/src/honoMiddlewares/authMiddlewares/handleOAuthMiddleware.ts b/server/src/honoMiddlewares/authMiddlewares/handleOAuthMiddleware.ts
new file mode 100644
index 000000000..0093e0e07
--- /dev/null
+++ b/server/src/honoMiddlewares/authMiddlewares/handleOAuthMiddleware.ts
@@ -0,0 +1,79 @@
+import {
+ AppEnv,
+ AuthType,
+ ErrCode,
+ RecaseError,
+ sortFeatures,
+} from "@autumn/shared";
+import type { Context, Next } from "hono";
+import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
+import { getOAuthAccessTokenRecord } from "@/internal/auth/oauth/oauthAccessTokenApiKey.js";
+import { oauthConsentRepo } from "@/internal/auth/repos/index.js";
+import { OrgService } from "@/internal/orgs/OrgService.js";
+
+const getOAuthEnvironment = ({ c }: { c: Context }) => {
+ const env = c.req.header("x-autumn-environment") ?? AppEnv.Sandbox;
+ if (env === AppEnv.Live || env === AppEnv.Sandbox) return env;
+
+ throw new RecaseError({
+ message: "Invalid x-autumn-environment",
+ code: ErrCode.InvalidRequest,
+ statusCode: 400,
+ });
+};
+
+export const handleOAuthMiddleware = async ({
+ c,
+ token,
+ next,
+}: {
+ c: Context;
+ token: string;
+ next: Next;
+}) => {
+ const ctx = c.get("ctx");
+ const env = getOAuthEnvironment({ c });
+ const tokenRecord = await getOAuthAccessTokenRecord({
+ db: ctx.db,
+ accessToken: token,
+ resource: c.req.header("x-autumn-oauth-resource") ?? null,
+ requestedScopes: null,
+ });
+ const consent = await oauthConsentRepo.getForClientUserOrg({
+ db: ctx.db,
+ clientId: tokenRecord.clientId,
+ userId: tokenRecord.userId,
+ referenceId: tokenRecord.referenceId,
+ env,
+ });
+
+ if (!consent) {
+ throw new RecaseError({
+ message: "OAuth consent not found for environment",
+ code: ErrCode.InvalidRequest,
+ statusCode: 401,
+ });
+ }
+
+ const data = await OrgService.getWithFeatures({
+ db: ctx.db,
+ orgId: tokenRecord.referenceId,
+ env,
+ });
+ if (!data) {
+ throw new RecaseError({
+ message: "Org not found",
+ code: ErrCode.OrgNotFound,
+ statusCode: 404,
+ });
+ }
+
+ ctx.org = data.org;
+ ctx.features = sortFeatures({ features: data.features }) ?? [];
+ ctx.env = env;
+ ctx.userId = tokenRecord.userId;
+ ctx.authType = AuthType.SecretKey;
+ ctx.scopes = tokenRecord.scopes;
+
+ await next();
+};
diff --git a/server/src/honoMiddlewares/secretKeyMiddleware.ts b/server/src/honoMiddlewares/secretKeyMiddleware.ts
index f158eb0ca..441d88505 100644
--- a/server/src/honoMiddlewares/secretKeyMiddleware.ts
+++ b/server/src/honoMiddlewares/secretKeyMiddleware.ts
@@ -1,7 +1,14 @@
-import { AuthType, ErrCode, type Feature, RecaseError } from "@autumn/shared";
+import {
+ getBearerToken,
+ isOAuthToken,
+ isPublishableKeyPrefix,
+ isSecretKeyPrefix,
+} from "@autumn/auth";
+import { AuthType, ErrCode, RecaseError, sortFeatures } from "@autumn/shared";
import type { Context, Next } from "hono";
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
import { verifyKey } from "@/internal/dev/api-keys/apiKeyUtils.js";
+import { handleOAuthMiddleware } from "./authMiddlewares/handleOAuthMiddleware.js";
import { betterAuthMiddleware } from "./betterAuthMiddleware.js";
import { publicKeyMiddleware } from "./publicKeyMiddleware.js";
@@ -29,12 +36,11 @@ export const secretKeyMiddleware = async (c: Context, next: Next) => {
return betterAuthMiddleware(c, next);
}
- const authHeader =
- c.req.header("authorization") || c.req.header("Authorization");
+ const bearerToken = getBearerToken({ headers: c.req.raw.headers });
// Step 1 & 2: Check if Authorization header exists
// If from dashboard and no Bearer token, use Better Auth session instead
- if (!authHeader || !authHeader.startsWith("Bearer ")) {
+ if (!bearerToken) {
throw new RecaseError({
message: "Secret key not found in Authorization header",
code: ErrCode.NoSecretKey,
@@ -42,30 +48,31 @@ export const secretKeyMiddleware = async (c: Context, next: Next) => {
});
}
- // Step 2: Extract and validate API key format
- const apiKey = authHeader.split(" ")[1];
-
- if (!apiKey.startsWith("am_")) {
- throw new RecaseError({
- message: `Invalid secret key: ${maskApiKey(apiKey)}`,
- code: ErrCode.InvalidSecretKey,
- statusCode: 401,
- });
+ if (isOAuthToken({ token: bearerToken })) {
+ return handleOAuthMiddleware({ c, token: bearerToken, next });
}
// Step 3: Handle publishable key verification
- if (apiKey.startsWith("am_pk")) {
- return publicKeyMiddleware(c, apiKey, next);
+ if (isPublishableKeyPrefix({ token: bearerToken })) {
+ return publicKeyMiddleware(c, bearerToken, next);
+ }
+
+ if (!isSecretKeyPrefix({ token: bearerToken })) {
+ throw new RecaseError({
+ message: "Invalid authorization token prefix",
+ code: ErrCode.InvalidRequest,
+ statusCode: 401,
+ });
}
// Step 4: Verify the API key
const { valid, data } = await verifyKey({
db: ctx.db,
- key: apiKey,
+ key: bearerToken,
});
if (!valid || !data) {
- const maskedKey = maskApiKey(apiKey);
+ const maskedKey = maskApiKey(bearerToken);
throw new RecaseError({
message: `Invalid secret key: ${maskedKey}`,
code: ErrCode.InvalidSecretKey,
@@ -77,13 +84,7 @@ export const secretKeyMiddleware = async (c: Context, next: Next) => {
const { org, features, env, userId } = data;
const scopes = (data as { scopes?: string[] | null }).scopes ?? [];
- if (features) {
- features.sort((a: Feature, b: Feature) => {
- if (a.archived && !b.archived) return 1;
- if (!a.archived && b.archived) return -1;
- return 0;
- });
- }
+ sortFeatures({ features });
ctx.org = org;
ctx.features = features;
diff --git a/server/src/initHono.ts b/server/src/initHono.ts
index eaefbfd41..15badabc1 100644
--- a/server/src/initHono.ts
+++ b/server/src/initHono.ts
@@ -1,10 +1,4 @@
-import { oauthClient } from "@autumn/shared";
-import {
- oauthProviderAuthServerMetadata,
- oauthProviderOpenIdConfigMetadata,
-} from "@better-auth/oauth-provider";
import { httpInstrumentationMiddleware } from "@hono/otel";
-import { eq } from "drizzle-orm";
import { Hono } from "hono";
import { cors } from "hono/cors";
import { autumnWebhookRouter } from "./external/autumn/autumnWebhookRouter.js";
@@ -19,12 +13,13 @@ import type { HonoEnv } from "./honoUtils/HonoEnv.js";
import { handleHealthCheck } from "./honoUtils/handleHealthCheck.js";
import { handleReadyCheck } from "./honoUtils/handleReadyCheck.js";
import { handleListAuthOrganizations } from "./internal/auth/handleListAuthOrganizations.js";
+import { oauthRouter } from "./internal/auth/oauth/oauthRouter.js";
import { cliRouter } from "./internal/dev/cli/cliRouter.js";
import { handleOAuthCallback } from "./internal/orgs/handlers/stripeHandlers/handleOAuthCallback.js";
+import { handleRevenueCatOAuthCallback } from "./internal/orgs/handlers/revenueCatHandlers/handleRevenueCatOAuthCallback.js";
import { apiRouter } from "./routers/apiRouter.js";
import { createChatProxyRouter } from "./routers/chatProxyRouter.js";
import { internalRouter } from "./routers/internalRouter.js";
-import { mcpProxyRouter } from "./routers/mcpProxyRouter.js";
import { publicRouter } from "./routers/publicRouter.js";
import { auth } from "./utils/auth.js";
import { isAllowedOrigin } from "./utils/corsOrigins.js";
@@ -71,21 +66,7 @@ export const createHonoApp = () => {
}),
);
- app.get("/api/auth/.well-known/openid-configuration", (c) => {
- return oauthProviderOpenIdConfigMetadata(auth)(c.req.raw);
- });
-
- app.get("/.well-known/oauth-authorization-server", (c) => {
- return oauthProviderAuthServerMetadata(auth)(c.req.raw);
- });
-
- app.get("/api/auth/.well-known/oauth-authorization-server", (c) => {
- return oauthProviderAuthServerMetadata(auth)(c.req.raw);
- });
-
- app.get("/.well-known/oauth-authorization-server/api/auth", (c) => {
- return oauthProviderAuthServerMetadata(auth)(c.req.raw);
- });
+ app.route("", oauthRouter);
// Better Auth's joined Drizzle query defaults to 100 memberships.
app.get("/api/auth/organization/list", handleListAuthOrganizations);
@@ -98,11 +79,10 @@ export const createHonoApp = () => {
// Health check endpoint for AWS/ECS load balancer
app.get("/stripe/oauth_callback", handleOAuthCallback);
+ app.get("/revenuecat/oauth_callback", handleRevenueCatOAuthCallback);
app.get("/ready/:token", handleReadyCheck);
app.get("/", handleHealthCheck);
- app.route("", mcpProxyRouter);
-
// Step 1: OTel HTTP span + base middleware + span enrichment
app.use(
"*",
@@ -114,33 +94,6 @@ export const createHonoApp = () => {
app.use("*", baseMiddleware);
app.use("*", replicaDbMiddleware);
- // Public endpoint to get OAuth client name (for consent page)
- app.get("/oauth/client/:client_id", async (c) => {
- const clientId = c.req.param("client_id");
- if (!clientId) {
- return c.json({ error: "client_id is required" }, 400);
- }
-
- const db = c.get("ctx").db;
- const client = await db
- .select({
- name: oauthClient.name,
- clientId: oauthClient.clientId,
- })
- .from(oauthClient)
- .where(eq(oauthClient.clientId, clientId))
- .limit(1);
-
- if (!client.length) {
- return c.json({ error: "Client not found" }, 404);
- }
-
- return c.json({
- client_id: client[0].clientId,
- name: client[0].name || "Unknown Application",
- });
- });
-
// CLI routes (uses Bearer token auth, not session auth)
app.route("/cli", cliRouter);
diff --git a/server/src/internal/admin/adminRouter.ts b/server/src/internal/admin/adminRouter.ts
index 0bae2e7b6..9097cc1be 100644
--- a/server/src/internal/admin/adminRouter.ts
+++ b/server/src/internal/admin/adminRouter.ts
@@ -44,6 +44,7 @@ import { handleUpsertAdminRateLimitRedisAllowlistConfig } from "./handleUpsertAd
import { handleUpsertAdminRedisV2CacheConfig } from "./handleUpsertAdminRedisV2CacheConfig";
import { handleUpsertAdminRequestBlockConfig } from "./handleUpsertAdminRequestBlockConfig";
import { handleUpsertAdminStripeSyncConfig } from "./handleUpsertAdminStripeSyncConfig";
+import { handleUpsertSlackMcpOAuthClient } from "./handleUpsertSlackMcpOAuthClient";
import { handleDeleteRollout } from "./rollouts/handleDeleteRollout";
import { handleDeleteRolloutOrg } from "./rollouts/handleDeleteRolloutOrg";
import { handleGetRollouts } from "./rollouts/handleGetRollouts";
@@ -159,6 +160,10 @@ honoAdminRouter.delete("/cache-v2-ramp", ...handleDeleteAdminCacheV2Ramp);
honoAdminRouter.get("/org-member", ...handleGetOrgMember);
honoAdminRouter.get("/master-stripe-account", ...handleGetMasterStripeAccount);
honoAdminRouter.get("/oauth-clients", ...handleListOAuthClients);
+honoAdminRouter.post(
+ "/oauth-clients/slack-mcp",
+ ...handleUpsertSlackMcpOAuthClient,
+);
honoAdminRouter.post("/invoice-line-items", ...handleGetInvoiceLineItems);
honoAdminRouter.get("/rollouts", ...handleGetRollouts);
diff --git a/server/src/internal/admin/handleListOAuthClients.ts b/server/src/internal/admin/handleListOAuthClients.ts
index 01cf75848..0147e1804 100644
--- a/server/src/internal/admin/handleListOAuthClients.ts
+++ b/server/src/internal/admin/handleListOAuthClients.ts
@@ -1,5 +1,5 @@
-import { oauthClient, Scopes } from "@autumn/shared";
-import { desc } from "drizzle-orm";
+import { Scopes } from "@autumn/shared";
+import { oauthClientRepo } from "@/internal/auth/repos/index.js";
import { createRoute } from "../../honoMiddlewares/routeHandler";
export const handleListOAuthClients = createRoute({
@@ -8,24 +8,7 @@ export const handleListOAuthClients = createRoute({
const ctx = c.get("ctx");
const { db } = ctx;
- const clients = await db
- .select({
- id: oauthClient.id,
- clientId: oauthClient.clientId,
- name: oauthClient.name,
- redirectUris: oauthClient.redirectUris,
- public: oauthClient.public,
- disabled: oauthClient.disabled,
- skipConsent: oauthClient.skipConsent,
- scopes: oauthClient.scopes,
- tokenEndpointAuthMethod: oauthClient.tokenEndpointAuthMethod,
- grantTypes: oauthClient.grantTypes,
- responseTypes: oauthClient.responseTypes,
- createdAt: oauthClient.createdAt,
- updatedAt: oauthClient.updatedAt,
- })
- .from(oauthClient)
- .orderBy(desc(oauthClient.createdAt));
+ const clients = await oauthClientRepo.listForAdmin({ db });
return c.json({
clients: clients.map((client) => ({
diff --git a/server/src/internal/admin/handleUpsertSlackMcpOAuthClient.ts b/server/src/internal/admin/handleUpsertSlackMcpOAuthClient.ts
new file mode 100644
index 000000000..0e818a667
--- /dev/null
+++ b/server/src/internal/admin/handleUpsertSlackMcpOAuthClient.ts
@@ -0,0 +1,37 @@
+import { ErrCode, RecaseError, Scopes } from "@autumn/shared";
+import { registerMcpOAuthClient } from "@/internal/auth/actions/index.js";
+import { createRoute } from "../../honoMiddlewares/routeHandler";
+
+const getClientUrl = () =>
+ (process.env.CLIENT_URL || "http://localhost:3000").replace(/\/+$/, "");
+
+const getSlackMcpRedirectUris = () => {
+ const clientUrl = getClientUrl();
+ return [
+ `${clientUrl}/admin/oauth/slack-mcp/callback`,
+ `${clientUrl}/sandbox/admin/oauth/slack-mcp/callback`,
+ ];
+};
+
+export const handleUpsertSlackMcpOAuthClient = createRoute({
+ scopes: [Scopes.Superuser],
+ handler: async (c) => {
+ const { db } = c.get("ctx");
+ const result = await registerMcpOAuthClient({
+ db,
+ clientName: "Slack MCP",
+ redirectUris: getSlackMcpRedirectUris(),
+ scope: undefined,
+ });
+
+ if ("error" in result) {
+ throw new RecaseError({
+ message: result.error,
+ code: ErrCode.InvalidRequest,
+ statusCode: result.status,
+ });
+ }
+
+ return c.json(result.body, result.status);
+ },
+});
diff --git a/server/src/internal/analytics/actions/aggregate.ts b/server/src/internal/analytics/actions/aggregate.ts
index 6c93450e7..56752befd 100644
--- a/server/src/internal/analytics/actions/aggregate.ts
+++ b/server/src/internal/analytics/actions/aggregate.ts
@@ -5,6 +5,7 @@ import {
type ClickHouseResult,
type TimeseriesEventsParams,
} from "@autumn/shared";
+import { TZDate } from "@date-fns/tz";
import { UTCDate } from "@date-fns/utc";
import { addDays, addHours, addMonths, format, sub } from "date-fns";
import { Decimal } from "decimal.js";
@@ -117,22 +118,25 @@ const calculateDateRange = async ({
};
};
-/** Generates all periods between start and end dates based on bin size */
-const generateAllPeriods = ({
+// Grid must match the pipe's buckets for the string join: hour buckets are UTC,
+// day/month buckets are in the viewer's timezone. startDate/endDate are UTC.
+export const generateAllPeriods = ({
startDate,
endDate,
binSize,
+ timezone,
}: {
startDate: string;
endDate: string;
binSize: string;
+ timezone?: string;
}): string[] => {
const periods: string[] = [];
- let current = new UTCDate(startDate);
- const end = new UTCDate(endDate);
- // Truncate to bin start
+ // Hour buckets stay on UTC to match the pipe's raw `hour` column.
if (binSize === "hour") {
+ const end = new UTCDate(endDate);
+ let current = new UTCDate(startDate);
current = new UTCDate(
current.getFullYear(),
current.getMonth(),
@@ -142,26 +146,36 @@ const generateAllPeriods = ({
0,
0,
);
- } else if (binSize === "month") {
- current = new UTCDate(current.getFullYear(), current.getMonth(), 1);
- } else {
- // day
- current = new UTCDate(
- current.getFullYear(),
- current.getMonth(),
- current.getDate(),
- );
+ while (current <= end) {
+ periods.push(format(current, "yyyy-MM-dd HH:mm:ss"));
+ current = addHours(current, 1);
+ }
+ return periods;
}
- while (current <= end) {
+ // Day/month: build the grid in the viewer's zone ("UTC" = old behavior).
+ const tz = timezone ?? "UTC";
+ const startInViewerTz = new TZDate(new UTCDate(startDate).getTime(), tz);
+ const end = new TZDate(new UTCDate(endDate).getTime(), tz);
+
+ let current =
+ binSize === "month"
+ ? new TZDate(
+ startInViewerTz.getFullYear(),
+ startInViewerTz.getMonth(),
+ 1,
+ tz,
+ )
+ : new TZDate(
+ startInViewerTz.getFullYear(),
+ startInViewerTz.getMonth(),
+ startInViewerTz.getDate(),
+ tz,
+ );
+
+ while (current.getTime() <= end.getTime()) {
periods.push(format(current, "yyyy-MM-dd HH:mm:ss"));
- if (binSize === "hour") {
- current = addHours(current, 1);
- } else if (binSize === "month") {
- current = addMonths(current, 1);
- } else {
- current = addDays(current, 1);
- }
+ current = binSize === "month" ? addMonths(current, 1) : addDays(current, 1);
}
return periods;
@@ -186,6 +200,7 @@ const formatSimpleResults = ({
startDate,
endDate,
binSize,
+ timezone,
}: {
rows: AggregateSimplePipeRow[];
eventNames: string[];
@@ -193,8 +208,14 @@ const formatSimpleResults = ({
startDate: string;
endDate: string;
binSize: string;
+ timezone?: string;
}): ClickHouseResult => {
- const allPeriods = generateAllPeriods({ startDate, endDate, binSize });
+ const allPeriods = generateAllPeriods({
+ startDate,
+ endDate,
+ binSize,
+ timezone,
+ });
// Initialize with all periods and all event columns set to 0
const periodMap = new Map>();
@@ -245,6 +266,7 @@ const formatGroupableResults = ({
startDate,
endDate,
binSize,
+ timezone,
}: {
rows: AggregateGroupablePipeRow[];
eventNames: string[];
@@ -253,9 +275,15 @@ const formatGroupableResults = ({
startDate: string;
endDate: string;
binSize: string;
+ timezone?: string;
maxGroups?: number;
}): ClickHouseResult => {
- const allPeriods = generateAllPeriods({ startDate, endDate, binSize });
+ const allPeriods = generateAllPeriods({
+ startDate,
+ endDate,
+ binSize,
+ timezone,
+ });
const groupByColumn = groupBy;
// Collect all unique group values across all bins (for backfilling zeros).
@@ -428,6 +456,7 @@ export const aggregate = async ({
startDate,
endDate,
binSize,
+ timezone,
maxGroups: params.max_groups,
});
} else {
@@ -454,6 +483,7 @@ export const aggregate = async ({
startDate,
endDate,
binSize,
+ timezone,
});
}
diff --git a/server/src/internal/auth/actions/index.ts b/server/src/internal/auth/actions/index.ts
new file mode 100644
index 000000000..ce439fdf0
--- /dev/null
+++ b/server/src/internal/auth/actions/index.ts
@@ -0,0 +1,4 @@
+export {
+ isSafeOAuthRedirectUri,
+ registerMcpOAuthClient,
+} from "./registerMcpOAuthClient.js";
diff --git a/server/src/internal/auth/actions/registerMcpOAuthClient.ts b/server/src/internal/auth/actions/registerMcpOAuthClient.ts
new file mode 100644
index 000000000..ce58cdcdb
--- /dev/null
+++ b/server/src/internal/auth/actions/registerMcpOAuthClient.ts
@@ -0,0 +1,321 @@
+import { ALL_SCOPES } from "@autumn/shared";
+import type { DrizzleCli } from "@/db/initDrizzle.js";
+import { generateId } from "@/utils/genUtils.js";
+import { type OAuthClientRecord, oauthClientRepo } from "../repos/index.js";
+
+const MCP_CLIENT_KIND = "mcp_client";
+export const SLACK_MCP_OAUTH_CLIENT_ID = "autumn_mcp_slack";
+const REGISTER_CACHE_TTL_MS = 5 * 60 * 1000;
+const DANGEROUS_REDIRECT_SCHEMES = new Set([
+ "javascript:",
+ "data:",
+ "vbscript:",
+]);
+
+type MpcClientType = "claude" | "codex" | "cursor" | "opencode" | "slack";
+
+type MpcClientInfo = {
+ type: MpcClientType;
+ name: string;
+ clientId: string;
+};
+
+type McpMetadata = {
+ kind?: string;
+ mcpClientType?: string;
+ redirectNames?: Record;
+};
+
+type RegistrationResponse = {
+ body: {
+ client_id: string;
+ client_id_issued_at: number;
+ client_name: string | null;
+ redirect_uris: string[];
+ scope: string;
+ token_endpoint_auth_method: "none";
+ grant_types: ["authorization_code", "refresh_token"];
+ response_types: ["code"];
+ public: true;
+ type: "native";
+ };
+ status: 200 | 201;
+};
+
+const registerCache = new Map();
+
+const parseMetadata = (metadata: unknown): McpMetadata => {
+ if (!metadata) return {};
+ if (typeof metadata === "string") {
+ try {
+ const parsed = JSON.parse(metadata);
+ return parsed && typeof parsed === "object" ? parsed : {};
+ } catch {
+ return {};
+ }
+ }
+
+ return typeof metadata === "object" ? metadata : {};
+};
+
+const isLocalhost = (hostname: string) =>
+ hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
+
+export const isSafeOAuthRedirectUri = (redirectUri: string) => {
+ if (!URL.canParse(redirectUri)) return false;
+
+ const url = new URL(redirectUri);
+ if (DANGEROUS_REDIRECT_SCHEMES.has(url.protocol)) return false;
+ if (url.protocol === "http:") return isLocalhost(url.hostname);
+
+ return true;
+};
+
+const normalize = (value: string) => value.trim().toLowerCase();
+
+const classifyMcpClient = ({
+ clientName,
+ redirectUris,
+}: {
+ clientName: unknown;
+ redirectUris: string[];
+}): MpcClientInfo | null => {
+ const haystack = [
+ typeof clientName === "string" ? clientName : "",
+ ...redirectUris,
+ ]
+ .join(" ")
+ .toLowerCase();
+
+ if (haystack.includes("cursor")) {
+ return { type: "cursor", name: "Cursor", clientId: "autumn_mcp_cursor" };
+ }
+ if (haystack.includes("claude")) {
+ return { type: "claude", name: "Claude", clientId: "autumn_mcp_claude" };
+ }
+ if (
+ haystack.includes("opencode") ||
+ haystack.includes("open-code") ||
+ haystack.includes("open code")
+ ) {
+ return {
+ type: "opencode",
+ name: "OpenCode",
+ clientId: "autumn_mcp_opencode",
+ };
+ }
+ if (haystack.includes("codex")) {
+ return { type: "codex", name: "Codex", clientId: "autumn_mcp_codex" };
+ }
+ if (haystack.includes("slack")) {
+ return {
+ type: "slack",
+ name: "Slack",
+ clientId: SLACK_MCP_OAUTH_CLIENT_ID,
+ };
+ }
+
+ return null;
+};
+
+const getRequestedScopes = (scope: unknown) => {
+ if (typeof scope !== "string" || !scope.trim()) return [...ALL_SCOPES];
+ const allowed = new Set(ALL_SCOPES);
+ return scope.split(" ").filter((scope) => allowed.has(scope as never));
+};
+
+const mergeMetadata = ({
+ client,
+ info,
+ redirectUris,
+}: {
+ client: OAuthClientRecord | null;
+ info: MpcClientInfo;
+ redirectUris: string[];
+}) => {
+ const existing = parseMetadata(client?.metadata);
+ const redirectNames = { ...(existing.redirectNames ?? {}) };
+ for (const redirectUri of redirectUris) {
+ redirectNames[redirectUri] = info.name;
+ }
+
+ return {
+ ...existing,
+ kind: MCP_CLIENT_KIND,
+ mcpClientType: info.type,
+ redirectNames,
+ };
+};
+
+const clientMatches = ({
+ client,
+ info,
+ redirectUris,
+}: {
+ client: OAuthClientRecord;
+ info: MpcClientInfo;
+ redirectUris: string[];
+}) => {
+ const metadata = parseMetadata(client.metadata);
+ if (
+ metadata.kind === MCP_CLIENT_KIND &&
+ metadata.mcpClientType === info.type
+ ) {
+ return true;
+ }
+ if (client.clientId === info.clientId) return true;
+
+ const requested = new Set(redirectUris);
+ const hasMatchingRedirectUri = client.redirectUris.some((redirectUri) =>
+ requested.has(redirectUri),
+ );
+ if (!hasMatchingRedirectUri) return false;
+
+ if (normalize(client.name ?? "") === normalize(info.name)) return true;
+ return (
+ classifyMcpClient({
+ clientName: client.name,
+ redirectUris: client.redirectUris,
+ })?.type === info.type
+ );
+};
+
+const getCachedRegistration = (cacheKey: string) => {
+ const cached = registerCache.get(cacheKey);
+ if (!cached || cached.expiresAt < Date.now()) {
+ registerCache.delete(cacheKey);
+ return null;
+ }
+
+ return cached.body;
+};
+
+const setCachedRegistration = (cacheKey: string, body: unknown) => {
+ registerCache.set(cacheKey, {
+ expiresAt: Date.now() + REGISTER_CACHE_TTL_MS,
+ body,
+ });
+};
+
+const getRegistrationResponse = (
+ client: OAuthClientRecord,
+ status: 200 | 201,
+): RegistrationResponse => ({
+ body: {
+ client_id: client.clientId,
+ client_id_issued_at: client.createdAt
+ ? Math.floor(client.createdAt.getTime() / 1000)
+ : Math.floor(Date.now() / 1000),
+ client_name: client.name,
+ redirect_uris: client.redirectUris,
+ scope: client.scopes?.join(" ") ?? "",
+ token_endpoint_auth_method: "none",
+ grant_types: ["authorization_code", "refresh_token"],
+ response_types: ["code"],
+ public: true,
+ type: "native",
+ },
+ status,
+});
+
+export const registerMcpOAuthClient = async ({
+ db,
+ clientName,
+ redirectUris,
+ scope,
+}: {
+ db: DrizzleCli;
+ clientName: unknown;
+ redirectUris: string[];
+ scope: unknown;
+}): Promise => {
+ if (redirectUris.length === 0) {
+ return { error: "redirect_uris is required", status: 400 };
+ }
+ if (!redirectUris.every(isSafeOAuthRedirectUri)) {
+ return { error: "invalid_redirect_uri", status: 400 };
+ }
+
+ const info = classifyMcpClient({ clientName, redirectUris });
+ if (!info) {
+ return { error: "unsupported_mcp_client", status: 400 };
+ }
+
+ const requestedScopes = getRequestedScopes(scope);
+ const scopeKey = [...requestedScopes].sort().join(" ");
+ const cacheKey = `${info.type}:${[...redirectUris].sort().join("|")}:${scopeKey}`;
+ const cached = getCachedRegistration(cacheKey);
+ if (cached)
+ return { body: cached as RegistrationResponse["body"], status: 200 };
+
+ const clients = await oauthClientRepo.list({ db });
+ const existingClient =
+ clients.find((client) => clientMatches({ client, info, redirectUris })) ??
+ null;
+ const now = new Date();
+
+ if (existingClient) {
+ const mergedRedirectUris = [
+ ...new Set([...existingClient.redirectUris, ...redirectUris]),
+ ];
+ const mergedScopes = [
+ ...new Set([...(existingClient.scopes ?? []), ...requestedScopes]),
+ ];
+
+ const updatedClient = await oauthClientRepo.updateById({
+ db,
+ id: existingClient.id,
+ updates: {
+ name: info.name,
+ redirectUris: mergedRedirectUris,
+ scopes: mergedScopes,
+ tokenEndpointAuthMethod: "none",
+ grantTypes: ["authorization_code", "refresh_token"],
+ responseTypes: ["code"],
+ public: true,
+ type: "native",
+ metadata: mergeMetadata({ client: existingClient, info, redirectUris }),
+ updatedAt: now,
+ },
+ });
+
+ const response = getRegistrationResponse(updatedClient!, 200);
+ setCachedRegistration(cacheKey, response.body);
+ return response;
+ }
+
+ const client = await oauthClientRepo.upsert({
+ db,
+ insert: {
+ id: generateId("oauth_client"),
+ clientId: info.clientId,
+ name: info.name,
+ redirectUris,
+ scopes: requestedScopes,
+ tokenEndpointAuthMethod: "none",
+ grantTypes: ["authorization_code", "refresh_token"],
+ responseTypes: ["code"],
+ public: true,
+ type: "native",
+ metadata: mergeMetadata({ client: null, info, redirectUris }),
+ createdAt: now,
+ updatedAt: now,
+ },
+ update: {
+ name: info.name,
+ redirectUris,
+ scopes: requestedScopes,
+ tokenEndpointAuthMethod: "none",
+ grantTypes: ["authorization_code", "refresh_token"],
+ responseTypes: ["code"],
+ public: true,
+ type: "native",
+ metadata: mergeMetadata({ client: null, info, redirectUris }),
+ updatedAt: now,
+ },
+ });
+
+ const response = getRegistrationResponse(client!, 201);
+ setCachedRegistration(cacheKey, response.body);
+ return response;
+};
diff --git a/server/src/internal/auth/oauth/atmnOAuthClients.ts b/server/src/internal/auth/oauth/atmnOAuthClients.ts
new file mode 100644
index 000000000..40673e395
--- /dev/null
+++ b/server/src/internal/auth/oauth/atmnOAuthClients.ts
@@ -0,0 +1,63 @@
+import type { DrizzleCli } from "@/db/initDrizzle.js";
+import { oauthClientRepo } from "../repos/index.js";
+
+const ATMN_OAUTH_CLIENT_NAMES = new Set(["atmn", "autumn cli"]);
+
+const configuredAtmnClientIds = () =>
+ new Set(
+ (process.env.ATMN_OAUTH_CLIENT_IDS ?? "")
+ .split(",")
+ .map((id) => id.trim())
+ .filter(Boolean),
+ );
+
+const metadataMarksAtmn = (metadata: unknown) => {
+ if (!metadata) return false;
+ let metadataObject = metadata;
+ if (typeof metadata === "string") {
+ try {
+ metadataObject = JSON.parse(metadata);
+ } catch {
+ return false;
+ }
+ }
+
+ if (!metadataObject || typeof metadataObject !== "object") return false;
+
+ const metadataRecord = metadataObject as Record;
+ return (
+ metadataRecord.kind === "atmn" ||
+ metadataRecord.client === "atmn" ||
+ metadataRecord.clientType === "atmn" ||
+ metadataRecord.client_type === "atmn" ||
+ metadataRecord.source === "autumn-cli"
+ );
+};
+
+export const isAtmnOAuthClientRecord = ({
+ clientId,
+ name,
+ metadata,
+}: {
+ clientId: string | null | undefined;
+ name: string | null | undefined;
+ metadata?: unknown;
+}) => {
+ if (clientId && configuredAtmnClientIds().has(clientId)) return true;
+ if (metadataMarksAtmn(metadata)) return true;
+
+ const normalizedName = name?.trim().toLowerCase();
+ return !!normalizedName && ATMN_OAUTH_CLIENT_NAMES.has(normalizedName);
+};
+
+export const isAtmnOAuthClientId = async ({
+ db,
+ clientId,
+}: {
+ db: DrizzleCli;
+ clientId: string;
+}) => {
+ const client = await oauthClientRepo.getByClientId({ db, clientId });
+
+ return isAtmnOAuthClientRecord(client ?? { clientId, name: null });
+};
diff --git a/server/src/internal/auth/oauth/handleGetOAuthClient.ts b/server/src/internal/auth/oauth/handleGetOAuthClient.ts
new file mode 100644
index 000000000..57d21503a
--- /dev/null
+++ b/server/src/internal/auth/oauth/handleGetOAuthClient.ts
@@ -0,0 +1,37 @@
+import type { Context } from "hono";
+import { db } from "@/db/initDrizzle.js";
+import { oauthClientRepo } from "../repos/index.js";
+import { isAtmnOAuthClientRecord } from "./atmnOAuthClients.js";
+import {
+ getInternalMcpDisplayName,
+ isInternalMcpOAuthClientRecord,
+} from "./internalMcpOAuthClients.js";
+
+export const handleGetOAuthClient = async (c: Context) => {
+ const clientId = c.req.param("client_id");
+ const redirectUri = c.req.query("redirect_uri");
+ if (!clientId) {
+ return c.json({ error: "client_id is required" }, 400);
+ }
+
+ const client = await oauthClientRepo.getByClientId({ db, clientId });
+
+ if (!client) {
+ return c.json({ error: "Client not found" }, 404);
+ }
+
+ const isInternalMcp = isInternalMcpOAuthClientRecord(client);
+ const internalMcpName = isInternalMcp
+ ? getInternalMcpDisplayName({
+ metadata: client.metadata,
+ redirectUri,
+ })
+ : null;
+
+ return c.json({
+ client_id: client.clientId,
+ name: internalMcpName || client.name || "Unknown Application",
+ is_atmn: isAtmnOAuthClientRecord(client),
+ is_internal_mcp: isInternalMcp,
+ });
+};
diff --git a/server/src/internal/auth/oauth/handleMcpOAuthRegistration.ts b/server/src/internal/auth/oauth/handleMcpOAuthRegistration.ts
new file mode 100644
index 000000000..f6abd4b61
--- /dev/null
+++ b/server/src/internal/auth/oauth/handleMcpOAuthRegistration.ts
@@ -0,0 +1,35 @@
+import type { Context } from "hono";
+import { db } from "@/db/initDrizzle.js";
+import { registerMcpOAuthClient } from "../actions/index.js";
+
+type RegisterBody = {
+ redirect_uris?: unknown;
+ client_name?: unknown;
+ scope?: unknown;
+};
+
+const parseJsonObject = async (request: Request) => {
+ const body = await request.json().catch(() => null);
+ return body && typeof body === "object" ? (body as RegisterBody) : {};
+};
+
+const getRedirectUris = (value: unknown) =>
+ Array.isArray(value)
+ ? value.filter((uri): uri is string => typeof uri === "string" && !!uri)
+ : [];
+
+export const handleMcpOAuthRegistration = async (c: Context) => {
+ const body = await parseJsonObject(c.req.raw);
+ const result = await registerMcpOAuthClient({
+ db,
+ clientName: body.client_name,
+ redirectUris: getRedirectUris(body.redirect_uris),
+ scope: body.scope,
+ });
+
+ if ("error" in result) {
+ return c.json({ error: result.error }, result.status);
+ }
+
+ return c.json(result.body, result.status);
+};
diff --git a/server/src/internal/auth/oauth/handleOAuthConsentWithEnv.ts b/server/src/internal/auth/oauth/handleOAuthConsentWithEnv.ts
new file mode 100644
index 000000000..80e803493
--- /dev/null
+++ b/server/src/internal/auth/oauth/handleOAuthConsentWithEnv.ts
@@ -0,0 +1,98 @@
+import { AppEnv } from "@autumn/shared";
+import type { Context } from "hono";
+import { db } from "@/db/initDrizzle.js";
+import { auth } from "@/utils/auth.js";
+import { oauthConsentRepo } from "../repos/index.js";
+import { isAtmnOAuthClientId } from "./atmnOAuthClients.js";
+
+type RequestFields = Record;
+
+const parseRequestFields = async (request: Request) => {
+ const contentType = request.headers.get("content-type") ?? "";
+ const rawBody = await request.text();
+ if (!rawBody) return {};
+
+ if (contentType.includes("application/json")) {
+ try {
+ const body = JSON.parse(rawBody);
+ return body && typeof body === "object" ? (body as RequestFields) : {};
+ } catch {
+ return {};
+ }
+ }
+
+ const params = new URLSearchParams(rawBody);
+ return Object.fromEntries(params.entries());
+};
+
+const getString = (value: unknown) =>
+ typeof value === "string" && value.length > 0 ? value : null;
+
+const parseEnv = (value: unknown) => {
+ if (value === AppEnv.Live || value === AppEnv.Sandbox) return value;
+ return null;
+};
+
+const acceptedConsent = (value: unknown) => value === true || value === "true";
+
+const getNestedOAuthField = (value: unknown, key: string) => {
+ if (!value) return null;
+
+ if (typeof value === "string") {
+ try {
+ return getString(JSON.parse(value)?.[key]);
+ } catch {
+ return new URLSearchParams(value).get(key);
+ }
+ }
+
+ if (typeof value === "object") {
+ return getString((value as Record)[key]);
+ }
+
+ return null;
+};
+
+const getClientIdFromFields = (fields: RequestFields) =>
+ getString(fields.client_id) ??
+ getNestedOAuthField(fields.oauth_query, "client_id");
+
+const getRedirectUriFromFields = (fields: RequestFields) =>
+ getString(fields.redirect_uri) ??
+ getString(fields.redirectUri) ??
+ getNestedOAuthField(fields.oauth_query, "redirect_uri");
+
+export const handleOAuthConsentWithEnv = async (c: Context) => {
+ const fields = await parseRequestFields(c.req.raw.clone());
+ const response = await auth.handler(c.req.raw);
+
+ if (!response.ok || !acceptedConsent(fields.accept)) {
+ return response;
+ }
+
+ const clientId = getClientIdFromFields(fields);
+ const redirectUri = getRedirectUriFromFields(fields);
+ const env = parseEnv(fields.env);
+ if (!clientId || !env || (await isAtmnOAuthClientId({ db, clientId }))) {
+ return response;
+ }
+
+ const session = await auth.api.getSession({
+ headers: c.req.raw.headers,
+ });
+
+ const userId = session?.user?.id;
+ const orgId = session?.session?.activeOrganizationId;
+ if (!userId || !orgId) return response;
+
+ await oauthConsentRepo.updateEnv({
+ db,
+ clientId,
+ userId,
+ referenceId: orgId,
+ env,
+ redirectUri,
+ });
+
+ return response;
+};
diff --git a/server/src/internal/auth/oauth/handleOAuthTokenWithApiKey.ts b/server/src/internal/auth/oauth/handleOAuthTokenWithApiKey.ts
new file mode 100644
index 000000000..76da01681
--- /dev/null
+++ b/server/src/internal/auth/oauth/handleOAuthTokenWithApiKey.ts
@@ -0,0 +1,183 @@
+import { prefixOAuthToken } from "@autumn/auth";
+import { RecaseError } from "@autumn/shared";
+import type { Context } from "hono";
+import { db } from "@/db/initDrizzle.js";
+import { auth } from "@/utils/auth.js";
+import { SLACK_MCP_OAUTH_CLIENT_ID } from "../actions/registerMcpOAuthClient.js";
+import {
+ getExternalOAuthApiKeyForToken,
+ getOAuthAccessTokenRecord,
+ scopesFromOAuthScopeString,
+} from "./oauthAccessTokenApiKey.js";
+
+const getString = (value: unknown) =>
+ typeof value === "string" && value.length > 0 ? value : null;
+
+const isRecord = (value: unknown): value is Record =>
+ typeof value === "object" && value !== null && !Array.isArray(value);
+
+const getTokenPayload = (body: Record) => {
+ const response = body.response;
+ if (isRecord(response)) return response;
+ return body;
+};
+
+const rewriteTokenBody = ({
+ apiKey,
+ body,
+ scopes,
+}: {
+ apiKey: string;
+ body: Record;
+ scopes: string[];
+}) => {
+ const response = body.response;
+ if (isRecord(response)) {
+ return {
+ ...body,
+ response: {
+ ...response,
+ access_token: apiKey,
+ scope: scopes.join(" "),
+ },
+ };
+ }
+
+ return {
+ ...body,
+ access_token: apiKey,
+ scope: scopes.join(" "),
+ };
+};
+
+const rewriteOAuthAccessTokenBody = ({
+ accessToken,
+ body,
+}: {
+ accessToken: string;
+ body: Record;
+}) => {
+ const response = body.response;
+ if (isRecord(response)) {
+ return {
+ ...body,
+ response: {
+ ...response,
+ access_token: accessToken,
+ },
+ };
+ }
+
+ return {
+ ...body,
+ access_token: accessToken,
+ };
+};
+
+const tokenResponseHeaders = (response?: Response) => {
+ const headers = new Headers(response?.headers);
+ headers.set("Content-Type", "application/json");
+ headers.set("Cache-Control", "no-store");
+ headers.set("Pragma", "no-cache");
+ headers.delete("Content-Length");
+ return headers;
+};
+
+const jsonTokenResponse = ({
+ body,
+ response,
+ status,
+}: {
+ body: unknown;
+ response?: Response;
+ status: number;
+}) =>
+ new Response(JSON.stringify(body), {
+ status,
+ headers: tokenResponseHeaders(response),
+ });
+
+const getResourceFromTokenRequest = async (request: Request) => {
+ const contentType = request.headers.get("content-type") ?? "";
+ const rawBody = await request.text();
+ if (!rawBody) return null;
+
+ if (contentType.includes("application/json")) {
+ try {
+ const body = JSON.parse(rawBody) as Record;
+ const resource = body.resource;
+ if (Array.isArray(resource)) return getString(resource[0]);
+ return getString(resource);
+ } catch {
+ return null;
+ }
+ }
+
+ const params = new URLSearchParams(rawBody);
+ return params.getAll("resource")[0] ?? null;
+};
+
+export const handleOAuthTokenWithApiKey = async (c: Context) => {
+ const resource = await getResourceFromTokenRequest(c.req.raw.clone());
+ const response = await auth.handler(c.req.raw);
+ if (!response.ok) return response;
+
+ let body: Record;
+ try {
+ body = (await response.clone().json()) as Record;
+ } catch {
+ return response;
+ }
+
+ const tokenPayload = getTokenPayload(body);
+ const accessToken = getString(tokenPayload.access_token);
+ if (!accessToken) return response;
+
+ const requestedScopes = scopesFromOAuthScopeString(tokenPayload.scope);
+ let apiKeyResult: Awaited>;
+ try {
+ const tokenRecord = await getOAuthAccessTokenRecord({
+ db,
+ accessToken,
+ resource,
+ requestedScopes,
+ });
+ if (tokenRecord.clientId === SLACK_MCP_OAUTH_CLIENT_ID) {
+ return jsonTokenResponse({
+ body: rewriteOAuthAccessTokenBody({
+ accessToken: prefixOAuthToken({ token: accessToken }),
+ body,
+ }),
+ response,
+ status: response.status,
+ });
+ }
+ apiKeyResult = await getExternalOAuthApiKeyForToken({
+ db,
+ tokenRecord,
+ requestedScopes,
+ });
+ } catch (error) {
+ if (error instanceof RecaseError) {
+ return jsonTokenResponse({
+ body: {
+ error: "invalid_grant",
+ error_description: error.message,
+ },
+ status: error.statusCode,
+ });
+ }
+ throw error;
+ }
+ if (!apiKeyResult) return response;
+
+ return jsonTokenResponse({
+ body: rewriteTokenBody({
+ apiKey: apiKeyResult.apiKey,
+ body,
+ scopes: apiKeyResult.scopes,
+ }),
+ response,
+ status: response.status,
+ });
+};
diff --git a/server/src/internal/auth/oauth/internalMcpOAuthClients.ts b/server/src/internal/auth/oauth/internalMcpOAuthClients.ts
new file mode 100644
index 000000000..ff9894d0a
--- /dev/null
+++ b/server/src/internal/auth/oauth/internalMcpOAuthClients.ts
@@ -0,0 +1,103 @@
+import type { Context } from "hono";
+import { type DrizzleCli, db } from "@/db/initDrizzle.js";
+import { auth } from "@/utils/auth.js";
+import { oauthClientRepo } from "../repos/index.js";
+
+const INTERNAL_MCP_CLIENT_ID = process.env.INTERNAL_MCP_OAUTH_CLIENT_ID;
+const INTERNAL_MCP_CLIENT_NAME = "Autumn internal-mcp";
+const INTERNAL_MCP_CLIENT_NAME_NORMALIZED =
+ INTERNAL_MCP_CLIENT_NAME.toLowerCase();
+const INTERNAL_MCP_KIND = "internal_mcp";
+const MCP_CLIENT_KIND = "mcp_client";
+
+type InternalMcpMetadata = {
+ kind?: string;
+ mcpClientType?: string;
+ redirectNames?: Record;
+};
+
+const parseMetadata = (metadata: unknown): InternalMcpMetadata => {
+ if (!metadata) return {};
+ if (typeof metadata === "string") {
+ try {
+ const parsed = JSON.parse(metadata);
+ return parsed && typeof parsed === "object" ? parsed : {};
+ } catch {
+ return {};
+ }
+ }
+
+ return typeof metadata === "object" ? metadata : {};
+};
+
+const inferClientNameFromRedirectUri = (redirectUri: string) => {
+ const normalized = redirectUri.toLowerCase();
+ if (normalized.includes("cursor")) return "Cursor";
+ if (normalized.includes("claude")) return "Claude";
+ if (normalized.includes("opencode")) return "OpenCode";
+ if (normalized.includes("open-code")) return "OpenCode";
+ if (normalized.includes("slack")) return "Slack";
+ if (normalized.includes("codex")) return "Codex";
+ return "MCP client";
+};
+
+export const isInternalMcpOAuthClientRecord = ({
+ clientId,
+ name,
+ metadata,
+}: {
+ clientId: string | null | undefined;
+ name: string | null | undefined;
+ metadata?: unknown;
+}) => {
+ if (INTERNAL_MCP_CLIENT_ID && clientId === INTERNAL_MCP_CLIENT_ID)
+ return true;
+ if (name?.trim().toLowerCase() === INTERNAL_MCP_CLIENT_NAME_NORMALIZED) {
+ return true;
+ }
+ const parsedMetadata = parseMetadata(metadata);
+ return [INTERNAL_MCP_KIND, MCP_CLIENT_KIND].includes(
+ parsedMetadata.kind ?? "",
+ );
+};
+
+export const getInternalMcpDisplayName = ({
+ metadata,
+ redirectUri,
+}: {
+ metadata: unknown;
+ redirectUri: string | null | undefined;
+}) => {
+ if (!redirectUri) return null;
+ const metadataObject = parseMetadata(metadata);
+ return (
+ metadataObject.redirectNames?.[redirectUri] ??
+ inferClientNameFromRedirectUri(redirectUri)
+ );
+};
+
+export const isInternalMcpOAuthClientId = async ({
+ db,
+ clientId,
+}: {
+ db: DrizzleCli;
+ clientId: string;
+}) => {
+ const client = await oauthClientRepo.getByClientId({ db, clientId });
+
+ return isInternalMcpOAuthClientRecord(client ?? { clientId, name: null });
+};
+
+export const handleInternalMcpOAuthAuthorize = async (c: Context) => {
+ const url = new URL(c.req.raw.url);
+ const clientId = url.searchParams.get("client_id");
+ if (!clientId || !(await isInternalMcpOAuthClientId({ db, clientId }))) {
+ return auth.handler(c.req.raw);
+ }
+
+ const prompts = new Set(url.searchParams.get("prompt")?.split(" ") ?? []);
+ prompts.add("consent");
+ url.searchParams.set("prompt", [...prompts].filter(Boolean).join(" "));
+
+ return auth.handler(new Request(url, c.req.raw));
+};
diff --git a/server/src/internal/auth/oauth/oauthAccessTokenApiKey.ts b/server/src/internal/auth/oauth/oauthAccessTokenApiKey.ts
new file mode 100644
index 000000000..c464e7ecd
--- /dev/null
+++ b/server/src/internal/auth/oauth/oauthAccessTokenApiKey.ts
@@ -0,0 +1,176 @@
+import { stripOAuthTokenPrefix } from "@autumn/auth";
+import {
+ AppEnv,
+ checkScopes,
+ ErrCode,
+ RecaseError,
+ type ScopeString,
+} from "@autumn/shared";
+import { verifyAccessToken } from "better-auth/oauth2";
+import type { DrizzleCli } from "@/db/initDrizzle.js";
+import {
+ parseRequestedScopes,
+ type ResourceAccessTokenRecord,
+ tokenRecordFromResourceToken,
+} from "@/internal/dev/cli/oauthApiKeyUtils.js";
+import { hashOAuthToken } from "@/utils/oauthUtils.js";
+import { oauthAccessTokenRepo, oauthConsentRepo } from "../repos/index.js";
+import { isAtmnOAuthClientId } from "./atmnOAuthClients.js";
+import { rotateOAuthConsentApiKey } from "./oauthConsentApiKey.js";
+
+const getOAuthIssuer = () =>
+ `${process.env.BETTER_AUTH_URL?.replace(/\/$/, "") ?? ""}/api/auth`;
+
+const verifyResourceAccessToken = async ({
+ accessToken,
+ resource,
+ requestedScopes,
+}: {
+ accessToken: string;
+ resource: string | null;
+ requestedScopes: ScopeString[] | null;
+}) => {
+ if (!resource) return null;
+
+ const issuer = getOAuthIssuer();
+ try {
+ const payload = await verifyAccessToken(accessToken, {
+ jwksUrl: `${issuer}/jwks`,
+ verifyOptions: {
+ audience: resource,
+ issuer,
+ },
+ scopes: requestedScopes ?? undefined,
+ });
+
+ return tokenRecordFromResourceToken(payload as Record);
+ } catch {
+ return null;
+ }
+};
+
+export const getOAuthAccessTokenRecord = async ({
+ db,
+ accessToken,
+ resource,
+ requestedScopes,
+}: {
+ db: DrizzleCli;
+ accessToken: string;
+ resource: string | null;
+ requestedScopes: ScopeString[] | null;
+}) => {
+ const rawAccessToken = stripOAuthTokenPrefix({ token: accessToken });
+ const hashedToken = await hashOAuthToken(rawAccessToken);
+ const tokenValues = [...new Set([hashedToken, rawAccessToken])];
+ const tokenRecord =
+ (await oauthAccessTokenRepo.getValidByTokenValues({ db, tokenValues })) ??
+ (await verifyResourceAccessToken({
+ accessToken: rawAccessToken,
+ resource,
+ requestedScopes,
+ }));
+
+ if (!tokenRecord) {
+ throw new RecaseError({
+ message: "Invalid or expired access token",
+ code: ErrCode.InvalidRequest,
+ statusCode: 401,
+ });
+ }
+
+ if (requestedScopes) {
+ const { allowed, missing } = checkScopes(
+ requestedScopes,
+ tokenRecord.scopes,
+ );
+ if (!allowed) {
+ throw new RecaseError({
+ message: `Insufficient scopes. Missing: ${missing.join(", ")}`,
+ code: ErrCode.InsufficientScopes,
+ statusCode: 403,
+ });
+ }
+ }
+
+ const userId = tokenRecord.userId;
+ if (!userId) {
+ throw new RecaseError({
+ message: "Token missing user information",
+ code: ErrCode.InvalidRequest,
+ statusCode: 401,
+ });
+ }
+
+ const orgId = tokenRecord.referenceId;
+ if (!orgId) {
+ throw new RecaseError({
+ message: "No organization found. Please select an organization.",
+ code: ErrCode.InvalidRequest,
+ statusCode: 400,
+ });
+ }
+
+ return tokenRecord as ResourceAccessTokenRecord & {
+ userId: string;
+ referenceId: string;
+ };
+};
+
+export const getExternalOAuthApiKeyForToken = async ({
+ db,
+ tokenRecord,
+ requestedScopes,
+}: {
+ db: DrizzleCli;
+ tokenRecord: ResourceAccessTokenRecord & {
+ userId: string;
+ referenceId: string;
+ };
+ requestedScopes: ScopeString[] | null;
+}) => {
+ const isAtmnClient = await isAtmnOAuthClientId({
+ db,
+ clientId: tokenRecord.clientId,
+ });
+ if (isAtmnClient) return null;
+
+ const consent = await oauthConsentRepo.getForClientUserOrg({
+ db,
+ clientId: tokenRecord.clientId,
+ userId: tokenRecord.userId,
+ referenceId: tokenRecord.referenceId,
+ });
+
+ if (!consent) {
+ throw new RecaseError({
+ message: "OAuth consent not found",
+ code: ErrCode.InvalidRequest,
+ statusCode: 400,
+ });
+ }
+
+ const env = consent.env ?? AppEnv.Sandbox;
+ const scopes = requestedScopes ?? (tokenRecord.scopes as ScopeString[]);
+ const apiKey = await rotateOAuthConsentApiKey({
+ db,
+ consent,
+ tokenRecord,
+ env,
+ scopes,
+ });
+
+ return {
+ apiKey,
+ env,
+ orgId: tokenRecord.referenceId,
+ userId: tokenRecord.userId,
+ clientId: tokenRecord.clientId,
+ scopes,
+ };
+};
+
+export const scopesFromOAuthScopeString = (scope: unknown) => {
+ if (typeof scope !== "string") return null;
+ return parseRequestedScopes(scope.split(/\s+/).filter(Boolean));
+};
diff --git a/server/src/internal/auth/oauth/oauthConsentApiKey.ts b/server/src/internal/auth/oauth/oauthConsentApiKey.ts
new file mode 100644
index 000000000..cfa9eccbe
--- /dev/null
+++ b/server/src/internal/auth/oauth/oauthConsentApiKey.ts
@@ -0,0 +1,121 @@
+import { AppEnv, type ScopeString } from "@autumn/shared";
+import type { DrizzleCli } from "@/db/initDrizzle.js";
+import {
+ ApiKeyPrefix,
+ createKey,
+ hashApiKey,
+} from "@/internal/dev/api-keys/apiKeyUtils.js";
+import type { ResourceAccessTokenRecord } from "@/internal/dev/cli/oauthApiKeyUtils.js";
+import {
+ type OAuthConsentApiKeyRecord,
+ oauthApiKeyRepo,
+ oauthClientRepo,
+ oauthConsentRepo,
+} from "../repos/index.js";
+
+type OAuthApiKeyTokenRecord = ResourceAccessTokenRecord & {
+ userId: string;
+ referenceId: string;
+};
+
+const getOAuthClientApiKeyName = async ({
+ db,
+ clientId,
+}: {
+ db: DrizzleCli;
+ clientId: string;
+}) => {
+ const client = await oauthClientRepo.getByClientId({ db, clientId });
+
+ return `OAuth Key - ${client?.name || clientId.slice(0, 8)}`;
+};
+
+const createConsentApiKey = async ({
+ db,
+ consent,
+ tokenRecord,
+ env,
+ scopes,
+}: {
+ db: DrizzleCli;
+ consent: OAuthConsentApiKeyRecord;
+ tokenRecord: OAuthApiKeyTokenRecord;
+ env: AppEnv;
+ scopes: ScopeString[];
+}) => {
+ const prefix = env === AppEnv.Live ? ApiKeyPrefix.Live : ApiKeyPrefix.Sandbox;
+ const keyName = await getOAuthClientApiKeyName({
+ db,
+ clientId: tokenRecord.clientId,
+ });
+ const apiKey = await createKey({
+ db,
+ env,
+ name: keyName,
+ orgId: tokenRecord.referenceId,
+ userId: tokenRecord.userId ?? undefined,
+ prefix,
+ meta: {
+ oauth_consent_id: consent.id,
+ oauth_client_id: tokenRecord.clientId,
+ oauth_redirect_uri: consent.redirectUri,
+ created_via: "oauth",
+ generatedAt: new Date().toISOString(),
+ env,
+ },
+ scopes,
+ });
+
+ const hashedKey = hashApiKey(apiKey);
+ const apiKeyId = await oauthApiKeyRepo.getIdByHashedKey({ db, hashedKey });
+ if (!apiKeyId) {
+ throw new Error("OAuth API key was not persisted");
+ }
+
+ await oauthConsentRepo.updateApiKey({
+ db,
+ consentId: consent.id,
+ env,
+ oauthApiKeyId: apiKeyId,
+ });
+
+ return { apiKey, apiKeyId };
+};
+
+export const rotateOAuthConsentApiKey = async ({
+ db,
+ consent,
+ tokenRecord,
+ env,
+ scopes,
+}: {
+ db: DrizzleCli;
+ consent: OAuthConsentApiKeyRecord;
+ tokenRecord: OAuthApiKeyTokenRecord;
+ env: AppEnv;
+ scopes: ScopeString[];
+}) => {
+ const previousApiKeyId = consent.oauthApiKeyId;
+ const { apiKey, apiKeyId } = await createConsentApiKey({
+ db,
+ consent,
+ tokenRecord,
+ env,
+ scopes,
+ });
+
+ if (previousApiKeyId && previousApiKeyId !== apiKeyId) {
+ await oauthApiKeyRepo.deleteConsentLinked({
+ db,
+ apiKeyId: previousApiKeyId,
+ consentId: consent.id,
+ clientId: tokenRecord.clientId,
+ redirectUri: consent.redirectUri,
+ orgId: tokenRecord.referenceId,
+ userId: tokenRecord.userId,
+ env,
+ });
+ }
+
+ return apiKey;
+};
diff --git a/server/src/internal/auth/oauth/oauthRouter.ts b/server/src/internal/auth/oauth/oauthRouter.ts
new file mode 100644
index 000000000..6e514e2fa
--- /dev/null
+++ b/server/src/internal/auth/oauth/oauthRouter.ts
@@ -0,0 +1,55 @@
+import {
+ oauthProviderAuthServerMetadata,
+ oauthProviderOpenIdConfigMetadata,
+} from "@better-auth/oauth-provider";
+import { type Context, Hono } from "hono";
+import { rateLimiter } from "hono-rate-limiter";
+import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
+import { auth } from "@/utils/auth.js";
+import { handleGetOAuthClient } from "./handleGetOAuthClient.js";
+import { handleMcpOAuthRegistration } from "./handleMcpOAuthRegistration.js";
+import { handleOAuthConsentWithEnv } from "./handleOAuthConsentWithEnv.js";
+import { handleOAuthTokenWithApiKey } from "./handleOAuthTokenWithApiKey.js";
+import { handleInternalMcpOAuthAuthorize } from "./internalMcpOAuthClients.js";
+
+export const oauthRouter = new Hono();
+
+const getClientLookupRateLimitKey = (c: Context) =>
+ c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ??
+ c.req.header("x-real-ip") ??
+ c.req.header("cf-connecting-ip") ??
+ "unknown";
+
+const oauthClientLookupLimiter = rateLimiter({
+ windowMs: 60 * 1000,
+ limit: process.env.NODE_ENV === "development" ? 1000 : 60,
+ standardHeaders: "draft-6",
+ keyGenerator: getClientLookupRateLimitKey,
+});
+
+oauthRouter.get("/api/auth/.well-known/openid-configuration", (c) => {
+ return oauthProviderOpenIdConfigMetadata(auth)(c.req.raw);
+});
+
+oauthRouter.get("/.well-known/oauth-authorization-server", (c) => {
+ return oauthProviderAuthServerMetadata(auth)(c.req.raw);
+});
+
+oauthRouter.get("/api/auth/.well-known/oauth-authorization-server", (c) => {
+ return oauthProviderAuthServerMetadata(auth)(c.req.raw);
+});
+
+oauthRouter.get("/.well-known/oauth-authorization-server/api/auth", (c) => {
+ return oauthProviderAuthServerMetadata(auth)(c.req.raw);
+});
+
+oauthRouter.post("/api/auth/oauth2/consent", handleOAuthConsentWithEnv);
+oauthRouter.post("/api/auth/oauth2/token", handleOAuthTokenWithApiKey);
+oauthRouter.get("/api/auth/oauth2/authorize", handleInternalMcpOAuthAuthorize);
+oauthRouter.post("/api/auth/oauth2/register", handleMcpOAuthRegistration);
+
+oauthRouter.get(
+ "/oauth/client/:client_id",
+ oauthClientLookupLimiter,
+ handleGetOAuthClient,
+);
diff --git a/server/src/internal/auth/repos/index.ts b/server/src/internal/auth/repos/index.ts
new file mode 100644
index 000000000..39aa72a56
--- /dev/null
+++ b/server/src/internal/auth/repos/index.ts
@@ -0,0 +1,11 @@
+export { oauthAccessTokenRepo } from "./oauthAccessTokenRepo.js";
+export { oauthApiKeyRepo } from "./oauthApiKeyRepo.js";
+export {
+ type OAuthClientRecord,
+ oauthClientRepo,
+} from "./oauthClientRepo.js";
+export {
+ type OAuthConsentApiKeyRecord,
+ oauthConsentRepo,
+} from "./oauthConsentRepo.js";
+export { oauthRefreshTokenRepo } from "./oauthRefreshTokenRepo.js";
diff --git a/server/src/internal/auth/repos/oauthAccessTokenRepo.ts b/server/src/internal/auth/repos/oauthAccessTokenRepo.ts
new file mode 100644
index 000000000..674f6d2ea
--- /dev/null
+++ b/server/src/internal/auth/repos/oauthAccessTokenRepo.ts
@@ -0,0 +1,49 @@
+import { oauthAccessToken } from "@autumn/shared";
+import { and, eq, gt, inArray, isNull } from "drizzle-orm";
+import type { DrizzleCli } from "@/db/initDrizzle.js";
+
+export const getValidOAuthAccessTokenByTokenValues = async ({
+ db,
+ tokenValues,
+}: {
+ db: DrizzleCli;
+ tokenValues: string[];
+}) => {
+ const [token] = await db
+ .select()
+ .from(oauthAccessToken)
+ .where(
+ and(
+ inArray(oauthAccessToken.token, tokenValues),
+ gt(oauthAccessToken.expiresAt, new Date()),
+ ),
+ )
+ .limit(1);
+
+ return token ?? null;
+};
+
+export const deleteOAuthAccessTokensByClientAndReference = async ({
+ db,
+ clientId,
+ referenceId,
+}: {
+ db: DrizzleCli;
+ clientId: string;
+ referenceId: string | null;
+}) =>
+ db
+ .delete(oauthAccessToken)
+ .where(
+ and(
+ eq(oauthAccessToken.clientId, clientId),
+ referenceId
+ ? eq(oauthAccessToken.referenceId, referenceId)
+ : isNull(oauthAccessToken.referenceId),
+ ),
+ );
+
+export const oauthAccessTokenRepo = {
+ getValidByTokenValues: getValidOAuthAccessTokenByTokenValues,
+ deleteByClientAndReference: deleteOAuthAccessTokensByClientAndReference,
+};
diff --git a/server/src/internal/auth/repos/oauthApiKeyRepo.ts b/server/src/internal/auth/repos/oauthApiKeyRepo.ts
new file mode 100644
index 000000000..dd74ba4e2
--- /dev/null
+++ b/server/src/internal/auth/repos/oauthApiKeyRepo.ts
@@ -0,0 +1,156 @@
+import { type AppEnv, apiKeys } from "@autumn/shared";
+import { eq, sql } from "drizzle-orm";
+import type { DrizzleCli } from "@/db/initDrizzle.js";
+import { clearSecretKeyCache } from "@/internal/dev/api-keys/cacheApiKeyUtils.js";
+
+type OAuthApiKeyRecord = {
+ id: string;
+ orgId: string | null;
+ userId: string | null;
+ env: string | null;
+ hashedKey: string | null;
+ meta: unknown;
+};
+
+const isRecord = (value: unknown): value is Record =>
+ typeof value === "object" && value !== null && !Array.isArray(value);
+
+export const isOAuthConsentLinkedApiKey = ({
+ apiKey,
+ consentId,
+ clientId,
+ redirectUri,
+ orgId,
+ userId,
+ env,
+}: {
+ apiKey: OAuthApiKeyRecord;
+ consentId: string;
+ clientId: string;
+ redirectUri: string | null;
+ orgId: string;
+ userId: string;
+ env: AppEnv;
+}) => {
+ if (
+ apiKey.orgId !== orgId ||
+ apiKey.userId !== userId ||
+ apiKey.env !== env ||
+ !isRecord(apiKey.meta)
+ ) {
+ return false;
+ }
+
+ return (
+ apiKey.meta.created_via === "oauth" &&
+ apiKey.meta.oauth_consent_id === consentId &&
+ apiKey.meta.oauth_client_id === clientId &&
+ apiKey.meta.oauth_redirect_uri === redirectUri &&
+ apiKey.meta.env === env
+ );
+};
+
+export const deleteOAuthConsentLinkedApiKey = async ({
+ db,
+ apiKeyId,
+ consentId,
+ clientId,
+ redirectUri,
+ orgId,
+ userId,
+ env,
+}: {
+ db: DrizzleCli;
+ apiKeyId: string;
+ consentId: string;
+ clientId: string;
+ redirectUri: string | null;
+ orgId: string;
+ userId: string;
+ env: AppEnv;
+}) => {
+ const [apiKey] = await db
+ .select({
+ id: apiKeys.id,
+ orgId: apiKeys.org_id,
+ userId: apiKeys.user_id,
+ env: apiKeys.env,
+ hashedKey: apiKeys.hashed_key,
+ meta: apiKeys.meta,
+ })
+ .from(apiKeys)
+ .where(eq(apiKeys.id, apiKeyId))
+ .limit(1);
+
+ if (!apiKey) return { deleted: false, reason: "not_found" as const };
+
+ if (
+ !isOAuthConsentLinkedApiKey({
+ apiKey,
+ consentId,
+ clientId,
+ redirectUri,
+ orgId,
+ userId,
+ env,
+ })
+ ) {
+ return { deleted: false, reason: "guard_failed" as const };
+ }
+
+ await db.delete(apiKeys).where(eq(apiKeys.id, apiKeyId));
+
+ if (apiKey.hashedKey)
+ await clearSecretKeyCache({ hashedKey: apiKey.hashedKey });
+
+ return { deleted: true, reason: null };
+};
+
+export const listOAuthApiKeysByConsentId = async ({
+ db,
+ consentId,
+}: {
+ db: DrizzleCli;
+ consentId: string;
+}) =>
+ db
+ .select({
+ id: apiKeys.id,
+ prefix: apiKeys.prefix,
+ env: apiKeys.env,
+ name: apiKeys.name,
+ hashed_key: apiKeys.hashed_key,
+ })
+ .from(apiKeys)
+ .where(sql`${apiKeys.meta}->>'oauth_consent_id' = ${consentId}`);
+
+export const deleteOAuthApiKeyById = async ({
+ db,
+ apiKeyId,
+}: {
+ db: DrizzleCli;
+ apiKeyId: string;
+}) => db.delete(apiKeys).where(eq(apiKeys.id, apiKeyId));
+
+export const getApiKeyIdByHashedKey = async ({
+ db,
+ hashedKey,
+}: {
+ db: DrizzleCli;
+ hashedKey: string;
+}) => {
+ const [keyRecord] = await db
+ .select({ id: apiKeys.id })
+ .from(apiKeys)
+ .where(eq(apiKeys.hashed_key, hashedKey))
+ .limit(1);
+
+ return keyRecord?.id ?? null;
+};
+
+export const oauthApiKeyRepo = {
+ listByConsentId: listOAuthApiKeysByConsentId,
+ deleteById: deleteOAuthApiKeyById,
+ deleteConsentLinked: deleteOAuthConsentLinkedApiKey,
+ getIdByHashedKey: getApiKeyIdByHashedKey,
+};
diff --git a/server/src/internal/auth/repos/oauthClientRepo.ts b/server/src/internal/auth/repos/oauthClientRepo.ts
new file mode 100644
index 000000000..95ddccc9a
--- /dev/null
+++ b/server/src/internal/auth/repos/oauthClientRepo.ts
@@ -0,0 +1,141 @@
+import { oauthClient } from "@autumn/shared";
+import { desc, eq } from "drizzle-orm";
+import type { DrizzleCli } from "@/db/initDrizzle.js";
+
+export type OAuthClientRecord = {
+ id: string;
+ clientId: string;
+ name: string | null;
+ redirectUris: string[];
+ scopes: string[] | null;
+ metadata: unknown;
+ createdAt: Date | null;
+};
+
+const oauthClientSelect = {
+ id: oauthClient.id,
+ clientId: oauthClient.clientId,
+ name: oauthClient.name,
+ redirectUris: oauthClient.redirectUris,
+ scopes: oauthClient.scopes,
+ metadata: oauthClient.metadata,
+ createdAt: oauthClient.createdAt,
+};
+
+export const listOAuthClients = async ({ db }: { db: DrizzleCli }) =>
+ db.select(oauthClientSelect).from(oauthClient);
+
+export const listOAuthClientsForAdmin = async ({ db }: { db: DrizzleCli }) =>
+ db
+ .select({
+ id: oauthClient.id,
+ clientId: oauthClient.clientId,
+ name: oauthClient.name,
+ redirectUris: oauthClient.redirectUris,
+ public: oauthClient.public,
+ disabled: oauthClient.disabled,
+ skipConsent: oauthClient.skipConsent,
+ scopes: oauthClient.scopes,
+ tokenEndpointAuthMethod: oauthClient.tokenEndpointAuthMethod,
+ grantTypes: oauthClient.grantTypes,
+ responseTypes: oauthClient.responseTypes,
+ createdAt: oauthClient.createdAt,
+ updatedAt: oauthClient.updatedAt,
+ })
+ .from(oauthClient)
+ .orderBy(desc(oauthClient.createdAt));
+
+export const getOAuthClientByClientId = async ({
+ db,
+ clientId,
+}: {
+ db: DrizzleCli;
+ clientId: string;
+}) => {
+ const [client] = await db
+ .select(oauthClientSelect)
+ .from(oauthClient)
+ .where(eq(oauthClient.clientId, clientId))
+ .limit(1);
+
+ return client ?? null;
+};
+
+export const updateOAuthClientById = async ({
+ db,
+ id,
+ updates,
+}: {
+ db: DrizzleCli;
+ id: string;
+ updates: {
+ name: string;
+ redirectUris: string[];
+ scopes: string[];
+ tokenEndpointAuthMethod: string;
+ grantTypes: string[];
+ responseTypes: string[];
+ public: boolean;
+ type: string;
+ metadata: unknown;
+ updatedAt: Date;
+ };
+}) => {
+ const [client] = await db
+ .update(oauthClient)
+ .set(updates)
+ .where(eq(oauthClient.id, id))
+ .returning(oauthClientSelect);
+
+ return client ?? null;
+};
+
+export const upsertOAuthClient = async ({
+ db,
+ insert,
+ update,
+}: {
+ db: DrizzleCli;
+ insert: {
+ id: string;
+ clientId: string;
+ name: string;
+ redirectUris: string[];
+ scopes: string[];
+ tokenEndpointAuthMethod: string;
+ grantTypes: string[];
+ responseTypes: string[];
+ public: boolean;
+ type: string;
+ metadata: unknown;
+ createdAt: Date;
+ updatedAt: Date;
+ };
+ update: {
+ name: string;
+ redirectUris: string[];
+ scopes: string[];
+ tokenEndpointAuthMethod: string;
+ grantTypes: string[];
+ responseTypes: string[];
+ public: boolean;
+ type: string;
+ metadata: unknown;
+ updatedAt: Date;
+ };
+}) => {
+ await db.insert(oauthClient).values(insert).onConflictDoUpdate({
+ target: oauthClient.clientId,
+ set: update,
+ });
+
+ return getOAuthClientByClientId({ db, clientId: insert.clientId });
+};
+
+export const oauthClientRepo = {
+ list: listOAuthClients,
+ listForAdmin: listOAuthClientsForAdmin,
+ getByClientId: getOAuthClientByClientId,
+ updateById: updateOAuthClientById,
+ upsert: upsertOAuthClient,
+};
diff --git a/server/src/internal/auth/repos/oauthConsentRepo.ts b/server/src/internal/auth/repos/oauthConsentRepo.ts
new file mode 100644
index 000000000..23e24dae6
--- /dev/null
+++ b/server/src/internal/auth/repos/oauthConsentRepo.ts
@@ -0,0 +1,147 @@
+import { type AppEnv, oauthConsent } from "@autumn/shared";
+import { and, eq } from "drizzle-orm";
+import type { DrizzleCli } from "@/db/initDrizzle.js";
+
+export type OAuthConsentApiKeyRecord = {
+ id: string;
+ env: AppEnv | null;
+ oauthApiKeyId: string | null;
+ redirectUri: string | null;
+};
+
+export const listOAuthConsentsByReferenceId = async ({
+ db,
+ referenceId,
+}: {
+ db: DrizzleCli;
+ referenceId: string;
+}) =>
+ db
+ .select({
+ id: oauthConsent.id,
+ clientId: oauthConsent.clientId,
+ userId: oauthConsent.userId,
+ referenceId: oauthConsent.referenceId,
+ scopes: oauthConsent.scopes,
+ createdAt: oauthConsent.createdAt,
+ updatedAt: oauthConsent.updatedAt,
+ })
+ .from(oauthConsent)
+ .where(eq(oauthConsent.referenceId, referenceId));
+
+export const getOAuthConsentOwner = async ({
+ db,
+ consentId,
+}: {
+ db: DrizzleCli;
+ consentId: string;
+}) => {
+ const [consent] = await db
+ .select({
+ id: oauthConsent.id,
+ clientId: oauthConsent.clientId,
+ referenceId: oauthConsent.referenceId,
+ })
+ .from(oauthConsent)
+ .where(eq(oauthConsent.id, consentId))
+ .limit(1);
+
+ return consent ?? null;
+};
+
+export const updateOAuthConsentEnv = async ({
+ db,
+ clientId,
+ userId,
+ referenceId,
+ env,
+ redirectUri,
+}: {
+ db: DrizzleCli;
+ clientId: string;
+ userId: string;
+ referenceId: string;
+ env: AppEnv;
+ redirectUri: string | null;
+}) =>
+ db
+ .update(oauthConsent)
+ .set({ env, redirectUri, updatedAt: new Date() })
+ .where(
+ and(
+ eq(oauthConsent.clientId, clientId),
+ eq(oauthConsent.userId, userId),
+ eq(oauthConsent.referenceId, referenceId),
+ ),
+ );
+
+export const getOAuthConsentForClientUserOrg = async ({
+ db,
+ clientId,
+ userId,
+ referenceId,
+ env,
+}: {
+ db: DrizzleCli;
+ clientId: string;
+ userId: string;
+ referenceId: string;
+ env?: AppEnv;
+}) => {
+ const [consent] = await db
+ .select({
+ id: oauthConsent.id,
+ env: oauthConsent.env,
+ oauthApiKeyId: oauthConsent.oauthApiKeyId,
+ redirectUri: oauthConsent.redirectUri,
+ })
+ .from(oauthConsent)
+ .where(
+ and(
+ eq(oauthConsent.clientId, clientId),
+ eq(oauthConsent.userId, userId),
+ eq(oauthConsent.referenceId, referenceId),
+ ...(env ? [eq(oauthConsent.env, env)] : []),
+ ),
+ )
+ .limit(1);
+
+ return consent ?? null;
+};
+
+export const updateOAuthConsentApiKey = async ({
+ db,
+ consentId,
+ env,
+ oauthApiKeyId,
+}: {
+ db: DrizzleCli;
+ consentId: string;
+ env: AppEnv;
+ oauthApiKeyId: string | null;
+}) =>
+ db
+ .update(oauthConsent)
+ .set({
+ env,
+ oauthApiKeyId,
+ updatedAt: new Date(),
+ })
+ .where(eq(oauthConsent.id, consentId));
+
+export const deleteOAuthConsentById = async ({
+ db,
+ consentId,
+}: {
+ db: DrizzleCli;
+ consentId: string;
+}) => db.delete(oauthConsent).where(eq(oauthConsent.id, consentId));
+
+export const oauthConsentRepo = {
+ listByReferenceId: listOAuthConsentsByReferenceId,
+ getOwner: getOAuthConsentOwner,
+ updateEnv: updateOAuthConsentEnv,
+ getForClientUserOrg: getOAuthConsentForClientUserOrg,
+ updateApiKey: updateOAuthConsentApiKey,
+ deleteById: deleteOAuthConsentById,
+};
diff --git a/server/src/internal/auth/repos/oauthRefreshTokenRepo.ts b/server/src/internal/auth/repos/oauthRefreshTokenRepo.ts
new file mode 100644
index 000000000..c92fc5bac
--- /dev/null
+++ b/server/src/internal/auth/repos/oauthRefreshTokenRepo.ts
@@ -0,0 +1,27 @@
+import { oauthRefreshToken } from "@autumn/shared";
+import { and, eq, isNull } from "drizzle-orm";
+import type { DrizzleCli } from "@/db/initDrizzle.js";
+
+export const deleteOAuthRefreshTokensByClientAndReference = async ({
+ db,
+ clientId,
+ referenceId,
+}: {
+ db: DrizzleCli;
+ clientId: string;
+ referenceId: string | null;
+}) =>
+ db
+ .delete(oauthRefreshToken)
+ .where(
+ and(
+ eq(oauthRefreshToken.clientId, clientId),
+ referenceId
+ ? eq(oauthRefreshToken.referenceId, referenceId)
+ : isNull(oauthRefreshToken.referenceId),
+ ),
+ );
+
+export const oauthRefreshTokenRepo = {
+ deleteByClientAndReference: deleteOAuthRefreshTokensByClientAndReference,
+};
diff --git a/server/src/internal/balances/utils/allocatedInvoice/compute/computeAllocatedInvoiceLineItems.ts b/server/src/internal/balances/utils/allocatedInvoice/compute/computeAllocatedInvoiceLineItems.ts
index bd331e2d3..c29f3b685 100644
--- a/server/src/internal/balances/utils/allocatedInvoice/compute/computeAllocatedInvoiceLineItems.ts
+++ b/server/src/internal/balances/utils/allocatedInvoice/compute/computeAllocatedInvoiceLineItems.ts
@@ -1,6 +1,8 @@
import {
+ BILLING_AMOUNT_EPSILON,
cusEntToCusPrice,
InternalError,
+ type LineItem,
type LineItemContext,
orgToCurrency,
priceToProrationConfig,
@@ -10,6 +12,7 @@ import {
import { isStripeSubscriptionTrialing } from "@/external/stripe/subscriptions/utils/classifyStripeSubscriptionUtils";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { getLineItemBillingPeriod } from "@/internal/billing/v2/utils/lineItems/getLineItemBillingPeriod";
+import { getRefundLineItemsForPrice } from "@/internal/billing/v2/utils/lineItems/getRefundLineItemsForPrice";
import type { AllocatedInvoiceContext } from "../allocatedInvoiceContext";
import { allocatedInvoiceIsUpgrade } from "./allocatedInvoiceIsUpgrade";
@@ -66,7 +69,7 @@ export const computeAllocatedInvoiceLineItems = ({
customerProduct,
};
- const previousLIneItem = usagePriceToLineItem({
+ const catalogRefundLineItem = usagePriceToLineItem({
cusEnt: previousCustomerEntitlement,
context: {
...lineItemContext,
@@ -78,6 +81,14 @@ export const computeAllocatedInvoiceLineItems = ({
},
});
+ const previousLineItems = getRefundLineItemsForPrice({
+ ctx,
+ customerProduct,
+ billingContext,
+ priceId: customerPrice.price.id,
+ catalogFallback: catalogRefundLineItem,
+ });
+
const newLineItem = usagePriceToLineItem({
cusEnt: billingContext.updatedCustomerEntitlement,
context: lineItemContext,
@@ -87,15 +98,16 @@ export const computeAllocatedInvoiceLineItems = ({
},
});
- // Don't return line items if they sum to 0
- if (
+ const netAmount = Math.abs(
sumValues([
- previousLIneItem?.amountAfterDiscounts ?? 0,
+ ...previousLineItems.map((li) => li.amountAfterDiscounts ?? 0),
newLineItem?.amountAfterDiscounts ?? 0,
- ]) === 0
- ) {
- return [];
- }
+ ]),
+ );
- return [previousLIneItem, newLineItem];
+ if (netAmount < BILLING_AMOUNT_EPSILON) return [];
+
+ return [...previousLineItems, newLineItem].filter(
+ (li): li is LineItem => li !== undefined,
+ );
};
diff --git a/server/src/internal/balances/utils/allocatedInvoice/setupAllocatedInvoiceContext.ts b/server/src/internal/balances/utils/allocatedInvoice/setupAllocatedInvoiceContext.ts
index 70d92d83b..dfd562ab5 100644
--- a/server/src/internal/balances/utils/allocatedInvoice/setupAllocatedInvoiceContext.ts
+++ b/server/src/internal/balances/utils/allocatedInvoice/setupAllocatedInvoiceContext.ts
@@ -10,6 +10,7 @@ import {
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { setupStripeBillingContext } from "@/internal/billing/v2/providers/stripe/setup/setupStripeBillingContext.js";
+import { fetchStoredLineItemsForBilling } from "@/internal/billing/v2/setup/fetchStoredLineItemsForBilling.js";
import { setupFullCustomerContext } from "@/internal/billing/v2/setup/setupFullCustomerContext.js";
import { applyDeductionUpdateToCustomerEntitlement } from "../deduction/applyDeductionUpdateToCustomerEntitlement.js";
import { applyDeductionUpdateToFullCustomer } from "../deduction/applyDeductionUpdateToFullCustomer.js";
@@ -108,6 +109,12 @@ export const setupAllocatedInvoiceContext = async ({
cusEnt: newCustomerEntitlement,
});
+ const { storedChargeLineItems, storedRefundLineItems } =
+ await fetchStoredLineItemsForBilling({
+ db: ctx.db,
+ customerProductIds: [cusProduct.id],
+ });
+
return {
// BillingContext fields
fullCustomer,
@@ -120,6 +127,8 @@ export const setupAllocatedInvoiceContext = async ({
stripeSubscription,
stripeSubscriptionSchedule,
stripeDiscounts,
+ storedChargeLineItems,
+ storedRefundLineItems,
paymentMethod,
billingVersion: BillingVersion.V2,
diff --git a/server/src/internal/billing/v2/actions/attach/compute/computeAttachNewCustomerProduct.ts b/server/src/internal/billing/v2/actions/attach/compute/computeAttachNewCustomerProduct.ts
index 29dab0a44..99b99eaf7 100644
--- a/server/src/internal/billing/v2/actions/attach/compute/computeAttachNewCustomerProduct.ts
+++ b/server/src/internal/billing/v2/actions/attach/compute/computeAttachNewCustomerProduct.ts
@@ -66,6 +66,7 @@ export const computeAttachNewCustomerProduct = ({
accessStartsAt,
billingStartsAt,
paymentMethod,
+ processorTypeOverride,
} = attachBillingContext;
const currentCustomerEntitlements =
@@ -142,6 +143,7 @@ export const computeAttachNewCustomerProduct = ({
accessStartsAt,
collectionMethod,
externalId,
+ processorType: processorTypeOverride,
billingCycleAnchorResetsAt: getScheduledBillingCycleAnchorResetAt({
requestedBillingCycleAnchor,
currentEpochMs,
diff --git a/server/src/internal/billing/v2/actions/attach/errors/handleAttachV2Errors.ts b/server/src/internal/billing/v2/actions/attach/errors/handleAttachV2Errors.ts
index c6a581847..92aa3fdcd 100644
--- a/server/src/internal/billing/v2/actions/attach/errors/handleAttachV2Errors.ts
+++ b/server/src/internal/billing/v2/actions/attach/errors/handleAttachV2Errors.ts
@@ -39,11 +39,15 @@ export const handleAttachV2Errors = async ({
const { autumn: autumnBillingPlan } = billingPlan;
// 1.1. External PSP errors (RevenueCat)
- handleExternalPSPErrors({
- customerProducts: billingContext.fullCustomer.customer_products,
- attachProduct: billingContext.attachProduct,
- action: "attach",
- });
+ // Skipped when the caller IS the external PSP origin (e.g. the RevenueCat
+ // webhook handler attaching onto its own RC-managed customer).
+ if (!billingContext.skipExternalPSPGuard) {
+ handleExternalPSPErrors({
+ customerProducts: billingContext.fullCustomer.customer_products,
+ attachProduct: billingContext.attachProduct,
+ action: "attach",
+ });
+ }
// 1.2. Custom Payment Method errors (Vercel)
handleCustomPaymentMethodErrorsV2({ billingContext });
diff --git a/server/src/internal/billing/v2/actions/attach/errors/handleCurrentCustomerProductErrors.ts b/server/src/internal/billing/v2/actions/attach/errors/handleCurrentCustomerProductErrors.ts
index 6503f7dae..eceb02f34 100644
--- a/server/src/internal/billing/v2/actions/attach/errors/handleCurrentCustomerProductErrors.ts
+++ b/server/src/internal/billing/v2/actions/attach/errors/handleCurrentCustomerProductErrors.ts
@@ -10,8 +10,12 @@ export const handleCurrentCustomerProductErrors = ({
}: {
billingContext: AttachBillingContext;
}) => {
- const { currentCustomerProduct, attachProduct, stripeSubscription } =
- billingContext;
+ const {
+ currentCustomerProduct,
+ attachProduct,
+ stripeSubscription,
+ skipExternalPSPGuard,
+ } = billingContext;
if (currentCustomerProduct?.product.id === attachProduct.id) {
throw new RecaseError({
@@ -21,7 +25,17 @@ export const handleCurrentCustomerProductErrors = ({
});
}
- if (isCustomerProductPaid(currentCustomerProduct) && !stripeSubscription) {
+ // The "paid but no Stripe sub" guard catches broken Stripe linkage.
+ // External-PSP origin callers (e.g. RevenueCat) legitimately have paid
+ // current products with no Stripe subscription — they opt out via
+ // `skipExternalPSPGuard`. Stripe-origin cus_products with `processor: null`
+ // must still be checked, so this is gated on the explicit flag rather than
+ // on `cusProductToProcessorType`.
+ if (
+ !skipExternalPSPGuard &&
+ isCustomerProductPaid(currentCustomerProduct) &&
+ !stripeSubscription
+ ) {
throw new RecaseError({
message: `Cannot attach because the customer's current product '${currentCustomerProduct?.product.name}' is paid but no stripe subscription is linked to it`,
});
diff --git a/server/src/internal/billing/v2/actions/attach/setup/setupAttachBillingContext.ts b/server/src/internal/billing/v2/actions/attach/setup/setupAttachBillingContext.ts
index adb77132f..d627b6080 100644
--- a/server/src/internal/billing/v2/actions/attach/setup/setupAttachBillingContext.ts
+++ b/server/src/internal/billing/v2/actions/attach/setup/setupAttachBillingContext.ts
@@ -26,6 +26,7 @@ import { setupInvoiceModeContext } from "@/internal/billing/v2/setup/setupInvoic
import { setupPaymentBehaviorIntent } from "@/internal/billing/v2/setup/setupPaymentBehaviorIntent";
import { setupResetCycleAnchor } from "@/internal/billing/v2/setup/setupResetCycleAnchor";
import { setupTransitionConfigs } from "@/internal/billing/v2/setup/setupTransitionConfigs";
+import { fetchStoredLineItemsForSubscriptionBilling } from "@/internal/billing/v2/setup/fetchStoredLineItemsForSubscriptionBilling";
import { setupAdjustableQuantities } from "../../../setup/setupAdjustableQuantities";
import { setupAnchorResetRefund } from "../../../setup/setupAnchorResetRefund";
import { setupIgnoreProrationBehavior } from "../../../setup/setupIgnoreProrationBehavior";
@@ -126,7 +127,11 @@ export const setupAttachBillingContext = async ({
// no_billing_changes blocks WRITES but should still allow reading the
// existing Stripe sub when one is linked — needed so the new cusProduct
// inherits subscription_ids and the paid-product guard doesn't misfire.
- const skipBillingFetching = orgDisableStripeWrites({ ctx });
+ // External-PSP origin callers (e.g. RevenueCat) opt out of fetching
+ // entirely via `contextOverride.skipBillingFetching`.
+ const skipBillingFetching =
+ orgDisableStripeWrites({ ctx }) ||
+ contextOverride.skipBillingFetching === true;
const skipBillingChangesBase =
skipBillingFetching ||
@@ -261,6 +266,17 @@ export const setupAttachBillingContext = async ({
contextOverride,
});
+ const outgoingCusProductIds = currentCustomerProduct
+ ? [currentCustomerProduct.id]
+ : [];
+ const { storedChargeLineItems, storedRefundLineItems } =
+ await fetchStoredLineItemsForSubscriptionBilling({
+ db: ctx.db,
+ fullCustomer,
+ stripeSubscription,
+ outgoingCusProductIds,
+ });
+
return {
fullCustomer,
fullProducts: [attachProduct],
@@ -298,6 +314,8 @@ export const setupAttachBillingContext = async ({
paymentBehaviorIntent,
shouldFinalizeFirstInvoice,
skipCustomPaymentMethodGuard: contextOverride.skipCustomPaymentMethodGuard,
+ skipExternalPSPGuard: contextOverride.skipExternalPSPGuard,
+ processorTypeOverride: contextOverride.processorTypeOverride,
enablePlanImmediately: params.enable_plan_immediately ?? false,
accessStartsAt,
@@ -321,6 +339,9 @@ export const setupAttachBillingContext = async ({
skipBillingChanges,
dryRunStripe: preview,
+ storedChargeLineItems,
+ storedRefundLineItems,
+
anchorResetRefund: setupAnchorResetRefund({
billingCycleAnchor: params.billing_cycle_anchor,
prorationBehavior: params.proration_behavior,
diff --git a/server/src/internal/billing/v2/actions/common/immediateMultiProduct/setupImmediateMultiProductBillingContext.ts b/server/src/internal/billing/v2/actions/common/immediateMultiProduct/setupImmediateMultiProductBillingContext.ts
index 5277e7d89..b2102ab76 100644
--- a/server/src/internal/billing/v2/actions/common/immediateMultiProduct/setupImmediateMultiProductBillingContext.ts
+++ b/server/src/internal/billing/v2/actions/common/immediateMultiProduct/setupImmediateMultiProductBillingContext.ts
@@ -15,6 +15,7 @@ import { setupAttachProductContext } from "@/internal/billing/v2/actions/attach/
import { setupAttachTransitionContext } from "@/internal/billing/v2/actions/attach/setup/setupAttachTransitionContext";
import { setupStripeBillingContext } from "@/internal/billing/v2/providers/stripe/setup/setupStripeBillingContext";
import { setupBillingCycleAnchor } from "@/internal/billing/v2/setup/setupBillingCycleAnchor";
+import { fetchStoredLineItemsForSubscriptionBilling } from "@/internal/billing/v2/setup/fetchStoredLineItemsForSubscriptionBilling";
import { setupFeatureQuantitiesContext } from "@/internal/billing/v2/setup/setupFeatureQuantitiesContext";
import { setupFullCustomerContext } from "@/internal/billing/v2/setup/setupFullCustomerContext";
import { setupInvoiceModeContext } from "@/internal/billing/v2/setup/setupInvoiceModeContext";
@@ -226,6 +227,17 @@ export const setupImmediateMultiProductBillingContext = async ({
(productContext) => productContext.customEnts,
);
+ const outgoingCusProductIds = productContexts
+ .map((pc) => pc.currentCustomerProduct?.id)
+ .filter((id): id is string => id != null);
+ const { storedChargeLineItems, storedRefundLineItems } =
+ await fetchStoredLineItemsForSubscriptionBilling({
+ db: ctx.db,
+ fullCustomer,
+ stripeSubscription,
+ outgoingCusProductIds,
+ });
+
return {
fullCustomer,
fullProducts,
@@ -265,5 +277,7 @@ export const setupImmediateMultiProductBillingContext = async ({
params.success_url ?? orgToReturnUrl({ org: ctx.org, env: ctx.env }),
checkoutSessionParams: params.checkout_session_params,
dryRunStripe: preview,
+ storedChargeLineItems,
+ storedRefundLineItems,
};
};
diff --git a/server/src/internal/billing/v2/actions/updateSubscription/compute/customPlan/computeCustomPlanNewCustomerProduct.ts b/server/src/internal/billing/v2/actions/updateSubscription/compute/customPlan/computeCustomPlanNewCustomerProduct.ts
index 745cfb392..939be3716 100644
--- a/server/src/internal/billing/v2/actions/updateSubscription/compute/customPlan/computeCustomPlanNewCustomerProduct.ts
+++ b/server/src/internal/billing/v2/actions/updateSubscription/compute/customPlan/computeCustomPlanNewCustomerProduct.ts
@@ -90,7 +90,7 @@ export const computeCustomPlanNewCustomerProduct = ({
? { subscriptionId: params.processor_subscription_id }
: {}),
- ...(params.status ? { status: params.status } : {}),
+ status: params.status ?? currentCustomerProduct.status,
onTrialEnd:
trialContext?.onEnd ??
diff --git a/server/src/internal/billing/v2/actions/updateSubscription/compute/updateQuantity/computeUpdateQuantityLineItems.ts b/server/src/internal/billing/v2/actions/updateSubscription/compute/updateQuantity/computeUpdateQuantityLineItems.ts
index 7024f0c88..e646256dc 100644
--- a/server/src/internal/billing/v2/actions/updateSubscription/compute/updateQuantity/computeUpdateQuantityLineItems.ts
+++ b/server/src/internal/billing/v2/actions/updateSubscription/compute/updateQuantity/computeUpdateQuantityLineItems.ts
@@ -1,5 +1,6 @@
import type { BillingContext } from "@autumn/shared";
import {
+ BILLING_AMOUNT_EPSILON,
type BillingPeriod,
cloneEntitlementWithUpdatedQuantity,
cusEntToCusPrice,
@@ -8,6 +9,7 @@ import {
type FullCusProduct,
findPrepaidCustomerEntitlement,
InternalError,
+ type LineItem,
type LineItemContext,
orgToCurrency,
priceToProrationConfig,
@@ -15,6 +17,7 @@ import {
usagePriceToLineItem,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
+import { getRefundLineItemsForPrice } from "@/internal/billing/v2/utils/lineItems/getRefundLineItemsForPrice";
export const computeUpdateQuantityLineItems = ({
ctx,
@@ -88,7 +91,7 @@ export const computeUpdateQuantityLineItems = ({
customerProduct,
};
- const refundLineItem = usagePriceToLineItem({
+ const catalogRefundLineItem = usagePriceToLineItem({
cusEnt: prepaidCustomerEntitlement,
context: {
...lineItemContext,
@@ -100,6 +103,14 @@ export const computeUpdateQuantityLineItems = ({
},
});
+ const refundLineItems = getRefundLineItemsForPrice({
+ ctx,
+ customerProduct,
+ billingContext,
+ priceId: customerPrice.price.id,
+ catalogFallback: catalogRefundLineItem,
+ });
+
const chargeLineItem = usagePriceToLineItem({
cusEnt: newCustomerEntitlement,
context: lineItemContext,
@@ -109,15 +120,16 @@ export const computeUpdateQuantityLineItems = ({
},
});
- // Don't return line items if they sum to 0
- if (
+ const netAmount = Math.abs(
sumValues([
- refundLineItem?.amountAfterDiscounts ?? 0,
+ ...refundLineItems.map((li) => li.amountAfterDiscounts ?? 0),
chargeLineItem?.amountAfterDiscounts ?? 0,
- ]) === 0
- ) {
- return [];
- }
+ ]),
+ );
- return [refundLineItem, chargeLineItem];
+ if (netAmount < BILLING_AMOUNT_EPSILON) return [];
+
+ return [...refundLineItems, chargeLineItem].filter(
+ (li): li is LineItem => li !== undefined,
+ );
};
diff --git a/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionBillingContext.ts b/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionBillingContext.ts
index 72eaf361f..5bb510c40 100644
--- a/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionBillingContext.ts
+++ b/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionBillingContext.ts
@@ -19,6 +19,7 @@ import { setupFullCustomerContext } from "@/internal/billing/v2/setup/setupFullC
import { setupIgnoreProrationBehavior } from "@/internal/billing/v2/setup/setupIgnoreProrationBehavior";
import { setupInvoiceModeContext } from "@/internal/billing/v2/setup/setupInvoiceModeContext";
import { setupResetCycleAnchor } from "@/internal/billing/v2/setup/setupResetCycleAnchor";
+import { fetchStoredLineItemsForSubscriptionBilling } from "@/internal/billing/v2/setup/fetchStoredLineItemsForSubscriptionBilling";
import { setupAttachCheckoutMode } from "../../attach/setup/setupAttachCheckoutMode";
import { setupUpdateSubscriptionIntent } from "./setupUpdateSubscriptionIntent";
import { setupUpdateSubscriptionTrialContext } from "./setupUpdateSubscriptionTrialContext";
@@ -176,6 +177,14 @@ export const setupUpdateSubscriptionBillingContext = async ({
customerProduct,
});
+ const { storedChargeLineItems, storedRefundLineItems } =
+ await fetchStoredLineItemsForSubscriptionBilling({
+ db: ctx.db,
+ fullCustomer,
+ stripeSubscription,
+ outgoingCusProductIds: [customerProduct.id],
+ });
+
return {
intent,
fullCustomer,
@@ -217,6 +226,9 @@ export const setupUpdateSubscriptionBillingContext = async ({
skipBillingChanges,
dryRunStripe: preview,
+ storedChargeLineItems,
+ storedRefundLineItems,
+
checkoutMode,
anchorResetRefund: setupAnchorResetRefund({
diff --git a/server/src/internal/billing/v2/compute/computeAutumnUtils/buildAutumnLineItems.ts b/server/src/internal/billing/v2/compute/computeAutumnUtils/buildAutumnLineItems.ts
index 44a586912..c698ca2cb 100644
--- a/server/src/internal/billing/v2/compute/computeAutumnUtils/buildAutumnLineItems.ts
+++ b/server/src/internal/billing/v2/compute/computeAutumnUtils/buildAutumnLineItems.ts
@@ -5,6 +5,7 @@ import type {
UpdateCustomerEntitlement,
} from "@autumn/shared";
import { customerProductToArrearLineItems } from "@/internal/billing/v2/utils/lineItems/customerProductToArrearLineItems";
+import { getRefundLineItems } from "@/internal/billing/v2/utils/lineItems/getRefundLineItems";
import type { AutumnContext } from "../../../../../honoUtils/HonoEnv";
import { customerProductToLineItems } from "../../utils/lineItems/customerProductToLineItems";
import { logBuildAutumnLineItems } from "./logBuildAutumnLineItems";
@@ -55,11 +56,10 @@ export const buildAutumnLineItems = ({
// Get line items for ongoing cus product
const deletedLineItems = customerProductsToDelete.flatMap((customerProduct) =>
- customerProductToLineItems({
+ getRefundLineItems({
ctx,
customerProduct,
billingContext,
- direction: "refund",
priceFilters: { excludeOneOffPrices: true },
}),
);
diff --git a/server/src/internal/billing/v2/compute/computeAutumnUtils/buildSharedSubscriptionTrialLineItems.ts b/server/src/internal/billing/v2/compute/computeAutumnUtils/buildSharedSubscriptionTrialLineItems.ts
index f93ba10af..a8f9f3a9d 100644
--- a/server/src/internal/billing/v2/compute/computeAutumnUtils/buildSharedSubscriptionTrialLineItems.ts
+++ b/server/src/internal/billing/v2/compute/computeAutumnUtils/buildSharedSubscriptionTrialLineItems.ts
@@ -13,7 +13,7 @@ import {
getDeleteCustomerProducts,
getUpdateCustomerProducts,
} from "@/internal/billing/v2/utils/billingPlan/customerProductPlanMutations";
-import { customerProductToLineItems } from "@/internal/billing/v2/utils/lineItems/customerProductToLineItems";
+import { getLineItemsForDirection } from "@/internal/billing/v2/utils/lineItems/getLineItemsForDirection";
const formatLineItem = (item: LineItem) => ({
description: item.description,
@@ -141,9 +141,9 @@ export const buildSharedSubscriptionTrialLineItems = ({
const lineItems: LineItem[] = [];
for (const customerProduct of siblingCustomerProducts) {
lineItems.push(
- ...customerProductToLineItems({
+ ...getLineItemsForDirection({
ctx,
- customerProduct: customerProduct,
+ customerProduct,
billingContext,
direction,
priceFilters: { excludeOneOffPrices: true },
diff --git a/server/src/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionScheduleAction.ts b/server/src/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionScheduleAction.ts
index eab92010f..7c46d271f 100644
--- a/server/src/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionScheduleAction.ts
+++ b/server/src/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionScheduleAction.ts
@@ -21,6 +21,7 @@ const toCreatePhase = (
...(item.metadata && { metadata: item.metadata }),
})),
end_date: typeof phase.end_date === "number" ? phase.end_date : undefined,
+ proration_behavior: phase.proration_behavior,
discounts: phase.discounts as
| Stripe.SubscriptionScheduleCreateParams.Phase.Discount[]
| undefined,
diff --git a/server/src/internal/billing/v2/providers/stripe/utils/subscriptionSchedules/buildStripePhasesUpdate.ts b/server/src/internal/billing/v2/providers/stripe/utils/subscriptionSchedules/buildStripePhasesUpdate.ts
index 164fd8904..8b3d1f8e2 100644
--- a/server/src/internal/billing/v2/providers/stripe/utils/subscriptionSchedules/buildStripePhasesUpdate.ts
+++ b/server/src/internal/billing/v2/providers/stripe/utils/subscriptionSchedules/buildStripePhasesUpdate.ts
@@ -231,6 +231,10 @@ export const buildStripePhasesUpdate = ({
const phaseStartDateSeconds = msToSeconds(startMs);
const isBillingCycleAnchorResetPhase =
billingCycleAnchorResetAt === startMs;
+ const shouldInvoicePhaseTransition =
+ phaseIndex > 0 && phaseItems.length > 0;
+ const shouldAlwaysInvoice =
+ shouldInvoicePhaseTransition || isBillingCycleAnchorResetPhase;
const phase: Stripe.SubscriptionScheduleUpdateParams.Phase = {
items: phaseItems,
start_date: phaseStartDateSeconds,
@@ -239,9 +243,7 @@ export const buildStripePhasesUpdate = ({
billing_cycle_anchor: isBillingCycleAnchorResetPhase
? "phase_start"
: undefined,
- proration_behavior: isBillingCycleAnchorResetPhase
- ? "always_invoice"
- : undefined,
+ proration_behavior: shouldAlwaysInvoice ? "always_invoice" : undefined,
discounts: stripeDiscountsToPhaseDiscounts({
stripeDiscounts: billingContext.stripeDiscounts,
phaseStartDateSeconds,
diff --git a/server/src/internal/billing/v2/setup/fetchStoredLineItemsForBilling.ts b/server/src/internal/billing/v2/setup/fetchStoredLineItemsForBilling.ts
new file mode 100644
index 000000000..3f865f512
--- /dev/null
+++ b/server/src/internal/billing/v2/setup/fetchStoredLineItemsForBilling.ts
@@ -0,0 +1,41 @@
+import type { DbInvoiceLineItem } from "@autumn/shared";
+import type { DrizzleCli } from "@/db/initDrizzle";
+import { invoiceLineItemRepo } from "@/internal/invoices/lineItems/repos";
+
+const deduplicateById = (rows: DbInvoiceLineItem[]): DbInvoiceLineItem[] => {
+ const seen = new Set();
+ return rows.filter((row) => {
+ if (seen.has(row.id)) return false;
+ seen.add(row.id);
+ return true;
+ });
+};
+
+export const fetchStoredLineItemsForBilling = async ({
+ db,
+ customerProductIds,
+}: {
+ db: DrizzleCli;
+ customerProductIds: string[];
+}): Promise<{
+ storedChargeLineItems: DbInvoiceLineItem[];
+ storedRefundLineItems: DbInvoiceLineItem[];
+}> => {
+ if (customerProductIds.length === 0) {
+ return { storedChargeLineItems: [], storedRefundLineItems: [] };
+ }
+
+ const allRows = await invoiceLineItemRepo.getByCustomerProductIds({
+ db,
+ customerProductIds,
+ });
+
+ return {
+ storedChargeLineItems: deduplicateById(
+ allRows.filter((row) => row.direction === "charge"),
+ ),
+ storedRefundLineItems: deduplicateById(
+ allRows.filter((row) => row.direction === "refund"),
+ ),
+ };
+};
diff --git a/server/src/internal/billing/v2/setup/fetchStoredLineItemsForSubscriptionBilling.ts b/server/src/internal/billing/v2/setup/fetchStoredLineItemsForSubscriptionBilling.ts
new file mode 100644
index 000000000..2ee3e06aa
--- /dev/null
+++ b/server/src/internal/billing/v2/setup/fetchStoredLineItemsForSubscriptionBilling.ts
@@ -0,0 +1,27 @@
+import type { FullCustomer } from "@autumn/shared";
+import type Stripe from "stripe";
+import type { DrizzleCli } from "@/db/initDrizzle";
+import { fetchStoredLineItemsForBilling } from "./fetchStoredLineItemsForBilling";
+import { getSiblingCusProductIds } from "./getSiblingCusProductIds";
+
+export const fetchStoredLineItemsForSubscriptionBilling = async ({
+ db,
+ fullCustomer,
+ stripeSubscription,
+ outgoingCusProductIds,
+}: {
+ db: DrizzleCli;
+ fullCustomer: FullCustomer;
+ stripeSubscription?: Stripe.Subscription;
+ outgoingCusProductIds: string[];
+}) => {
+ const siblingIds = getSiblingCusProductIds({
+ fullCustomer,
+ stripeSubscription,
+ excludeIds: outgoingCusProductIds,
+ });
+ return fetchStoredLineItemsForBilling({
+ db,
+ customerProductIds: [...outgoingCusProductIds, ...siblingIds],
+ });
+};
diff --git a/server/src/internal/billing/v2/setup/getSiblingCusProductIds.ts b/server/src/internal/billing/v2/setup/getSiblingCusProductIds.ts
new file mode 100644
index 000000000..b6daf5906
--- /dev/null
+++ b/server/src/internal/billing/v2/setup/getSiblingCusProductIds.ts
@@ -0,0 +1,26 @@
+import { cp, type FullCustomer } from "@autumn/shared";
+import type Stripe from "stripe";
+
+export const getSiblingCusProductIds = ({
+ fullCustomer,
+ stripeSubscription,
+ excludeIds = [],
+}: {
+ fullCustomer: FullCustomer;
+ stripeSubscription?: Stripe.Subscription;
+ excludeIds?: string[];
+}): string[] => {
+ if (!stripeSubscription) return [];
+
+ const excluded = new Set(excludeIds);
+
+ return fullCustomer.customer_products
+ .filter(
+ (cusProduct) =>
+ !excluded.has(cusProduct.id) &&
+ cp(cusProduct).paid().recurring().onStripeSubscription({
+ stripeSubscriptionId: stripeSubscription.id,
+ }).valid,
+ )
+ .map((cusProduct) => cusProduct.id);
+};
diff --git a/server/src/internal/billing/v2/utils/billingPlan/toNextCyclePreview/billingPlanToNextCycleLineItems.ts b/server/src/internal/billing/v2/utils/billingPlan/toNextCyclePreview/billingPlanToNextCycleLineItems.ts
index 7f7127e69..1ab8c3883 100644
--- a/server/src/internal/billing/v2/utils/billingPlan/toNextCyclePreview/billingPlanToNextCycleLineItems.ts
+++ b/server/src/internal/billing/v2/utils/billingPlan/toNextCyclePreview/billingPlanToNextCycleLineItems.ts
@@ -11,7 +11,7 @@ import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { applyStripeDiscountsToLineItems } from "@/internal/billing/v2/providers/stripe/utils/discounts/applyStripeDiscountsToLineItems";
import { filterStripeDiscountsForNextCycle } from "@/internal/billing/v2/providers/stripe/utils/discounts/filterStripeDiscountsForNextCycle";
import { customerProductToArrearLineItems } from "../../lineItems/customerProductToArrearLineItems";
-import { customerProductToLineItems } from "../../lineItems/customerProductToLineItems";
+import { getLineItemsForDirection } from "../../lineItems/getLineItemsForDirection";
import { lineItemToPreviewLineItem } from "../../lineItems/lineItemToPreviewLineItem";
import { lineItemToPreviewUsageLineItem } from "../../lineItems/lineItemToPreviewUsageLineItem";
@@ -38,7 +38,7 @@ const buildLineItemsForSpec = ({
nextCycleStart: number;
}) => {
const lineItems = spec.customerProducts.flatMap((customerProduct) =>
- customerProductToLineItems({
+ getLineItemsForDirection({
ctx,
customerProduct,
billingContext: {
diff --git a/server/src/internal/billing/v2/utils/initFullCustomerProduct/initCustomerProduct.ts b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initCustomerProduct.ts
index aede0662a..5578f519f 100644
--- a/server/src/internal/billing/v2/utils/initFullCustomerProduct/initCustomerProduct.ts
+++ b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initCustomerProduct.ts
@@ -39,6 +39,7 @@ export const initCustomerProduct = ({
accessStartsAt,
previousCustomerProductId,
onTrialEnd,
+ processorType,
} = initOptions ?? {};
const internalEntityId =
@@ -96,8 +97,10 @@ export const initCustomerProduct = ({
status,
- // Legacy
- // processor: null,
+ // Only stamp `processor` when an explicit type was supplied (e.g. RevenueCat
+ // from external-PSP origin flows). Stripe-origin and legacy callers omit
+ // it; `cusProductToProcessorType` resolves the missing field to Stripe.
+ ...(processorType ? { processor: { type: processorType } } : {}),
starts_at: startsAt,
access_starts_at: accessStartsAt ?? null,
diff --git a/server/src/internal/billing/v2/utils/lineItems/chargeRowToRefundLineItem.ts b/server/src/internal/billing/v2/utils/lineItems/chargeRowToRefundLineItem.ts
new file mode 100644
index 000000000..bfcbcd21d
--- /dev/null
+++ b/server/src/internal/billing/v2/utils/lineItems/chargeRowToRefundLineItem.ts
@@ -0,0 +1,107 @@
+import { generateKsuid } from "@autumn/ksuid";
+import type { BillingContext } from "@autumn/shared";
+import {
+ customerProductToEntity,
+ type DbInvoiceLineItem,
+ type FullCusProduct,
+ type InvoiceLineItemDiscount,
+ type LineItem,
+ type LineItemContext,
+ LineItemSchema,
+ orgToCurrency,
+} from "@autumn/shared";
+import type { AutumnContext } from "@/honoUtils/HonoEnv";
+
+export const chargeRowToRefundLineItem = ({
+ chargeRow,
+ creditAmount,
+ customerProduct,
+ billingContext,
+ ctx,
+}: {
+ chargeRow: DbInvoiceLineItem;
+ creditAmount: number;
+ customerProduct: FullCusProduct;
+ billingContext: BillingContext;
+ ctx: AutumnContext;
+}): LineItem => {
+ const periodStart =
+ chargeRow.effective_period_start ?? billingContext.currentEpochMs;
+ const periodEnd =
+ chargeRow.effective_period_end ?? billingContext.currentEpochMs;
+
+ const entity = customerProductToEntity({
+ customerProduct,
+ entities: billingContext.fullCustomer.entities,
+ });
+
+ const matchingCusPrice = customerProduct.customer_prices.find(
+ (cp) =>
+ cp.price.id === chargeRow.price_id ||
+ (chargeRow.stripe_price_id != null &&
+ cp.price.config?.stripe_price_id === chargeRow.stripe_price_id),
+ );
+
+ const couponNameById = new Map(
+ (billingContext.stripeDiscounts ?? []).map((discount) => [
+ discount.source.coupon.id,
+ discount.source.coupon.name ?? discount.source.coupon.id,
+ ]),
+ );
+ const price =
+ matchingCusPrice?.price ?? customerProduct.customer_prices[0]?.price;
+
+ if (!price) {
+ throw new Error(
+ `[chargeRowToRefundLineItem] No price found on cusProduct ${customerProduct.id} for charge row ${chargeRow.id}`,
+ );
+ }
+
+ const context: LineItemContext = {
+ price,
+ product: customerProduct.product,
+ feature: undefined,
+ currency: orgToCurrency({ org: ctx.org }),
+ billingPeriod: { start: periodStart, end: periodEnd },
+ effectivePeriod: { start: billingContext.currentEpochMs, end: periodEnd },
+ direction: "refund",
+ now: billingContext.currentEpochMs,
+ billingTiming: "in_advance",
+ discountable: false,
+ entity,
+ customerProduct,
+ customerPrice: matchingCusPrice,
+ };
+
+ const description = chargeRow.description
+ ? `Unused ${chargeRow.description}`
+ : `Unused ${customerProduct.product.name}`;
+
+ const lineItemData = {
+ id: generateKsuid({ prefix: "invoice_li_" }),
+ amount: creditAmount,
+ amountAfterDiscounts: creditAmount,
+ description,
+ context,
+ stripePriceId: chargeRow.stripe_price_id ?? undefined,
+ stripeProductId: chargeRow.stripe_product_id ?? undefined,
+ chargeImmediately: true,
+ prorated: true,
+ discounts:
+ (chargeRow.discounts as InvoiceLineItemDiscount[] | null)?.map((d) => ({
+ amountOff: d.amount_off,
+ percentOff: d.percent_off,
+ stripeCouponId: d.stripe_coupon_id,
+ couponName: d.stripe_coupon_id
+ ? (couponNameById.get(d.stripe_coupon_id) ?? d.stripe_coupon_id)
+ : undefined,
+ })) ?? [],
+ };
+
+ const result = LineItemSchema.safeParse(lineItemData);
+ if (!result.success) {
+ throw result.error;
+ }
+
+ return result.data;
+};
diff --git a/server/src/internal/billing/v2/utils/lineItems/getLineItemsForDirection.ts b/server/src/internal/billing/v2/utils/lineItems/getLineItemsForDirection.ts
new file mode 100644
index 000000000..4938db98d
--- /dev/null
+++ b/server/src/internal/billing/v2/utils/lineItems/getLineItemsForDirection.ts
@@ -0,0 +1,39 @@
+import type { BillingContext, FullCusProduct, LineItem } from "@autumn/shared";
+import type { AutumnContext } from "@/honoUtils/HonoEnv";
+import { customerProductToLineItems } from "./customerProductToLineItems";
+import { getRefundLineItems } from "./getRefundLineItems";
+
+export const getLineItemsForDirection = ({
+ ctx,
+ customerProduct,
+ billingContext,
+ direction,
+ priceFilters,
+ billingCycleAnchorMsOverride,
+}: {
+ ctx: AutumnContext;
+ customerProduct: FullCusProduct;
+ billingContext: BillingContext;
+ direction: "charge" | "refund";
+ priceFilters?: { excludeOneOffPrices?: boolean };
+ billingCycleAnchorMsOverride?: BillingContext["billingCycleAnchorMs"];
+}): LineItem[] => {
+ if (direction === "refund") {
+ return getRefundLineItems({
+ ctx,
+ customerProduct,
+ billingContext,
+ priceFilters,
+ billingCycleAnchorMsOverride,
+ });
+ }
+
+ return customerProductToLineItems({
+ ctx,
+ customerProduct,
+ billingContext,
+ direction,
+ priceFilters,
+ billingCycleAnchorMsOverride,
+ });
+};
diff --git a/server/src/internal/billing/v2/utils/lineItems/getRefundLineItems.ts b/server/src/internal/billing/v2/utils/lineItems/getRefundLineItems.ts
new file mode 100644
index 000000000..b5941b2b6
--- /dev/null
+++ b/server/src/internal/billing/v2/utils/lineItems/getRefundLineItems.ts
@@ -0,0 +1,45 @@
+import type { BillingContext, FullCusProduct, LineItem } from "@autumn/shared";
+import type { AutumnContext } from "@/honoUtils/HonoEnv";
+import { customerProductToLineItems } from "./customerProductToLineItems";
+import { invoiceCreditFromStoredLineItems } from "./invoiceCreditFromStoredLineItems";
+
+export const getRefundLineItems = ({
+ ctx,
+ customerProduct,
+ billingContext,
+ priceFilters,
+ billingCycleAnchorMsOverride,
+}: {
+ ctx: AutumnContext;
+ customerProduct: FullCusProduct;
+ billingContext: BillingContext;
+ priceFilters?: { excludeOneOffPrices?: boolean };
+ billingCycleAnchorMsOverride?: BillingContext["billingCycleAnchorMs"];
+}): LineItem[] => {
+ const {
+ lineItems: matchedCredits,
+ allPricesResolved,
+ resolvedPriceIds,
+ } = invoiceCreditFromStoredLineItems({
+ ctx,
+ customerProduct,
+ billingContext,
+ });
+
+ if (allPricesResolved) return matchedCredits;
+
+ const catalogCredits = customerProductToLineItems({
+ ctx,
+ customerProduct,
+ billingContext,
+ direction: "refund",
+ priceFilters,
+ billingCycleAnchorMsOverride,
+ });
+
+ const fallbackCredits = catalogCredits.filter(
+ (li) => !resolvedPriceIds.includes(li.context.price.id),
+ );
+
+ return [...matchedCredits, ...fallbackCredits];
+};
diff --git a/server/src/internal/billing/v2/utils/lineItems/getRefundLineItemsForPrice.ts b/server/src/internal/billing/v2/utils/lineItems/getRefundLineItemsForPrice.ts
new file mode 100644
index 000000000..2afa57bf8
--- /dev/null
+++ b/server/src/internal/billing/v2/utils/lineItems/getRefundLineItemsForPrice.ts
@@ -0,0 +1,31 @@
+import type { BillingContext, FullCusProduct, LineItem } from "@autumn/shared";
+import type { AutumnContext } from "@/honoUtils/HonoEnv";
+import { getRefundLineItems } from "./getRefundLineItems";
+
+export const getRefundLineItemsForPrice = ({
+ ctx,
+ customerProduct,
+ billingContext,
+ priceId,
+ catalogFallback,
+}: {
+ ctx: AutumnContext;
+ customerProduct: FullCusProduct;
+ billingContext: BillingContext;
+ priceId: string;
+ catalogFallback: LineItem | undefined;
+}): LineItem[] => {
+ const matchedRefundLineItems = getRefundLineItems({
+ ctx,
+ customerProduct,
+ billingContext,
+ });
+
+ const matchedRefundsForPrice = matchedRefundLineItems.filter(
+ (li) => li.context.price.id === priceId,
+ );
+
+ if (matchedRefundsForPrice.length > 0) return matchedRefundsForPrice;
+
+ return catalogFallback ? [catalogFallback] : [];
+};
diff --git a/server/src/internal/billing/v2/utils/lineItems/invoiceCreditFromStoredLineItems.ts b/server/src/internal/billing/v2/utils/lineItems/invoiceCreditFromStoredLineItems.ts
new file mode 100644
index 000000000..423284811
--- /dev/null
+++ b/server/src/internal/billing/v2/utils/lineItems/invoiceCreditFromStoredLineItems.ts
@@ -0,0 +1,125 @@
+import type { BillingContext } from "@autumn/shared";
+import {
+ type FullCusProduct,
+ isOneOffPrice,
+ type LineItem,
+} from "@autumn/shared";
+import type { AutumnContext } from "@/honoUtils/HonoEnv";
+import { chargeRowToRefundLineItem } from "./chargeRowToRefundLineItem";
+import {
+ computeAlreadyRefundedForCharge,
+ computeProratedCredit,
+ splitMultiEntityAmount,
+} from "./storedLineItemUtils";
+
+type InvoiceMatchedCreditResult = {
+ lineItems: LineItem[];
+ allPricesResolved: boolean;
+ resolvedPriceIds: string[];
+};
+
+export const invoiceCreditFromStoredLineItems = ({
+ ctx,
+ customerProduct,
+ billingContext,
+}: {
+ ctx: AutumnContext;
+ customerProduct: FullCusProduct;
+ billingContext: BillingContext;
+}): InvoiceMatchedCreditResult => {
+ const { logger } = ctx;
+ const now = billingContext.currentEpochMs;
+ const chargeRows = billingContext.storedChargeLineItems ?? [];
+ const refundRows = billingContext.storedRefundLineItems ?? [];
+
+ const pricesToCredit = customerProduct.customer_prices.filter(
+ (cp) => !isOneOffPrice(cp.price),
+ );
+
+ if (pricesToCredit.length === 0) {
+ return { lineItems: [], allPricesResolved: true, resolvedPriceIds: [] };
+ }
+
+ const allLineItems: LineItem[] = [];
+ const resolvedPriceIds: string[] = [];
+ let anyMissed = false;
+
+ for (const cusPrice of pricesToCredit) {
+ const priceChargeRows = chargeRows.filter(
+ (row) =>
+ row.customer_product_ids.includes(customerProduct.id) &&
+ (row.price_id === cusPrice.price.id ||
+ row.stripe_price_id === cusPrice.price.config?.stripe_price_id),
+ );
+
+ const usableRows = priceChargeRows.filter(
+ (row) =>
+ row.customer_product_ids.length > 0 &&
+ row.effective_period_start != null &&
+ row.effective_period_end != null &&
+ row.effective_period_start < now &&
+ row.effective_period_end > now,
+ );
+
+ if (usableRows.length === 0) {
+ anyMissed = true;
+ logger.warn(
+ `[invoiceCreditFromStoredLineItems] No usable stored charge row for cusProduct=${customerProduct.id} price=${cusPrice.price.id}; falling back to catalog synthesis`,
+ );
+ continue;
+ }
+
+ resolvedPriceIds.push(cusPrice.price.id);
+
+ const currentPeriodRefunds = refundRows.filter(
+ (r) =>
+ r.customer_product_ids.includes(customerProduct.id) &&
+ r.effective_period_end != null &&
+ r.effective_period_start != null &&
+ r.effective_period_start < now &&
+ r.effective_period_end > now,
+ );
+
+ for (const chargeRow of usableRows) {
+ const attributedAmount = splitMultiEntityAmount(chargeRow);
+
+ const alreadyRefunded = computeAlreadyRefundedForCharge({
+ chargeRow,
+ refundRows: currentPeriodRefunds,
+ });
+
+ const adjustedChargeRow = {
+ ...chargeRow,
+ amount_after_discounts: attributedAmount,
+ };
+
+ const creditAmount = computeProratedCredit({
+ chargeRow: adjustedChargeRow,
+ now,
+ alreadyRefunded,
+ });
+
+ if (creditAmount === 0) continue;
+
+ allLineItems.push(
+ chargeRowToRefundLineItem({
+ chargeRow,
+ creditAmount,
+ customerProduct,
+ billingContext,
+ ctx,
+ }),
+ );
+ }
+ }
+
+ if (anyMissed && allLineItems.length === 0) {
+ return { lineItems: [], allPricesResolved: false, resolvedPriceIds };
+ }
+
+ return {
+ lineItems: allLineItems,
+ allPricesResolved: !anyMissed,
+ resolvedPriceIds,
+ };
+};
diff --git a/server/src/internal/billing/v2/utils/lineItems/storedLineItemUtils.ts b/server/src/internal/billing/v2/utils/lineItems/storedLineItemUtils.ts
new file mode 100644
index 000000000..be302ee4f
--- /dev/null
+++ b/server/src/internal/billing/v2/utils/lineItems/storedLineItemUtils.ts
@@ -0,0 +1,81 @@
+import type { DbInvoiceLineItem } from "@autumn/shared";
+import { Decimal } from "decimal.js";
+
+export const isWithinPeriod = (
+ inner: DbInvoiceLineItem,
+ outer: DbInvoiceLineItem,
+): boolean =>
+ inner.effective_period_start != null &&
+ outer.effective_period_start != null &&
+ inner.effective_period_end != null &&
+ outer.effective_period_end != null &&
+ inner.effective_period_start >= outer.effective_period_start &&
+ inner.effective_period_end <= outer.effective_period_end;
+
+export const hasSamePrice = (
+ a: DbInvoiceLineItem,
+ b: DbInvoiceLineItem,
+): boolean =>
+ (a.price_id != null && a.price_id === b.price_id) ||
+ (a.stripe_price_id != null && a.stripe_price_id === b.stripe_price_id);
+
+export const computeProratedCredit = ({
+ chargeRow,
+ now,
+ alreadyRefunded,
+}: {
+ chargeRow: DbInvoiceLineItem;
+ now: number;
+ alreadyRefunded: number;
+}): number => {
+ const periodStart = chargeRow.effective_period_start;
+ const periodEnd = chargeRow.effective_period_end;
+
+ if (periodStart == null || periodEnd == null || periodEnd <= periodStart) {
+ return 0;
+ }
+
+ const totalCharged = chargeRow.amount_after_discounts;
+ const refundable = new Decimal(totalCharged).minus(alreadyRefunded);
+
+ if (refundable.lte(0)) return 0;
+
+ const remaining = new Decimal(periodEnd).minus(now);
+ const total = new Decimal(periodEnd).minus(periodStart);
+
+ if (remaining.lte(0)) return 0;
+
+ const prorationFraction = remaining.div(total);
+ return prorationFraction.mul(refundable).neg().toNumber();
+};
+
+export const computeAlreadyRefundedForCharge = ({
+ chargeRow,
+ refundRows,
+}: {
+ chargeRow: DbInvoiceLineItem;
+ refundRows: DbInvoiceLineItem[];
+}): number => {
+ const matchingRefunds = refundRows.filter(
+ (refund) =>
+ isWithinPeriod(refund, chargeRow) && hasSamePrice(refund, chargeRow),
+ );
+
+ return matchingRefunds.reduce(
+ (sum, r) =>
+ new Decimal(sum)
+ .plus(Math.abs(splitMultiEntityAmount(r)))
+ .toNumber(),
+ 0,
+ );
+};
+
+export const splitMultiEntityAmount = (
+ chargeRow: DbInvoiceLineItem,
+): number => {
+ const ids = chargeRow.customer_product_ids;
+ if (ids.length <= 1) return chargeRow.amount_after_discounts;
+ return new Decimal(chargeRow.amount_after_discounts)
+ .div(ids.length)
+ .toNumber();
+};
diff --git a/server/src/internal/customers/cusProducts/actions/index.ts b/server/src/internal/customers/cusProducts/actions/index.ts
index 8dbdb630f..3a623eaa3 100644
--- a/server/src/internal/customers/cusProducts/actions/index.ts
+++ b/server/src/internal/customers/cusProducts/actions/index.ts
@@ -8,6 +8,7 @@ import {
getExpiredCustomerProductsCache,
setExpiredCustomerProductsCache,
} from "./expiredCache";
+import { markCustomerProductActive } from "./markCustomerProductActive";
import { markCustomerProductPastDue } from "./markCustomerProductPastDue";
import { preserveOneOffPrepaidCarryOvers } from "./preserveOneOffPrepaidCarryOvers";
import { uncancelCustomerProduct } from "./uncancelCustomerProduct";
@@ -32,6 +33,9 @@ export const customerProductActions = {
/** Marks a customer product as past due and sends a PastDue webhook */
markPastDue: markCustomerProductPastDue,
+ /** Marks a customer product as active (e.g. recovery from past-due); webhook gated by sendWebhook flag */
+ markActive: markCustomerProductActive,
+
/**
* Persists remaining one-off prepaid balances as lifetime cusEnts before
* the customer product is expired (webhook-driven flows).
diff --git a/server/src/internal/customers/cusProducts/actions/markCustomerProductActive.ts b/server/src/internal/customers/cusProducts/actions/markCustomerProductActive.ts
new file mode 100644
index 000000000..ee358edf4
--- /dev/null
+++ b/server/src/internal/customers/cusProducts/actions/markCustomerProductActive.ts
@@ -0,0 +1,69 @@
+import {
+ AttachScenario,
+ CusProductStatus,
+ type FullCusProduct,
+ type FullCustomer,
+ type InsertCustomerProduct,
+} from "@autumn/shared";
+import type { AutumnContext } from "@/honoUtils/HonoEnv";
+import { addProductsUpdatedWebhookTask } from "@/internal/analytics/handlers/handleProductsUpdated";
+import { CusProductService } from "@/internal/customers/cusProducts/CusProductService";
+
+/**
+ * Marks a customer product as active (e.g. recovering from past-due).
+ *
+ * This action:
+ * 1. Sets status to Active on the customer product
+ * 2. Optionally sends a products_updated webhook with Renew scenario (off by default)
+ * 3. Updates the FullCustomer in memory
+ *
+ * Used by RevenueCat renewal webhooks (past-due → active recovery) and any
+ * external active-recovery flow.
+ */
+export const markCustomerProductActive = async ({
+ ctx,
+ customerProduct,
+ fullCustomer,
+ sendWebhook = false,
+}: {
+ ctx: AutumnContext;
+ customerProduct: FullCusProduct;
+ fullCustomer: FullCustomer;
+ sendWebhook?: boolean;
+}): Promise<{ updates: Partial }> => {
+ const { org, env } = ctx;
+
+ const updates: Partial = {
+ status: CusProductStatus.Active,
+ };
+
+ await CusProductService.update({
+ ctx,
+ cusProductId: customerProduct.id,
+ updates,
+ });
+
+ ctx.logger.debug(
+ `[markCustomerProductActive]: marking ${customerProduct.product.name} as active`,
+ );
+
+ if (sendWebhook) {
+ await addProductsUpdatedWebhookTask({
+ ctx,
+ internalCustomerId: customerProduct.internal_customer_id,
+ org,
+ env,
+ customerId: fullCustomer.id || "",
+ scenario: AttachScenario.Renew,
+ cusProduct: customerProduct,
+ });
+ }
+
+ fullCustomer.customer_products = fullCustomer.customer_products.map((cp) =>
+ cp.id === customerProduct.id
+ ? ({ ...cp, ...updates } as FullCusProduct)
+ : cp,
+ );
+
+ return { updates };
+};
diff --git a/server/src/internal/customers/cusUtils/getApiCustomerV2/getApiBalance/apiBalanceV2Utils.ts b/server/src/internal/customers/cusUtils/getApiCustomerV2/getApiBalance/apiBalanceV2Utils.ts
index 2f3fdd5f9..e099d1cbb 100644
--- a/server/src/internal/customers/cusUtils/getApiCustomerV2/getApiBalance/apiBalanceV2Utils.ts
+++ b/server/src/internal/customers/cusUtils/getApiCustomerV2/getApiBalance/apiBalanceV2Utils.ts
@@ -37,13 +37,19 @@ export const mergeAggregatedBalanceIntoApiBalanceV2 = ({
const aggregatedRolloverBalance =
aggregatedFeatureBalance.rollover_balance ?? 0;
const aggregatedRolloverUsage = aggregatedFeatureBalance.rollover_usage ?? 0;
+ const aggregatedRolloverGrant = new Decimal(aggregatedRolloverBalance)
+ .add(aggregatedRolloverUsage)
+ .toNumber();
// Aggregate rows do not retain the full per-entity/per-product breakdown, so
// the top-level summary is merged from the coarse aggregate values only.
- const granted = new Decimal(aggregatedAllowance)
+ const baseGranted = new Decimal(aggregatedAllowance)
.add(aggregatedPrepaidGrantFromOptions)
.add(aggregatedAdjustment)
.toNumber();
+ const granted = new Decimal(baseGranted)
+ .add(aggregatedRolloverGrant)
+ .toNumber();
// Main remaining is floored at 0 (matches legacy behaviour). Rollover
// remaining is added on top, since rollover balances are independent of
@@ -57,7 +63,7 @@ export const mergeAggregatedBalanceIntoApiBalanceV2 = ({
.toNumber();
// Usage mirrors the entity-view formula: (granted - main balance) + rollover usage.
- const usage = new Decimal(granted)
+ const usage = new Decimal(baseGranted)
.sub(aggregatedBalance)
.add(aggregatedRolloverUsage);
diff --git a/server/src/internal/customers/internalCusRouter.ts b/server/src/internal/customers/internalCusRouter.ts
index e02f430a9..69529004b 100644
--- a/server/src/internal/customers/internalCusRouter.ts
+++ b/server/src/internal/customers/internalCusRouter.ts
@@ -1,6 +1,7 @@
import { Hono } from "hono";
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
import { handleGetCustomer } from "@/internal/customers/internalHandlers/handleGetCustomer.js";
+import { handleClearCustomerCache } from "./handlers/handleClearCustomerCache.js";
import { handleCountCustomers } from "./internalHandlers/handleCountCustomers.js";
import { handleGetCusReferrals } from "./internalHandlers/handleGetCusReferrals.js";
import { handleGetCustomerProduct } from "./internalHandlers/handleGetCustomerProduct.js";
@@ -15,6 +16,7 @@ export const internalCusRouter = new Hono();
internalCusRouter.post("/all/search", ...handleSearchCustomers);
internalCusRouter.post("/all/full_customers", ...handleGetFullCustomers);
internalCusRouter.post("/all/count", ...handleCountCustomers);
+internalCusRouter.post("/clear_cache", ...handleClearCustomerCache);
internalCusRouter.get("/:customer_id", ...handleGetCustomer);
internalCusRouter.get(
"/:customer_id/product/:product_id",
diff --git a/server/src/internal/dev/cli/handlers/handleCreateOAuthApiKeys.ts b/server/src/internal/dev/cli/handlers/handleCreateOAuthApiKeys.ts
index 292a48173..7135434c2 100644
--- a/server/src/internal/dev/cli/handlers/handleCreateOAuthApiKeys.ts
+++ b/server/src/internal/dev/cli/handlers/handleCreateOAuthApiKeys.ts
@@ -1,28 +1,17 @@
-import {
- AppEnv,
- checkScopes,
- ErrCode,
- oauthAccessToken,
- oauthConsent,
- RecaseError,
- type ScopeString,
- Scopes,
-} from "@autumn/shared";
-import { verifyAccessToken } from "better-auth/oauth2";
-import { and, eq, gt } from "drizzle-orm";
+import { AppEnv, ErrCode, RecaseError, Scopes } from "@autumn/shared";
import { createRoute } from "@/honoMiddlewares/routeHandler.js";
-import { hashOAuthToken } from "@/utils/oauthUtils.js";
+import {
+ getExternalOAuthApiKeyForToken,
+ getOAuthAccessTokenRecord,
+} from "@/internal/auth/oauth/oauthAccessTokenApiKey.js";
+import { oauthConsentRepo } from "@/internal/auth/repos/index.js";
import { ApiKeyPrefix, createKey } from "../../api-keys/apiKeyUtils.js";
import {
type OAuthApiKeyRequestBody,
OAuthApiKeyRequestBodySchema,
parseRequestedScopes,
- tokenRecordFromResourceToken,
} from "../oauthApiKeyUtils.js";
-const getOAuthIssuer = () =>
- `${process.env.BETTER_AUTH_URL?.replace(/\/$/, "") ?? ""}/api/auth`;
-
const parseBody = (rawBody: string): OAuthApiKeyRequestBody => {
let body: unknown = {};
if (rawBody) {
@@ -47,34 +36,6 @@ const parseBody = (rawBody: string): OAuthApiKeyRequestBody => {
});
};
-const verifyResourceAccessToken = async ({
- accessToken,
- resource,
- requestedScopes,
-}: {
- accessToken: string;
- resource: string | null;
- requestedScopes: ScopeString[] | null;
-}) => {
- if (!resource) return null;
-
- const issuer = getOAuthIssuer();
- try {
- const payload = await verifyAccessToken(accessToken, {
- jwksUrl: `${issuer}/jwks`,
- verifyOptions: {
- audience: resource,
- issuer,
- },
- scopes: requestedScopes ?? undefined,
- });
-
- return tokenRecordFromResourceToken(payload as Record);
- } catch {
- return null;
- }
-};
-
/**
* Create API keys from an OAuth access token.
* Called by the CLI after completing the OAuth flow.
@@ -105,90 +66,49 @@ export const handleCreateOAuthApiKeys = createRoute({
const accessToken = authHeader.substring(7);
- // Better-auth stores opaque tokens as SHA-256 hashes in base64url format
- const hashedToken = await hashOAuthToken(accessToken);
-
- // Look up the token in the oauth_access_token table
- const tokenRecords = await db
- .select()
- .from(oauthAccessToken)
- .where(
- and(
- eq(oauthAccessToken.token, hashedToken),
- gt(oauthAccessToken.expiresAt, new Date()),
- ),
- )
- .limit(1);
-
- const tokenRecord =
- tokenRecords[0] ??
- (await verifyResourceAccessToken({
- accessToken,
- resource,
- requestedScopes,
- }));
-
- if (!tokenRecord) {
- throw new RecaseError({
- message: "Invalid or expired access token",
- code: ErrCode.InvalidRequest,
- statusCode: 401,
- });
- }
-
- if (requestedScopes) {
- const { allowed, missing } = checkScopes(
- requestedScopes,
- tokenRecord.scopes,
- );
- if (!allowed) {
- throw new RecaseError({
- message: `Insufficient scopes. Missing: ${missing.join(", ")}`,
- code: ErrCode.InsufficientScopes,
- statusCode: 403,
- });
- }
- }
-
+ const tokenRecord = await getOAuthAccessTokenRecord({
+ db,
+ accessToken,
+ resource,
+ requestedScopes,
+ });
const userId = tokenRecord.userId;
- if (!userId) {
- throw new RecaseError({
- message: "Token missing user information",
- code: ErrCode.InvalidRequest,
- statusCode: 401,
- });
- }
-
- // Get the org ID from the referenceId field (set by consentReferenceId)
const orgId = tokenRecord.referenceId;
- if (!orgId) {
- throw new RecaseError({
- message: "No organization found. Please select an organization.",
- code: ErrCode.InvalidRequest,
- statusCode: 400,
- });
- }
-
const clientId = tokenRecord.clientId;
- // Look up the OAuth consent to get its ID for linking API keys
- const consentRecords = await db
- .select({ id: oauthConsent.id })
- .from(oauthConsent)
- .where(
- and(
- eq(oauthConsent.clientId, clientId),
- eq(oauthConsent.userId, userId),
- eq(oauthConsent.referenceId, orgId),
- ),
- )
- .limit(1);
+ const externalApiKey = await getExternalOAuthApiKeyForToken({
+ db,
+ tokenRecord,
+ requestedScopes,
+ });
+ if (externalApiKey) {
+ return c.json({
+ sandbox_key:
+ externalApiKey.env === AppEnv.Sandbox
+ ? externalApiKey.apiKey
+ : undefined,
+ prod_key:
+ externalApiKey.env === AppEnv.Live
+ ? externalApiKey.apiKey
+ : undefined,
+ org_id: orgId,
+ user_id: userId,
+ client_id: clientId,
+ scopes: externalApiKey.scopes,
+ });
+ }
- const consentId = consentRecords[0]?.id || null;
+ const consent = await oauthConsentRepo.getForClientUserOrg({
+ db,
+ clientId,
+ userId,
+ referenceId: orgId,
+ });
- // Build meta with consent linkage
const meta = {
- oauth_consent_id: consentId,
+ oauth_consent_id: consent?.id ?? null,
+ oauth_client_id: clientId,
+ oauth_redirect_uri: consent?.redirectUri ?? null,
created_via: "oauth",
generatedAt: new Date().toISOString(),
};
diff --git a/server/src/internal/invoices/lineItems/repos/getByCustomerProductAndPeriod.ts b/server/src/internal/invoices/lineItems/repos/getByCustomerProductAndPeriod.ts
new file mode 100644
index 000000000..d28bedbef
--- /dev/null
+++ b/server/src/internal/invoices/lineItems/repos/getByCustomerProductAndPeriod.ts
@@ -0,0 +1,43 @@
+import { type DbInvoiceLineItem, invoiceLineItems } from "@autumn/shared";
+import { and, eq, gte, lte, sql } from "drizzle-orm";
+import type { DrizzleCli } from "@/db/initDrizzle";
+
+export const getByCustomerProductAndPeriod = async ({
+ db,
+ customerProductId,
+ direction,
+ priceId,
+ periodStartMs,
+ periodEndMs,
+}: {
+ db: DrizzleCli;
+ customerProductId: string;
+ direction: "charge" | "refund";
+ priceId?: string;
+ periodStartMs?: number;
+ periodEndMs?: number;
+}): Promise => {
+ const conditions = [
+ eq(invoiceLineItems.direction, direction),
+ sql`${invoiceLineItems.customer_product_ids}::jsonb @> ${JSON.stringify([customerProductId])}::jsonb`,
+ ];
+
+ if (priceId) {
+ conditions.push(eq(invoiceLineItems.price_id, priceId));
+ }
+
+ if (periodStartMs !== undefined) {
+ conditions.push(
+ lte(invoiceLineItems.effective_period_start, periodStartMs),
+ );
+ }
+
+ if (periodEndMs !== undefined) {
+ conditions.push(gte(invoiceLineItems.effective_period_end, periodEndMs));
+ }
+
+ return db
+ .select()
+ .from(invoiceLineItems)
+ .where(and(...conditions));
+};
diff --git a/server/src/internal/invoices/lineItems/repos/getByCustomerProductIds.ts b/server/src/internal/invoices/lineItems/repos/getByCustomerProductIds.ts
new file mode 100644
index 000000000..c158552bc
--- /dev/null
+++ b/server/src/internal/invoices/lineItems/repos/getByCustomerProductIds.ts
@@ -0,0 +1,34 @@
+import { type DbInvoiceLineItem, invoiceLineItems } from "@autumn/shared";
+import { and, inArray, sql } from "drizzle-orm";
+import type { DrizzleCli } from "@/db/initDrizzle";
+
+const ALL_DIRECTIONS = ["charge", "refund"] as const;
+
+/**
+ * Fetch all line items whose customer_product_ids array overlaps any of the
+ * given ids, in a single query (jsonb `?|` array-overlap, GIN-indexed).
+ */
+export const getByCustomerProductIds = async ({
+ db,
+ customerProductIds,
+ directions = ALL_DIRECTIONS,
+}: {
+ db: DrizzleCli;
+ customerProductIds: string[];
+ directions?: readonly ("charge" | "refund")[];
+}): Promise => {
+ if (customerProductIds.length === 0) return [];
+
+ return db
+ .select()
+ .from(invoiceLineItems)
+ .where(
+ and(
+ inArray(invoiceLineItems.direction, [...directions]),
+ sql`${invoiceLineItems.customer_product_ids} ?| ARRAY[${sql.join(
+ customerProductIds.map((id) => sql`${id}`),
+ sql`, `,
+ )}]::text[]`,
+ ),
+ );
+};
diff --git a/server/src/internal/invoices/lineItems/repos/index.ts b/server/src/internal/invoices/lineItems/repos/index.ts
index 58bcbe5d0..7ab563ce3 100644
--- a/server/src/internal/invoices/lineItems/repos/index.ts
+++ b/server/src/internal/invoices/lineItems/repos/index.ts
@@ -1,5 +1,7 @@
import { deleteByInvoiceId } from "./deleteByInvoiceId";
import { deleteStaleByStripeInvoiceId } from "./deleteStaleByStripeInvoiceId";
+import { getByCustomerProductAndPeriod } from "./getByCustomerProductAndPeriod";
+import { getByCustomerProductIds } from "./getByCustomerProductIds";
import { getByInvoiceId } from "./getByInvoiceId";
import { getByInvoiceIds } from "./getByInvoiceIds";
import { getByStripeInvoiceId } from "./getByStripeInvoiceId";
@@ -18,6 +20,8 @@ export const invoiceLineItemRepo = {
getByInvoiceId,
getByInvoiceIds,
getByStripeInvoiceId,
+ getByCustomerProductAndPeriod,
+ getByCustomerProductIds,
deleteByInvoiceId,
deleteStaleByStripeInvoiceId,
getDeferredByInvoiceItemIds,
diff --git a/server/src/internal/migrations/v2/run/migrateCustomer/setup/setupMigrationOperationBillingContext.ts b/server/src/internal/migrations/v2/run/migrateCustomer/setup/setupMigrationOperationBillingContext.ts
index 80755c8d6..43b1a3f19 100644
--- a/server/src/internal/migrations/v2/run/migrateCustomer/setup/setupMigrationOperationBillingContext.ts
+++ b/server/src/internal/migrations/v2/run/migrateCustomer/setup/setupMigrationOperationBillingContext.ts
@@ -10,6 +10,7 @@ import {
import type Stripe from "stripe";
import type { StripeSubscriptionWithDiscounts } from "@/external/stripe/subscriptions/index.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
+import { setupUpdateSubscriptionTrialContext } from "@/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionTrialContext.js";
import { setupBillingCycleAnchor } from "@/internal/billing/v2/setup/setupBillingCycleAnchor.js";
import { setupResetCycleAnchor } from "@/internal/billing/v2/setup/setupResetCycleAnchor.js";
import type { MigrateCustomerContext } from "@/internal/migrations/v2/operations/types/index.js";
@@ -71,12 +72,26 @@ export const setupMigrationOperationBillingContext = async ({
stripeCustomerContext.testClockFrozenTime ?? Date.now();
const resolvedFullProduct =
fullProduct ?? cusProductToProduct({ cusProduct: customerProduct });
- const billingCycleAnchorMs = setupBillingCycleAnchor({
+ const trialContext = setupUpdateSubscriptionTrialContext({
+ stripeSubscription,
+ customerProduct,
+ currentEpochMs,
+ params: {},
+ fullProduct: resolvedFullProduct,
+ });
+
+ let billingCycleAnchorMs = setupBillingCycleAnchor({
stripeSubscription,
customerProduct,
newFullProduct: resolvedFullProduct,
+ trialContext,
currentEpochMs,
});
+
+ if (trialContext?.trialEndsAt) {
+ billingCycleAnchorMs = trialContext.trialEndsAt;
+ }
+
const resetCycleAnchorMs = setupResetCycleAnchor({
billingCycleAnchorMs,
customerProduct,
@@ -92,6 +107,7 @@ export const setupMigrationOperationBillingContext = async ({
currentEpochMs,
billingCycleAnchorMs,
resetCycleAnchorMs,
+ trialContext,
stripeCustomer: stripeCustomerContext.stripeCustomer,
stripeSubscription,
stripeSubscriptionSchedule,
diff --git a/server/src/internal/misc/consent/handlers/handleGetConsentApiKeys.ts b/server/src/internal/misc/consent/handlers/handleGetConsentApiKeys.ts
index 0a43f4a99..35717703b 100644
--- a/server/src/internal/misc/consent/handlers/handleGetConsentApiKeys.ts
+++ b/server/src/internal/misc/consent/handlers/handleGetConsentApiKeys.ts
@@ -1,7 +1,10 @@
-import { apiKeys, oauthConsent, Scopes } from "@autumn/shared";
-import { eq, sql } from "drizzle-orm";
+import { Scopes } from "@autumn/shared";
import { z } from "zod/v4";
import { createRoute } from "@/honoMiddlewares/routeHandler.js";
+import {
+ oauthApiKeyRepo,
+ oauthConsentRepo,
+} from "@/internal/auth/repos/index.js";
/**
* Get API keys linked to a specific OAuth consent.
@@ -26,35 +29,28 @@ export const handleGetConsentApiKeys = createRoute({
return c.json({ error: "No organization found" }, 400);
}
- // First verify the consent belongs to this org
- const consentRecords = await db
- .select({ id: oauthConsent.id, referenceId: oauthConsent.referenceId })
- .from(oauthConsent)
- .where(eq(oauthConsent.id, consent_id))
- .limit(1);
+ const consent = await oauthConsentRepo.getOwner({
+ db,
+ consentId: consent_id,
+ });
- if (consentRecords.length === 0) {
+ if (!consent) {
return c.json({ error: "Consent not found" }, 404);
}
- if (consentRecords[0].referenceId !== org.id) {
+ if (consent.referenceId !== org.id) {
return c.json(
{ error: "Consent does not belong to this organization" },
403,
);
}
- // Query API keys where meta->>'oauth_consent_id' = consent_id
- // Only return prefix, env, name - NOT the hashed key
- const keys = await db
- .select({
- id: apiKeys.id,
- prefix: apiKeys.prefix,
- env: apiKeys.env,
- name: apiKeys.name,
+ const keys = (
+ await oauthApiKeyRepo.listByConsentId({
+ db,
+ consentId: consent_id,
})
- .from(apiKeys)
- .where(sql`${apiKeys.meta}->>'oauth_consent_id' = ${consent_id}`);
+ ).map(({ hashed_key: _hashedKey, ...key }) => key);
return c.json({ apiKeys: keys });
},
diff --git a/server/src/internal/misc/consent/handlers/handleGetOrgConsents.ts b/server/src/internal/misc/consent/handlers/handleGetOrgConsents.ts
index 74ca921df..b3bdee9eb 100644
--- a/server/src/internal/misc/consent/handlers/handleGetOrgConsents.ts
+++ b/server/src/internal/misc/consent/handlers/handleGetOrgConsents.ts
@@ -1,6 +1,6 @@
-import { ErrCode, oauthConsent, RecaseError, Scopes } from "@autumn/shared";
-import { eq } from "drizzle-orm";
+import { ErrCode, RecaseError, Scopes } from "@autumn/shared";
import { createRoute } from "@/honoMiddlewares/routeHandler.js";
+import { oauthConsentRepo } from "@/internal/auth/repos/index.js";
/**
* Get OAuth consents for the current organization.
@@ -26,19 +26,10 @@ export const handleGetOrgConsents = createRoute({
});
}
- // Query consents where referenceId matches the current org
- const consents = await db
- .select({
- id: oauthConsent.id,
- clientId: oauthConsent.clientId,
- userId: oauthConsent.userId,
- referenceId: oauthConsent.referenceId,
- scopes: oauthConsent.scopes,
- createdAt: oauthConsent.createdAt,
- updatedAt: oauthConsent.updatedAt,
- })
- .from(oauthConsent)
- .where(eq(oauthConsent.referenceId, org.id));
+ const consents = await oauthConsentRepo.listByReferenceId({
+ db,
+ referenceId: org.id,
+ });
return c.json({ consents });
},
diff --git a/server/src/internal/misc/consent/handlers/handleRevokeConsent.ts b/server/src/internal/misc/consent/handlers/handleRevokeConsent.ts
index ea06ab7aa..27c0470ea 100644
--- a/server/src/internal/misc/consent/handlers/handleRevokeConsent.ts
+++ b/server/src/internal/misc/consent/handlers/handleRevokeConsent.ts
@@ -1,15 +1,12 @@
-import {
- apiKeys,
- ErrCode,
- oauthAccessToken,
- oauthConsent,
- oauthRefreshToken,
- RecaseError,
- Scopes,
-} from "@autumn/shared";
-import { and, eq, sql } from "drizzle-orm";
+import { ErrCode, RecaseError, Scopes } from "@autumn/shared";
import { z } from "zod/v4";
import { createRoute } from "@/honoMiddlewares/routeHandler.js";
+import {
+ oauthAccessTokenRepo,
+ oauthApiKeyRepo,
+ oauthConsentRepo,
+ oauthRefreshTokenRepo,
+} from "@/internal/auth/repos/index.js";
import { clearSecretKeyCache } from "../../../dev/api-keys/cacheApiKeyUtils.js";
/**
@@ -42,18 +39,11 @@ export const handleRevokeConsent = createRoute({
});
}
- // 1. Get the consent and verify it belongs to this org
- const consentRecords = await db
- .select({
- id: oauthConsent.id,
- clientId: oauthConsent.clientId,
- referenceId: oauthConsent.referenceId,
- })
- .from(oauthConsent)
- .where(eq(oauthConsent.id, consent_id))
- .limit(1);
-
- if (consentRecords.length === 0) {
+ const consent = await oauthConsentRepo.getOwner({
+ db,
+ consentId: consent_id,
+ });
+ if (!consent) {
throw new RecaseError({
message: "Consent not found",
code: "not_found",
@@ -61,8 +51,6 @@ export const handleRevokeConsent = createRoute({
});
}
- const consent = consentRecords[0];
-
if (consent.referenceId !== org.id) {
throw new RecaseError({
message: "Consent does not belong to this organization",
@@ -73,51 +61,38 @@ export const handleRevokeConsent = createRoute({
const { clientId, referenceId } = consent;
- // 2. Get API keys linked to this consent (for cache invalidation and response)
- const linkedKeys = await db
- .select({
- id: apiKeys.id,
- prefix: apiKeys.prefix,
- hashed_key: apiKeys.hashed_key,
- })
- .from(apiKeys)
- .where(sql`${apiKeys.meta}->>'oauth_consent_id' = ${consent_id}`);
+ const linkedKeys = await oauthApiKeyRepo.listByConsentId({
+ db,
+ consentId: consent_id,
+ });
const deletedKeyPrefixes = linkedKeys.map((k) => k.prefix).filter(Boolean);
// 3. Delete API keys and invalidate their cache
for (const key of linkedKeys) {
- // Delete from database
- await db.delete(apiKeys).where(eq(apiKeys.id, key.id));
+ await oauthApiKeyRepo.deleteById({ db, apiKeyId: key.id });
- // Invalidate cache
if (key.hashed_key) {
await clearSecretKeyCache({ hashedKey: key.hashed_key });
}
}
// 4. Delete access tokens for this client + org
- await db
- .delete(oauthAccessToken)
- .where(
- and(
- eq(oauthAccessToken.clientId, clientId),
- eq(oauthAccessToken.referenceId, referenceId),
- ),
- );
+ await oauthAccessTokenRepo.deleteByClientAndReference({
+ db,
+ clientId,
+ referenceId,
+ });
// 5. Delete refresh tokens for this client + org
- await db
- .delete(oauthRefreshToken)
- .where(
- and(
- eq(oauthRefreshToken.clientId, clientId),
- eq(oauthRefreshToken.referenceId, referenceId),
- ),
- );
+ await oauthRefreshTokenRepo.deleteByClientAndReference({
+ db,
+ clientId,
+ referenceId,
+ });
// 6. Delete the consent
- await db.delete(oauthConsent).where(eq(oauthConsent.id, consent_id));
+ await oauthConsentRepo.deleteById({ db, consentId: consent_id });
return c.json({
success: true,
diff --git a/server/src/internal/orgs/handlers/handleRevenueCatConfig.ts b/server/src/internal/orgs/handlers/handleRevenueCatConfig.ts
index c00c9ac40..f7d12c1fe 100644
--- a/server/src/internal/orgs/handlers/handleRevenueCatConfig.ts
+++ b/server/src/internal/orgs/handlers/handleRevenueCatConfig.ts
@@ -6,6 +6,13 @@ import {
UpsertRevenueCatProcessorConfigSchema,
Scopes,
} from "@autumn/shared";
+import { getRevenuecatAccessToken } from "@server/external/revenueCat/misc/getRevenuecatAccessToken.js";
+import {
+ generateRevenuecatWebhookSecret,
+ getRevenuecatWebhookSecret,
+} from "@server/external/revenueCat/misc/getRevenuecatWebhookSecret.js";
+import { initRevenuecatCli } from "@server/external/revenueCat/misc/initRevenuecatCli.js";
+import { registerRevenuecatWebhook } from "@server/external/revenueCat/misc/registerRevenuecatWebhook.js";
import { createSvixApp } from "@server/external/svix/svixHelpers.js";
import { createSvixCli } from "@server/external/svix/svixUtils.js";
import { createRoute } from "@server/honoMiddlewares/routeHandler.js";
@@ -14,17 +21,7 @@ import { mask } from "@server/utils/genUtils.js";
import type { ApplicationOut } from "svix";
import { OrgService } from "../OrgService.js";
-// Generate a random 64-character alphanumeric string
-const generateWebhookSecret = (): string => {
- const chars =
- "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
- let result = "";
- const randomBytes = crypto.getRandomValues(new Uint8Array(64));
- for (let i = 0; i < 64; i++) {
- result += chars[randomBytes[i] % chars.length];
- }
- return result;
-};
+const generateWebhookSecret = generateRevenuecatWebhookSecret;
export const getRevenueCatConfigDisplay = ({
org,
@@ -37,6 +34,8 @@ export const getRevenueCatConfigDisplay = ({
if (!revenueCatConfig) {
return {
connected: false,
+ connection: "none" as const,
+ oauth_connected: false,
api_key: undefined,
sandbox_api_key: undefined,
project_id: undefined,
@@ -61,12 +60,28 @@ export const getRevenueCatConfigDisplay = ({
const apiKeyForEnv =
env === AppEnv.Live ? liveApiKeyDecrypted : sandboxApiKeyDecrypted;
+ const oauthForEnv =
+ env === AppEnv.Live
+ ? revenueCatConfig.oauth
+ : revenueCatConfig.sandbox_oauth;
+
+ const oauthConnected = !!oauthForEnv;
+ const connection = oauthConnected
+ ? ("oauth" as const)
+ : apiKeyForEnv
+ ? ("api_key" as const)
+ : ("none" as const);
+
return {
- connected: !!apiKeyForEnv && !!webhookSecret,
+ connected: (!!apiKeyForEnv || oauthConnected) && !!webhookSecret,
+ connection,
+ oauth_connected: oauthConnected,
api_key: mask(liveApiKeyDecrypted, 3, 2),
sandbox_api_key: mask(sandboxApiKeyDecrypted, 5, 5),
- project_id: revenueCatConfig.project_id,
- sandbox_project_id: revenueCatConfig.sandbox_project_id,
+ project_id: revenueCatConfig.project_id ?? oauthForEnv?.project_id,
+ sandbox_project_id:
+ revenueCatConfig.sandbox_project_id ??
+ revenueCatConfig.sandbox_oauth?.project_id,
webhook_secret: revenueCatConfig.webhook_secret,
sandbox_webhook_secret: revenueCatConfig.sandbox_webhook_secret,
};
@@ -108,6 +123,8 @@ export const handleGetRevenueCatConfig = createRoute({
// Return fresh config after update
return c.json({
connected: false,
+ connection: "none" as const,
+ oauth_connected: false,
api_key: undefined,
sandbox_api_key: undefined,
project_id: undefined,
@@ -127,7 +144,7 @@ export const handleUpsertRevenueCatConfig = createRoute({
scopes: [Scopes.Organisation.Write],
body: UpsertRevenueCatProcessorConfigSchema,
handler: async (c) => {
- const { db, org } = c.get("ctx");
+ const { db, org, logger } = c.get("ctx");
const body = c.req.valid("json");
@@ -157,6 +174,37 @@ export const handleUpsertRevenueCatConfig = createRoute({
},
});
+ // Best-effort: register the inbound RC webhook for any env whose project was just set
+ // (covers an org that connected OAuth, then selected its project here). Idempotent.
+ const targets: Array<{ env: AppEnv; projectId: string }> = [];
+ if (body.project_id) {
+ targets.push({ env: AppEnv.Live, projectId: body.project_id });
+ }
+ if (body.sandbox_project_id) {
+ targets.push({
+ env: AppEnv.Sandbox,
+ projectId: body.sandbox_project_id,
+ });
+ }
+ for (const { env, projectId } of targets) {
+ try {
+ const accessToken = await getRevenuecatAccessToken({ db, org, env });
+ const secret = getRevenuecatWebhookSecret({ org, env });
+ if (!accessToken || !secret) continue;
+ const rcCli = initRevenuecatCli({ accessToken, projectId });
+ await registerRevenuecatWebhook({
+ rcCli,
+ orgId: org.id,
+ env,
+ secret,
+ });
+ } catch (webhookError) {
+ logger.warn(
+ `[RC] webhook registration failed for org ${org.id} (${env}): ${webhookError}`,
+ );
+ }
+ }
+
return c.json({
success: true,
});
diff --git a/server/src/internal/orgs/handlers/revenueCatHandlers/handleDisconnectRevenueCat.ts b/server/src/internal/orgs/handlers/revenueCatHandlers/handleDisconnectRevenueCat.ts
new file mode 100644
index 000000000..4f5be5fd6
--- /dev/null
+++ b/server/src/internal/orgs/handlers/revenueCatHandlers/handleDisconnectRevenueCat.ts
@@ -0,0 +1,39 @@
+import { AppEnv, Scopes } from "@autumn/shared";
+import { createRoute } from "@/honoMiddlewares/routeHandler.js";
+import { OrgService } from "@/internal/orgs/OrgService.js";
+
+/**
+ * POST /revenuecat/disconnect — remove the current env's RevenueCat connection
+ * (OAuth tokens, project, api key). Keeps webhook_secret + mappings intact so a
+ * reconnect reuses them.
+ */
+export const handleDisconnectRevenueCat = createRoute({
+ scopes: [Scopes.Organisation.Write],
+ handler: async (c) => {
+ const { db, org, env } = c.get("ctx");
+
+ const existing = org.processor_configs?.revenuecat;
+ if (!existing) return c.json({ success: true });
+
+ const next = { ...existing };
+ if (env === AppEnv.Live) {
+ next.oauth = undefined;
+ next.project_id = undefined;
+ next.api_key = undefined;
+ } else {
+ next.sandbox_oauth = undefined;
+ next.sandbox_project_id = undefined;
+ next.sandbox_api_key = undefined;
+ }
+
+ await OrgService.update({
+ db,
+ orgId: org.id,
+ updates: {
+ processor_configs: { ...org.processor_configs, revenuecat: next },
+ },
+ });
+
+ return c.json({ success: true });
+ },
+});
diff --git a/server/src/internal/orgs/handlers/revenueCatHandlers/handleGetRevenueCatOAuthUrl.ts b/server/src/internal/orgs/handlers/revenueCatHandlers/handleGetRevenueCatOAuthUrl.ts
new file mode 100644
index 000000000..8840ff585
--- /dev/null
+++ b/server/src/internal/orgs/handlers/revenueCatHandlers/handleGetRevenueCatOAuthUrl.ts
@@ -0,0 +1,58 @@
+import { AppEnv, ErrCode, RecaseError, Scopes } from "@autumn/shared";
+import { z } from "zod/v4";
+import {
+ createRcAuthorizationUrl,
+ generateCodeVerifier,
+} from "@/external/revenueCat/misc/revenuecatOAuth.js";
+import { createRoute } from "@/honoMiddlewares/routeHandler.js";
+import { generateOAuthState } from "@/internal/platform/platformBeta/utils/oauthStateUtils.js";
+
+export const handleGetRevenueCatOAuthUrl = createRoute({
+ scopes: [Scopes.Organisation.Write],
+ query: z.object({
+ redirect_url: z.string().optional(),
+ }),
+ handler: async (c) => {
+ // Read `migrate` from the raw query — the validated query layer coerces
+ // "true"/"false" to booleans, which a z.string() field would reject.
+ const { redirect_url, migrate } = c.req.query();
+ const ctx = c.get("ctx");
+ const { org, env } = ctx;
+
+ if (
+ !process.env.REVENUECAT_OAUTH_CLIENT_ID ||
+ !process.env.REVENUECAT_OAUTH_CLIENT_SECRET
+ ) {
+ throw new RecaseError({
+ message: "RevenueCat OAuth client credentials not configured",
+ code: ErrCode.InternalError,
+ statusCode: 500,
+ });
+ }
+
+ const frontendUrl = process.env.CLIENT_URL || "http://localhost:5173";
+ const envPrefix = env === AppEnv.Sandbox ? "/sandbox" : "";
+ const redirectUri =
+ redirect_url || `${frontendUrl}${envPrefix}/dev?tab=revenuecat`;
+ const codeVerifier = generateCodeVerifier();
+
+ const stateKey = await generateOAuthState({
+ organizationSlug: org.slug,
+ env,
+ redirectUri,
+ masterOrgId: null,
+ codeVerifier,
+ provider: "revenuecat",
+ migration: migrate === "true",
+ });
+
+ const authUrl = createRcAuthorizationUrl({
+ state: stateKey,
+ codeVerifier,
+ });
+
+ return c.json({
+ oauth_url: authUrl.toString(),
+ });
+ },
+});
diff --git a/server/src/internal/orgs/handlers/revenueCatHandlers/handleRevenueCatOAuthCallback.ts b/server/src/internal/orgs/handlers/revenueCatHandlers/handleRevenueCatOAuthCallback.ts
new file mode 100644
index 000000000..07aa3bf04
--- /dev/null
+++ b/server/src/internal/orgs/handlers/revenueCatHandlers/handleRevenueCatOAuthCallback.ts
@@ -0,0 +1,330 @@
+import {
+ AppEnv,
+ type Organization,
+ type RevenueCatOAuthConfig,
+ type RevenueCatProcessorConfig,
+} from "@autumn/shared";
+import type { Context } from "hono";
+import { initDrizzle } from "@/db/initDrizzle.js";
+import { generateRevenuecatWebhookSecret } from "@/external/revenueCat/misc/getRevenuecatWebhookSecret.js";
+import { initRevenuecatCli } from "@/external/revenueCat/misc/initRevenuecatCli.js";
+import { RCMappingService } from "@/external/revenueCat/misc/RCMappingService.js";
+import { registerRevenuecatWebhook } from "@/external/revenueCat/misc/registerRevenuecatWebhook.js";
+import {
+ exchangeRcCode,
+ findMissingRcScopes,
+ RC_OAUTH_SCOPES,
+} from "@/external/revenueCat/misc/revenuecatOAuth.js";
+import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
+import { OrgService } from "@/internal/orgs/OrgService.js";
+import { clearOrgCache } from "@/internal/orgs/orgUtils/clearOrgCache.js";
+import { consumeOAuthState } from "@/internal/platform/platformBeta/utils/oauthStateUtils.js";
+import { encryptData } from "@/utils/encryptUtils.js";
+
+const buildOAuthConfig = ({
+ tokens,
+ projectId,
+}: {
+ tokens: Awaited>;
+ projectId?: string;
+}): RevenueCatOAuthConfig => ({
+ access_token: encryptData(tokens.accessToken()),
+ refresh_token: encryptData(tokens.refreshToken()),
+ expires_at: tokens.accessTokenExpiresAt().getTime(),
+ ...(tokens.hasScopes() ? { scope: tokens.scopes().join(" ") } : {}),
+ ...(projectId ? { project_id: projectId } : {}),
+ connected_at: Date.now(),
+});
+
+const mergeRevenueCatOAuth = ({
+ org,
+ env,
+ oauthConfig,
+ stripLegacy = false,
+}: {
+ org: Organization;
+ env: AppEnv;
+ oauthConfig: RevenueCatOAuthConfig;
+ // Migration: drop the env's legacy api_key + project_id once OAuth is connected.
+ stripLegacy?: boolean;
+}): RevenueCatProcessorConfig => {
+ const existing = org.processor_configs?.revenuecat || {};
+
+ let base = existing;
+ if (stripLegacy) {
+ if (env === AppEnv.Live) {
+ const { api_key, project_id, ...rest } = existing;
+ base = rest;
+ } else {
+ const { sandbox_api_key, sandbox_project_id, ...rest } = existing;
+ base = rest;
+ }
+ }
+
+ return {
+ ...base,
+ ...(env === AppEnv.Live
+ ? { oauth: oauthConfig }
+ : { sandbox_oauth: oauthConfig }),
+ };
+};
+
+export const handleRevenueCatOAuthCallback = async (c: Context) => {
+ const query = c.req.query();
+ const { code, state, error } = query;
+
+ const { db } = initDrizzle();
+
+ const frontendUrl = process.env.CLIENT_URL || "http://localhost:3000";
+ let redirectUrl = new URL(`${frontendUrl}`);
+ redirectUrl.searchParams.set("tab", "revenuecat");
+ let isPlatformFlow = false;
+
+ if (error) {
+ redirectUrl.searchParams.set("error", error);
+ return c.redirect(redirectUrl.toString());
+ }
+
+ if (!code || !state) {
+ redirectUrl.searchParams.set("error", "missing_parameters");
+ return c.redirect(redirectUrl.toString());
+ }
+
+ try {
+ const redisState = await consumeOAuthState({ stateKey: state });
+
+ if (!redisState) {
+ redirectUrl.searchParams.set("error", "invalid_state");
+ return c.redirect(redirectUrl.toString());
+ }
+
+ const {
+ organization_slug,
+ env,
+ redirect_uri,
+ code_verifier,
+ provider,
+ master_org_id,
+ revenuecat_project_name,
+ migration,
+ } = redisState;
+
+ if (provider !== "revenuecat") {
+ redirectUrl.searchParams.set("error", "invalid_provider");
+ return c.redirect(redirectUrl.toString());
+ }
+
+ if (!code_verifier) {
+ redirectUrl.searchParams.set("error", "missing_code_verifier");
+ return c.redirect(redirectUrl.toString());
+ }
+
+ isPlatformFlow = master_org_id !== null;
+
+ if (isPlatformFlow) {
+ redirectUrl = new URL(redirect_uri);
+ } else {
+ redirectUrl = redirect_uri
+ ? new URL(redirect_uri)
+ : new URL(
+ `${frontendUrl}${env === AppEnv.Sandbox ? "/sandbox" : ""}/dev?tab=revenuecat`,
+ );
+ }
+
+ const org = await OrgService.getBySlug({ db, slug: organization_slug });
+
+ if (!org) {
+ console.error("Organization not found:", organization_slug);
+ if (isPlatformFlow) {
+ redirectUrl.searchParams.set("success", "false");
+ redirectUrl.searchParams.set("provider", "revenuecat");
+ redirectUrl.searchParams.set("message", "org_not_found");
+ } else {
+ redirectUrl.searchParams.set("error", "org_not_found");
+ }
+ return c.redirect(redirectUrl.toString());
+ }
+
+ if (isPlatformFlow && org.created_by !== master_org_id) {
+ console.error("Platform org mismatch:", org.id, master_org_id);
+ redirectUrl.searchParams.set("success", "false");
+ redirectUrl.searchParams.set("provider", "revenuecat");
+ redirectUrl.searchParams.set("message", "org_permission_denied");
+ return c.redirect(redirectUrl.toString());
+ }
+
+ const tokens = await exchangeRcCode({ code, codeVerifier: code_verifier });
+
+ const grantedScopes = tokens.hasScopes() ? tokens.scopes() : [];
+ const missingScopes = findMissingRcScopes(grantedScopes);
+
+ console.log(`[RCOAuth] Requested scopes: [${RC_OAUTH_SCOPES.join(", ")}]`);
+ console.log(`[RCOAuth] Called back: [${grantedScopes.join(", ")}]`);
+ console.log(`[RCOAuth] Missing: [${missingScopes.join(", ")}]`);
+
+ if (missingScopes.length > 0) {
+ if (isPlatformFlow) {
+ redirectUrl.searchParams.set("success", "false");
+ redirectUrl.searchParams.set("provider", "revenuecat");
+ redirectUrl.searchParams.set("message", "insufficient_scope");
+ } else {
+ redirectUrl.searchParams.set("error", "insufficient_scope");
+ redirectUrl.searchParams.set("missing_scopes", missingScopes.join(","));
+ }
+ return c.redirect(redirectUrl.toString());
+ }
+
+ const isMigration = !isPlatformFlow && migration === true;
+
+ let projectId: string | undefined;
+ if (isPlatformFlow) {
+ if (!revenuecat_project_name) {
+ redirectUrl.searchParams.set("success", "false");
+ redirectUrl.searchParams.set("provider", "revenuecat");
+ redirectUrl.searchParams.set("message", "missing_project_name");
+ return c.redirect(redirectUrl.toString());
+ }
+
+ const rcCli = initRevenuecatCli({ accessToken: tokens.accessToken() });
+ const project = await rcCli.createProject({
+ name: revenuecat_project_name,
+ });
+ projectId = project.id;
+ } else if (isMigration) {
+ // Migrate api-key → OAuth: the OAuth account must contain the org's existing
+ // project, and that project's products must cover the existing mappings.
+ const revenueCatConfig = org.processor_configs?.revenuecat;
+ const existingProjectId =
+ env === AppEnv.Live
+ ? revenueCatConfig?.project_id
+ : revenueCatConfig?.sandbox_project_id;
+
+ if (!existingProjectId) {
+ redirectUrl.searchParams.set("error", "no_project_to_migrate");
+ return c.redirect(redirectUrl.toString());
+ }
+
+ const accountCli = initRevenuecatCli({
+ accessToken: tokens.accessToken(),
+ });
+ const { projects } = await accountCli.listProjects();
+ if (!projects.some((p) => p.id === existingProjectId)) {
+ redirectUrl.searchParams.set("error", "project_not_in_account");
+ return c.redirect(redirectUrl.toString());
+ }
+
+ const projectCli = initRevenuecatCli({
+ accessToken: tokens.accessToken(),
+ projectId: existingProjectId,
+ });
+ const projectStoreIds = await projectCli.listProductStoreIdentifiers();
+ const mappings = await RCMappingService.getAll({
+ db,
+ orgId: org.id,
+ env,
+ });
+ const mappedIds = [
+ ...new Set(mappings.flatMap((m) => m.revenuecat_product_ids)),
+ ];
+ const allPresent = mappedIds.every((id) => projectStoreIds.has(id));
+ if (!allPresent) {
+ redirectUrl.searchParams.set("error", "products_mismatch");
+ return c.redirect(redirectUrl.toString());
+ }
+
+ projectId = existingProjectId;
+ }
+
+ const oauthConfig = buildOAuthConfig({ tokens, projectId });
+
+ // Ensure the env's webhook secret exists (the dashboard generates it lazily, which a
+ // platform-managed org never triggers) so we can register the webhook below.
+ const existingRc = org.processor_configs?.revenuecat;
+ const webhookSecret =
+ (env === AppEnv.Live
+ ? existingRc?.webhook_secret
+ : existingRc?.sandbox_webhook_secret) ??
+ generateRevenuecatWebhookSecret();
+
+ const mergedRc = mergeRevenueCatOAuth({
+ org,
+ env,
+ oauthConfig,
+ stripLegacy: isMigration,
+ });
+
+ await OrgService.update({
+ db,
+ orgId: org.id,
+ updates: {
+ processor_configs: {
+ ...org.processor_configs,
+ revenuecat:
+ env === AppEnv.Live
+ ? { ...mergedRc, webhook_secret: webhookSecret }
+ : { ...mergedRc, sandbox_webhook_secret: webhookSecret },
+ },
+ },
+ });
+
+ await clearOrgCache({ db, orgId: org.id });
+
+ // Best-effort: register the inbound webhook with RevenueCat (idempotent). Needs a project.
+ if (projectId) {
+ try {
+ const webhookCli = initRevenuecatCli({
+ accessToken: tokens.accessToken(),
+ projectId,
+ });
+ await registerRevenuecatWebhook({
+ rcCli: webhookCli,
+ orgId: org.id,
+ env,
+ secret: webhookSecret,
+ });
+ } catch (webhookError) {
+ console.error(
+ `[RC] webhook registration failed for org ${org.id} (${env}): ${webhookError}`,
+ );
+ }
+ }
+
+ console.log(`Successfully connected RevenueCat OAuth for org ${org.id}`);
+
+ if (isPlatformFlow) {
+ redirectUrl.searchParams.set("success", "true");
+ redirectUrl.searchParams.set("provider", "revenuecat");
+ redirectUrl.searchParams.set("organization_slug", organization_slug);
+ redirectUrl.searchParams.set(
+ "env",
+ env === AppEnv.Live ? "live" : "test",
+ );
+ if (projectId) {
+ redirectUrl.searchParams.set("revenuecat_project_id", projectId);
+ }
+ } else {
+ redirectUrl.searchParams.set("success", "true");
+ }
+ return c.redirect(redirectUrl.toString());
+ } catch (callbackError: unknown) {
+ console.error("Error in RevenueCat OAuth callback:", callbackError);
+ if (isPlatformFlow) {
+ redirectUrl.searchParams.set("success", "false");
+ redirectUrl.searchParams.set("provider", "revenuecat");
+ redirectUrl.searchParams.set(
+ "message",
+ callbackError instanceof Error
+ ? callbackError.message
+ : "unknown_error",
+ );
+ } else {
+ redirectUrl.searchParams.set(
+ "error",
+ callbackError instanceof Error
+ ? callbackError.message
+ : "unknown_error",
+ );
+ }
+ return c.redirect(redirectUrl.toString());
+ }
+};
diff --git a/server/src/internal/orgs/handlers/revenueCatHandlers/handleRevenueCatWebhook.ts b/server/src/internal/orgs/handlers/revenueCatHandlers/handleRevenueCatWebhook.ts
new file mode 100644
index 000000000..eec6aa7c2
--- /dev/null
+++ b/server/src/internal/orgs/handlers/revenueCatHandlers/handleRevenueCatWebhook.ts
@@ -0,0 +1,116 @@
+import { AppEnv, ErrCode, RecaseError, Scopes } from "@autumn/shared";
+import { StatusCodes } from "http-status-codes";
+import {
+ getRevenuecatAccessToken,
+ getRevenuecatProjectId,
+} from "@/external/revenueCat/misc/getRevenuecatAccessToken.js";
+import {
+ generateRevenuecatWebhookSecret,
+ getRevenuecatWebhookSecret,
+} from "@/external/revenueCat/misc/getRevenuecatWebhookSecret.js";
+import { initRevenuecatCli } from "@/external/revenueCat/misc/initRevenuecatCli.js";
+import {
+ getRevenuecatWebhookUrl,
+ registerRevenuecatWebhook,
+} from "@/external/revenueCat/misc/registerRevenuecatWebhook.js";
+import { createRoute } from "@/honoMiddlewares/routeHandler.js";
+import { OrgService } from "@/internal/orgs/OrgService.js";
+
+type WebhookStatus = "registered" | "not_registered" | "unknown";
+
+/** GET /revenuecat/webhook — does the current env's webhook exist on the RC project? + the URL/secret. */
+export const handleGetRevenueCatWebhook = createRoute({
+ scopes: [Scopes.Organisation.Read],
+ handler: async (c) => {
+ const { db, org, env } = c.get("ctx");
+
+ const url = getRevenuecatWebhookUrl({ orgId: org.id, env });
+ const secret = getRevenuecatWebhookSecret({ org, env }) ?? null;
+ const revenueCatConfig = org.processor_configs?.revenuecat;
+ const projectId = revenueCatConfig
+ ? getRevenuecatProjectId({ revenueCatConfig, env })
+ : undefined;
+ const accessToken = await getRevenuecatAccessToken({ db, org, env });
+
+ let status: WebhookStatus = "unknown";
+ if (url && projectId && accessToken) {
+ try {
+ const rcCli = initRevenuecatCli({ projectId, accessToken });
+ const hooks = await rcCli.listWebhookIntegrations();
+ // Match a webhook pointing at THIS org's receiver whose scope covers this env
+ // (the specific env, or "both" = null). The host/env-suffix can drift (e.g. ngrok
+ // rotates), so key off the org receiver path + the RC `environment` scope, not the
+ // exact URL string.
+ const orgPath = `/webhooks/revenuecat/${org.id}`;
+ const targetEnv = env === AppEnv.Live ? "production" : "sandbox";
+ status = hooks.some(
+ (hook) =>
+ hook.url?.includes(orgPath) &&
+ (hook.environment == null || hook.environment === targetEnv),
+ )
+ ? "registered"
+ : "not_registered";
+ } catch {
+ // e.g. the OAuth client lacks the integrations scope → can't verify
+ status = "unknown";
+ }
+ }
+
+ return c.json({ status, url, secret });
+ },
+});
+
+/** POST /revenuecat/webhook — register (idempotent) the current env's webhook on the RC project. */
+export const handleRegisterRevenueCatWebhook = createRoute({
+ scopes: [Scopes.Organisation.Write],
+ handler: async (c) => {
+ const { db, org, env } = c.get("ctx");
+
+ const revenueCatConfig = org.processor_configs?.revenuecat;
+ const projectId = revenueCatConfig
+ ? getRevenuecatProjectId({ revenueCatConfig, env })
+ : undefined;
+ const accessToken = await getRevenuecatAccessToken({ db, org, env });
+ if (!projectId || !accessToken) {
+ throw new RecaseError({
+ message: "Connect RevenueCat (and select a project) before registering a webhook",
+ code: ErrCode.InvalidRequest,
+ statusCode: StatusCodes.BAD_REQUEST,
+ });
+ }
+
+ // Ensure the env's webhook secret exists (generate + persist if missing).
+ let secret = getRevenuecatWebhookSecret({ org, env });
+ if (!secret) {
+ secret = generateRevenuecatWebhookSecret();
+ const existing = org.processor_configs?.revenuecat ?? {};
+ await OrgService.update({
+ db,
+ orgId: org.id,
+ updates: {
+ processor_configs: {
+ ...org.processor_configs,
+ revenuecat:
+ env === AppEnv.Live
+ ? { ...existing, webhook_secret: secret }
+ : { ...existing, sandbox_webhook_secret: secret },
+ },
+ },
+ });
+ }
+
+ const rcCli = initRevenuecatCli({ projectId, accessToken });
+ const result = await registerRevenuecatWebhook({
+ rcCli,
+ orgId: org.id,
+ env,
+ secret,
+ });
+
+ return c.json({
+ status: result === "skipped" ? "unknown" : "registered",
+ url: getRevenuecatWebhookUrl({ orgId: org.id, env }),
+ secret,
+ });
+ },
+});
diff --git a/server/src/internal/orgs/handlers/stripeHandlers/handleGetOAuthUrl.ts b/server/src/internal/orgs/handlers/stripeHandlers/handleGetOAuthUrl.ts
index bc741f220..3b69428ca 100644
--- a/server/src/internal/orgs/handlers/stripeHandlers/handleGetOAuthUrl.ts
+++ b/server/src/internal/orgs/handlers/stripeHandlers/handleGetOAuthUrl.ts
@@ -20,7 +20,7 @@ export const handleGetOAuthUrl = createRoute({
if (!clientId) {
throw new RecaseError({
- message: `Stripe ${env === AppEnv.Live ? "live" : "test"} client ID not configured`,
+ message: `Stripe ${env} client ID not configured`,
code: ErrCode.InternalError,
statusCode: 500,
});
@@ -33,7 +33,7 @@ export const handleGetOAuthUrl = createRoute({
const stateKey = await generateOAuthState({
organizationSlug: org.slug,
- env: env === AppEnv.Live ? "live" : "test",
+ env,
redirectUri,
masterOrgId: null, // null for standard flow
});
diff --git a/server/src/internal/orgs/orgRouter.ts b/server/src/internal/orgs/orgRouter.ts
index a1e815e83..9fcb3addc 100644
--- a/server/src/internal/orgs/orgRouter.ts
+++ b/server/src/internal/orgs/orgRouter.ts
@@ -1,7 +1,18 @@
import { Hono } from "hono";
import { handleGetRCMappings } from "@/external/revenueCat/handlers/handleGetRevenuecatMappings.js";
import { handleGetRevenueCatProducts } from "@/external/revenueCat/handlers/handleGetRevenuecatProducts.js";
+import {
+ handleCreateRevenueCatProject,
+ handleGetRevenueCatProjects,
+} from "@/external/revenueCat/handlers/handleGetRevenuecatProjects.js";
+import { handlePreflightRevenueCatSync } from "@/external/revenueCat/handlers/handlePreflightRevenueCatSync.js";
import { handleSaveRCMappings } from "@/external/revenueCat/handlers/handleSaveRevenuecatMappings.js";
+import { handleSyncRevenueCatProducts } from "@/external/revenueCat/handlers/handleSyncRevenueCatProducts.js";
+import { handleDisconnectRevenueCat } from "@/internal/orgs/handlers/revenueCatHandlers/handleDisconnectRevenueCat.js";
+import {
+ handleGetRevenueCatWebhook,
+ handleRegisterRevenueCatWebhook,
+} from "@/internal/orgs/handlers/revenueCatHandlers/handleRevenueCatWebhook.js";
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
import { handleDeleteOrg } from "./handlers/crudHandlers/handleDeleteOrg.js";
import { handleGetOrg } from "./handlers/crudHandlers/handleGetOrg.js";
@@ -30,6 +41,7 @@ import { handleConnectStripe } from "./handlers/stripeHandlers/handleConnectStri
import { handleDeleteStripe } from "./handlers/stripeHandlers/handleDeleteStripe.js";
import { handleGetOAuthUrl } from "./handlers/stripeHandlers/handleGetOAuthUrl.js";
import { handleGetStripeAccount } from "./handlers/stripeHandlers/handleGetStripeAccount.js";
+import { handleGetRevenueCatOAuthUrl } from "./handlers/revenueCatHandlers/handleGetRevenueCatOAuthUrl.js";
export const internalOrgRouter = new Hono();
@@ -69,6 +81,14 @@ honoOrgRouter.get("/vercel_sink", ...handleGetVercelSink);
honoOrgRouter.get("/revenuecat", ...handleGetRevenueCatConfig);
honoOrgRouter.patch("/revenuecat", ...handleUpsertRevenueCatConfig);
+honoOrgRouter.get("/revenuecat/oauth_url", ...handleGetRevenueCatOAuthUrl);
honoOrgRouter.post("/revenuecat/products", ...handleGetRevenueCatProducts);
+honoOrgRouter.get("/revenuecat/projects", ...handleGetRevenueCatProjects);
+honoOrgRouter.post("/revenuecat/projects", ...handleCreateRevenueCatProject);
+honoOrgRouter.post("/revenuecat/sync", ...handleSyncRevenueCatProducts);
+honoOrgRouter.post("/revenuecat/preflight", ...handlePreflightRevenueCatSync);
honoOrgRouter.get("/revenuecat/mappings", ...handleGetRCMappings);
honoOrgRouter.post("/revenuecat/mappings", ...handleSaveRCMappings);
+honoOrgRouter.get("/revenuecat/webhook", ...handleGetRevenueCatWebhook);
+honoOrgRouter.post("/revenuecat/webhook", ...handleRegisterRevenueCatWebhook);
+honoOrgRouter.post("/revenuecat/disconnect", ...handleDisconnectRevenueCat);
diff --git a/server/src/internal/platform/platformBeta/handlers/handleCreatePlatformOrg.ts b/server/src/internal/platform/platformBeta/handlers/handleCreatePlatformOrg.ts
index d53e129a5..38285879b 100644
--- a/server/src/internal/platform/platformBeta/handlers/handleCreatePlatformOrg.ts
+++ b/server/src/internal/platform/platformBeta/handlers/handleCreatePlatformOrg.ts
@@ -163,6 +163,7 @@ export const handleCreatePlatformOrg = createRoute({
}
return c.json({
+ org_id: org.id,
test_secret_key,
live_secret_key,
org_slug: org.slug,
diff --git a/server/src/internal/platform/platformBeta/handlers/handleGetPlatformOAuth.ts b/server/src/internal/platform/platformBeta/handlers/handleGetPlatformOAuth.ts
index 609775dd5..f1205100d 100644
--- a/server/src/internal/platform/platformBeta/handlers/handleGetPlatformOAuth.ts
+++ b/server/src/internal/platform/platformBeta/handlers/handleGetPlatformOAuth.ts
@@ -1,4 +1,4 @@
-import { ErrCode, RecaseError, Scopes } from "@autumn/shared";
+import { AppEnv, ErrCode, RecaseError, Scopes } from "@autumn/shared";
import { z } from "zod/v4";
import { createRoute } from "@/honoMiddlewares/routeHandler.js";
import { generateOAuthState } from "../utils/oauthStateUtils.js";
@@ -36,7 +36,7 @@ export const handleGetPlatformOAuth = createRoute({
// Generate OAuth state and store in Redis
const stateKey = await generateOAuthState({
organizationSlug: org.slug,
- env,
+ env: env === "live" ? AppEnv.Live : AppEnv.Sandbox,
redirectUri: redirect_url,
masterOrgId: masterOrg.id,
});
diff --git a/server/src/internal/platform/platformBeta/handlers/handleGetRevenueCatKeys.ts b/server/src/internal/platform/platformBeta/handlers/handleGetRevenueCatKeys.ts
new file mode 100644
index 000000000..c4305c1ec
--- /dev/null
+++ b/server/src/internal/platform/platformBeta/handlers/handleGetRevenueCatKeys.ts
@@ -0,0 +1,68 @@
+import { AppEnv, GetRevenueCatKeysSchema, Scopes } from "@autumn/shared";
+import {
+ getRevenuecatAccessToken,
+ getRevenuecatProjectId,
+ refreshRevenuecatOAuthAccessToken,
+} from "@/external/revenueCat/misc/getRevenuecatAccessToken.js";
+import { initRevenuecatCli } from "@/external/revenueCat/misc/initRevenuecatCli.js";
+import { createRoute } from "@/honoMiddlewares/routeHandler.js";
+import { validatePlatformOrg } from "../utils/validatePlatformOrg.js";
+
+// The stores a managed org's mobile apps actually ship against.
+const KEY_APP_TYPES = new Set(["test_store", "app_store", "play_store"]);
+
+/**
+ * POST /platform.get_revenuecat_keys — return a managed org's RevenueCat public
+ * (SDK) API keys per app, for the test store / App Store / Play Store.
+ */
+export const handleGetRevenueCatKeys = createRoute({
+ scopes: [Scopes.Platform.Write],
+ body: GetRevenueCatKeysSchema,
+ handler: async (c) => {
+ const ctx = c.get("ctx");
+ const { db, org: masterOrg } = ctx;
+ const { organization_slug, env } = c.req.valid("json");
+ const appEnv = env === "live" ? AppEnv.Live : AppEnv.Sandbox;
+
+ const org = await validatePlatformOrg({
+ db,
+ organizationSlug: organization_slug,
+ masterOrg,
+ });
+
+ const revenueCatConfig = org.processor_configs?.revenuecat;
+ if (!revenueCatConfig) return c.json({ apps: [], oauth_access_token: null });
+
+ const projectId = getRevenuecatProjectId({ revenueCatConfig, env: appEnv });
+ // Force-refresh the OAuth token so the master gets a fresh, full-lifetime access token.
+ // We keep the rotated refresh token; only the access token is ever handed out.
+ const oauthAccessToken = await refreshRevenuecatOAuthAccessToken({
+ db,
+ org,
+ env: appEnv,
+ });
+ // api-key orgs have no OAuth token — fall back to the api key for the CLI only.
+ const accessToken =
+ oauthAccessToken ??
+ (await getRevenuecatAccessToken({ db, org, env: appEnv }));
+ if (!projectId || !accessToken) {
+ return c.json({ apps: [], oauth_access_token: null });
+ }
+
+ const rcCli = initRevenuecatCli({ projectId, accessToken });
+ const apps = (await rcCli.listApps()).filter((app) =>
+ KEY_APP_TYPES.has(app.type),
+ );
+
+ const result = await Promise.all(
+ apps.map(async (app) => ({
+ app_id: app.id,
+ app_type: app.type,
+ name: app.name,
+ api_keys: await rcCli.listAppPublicApiKeys(app.id),
+ })),
+ );
+
+ return c.json({ apps: result, oauth_access_token: oauthAccessToken });
+ },
+});
diff --git a/server/src/internal/platform/platformBeta/handlers/handleLinkRevenueCat.ts b/server/src/internal/platform/platformBeta/handlers/handleLinkRevenueCat.ts
new file mode 100644
index 000000000..b2ea06dbe
--- /dev/null
+++ b/server/src/internal/platform/platformBeta/handlers/handleLinkRevenueCat.ts
@@ -0,0 +1,78 @@
+import {
+ AppEnv,
+ ErrCode,
+ LinkRevenueCatSchema,
+ RecaseError,
+ Scopes,
+} from "@autumn/shared";
+import {
+ createRcAuthorizationUrl,
+ generateCodeVerifier,
+} from "@/external/revenueCat/misc/revenuecatOAuth.js";
+import { createRoute } from "@/honoMiddlewares/routeHandler.js";
+import { generateOAuthState } from "../utils/oauthStateUtils.js";
+import { validatePlatformOrg } from "../utils/validatePlatformOrg.js";
+
+/**
+ * POST /platform.link_revenuecat
+ * Generates RevenueCat OAuth URL for a platform-managed organization.
+ */
+export const handleLinkRevenueCat = createRoute({
+ scopes: [Scopes.Platform.Write],
+ body: LinkRevenueCatSchema,
+ handler: async (c) => {
+ const ctx = c.get("ctx");
+ const { db, org: masterOrg, logger } = ctx;
+
+ const { organization_slug, env, project_name, redirect_url } =
+ c.req.valid("json");
+
+ const org = await validatePlatformOrg({
+ db,
+ organizationSlug: organization_slug,
+ masterOrg,
+ });
+
+ const rcConfig = org.processor_configs?.revenuecat;
+ const isLinked =
+ env === "live"
+ ? !!(rcConfig?.oauth || rcConfig?.project_id || rcConfig?.api_key)
+ : !!(
+ rcConfig?.sandbox_oauth ||
+ rcConfig?.sandbox_project_id ||
+ rcConfig?.sandbox_api_key
+ );
+
+ if (isLinked) {
+ throw new RecaseError({
+ message: `RevenueCat already linked for ${env} environment`,
+ code: ErrCode.InvalidRequest,
+ statusCode: 400,
+ });
+ }
+
+ const codeVerifier = generateCodeVerifier();
+ const stateKey = await generateOAuthState({
+ organizationSlug: org.slug,
+ env: env === "live" ? AppEnv.Live : AppEnv.Sandbox,
+ redirectUri: redirect_url,
+ masterOrgId: masterOrg.id,
+ codeVerifier,
+ provider: "revenuecat",
+ revenuecatProjectName: project_name,
+ });
+
+ const authUrl = createRcAuthorizationUrl({
+ state: stateKey,
+ codeVerifier,
+ });
+
+ logger.info(
+ `Generated RevenueCat OAuth URL for platform org ${org.slug} (${env})`,
+ );
+
+ return c.json({
+ oauth_url: authUrl.toString(),
+ });
+ },
+});
diff --git a/server/src/internal/platform/platformBeta/handlers/handleSyncRevenueCat.ts b/server/src/internal/platform/platformBeta/handlers/handleSyncRevenueCat.ts
new file mode 100644
index 000000000..0193470c8
--- /dev/null
+++ b/server/src/internal/platform/platformBeta/handlers/handleSyncRevenueCat.ts
@@ -0,0 +1,46 @@
+import { AppEnv, Scopes, SyncRevenueCatSchema } from "@autumn/shared";
+import { syncProductsToRevenueCat } from "@/external/revenueCat/sync/syncRevenueCatProducts.js";
+import { createRoute } from "@/honoMiddlewares/routeHandler.js";
+import { ProductService } from "@/internal/products/ProductService.js";
+import { validatePlatformOrg } from "../utils/validatePlatformOrg.js";
+
+/**
+ * POST /platform.sync_revenuecat — push a managed org's plans into RevenueCat.
+ * Omit product_ids to sync every plan in the org/env.
+ */
+export const handleSyncRevenueCat = createRoute({
+ scopes: [Scopes.Platform.Write],
+ // Accepts "test"/"sandbox"/"live" — "test" + "sandbox" both map to AppEnv.Sandbox below.
+ body: SyncRevenueCatSchema,
+ handler: async (c) => {
+ const ctx = c.get("ctx");
+ const { db, org: masterOrg } = ctx;
+ const { organization_slug, env, product_ids } = c.req.valid("json");
+ const appEnv = env === "live" ? AppEnv.Live : AppEnv.Sandbox;
+
+ const org = await validatePlatformOrg({
+ db,
+ organizationSlug: organization_slug,
+ masterOrg,
+ });
+
+ const targetCtx = { ...ctx, org, env: appEnv };
+
+ let productIds = product_ids;
+ if (!productIds) {
+ const products = await ProductService.listFull({
+ db,
+ orgId: org.id,
+ env: appEnv,
+ });
+ productIds = products.map((p) => p.id);
+ }
+
+ const results = await syncProductsToRevenueCat({
+ ctx: targetCtx,
+ productIds,
+ });
+
+ return c.json({ results });
+ },
+});
diff --git a/server/src/internal/platform/platformBeta/platformRpcRouter.ts b/server/src/internal/platform/platformBeta/platformRpcRouter.ts
new file mode 100644
index 000000000..b8ef222c5
--- /dev/null
+++ b/server/src/internal/platform/platformBeta/platformRpcRouter.ts
@@ -0,0 +1,14 @@
+import { Hono } from "hono";
+import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
+import { handleGetRevenueCatKeys } from "./handlers/handleGetRevenueCatKeys.js";
+import { handleLinkRevenueCat } from "./handlers/handleLinkRevenueCat.js";
+import { handleSyncRevenueCat } from "./handlers/handleSyncRevenueCat.js";
+
+export const platformRpcRouter = new Hono();
+
+platformRpcRouter.post("/platform.link_revenuecat", ...handleLinkRevenueCat);
+platformRpcRouter.post("/platform.sync_revenuecat", ...handleSyncRevenueCat);
+platformRpcRouter.post(
+ "/platform.get_revenuecat_keys",
+ ...handleGetRevenueCatKeys,
+);
diff --git a/server/src/internal/platform/platformBeta/utils/oauthStateUtils.ts b/server/src/internal/platform/platformBeta/utils/oauthStateUtils.ts
index 7ad10d9bd..415b63e53 100644
--- a/server/src/internal/platform/platformBeta/utils/oauthStateUtils.ts
+++ b/server/src/internal/platform/platformBeta/utils/oauthStateUtils.ts
@@ -1,5 +1,5 @@
import { randomBytes } from "node:crypto";
-import { InternalError } from "@autumn/shared";
+import { AppEnv, InternalError } from "@autumn/shared";
import { CacheManager } from "../../../../utils/cacheUtils/CacheManager";
const STATE_KEY_PREFIX = "oauth_state:";
@@ -7,9 +7,14 @@ const STATE_EXPIRY_SECONDS = 10 * 60; // 10 minutes
type OAuthState = {
organization_slug: string;
- env: "test" | "live";
+ env: AppEnv;
redirect_uri: string;
master_org_id: string | null; // null for standard flow, string for platform flow
+ code_verifier?: string;
+ provider?: "stripe" | "revenuecat";
+ revenuecat_project_name?: string;
+ // true for the API-key → OAuth migration flow
+ migration?: boolean;
};
/**
@@ -21,11 +26,19 @@ export const generateOAuthState = async ({
env,
redirectUri,
masterOrgId,
+ codeVerifier,
+ provider,
+ revenuecatProjectName,
+ migration,
}: {
organizationSlug: string;
- env: "test" | "live";
+ env: AppEnv;
redirectUri: string;
masterOrgId: string | null;
+ codeVerifier?: string;
+ provider?: "stripe" | "revenuecat";
+ revenuecatProjectName?: string;
+ migration?: boolean;
}): Promise => {
const maxAttempts = 3;
@@ -40,6 +53,12 @@ export const generateOAuthState = async ({
env,
redirect_uri: redirectUri,
master_org_id: masterOrgId,
+ ...(codeVerifier ? { code_verifier: codeVerifier } : {}),
+ ...(provider ? { provider } : {}),
+ ...(revenuecatProjectName
+ ? { revenuecat_project_name: revenuecatProjectName }
+ : {}),
+ ...(migration ? { migration: true } : {}),
};
// Check if key exists first
diff --git a/server/src/internal/products/productRouter.ts b/server/src/internal/products/productRouter.ts
index a1b8a7678..79da803e6 100644
--- a/server/src/internal/products/productRouter.ts
+++ b/server/src/internal/products/productRouter.ts
@@ -1,4 +1,5 @@
import { Hono } from "hono";
+import { handleListRevenueCatMappings } from "@/external/revenueCat/handlers/handleListRevenueCatMappings.js";
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
import { handlePlanHasCustomersV2 } from "@/internal/products/handlers/handlePlanHasCustomersV2.js";
import { handleCopyProductV2 } from "./handlers/handleCopyProduct/handleCopyProductV2.js";
@@ -54,3 +55,7 @@ plansRpcRouter.post("/plans.create", ...handleCreatePlanV2);
plansRpcRouter.post("/plans.update", ...handleUpdatePlanV2);
plansRpcRouter.post("/plans.delete", ...handleDeletePlanV2);
plansRpcRouter.post("/plans.get", ...handleGetPlanV2);
+plansRpcRouter.post(
+ "/plans.revenuecat_mappings",
+ ...handleListRevenueCatMappings,
+);
diff --git a/server/src/routers/mcpProxyRouter.ts b/server/src/routers/mcpProxyRouter.ts
deleted file mode 100644
index bffcecc49..000000000
--- a/server/src/routers/mcpProxyRouter.ts
+++ /dev/null
@@ -1,71 +0,0 @@
-import { Hono } from "hono";
-import type { Context } from "hono";
-import type { HonoEnv } from "../honoUtils/HonoEnv.js";
-
-const hopByHopHeaders = [
- "connection",
- "keep-alive",
- "proxy-authenticate",
- "proxy-authorization",
- "te",
- "trailer",
- "transfer-encoding",
- "upgrade",
-];
-
-const getMcpUpstream = () => {
- const upstream = process.env.MCP_UPSTREAM_URL;
- if (!upstream) return null;
-
- try {
- return new URL(upstream);
- } catch {
- return null;
- }
-};
-
-const proxyMcp = async (c: Context) => {
- const upstream = getMcpUpstream();
- if (!upstream) {
- return c.json({ error: "MCP upstream not configured" }, 503);
- }
-
- const incomingUrl = new URL(c.req.url);
- const targetUrl = new URL(incomingUrl.pathname + incomingUrl.search, upstream);
- const headers = new Headers(c.req.raw.headers);
- const forwardedHost =
- headers.get("x-forwarded-host") ?? headers.get("host") ?? incomingUrl.host;
- const forwardedProto =
- headers.get("x-forwarded-proto") ?? incomingUrl.protocol.replace(":", "");
-
- for (const header of hopByHopHeaders) headers.delete(header);
-
- headers.delete("host");
- headers.set("x-autumn-forwarded-host", forwardedHost);
- headers.set("x-autumn-forwarded-proto", forwardedProto);
- headers.set("x-forwarded-host", forwardedHost);
- headers.set("x-forwarded-proto", forwardedProto);
-
- const hasBody = c.req.method !== "GET" && c.req.method !== "HEAD";
- const response = await fetch(targetUrl, {
- method: c.req.method,
- headers,
- body: hasBody ? c.req.raw.body : undefined,
- duplex: hasBody ? "half" : undefined,
- } as RequestInit & { duplex?: "half" });
-
- return new Response(response.body, {
- status: response.status,
- statusText: response.statusText,
- headers: response.headers,
- });
-};
-
-export const mcpProxyRouter = new Hono();
-
-mcpProxyRouter.all("/mcp", proxyMcp);
-mcpProxyRouter.all("/mcp/*", proxyMcp);
-mcpProxyRouter.all("/internal/mcp", proxyMcp);
-mcpProxyRouter.all("/internal/mcp/*", proxyMcp);
-mcpProxyRouter.all("/.well-known/oauth-protected-resource/mcp", proxyMcp);
-mcpProxyRouter.all("/.well-known/oauth-protected-resource/internal/mcp", proxyMcp);
diff --git a/server/src/routers/rpcRouter.ts b/server/src/routers/rpcRouter.ts
index b36b08d42..3b9d6d84d 100644
--- a/server/src/routers/rpcRouter.ts
+++ b/server/src/routers/rpcRouter.ts
@@ -6,6 +6,7 @@ import { entityRpcRouter } from "@/internal/entities/entityRouter";
import { eventsRpcRouter } from "@/internal/events/eventsRouter";
import { featureRpcRouter } from "@/internal/features/featureRouter";
import { migrationRpcRouter } from "@/internal/migrations/v2/migrationRouter";
+import { platformRpcRouter } from "@/internal/platform/platformBeta/platformRpcRouter";
import { plansRpcRouter } from "@/internal/products/productRouter";
import type { HonoEnv } from "../honoUtils/HonoEnv";
import { customerRpcRouter } from "../internal/customers/cusRouter";
@@ -32,3 +33,4 @@ rpcRouter.route("", referralRpcRouter);
rpcRouter.route("", entityRpcRouter);
rpcRouter.route("", featureRpcRouter);
rpcRouter.route("", migrationRpcRouter);
+rpcRouter.route("", platformRpcRouter);
diff --git a/server/src/utils/auth.ts b/server/src/utils/auth.ts
index 2a8f7a97f..c4a51e66f 100644
--- a/server/src/utils/auth.ts
+++ b/server/src/utils/auth.ts
@@ -91,7 +91,7 @@ const chatServerUrl =
process.env.CHAT_SERVER_URL ??
(isProductionAuth ? "https://chat.useautumn.com" : "http://localhost:3099");
-const mcpResourcePaths = ["/mcp", "/internal/mcp"];
+const mcpResourcePaths = ["/mcp"];
const mcpResourceBases = [
process.env.BETTER_AUTH_URL,
mcpServerUrl,
diff --git a/server/tests/integration/billing/create-schedule/create-schedule-annual-proration.test.ts b/server/tests/integration/billing/create-schedule/create-schedule-annual-proration.test.ts
index a8d55379a..ee4f75d59 100644
--- a/server/tests/integration/billing/create-schedule/create-schedule-annual-proration.test.ts
+++ b/server/tests/integration/billing/create-schedule/create-schedule-annual-proration.test.ts
@@ -79,6 +79,46 @@ const pendingStripeInvoiceItems = async ({
});
};
+const stripeInvoicesForCustomer = async ({
+ ctx,
+ customer,
+}: {
+ ctx: Awaited>["ctx"];
+ customer: ApiCustomerV3;
+}) => {
+ if (!customer.stripe_id)
+ throw new Error("Expected customer to have stripe_id");
+
+ const invoices = await ctx.stripeCli.invoices.list({
+ customer: customer.stripe_id,
+ limit: 100,
+ });
+
+ return await Promise.all(
+ invoices.data.map((invoice) =>
+ ctx.stripeCli.invoices.retrieve(invoice.id!, {
+ expand: ["lines.data.price"],
+ }),
+ ),
+ );
+};
+
+const stripeSchedulesForCustomer = async ({
+ ctx,
+ customer,
+}: {
+ ctx: Awaited>["ctx"];
+ customer: ApiCustomerV3;
+}) => {
+ if (!customer.stripe_id)
+ throw new Error("Expected customer to have stripe_id");
+
+ return await ctx.stripeCli.subscriptionSchedules.list({
+ customer: customer.stripe_id,
+ limit: 10,
+ });
+};
+
const periodDuration = (period: { start: number; end: number }) =>
(period.end - period.start) * 1000;
@@ -178,6 +218,70 @@ const expectedAnnualProrationDiff = ({
.toDecimalPlaces(2)
.toNumber();
+const expectAmountCloseTo = ({
+ actual,
+ expected,
+}: {
+ actual: Decimal | number;
+ expected: Decimal | number;
+}) => {
+ const diff = new Decimal(actual).minus(expected).abs();
+ expect(
+ diff.lte(0.01),
+ `Expected $${new Decimal(actual).toFixed(2)} to be within $0.01 of $${new Decimal(expected).toFixed(2)}`,
+ ).toBe(true);
+};
+
+const expectAutumnInvoiceWithTotal = ({
+ invoices,
+ total,
+}: {
+ invoices: NonNullable;
+ total: Decimal | number;
+}) => {
+ const invoice = invoices.find((candidate) =>
+ new Decimal(candidate.total).minus(total).abs().lte(0.01),
+ );
+
+ expect(
+ invoice,
+ `Expected Autumn invoice total $${new Decimal(total).toFixed(2)}`,
+ ).toBeDefined();
+ return invoice!;
+};
+
+const expectStripeInvoiceWithIntervalTotals = ({
+ invoices,
+ yearTotal,
+ monthTotal,
+}: {
+ invoices: Stripe.Invoice[];
+ yearTotal: number;
+ monthTotal: number;
+}) => {
+ const invoice = invoices.find((candidate) => {
+ const candidateYearTotal = intervalLineTotal({
+ invoice: candidate,
+ interval: "year",
+ });
+ const candidateMonthTotal = intervalLineTotal({
+ invoice: candidate,
+ interval: "month",
+ });
+
+ return (
+ candidateYearTotal.minus(yearTotal).abs().lte(0.01) &&
+ candidateMonthTotal.minus(monthTotal).abs().lte(0.01)
+ );
+ });
+
+ expect(
+ invoice,
+ `Expected Stripe invoice with yearly total $${yearTotal} and monthly total $${monthTotal}`,
+ ).toBeDefined();
+ return invoice!;
+};
+
test.concurrent(
`${chalk.yellowBright("create-schedule: customized annual prepaid proration ignores removed monthly prepaid")}`,
async () => {
@@ -261,6 +365,14 @@ test.concurrent(
ctx,
customer: initialCustomer,
});
+ const initialSchedules = await stripeSchedulesForCustomer({
+ ctx,
+ customer: initialCustomer,
+ });
+ expect(initialSchedules.data[0]?.phases[1]?.proration_behavior).toBe(
+ "always_invoice",
+ );
+ expect(initialSchedules.data[0]?.billing_mode?.type).toBe("flexible");
const annualPeriod = annualPeriodFromInitialInvoice({
invoice: initialInvoice,
});
@@ -273,11 +385,11 @@ test.concurrent(
const customerAfterTransition =
await autumnV1.customers.get(id);
- const transitionInvoice = await latestStripeInvoice({
+ const pendingItems = await pendingStripeInvoiceItems({
ctx,
customer: customerAfterTransition,
});
- const pendingItems = await pendingStripeInvoiceItems({
+ const stripeInvoices = await stripeInvoicesForCustomer({
ctx,
customer: customerAfterTransition,
});
@@ -287,10 +399,37 @@ test.concurrent(
transitionAt,
billingPeriod: annualPeriod,
});
- await expectCustomerInvoiceCorrect({
- customer: customerAfterTransition,
- count: 2,
- latestTotal: 10,
+ expect(customerAfterTransition.invoices).toHaveLength(3);
+ expectAutumnInvoiceWithTotal({
+ invoices: customerAfterTransition.invoices!,
+ total: 10,
+ });
+ expectAutumnInvoiceWithTotal({
+ invoices: customerAfterTransition.invoices!,
+ total: new Decimal(expectedProration).minus(10),
+ });
+ const prorationInvoice = expectStripeInvoiceWithIntervalTotals({
+ invoices: stripeInvoices,
+ yearTotal: expectedProration,
+ monthTotal: -10,
+ });
+ expectAmountCloseTo({
+ actual: intervalLineTotal({
+ invoice: prorationInvoice,
+ interval: "year",
+ }),
+ expected: expectedProration,
+ });
+ expectAmountCloseTo({
+ actual: intervalLineTotal({
+ invoice: prorationInvoice,
+ interval: "month",
+ }),
+ expected: -10,
+ });
+ expectAmountCloseTo({
+ actual: new Decimal(prorationInvoice.total).div(100),
+ expected: new Decimal(expectedProration).minus(10),
});
expect(
pendingItemIntervalTotal({
@@ -299,42 +438,15 @@ test.concurrent(
})
.toDecimalPlaces(2)
.toNumber(),
- ).toBe(expectedProration);
+ ).toBe(0);
expect(
- intervalLineTotal({ invoice: transitionInvoice, interval: "month" })
- .plus(
- pendingItemIntervalTotal({
- items: pendingItems.data,
- interval: "month",
- }),
- )
+ pendingItemIntervalTotal({
+ items: pendingItems.data,
+ interval: "month",
+ })
.toDecimalPlaces(2)
.toNumber(),
).toBe(0);
- expect(
- new Decimal(transitionInvoice.total)
- .div(100)
- .plus(
- pendingItemIntervalTotal({
- items: pendingItems.data,
- interval: "month",
- }),
- )
- .plus(
- pendingItemIntervalTotal({
- items: pendingItems.data,
- interval: "year",
- }),
- )
- .toDecimalPlaces(2)
- .toNumber(),
- ).toBe(expectedProration);
- expect(
- pendingItems.data.some(
- (item) => item.amount < 0 && item.amount !== -1000,
- ),
- ).toBe(true);
- expect(pendingItems.data.some((item) => item.amount > 0)).toBe(true);
},
);
@@ -415,6 +527,14 @@ test.concurrent(
ctx,
customer: initialCustomer,
});
+ const initialSchedules = await stripeSchedulesForCustomer({
+ ctx,
+ customer: initialCustomer,
+ });
+ expect(initialSchedules.data[0]?.phases[1]?.proration_behavior).toBe(
+ "always_invoice",
+ );
+ expect(initialSchedules.data[0]?.billing_mode?.type).toBe("flexible");
const annualPeriod = annualPeriodFromInitialInvoice({
invoice: initialInvoice,
});
@@ -427,11 +547,11 @@ test.concurrent(
const customerAfterTransition =
await autumnV1.customers.get(id);
- const transitionInvoice = await latestStripeInvoice({
+ const pendingItems = await pendingStripeInvoiceItems({
ctx,
customer: customerAfterTransition,
});
- const pendingItems = await pendingStripeInvoiceItems({
+ const stripeInvoices = await stripeInvoicesForCustomer({
ctx,
customer: customerAfterTransition,
});
@@ -441,10 +561,37 @@ test.concurrent(
transitionAt,
billingPeriod: annualPeriod,
});
- await expectCustomerInvoiceCorrect({
- customer: customerAfterTransition,
- count: 2,
- latestTotal: 10,
+ expect(customerAfterTransition.invoices).toHaveLength(3);
+ expectAutumnInvoiceWithTotal({
+ invoices: customerAfterTransition.invoices!,
+ total: 10,
+ });
+ expectAutumnInvoiceWithTotal({
+ invoices: customerAfterTransition.invoices!,
+ total: new Decimal(expectedProration).minus(10),
+ });
+ const prorationInvoice = expectStripeInvoiceWithIntervalTotals({
+ invoices: stripeInvoices,
+ yearTotal: expectedProration,
+ monthTotal: -10,
+ });
+ expectAmountCloseTo({
+ actual: intervalLineTotal({
+ invoice: prorationInvoice,
+ interval: "year",
+ }),
+ expected: expectedProration,
+ });
+ expectAmountCloseTo({
+ actual: intervalLineTotal({
+ invoice: prorationInvoice,
+ interval: "month",
+ }),
+ expected: -10,
+ });
+ expectAmountCloseTo({
+ actual: new Decimal(prorationInvoice.total).div(100),
+ expected: new Decimal(expectedProration).minus(10),
});
expect(
pendingItemIntervalTotal({
@@ -453,41 +600,14 @@ test.concurrent(
})
.toDecimalPlaces(2)
.toNumber(),
- ).toBe(expectedProration);
+ ).toBe(0);
expect(
- intervalLineTotal({ invoice: transitionInvoice, interval: "month" })
- .plus(
- pendingItemIntervalTotal({
- items: pendingItems.data,
- interval: "month",
- }),
- )
+ pendingItemIntervalTotal({
+ items: pendingItems.data,
+ interval: "month",
+ })
.toDecimalPlaces(2)
.toNumber(),
).toBe(0);
- expect(
- new Decimal(transitionInvoice.total)
- .div(100)
- .plus(
- pendingItemIntervalTotal({
- items: pendingItems.data,
- interval: "month",
- }),
- )
- .plus(
- pendingItemIntervalTotal({
- items: pendingItems.data,
- interval: "year",
- }),
- )
- .toDecimalPlaces(2)
- .toNumber(),
- ).toBe(expectedProration);
- expect(
- pendingItems.data.some(
- (item) => item.amount < 0 && item.amount !== -1000,
- ),
- ).toBe(true);
- expect(pendingItems.data.some((item) => item.amount > 0)).toBe(true);
},
);
diff --git a/server/tests/integration/billing/create-schedule/phases/create-schedule-phases.test.ts b/server/tests/integration/billing/create-schedule/phases/create-schedule-phases.test.ts
index 294e9ef25..d4423e972 100644
--- a/server/tests/integration/billing/create-schedule/phases/create-schedule-phases.test.ts
+++ b/server/tests/integration/billing/create-schedule/phases/create-schedule-phases.test.ts
@@ -1,6 +1,7 @@
import { expect, test } from "bun:test";
import {
type ApiCustomerV3,
+ applyProration,
type CreateScheduleParamsV0Input,
CusProductStatus,
customerProducts,
@@ -15,12 +16,133 @@ import { products } from "@tests/utils/fixtures/products";
import { advanceTestClock } from "@tests/utils/stripeUtils";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
+import { Decimal } from "decimal.js";
import { eq, inArray } from "drizzle-orm";
+import type Stripe from "stripe";
import {
getCustomerProductRows,
getRequiredScheduleId,
} from "../utils/createScheduleTestHelpers";
+const latestStripeInvoice = async ({
+ ctx,
+ customer,
+}: {
+ ctx: Awaited>["ctx"];
+ customer: ApiCustomerV3;
+}) => {
+ const stripeId = customer.invoices?.[0]?.stripe_id;
+ if (!stripeId) throw new Error("Expected latest invoice to have stripe_id");
+
+ return await ctx.stripeCli.invoices.retrieve(stripeId, {
+ expand: ["lines.data.price"],
+ });
+};
+
+const pendingStripeInvoiceItems = async ({
+ ctx,
+ customer,
+}: {
+ ctx: Awaited>["ctx"];
+ customer: ApiCustomerV3;
+}) => {
+ if (!customer.stripe_id)
+ throw new Error("Expected customer to have stripe_id");
+
+ return await ctx.stripeCli.invoiceItems.list({
+ customer: customer.stripe_id,
+ pending: true,
+ limit: 100,
+ });
+};
+
+const stripeInvoicesForCustomer = async ({
+ ctx,
+ customer,
+}: {
+ ctx: Awaited>["ctx"];
+ customer: ApiCustomerV3;
+}) => {
+ if (!customer.stripe_id)
+ throw new Error("Expected customer to have stripe_id");
+
+ const invoices = await ctx.stripeCli.invoices.list({
+ customer: customer.stripe_id,
+ limit: 100,
+ });
+
+ return await Promise.all(
+ invoices.data.map((invoice) =>
+ ctx.stripeCli.invoices.retrieve(invoice.id!, {
+ expand: ["lines.data.price"],
+ }),
+ ),
+ );
+};
+
+const lineAmountDollars = (line: Stripe.InvoiceLineItem) =>
+ new Decimal(line.amount).div(100);
+
+const invoiceLineTotal = (invoice: Stripe.Invoice) =>
+ invoice.lines.data.reduce(
+ (total, line) => total.plus(lineAmountDollars(line)),
+ new Decimal(0),
+ );
+
+const initialMonthlyPeriod = (invoice: Stripe.Invoice) => {
+ const monthlyLine = invoice.lines.data.find((line) => line.amount > 0);
+ if (!monthlyLine) throw new Error("Expected a positive monthly invoice line");
+
+ return {
+ start: monthlyLine.period.start * 1000,
+ end: monthlyLine.period.end * 1000,
+ };
+};
+
+const expectedMonthlyProrationDiff = ({
+ oldAmount,
+ newAmount,
+ transitionAt,
+ billingPeriod,
+}: {
+ oldAmount: number;
+ newAmount: number;
+ transitionAt: number;
+ billingPeriod: { start: number; end: number };
+}) =>
+ new Decimal(
+ applyProration({
+ now: transitionAt,
+ billingPeriod,
+ amount: newAmount,
+ }),
+ )
+ .minus(
+ applyProration({
+ now: transitionAt,
+ billingPeriod,
+ amount: oldAmount,
+ }),
+ )
+ .toDecimalPlaces(2)
+ .toNumber();
+
+const expectStripeInvoiceWithTotal = ({
+ invoices,
+ total,
+}: {
+ invoices: Stripe.Invoice[];
+ total: number;
+}) => {
+ const invoice = invoices.find((candidate) => {
+ const candidateTotal = new Decimal(candidate.total).div(100);
+ return candidateTotal.minus(total).abs().lte(0.01);
+ });
+
+ expect(invoice, `Expected Stripe invoice total $${total}`).toBeDefined();
+ return invoice!;
+};
+
test.concurrent(
`${chalk.yellowBright("create-schedule: bills the first phase immediately and stores later phases as scheduled")}`,
async () => {
@@ -403,3 +525,120 @@ test.concurrent(
});
},
);
+
+test.concurrent(
+ `${chalk.yellowBright("create-schedule: phase transition invoices monthly upgrade proration immediately")}`,
+ async () => {
+ const pro = products.pro({
+ id: "create-schedule-transition-invoice-pro",
+ items: [items.monthlyMessages({ includedUsage: 100 })],
+ });
+ const premium = products.premium({
+ id: "create-schedule-transition-invoice-premium",
+ items: [items.monthlyMessages({ includedUsage: 500 })],
+ });
+
+ const { customerId, autumnV1, ctx, testClockId, advancedTo } =
+ await initScenario({
+ customerId: "create-schedule-transition-invoice",
+ setup: [
+ s.customer({ paymentMethod: "success" }),
+ s.products({ list: [pro, premium] }),
+ ],
+ actions: [],
+ });
+
+ const now = advancedTo;
+ const transitionAt = now + ms.days(15);
+ await autumnV1.billing.createSchedule({
+ customer_id: customerId,
+ phases: [
+ {
+ starts_at: now,
+ plans: [{ plan_id: pro.id }],
+ },
+ {
+ starts_at: transitionAt,
+ plans: [{ plan_id: premium.id }],
+ },
+ ],
+ });
+
+ const initialCustomer =
+ await autumnV1.customers.get(customerId);
+ await expectCustomerInvoiceCorrect({
+ customer: initialCustomer,
+ count: 1,
+ latestTotal: 20,
+ });
+
+ const initialInvoice = await latestStripeInvoice({
+ ctx,
+ customer: initialCustomer,
+ });
+ const billingPeriod = initialMonthlyPeriod(initialInvoice);
+ const expectedProration = expectedMonthlyProrationDiff({
+ oldAmount: 20,
+ newAmount: 50,
+ transitionAt,
+ billingPeriod,
+ });
+
+ await advanceTestClock({
+ stripeCli: ctx.stripeCli,
+ testClockId: testClockId!,
+ advanceTo: transitionAt,
+ waitForSeconds: 30,
+ });
+
+ const customerAfterTransition =
+ await autumnV1.customers.get(customerId);
+ await expectCustomerProducts({
+ customer: customerAfterTransition,
+ active: [premium.id],
+ notPresent: [pro.id],
+ });
+ await expectCustomerInvoiceCorrect({
+ customer: customerAfterTransition,
+ count: 2,
+ latestTotal: expectedProration,
+ });
+ const stripeInvoices = await stripeInvoicesForCustomer({
+ ctx,
+ customer: customerAfterTransition,
+ });
+ const transitionInvoice = expectStripeInvoiceWithTotal({
+ invoices: stripeInvoices,
+ total: expectedProration,
+ });
+ expect(
+ invoiceLineTotal(transitionInvoice).toDecimalPlaces(2).toNumber(),
+ ).toBe(expectedProration);
+
+ const pendingItems = await pendingStripeInvoiceItems({
+ ctx,
+ customer: customerAfterTransition,
+ });
+ expect(pendingItems.data).toHaveLength(0);
+
+ await advanceTestClock({
+ stripeCli: ctx.stripeCli,
+ testClockId: testClockId!,
+ advanceTo: billingPeriod.end,
+ waitForSeconds: 30,
+ });
+
+ const customerAfterRenewal =
+ await autumnV1.customers.get(customerId);
+ await expectCustomerProducts({
+ customer: customerAfterRenewal,
+ active: [premium.id],
+ notPresent: [pro.id],
+ });
+ await expectCustomerInvoiceCorrect({
+ customer: customerAfterRenewal,
+ count: 3,
+ latestTotal: 50,
+ });
+ },
+);
diff --git a/server/tests/integration/billing/create-schedule/preview/create-schedule-preview.test.ts b/server/tests/integration/billing/create-schedule/preview/create-schedule-preview.test.ts
index ff92c485c..38f69b45c 100644
--- a/server/tests/integration/billing/create-schedule/preview/create-schedule-preview.test.ts
+++ b/server/tests/integration/billing/create-schedule/preview/create-schedule-preview.test.ts
@@ -1,5 +1,6 @@
import { expect, test } from "bun:test";
import {
+ type ApiCustomerV3,
type AttachPreviewResponse,
applyProration,
BillingInterval,
@@ -885,3 +886,72 @@ test.concurrent(
});
},
);
+
+test.concurrent(
+ `${chalk.yellowBright("create-schedule preview 15: invoice excludes unrelated pending Stripe invoice items")}`,
+ async () => {
+ const pro = products.pro({
+ id: "preview-pending-items-pro",
+ items: [items.monthlyMessages({ includedUsage: 100 })],
+ });
+ const premium = products.premium({
+ id: "preview-pending-items-premium",
+ items: [items.monthlyMessages({ includedUsage: 500 })],
+ });
+
+ const { customerId, autumnV1, ctx, advancedTo } = await initScenario({
+ customerId: "create-schedule-preview-pending-items",
+ setup: [
+ s.customer({ paymentMethod: "success" }),
+ s.products({ list: [pro, premium] }),
+ ],
+ actions: [s.billing.attach({ productId: pro.id })],
+ });
+
+ const customer = await autumnV1.customers.get(customerId);
+ const stripeSubscriptions = await ctx.stripeCli.subscriptions.list({
+ customer: customer.stripe_id!,
+ limit: 1,
+ });
+ const stripeSubscription = stripeSubscriptions.data[0];
+ expect(stripeSubscription).toBeDefined();
+
+ await ctx.stripeCli.invoiceItems.create({
+ customer: customer.stripe_id!,
+ subscription: stripeSubscription.id,
+ amount: 12345,
+ currency: "usd",
+ description: "Unrelated pending Stripe invoice item",
+ });
+
+ const params: CreateScheduleParamsV0Input = {
+ customer_id: customerId,
+ invoice_mode: {
+ enabled: true,
+ finalize: false,
+ enable_plan_immediately: true,
+ },
+ phases: [
+ {
+ starts_at: advancedTo,
+ plans: [{ plan_id: premium.id }],
+ },
+ ],
+ };
+ const preview = await previewCreateSchedule({ autumnV1, params });
+ const response = await autumnV1.billing.createSchedule(params);
+
+ expect(response.status).toBe("created");
+ expect(response.invoice?.total).toBe(preview.total);
+
+ const stripeInvoice = await ctx.stripeCli.invoices.retrieve(
+ response.invoice!.stripe_id!,
+ { expand: ["lines"] },
+ );
+ expect(
+ stripeInvoice.lines.data.some(
+ (line) => line.description === "Unrelated pending Stripe invoice item",
+ ),
+ ).toBe(false);
+ },
+);
diff --git a/server/tests/integration/billing/invoice-matched-credits/additional-coverage.test.ts b/server/tests/integration/billing/invoice-matched-credits/additional-coverage.test.ts
new file mode 100644
index 000000000..54c7b08a2
--- /dev/null
+++ b/server/tests/integration/billing/invoice-matched-credits/additional-coverage.test.ts
@@ -0,0 +1,290 @@
+/**
+ * Invoice-Matched Proration Credits — Additional Coverage
+ *
+ * 1. amount-off coupon cancel: refund based on discounted invoice
+ * 2. cancel after partial refund (upgrade then cancel): second refund nets the first
+ * 3. create-schedule with discount: immediate phase credit from stored charge
+ */
+
+import { expect, test } from "bun:test";
+import type { ApiCustomerV3, AttachPreviewResponse } from "@autumn/shared";
+import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
+import {
+ expectProductActive,
+ expectProductNotPresent,
+} from "@tests/integration/billing/utils/expectCustomerProductCorrect";
+import { items } from "@tests/utils/fixtures/items";
+import { products } from "@tests/utils/fixtures/products";
+import { advanceTestClock } from "@tests/utils/stripeUtils";
+import { advanceToNextInvoice } from "@tests/utils/testAttachUtils/testAttachUtils";
+import ctx from "@tests/utils/testInitUtils/createTestContext";
+import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
+import chalk from "chalk";
+import { createStripeCli } from "@/external/connect/createStripeCli";
+import { createAmountCoupon } from "../utils/discounts/discountTestUtils";
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// TEST 1: Amount-off coupon cancel — refund based on discounted invoice ($15)
+// ═══════════════════════════════════════════════════════════════════════════════
+
+test.concurrent(
+ `${chalk.yellowBright("invoice-matched-credits additional 1: amount-off coupon cancel — refund based on discounted invoice")}`,
+ async () => {
+ const customerId = "imc-add-amtoff-cancel";
+
+ const pro = products.pro({
+ id: "pro",
+ items: [items.monthlyMessages({ includedUsage: 500 })],
+ });
+
+ const { autumnV1, testClockId, advancedTo } = await initScenario({
+ customerId,
+ setup: [
+ s.customer({ paymentMethod: "success" }),
+ s.products({ list: [pro] }),
+ ],
+ actions: [],
+ });
+
+ const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env });
+ const coupon = await createAmountCoupon({
+ stripeCli,
+ amountOffCents: 500,
+ });
+
+ await autumnV1.billing.attach({
+ customer_id: customerId,
+ product_id: pro.id,
+ discounts: [{ reward_id: coupon.id }],
+ });
+
+ await new Promise((resolve) => setTimeout(resolve, 5000));
+
+ const customerAfterAttach =
+ await autumnV1.customers.get(customerId);
+ await expectCustomerInvoiceCorrect({
+ customer: customerAfterAttach,
+ count: 1,
+ latestTotal: 15,
+ });
+
+ const renewedAt = await advanceToNextInvoice({
+ stripeCli: ctx.stripeCli,
+ testClockId: testClockId!,
+ currentEpochMs: advancedTo,
+ });
+
+ const customerAfterRenewal =
+ await autumnV1.customers.get(customerId);
+ await expectCustomerInvoiceCorrect({
+ customer: customerAfterRenewal,
+ count: 2,
+ latestTotal: 15,
+ });
+
+ await advanceTestClock({
+ stripeCli: ctx.stripeCli,
+ testClockId: testClockId!,
+ startingFrom: new Date(renewedAt),
+ numberOfDays: 15,
+ });
+
+ const cancelParams = {
+ customer_id: customerId,
+ product_id: pro.id,
+ cancel_action: "cancel_immediately" as const,
+ };
+
+ const preview = await autumnV1.subscriptions.previewUpdate(cancelParams);
+
+ expect(preview.total).toBeLessThan(0);
+ expect(Math.abs(preview.total)).toBeLessThan(10);
+ expect(Math.abs(preview.total)).toBeGreaterThan(5);
+
+ await autumnV1.subscriptions.update(cancelParams);
+
+ const customerAfterCancel =
+ await autumnV1.customers.get(customerId);
+ await expectProductNotPresent({
+ customer: customerAfterCancel,
+ productId: pro.id,
+ });
+
+ await expectCustomerInvoiceCorrect({
+ customer: customerAfterCancel,
+ count: 3,
+ latestTotal: preview.total,
+ });
+ },
+ 300_000,
+);
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// TEST 2: Cancel after partial refund (upgrade then cancel) — second refund nets the first
+// ═══════════════════════════════════════════════════════════════════════════════
+
+test.concurrent(
+ `${chalk.yellowBright("invoice-matched-credits additional 2: cancel after upgrade — second refund nets the first")}`,
+ async () => {
+ const customerId = "imc-add-upg-then-cancel";
+
+ const pro = products.pro({
+ id: "pro",
+ items: [items.monthlyMessages({ includedUsage: 500 })],
+ });
+
+ const premium = products.premium({
+ id: "premium",
+ items: [items.monthlyMessages({ includedUsage: 1000 })],
+ });
+
+ const { autumnV1, autumnV2_2, testClockId, advancedTo } =
+ await initScenario({
+ customerId,
+ setup: [
+ s.customer({ testClock: true, paymentMethod: "success" }),
+ s.products({ list: [pro, premium] }),
+ ],
+ actions: [
+ s.billing.attach({ productId: pro.id }),
+ s.advanceTestClock({ toNextInvoice: true }),
+ s.advanceTestClock({ days: 10 }),
+ ],
+ });
+
+ const upgradeResult = await autumnV2_2.billing.attach({
+ customer_id: customerId,
+ plan_id: `premium_${customerId}`,
+ });
+
+ expect(upgradeResult.invoice).toBeDefined();
+
+ await new Promise((resolve) => setTimeout(resolve, 5000));
+
+ const customerAfterUpgrade =
+ await autumnV1.customers.get(customerId);
+ await expectProductActive({
+ customer: customerAfterUpgrade,
+ productId: `premium_${customerId}`,
+ });
+
+ const cancelParams = {
+ customer_id: customerId,
+ product_id: `premium_${customerId}`,
+ cancel_action: "cancel_immediately" as const,
+ };
+
+ const preview = await autumnV1.subscriptions.previewUpdate(cancelParams);
+
+ expect(preview.total).toBeLessThan(0);
+
+ await autumnV1.subscriptions.update(cancelParams);
+
+ const customerAfterCancel =
+ await autumnV1.customers.get(customerId);
+ await expectProductNotPresent({
+ customer: customerAfterCancel,
+ productId: `premium_${customerId}`,
+ });
+ },
+ 300_000,
+);
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// TEST 3: Scheduled upgrade with discount — immediate phase credit from stored charge
+// ═══════════════════════════════════════════════════════════════════════════════
+
+test.concurrent(
+ `${chalk.yellowBright("invoice-matched-credits additional 3: scheduled upgrade with discount — credit reflects discounted charge")}`,
+ async () => {
+ const customerId = "imc-add-sched-disc";
+
+ const pro = products.pro({
+ id: "pro",
+ items: [items.monthlyMessages({ includedUsage: 500 })],
+ });
+
+ const premium = products.premium({
+ id: "premium",
+ items: [items.monthlyMessages({ includedUsage: 1000 })],
+ });
+
+ const { autumnV1, autumnV2_2, testClockId, advancedTo } =
+ await initScenario({
+ customerId,
+ setup: [
+ s.customer({ testClock: true, paymentMethod: "success" }),
+ s.products({ list: [pro, premium] }),
+ ],
+ actions: [],
+ });
+
+ const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env });
+ const coupon = await createAmountCoupon({
+ stripeCli,
+ amountOffCents: 400,
+ });
+
+ await autumnV1.billing.attach({
+ customer_id: customerId,
+ product_id: `pro_${customerId}`,
+ discounts: [{ reward_id: coupon.id }],
+ });
+
+ await new Promise((resolve) => setTimeout(resolve, 5000));
+
+ const customerAfterAttach =
+ await autumnV1.customers.get(customerId);
+ await expectCustomerInvoiceCorrect({
+ customer: customerAfterAttach,
+ count: 1,
+ latestTotal: 16,
+ });
+
+ const renewedAt = await advanceToNextInvoice({
+ stripeCli: ctx.stripeCli,
+ testClockId: testClockId!,
+ currentEpochMs: advancedTo,
+ });
+
+ const customerAfterRenewal =
+ await autumnV1.customers.get(customerId);
+ await expectCustomerInvoiceCorrect({
+ customer: customerAfterRenewal,
+ count: 2,
+ latestTotal: 16,
+ });
+
+ await advanceTestClock({
+ stripeCli: ctx.stripeCli,
+ testClockId: testClockId!,
+ startingFrom: new Date(renewedAt),
+ numberOfDays: 15,
+ });
+
+ const preview = (await autumnV2_2.billing.previewAttach({
+ customer_id: customerId,
+ plan_id: `premium_${customerId}`,
+ })) as AttachPreviewResponse;
+
+ const creditLines = preview.line_items.filter((li) => li.total < 0);
+ expect(creditLines.length).toBeGreaterThan(0);
+
+ const creditTotal = creditLines.reduce((sum, li) => sum + li.total, 0);
+ expect(creditTotal).toBeLessThan(0);
+ expect(creditTotal).toBeGreaterThan(-16);
+
+ for (const creditLine of creditLines) {
+ const discounts = creditLine.discounts ?? [];
+ expect(discounts.length).toBe(0);
+ }
+
+ const result = await autumnV2_2.billing.attach({
+ customer_id: customerId,
+ plan_id: `premium_${customerId}`,
+ });
+
+ expect(result.invoice?.total).toBeCloseTo(preview.total, 0);
+ },
+ 300_000,
+);
diff --git a/server/tests/integration/billing/invoice-matched-credits/cancel.test.ts b/server/tests/integration/billing/invoice-matched-credits/cancel.test.ts
new file mode 100644
index 000000000..8741c18c9
--- /dev/null
+++ b/server/tests/integration/billing/invoice-matched-credits/cancel.test.ts
@@ -0,0 +1,279 @@
+/**
+ * Invoice-Matched Proration Credits — Cancel Tests
+ *
+ * Verifies that cancellation credits and refunds source amounts from stored
+ * invoice line items rather than catalog prices.
+ *
+ * - cancel_immediately with discount: credit reflects discounted charge ($16)
+ * - cancel_end_of_cycle: no immediate credit line items
+ * - refund_last_payment prorated with discount: refund based on discounted invoice
+ */
+
+import { expect, test } from "bun:test";
+import type { ApiCustomerV3 } from "@autumn/shared";
+import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
+import {
+ expectProductActive,
+ expectProductNotPresent,
+} from "@tests/integration/billing/utils/expectCustomerProductCorrect";
+import { items } from "@tests/utils/fixtures/items";
+import { products } from "@tests/utils/fixtures/products";
+import { advanceTestClock } from "@tests/utils/stripeUtils";
+import { advanceToNextInvoice } from "@tests/utils/testAttachUtils/testAttachUtils";
+import ctx from "@tests/utils/testInitUtils/createTestContext";
+import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
+import chalk from "chalk";
+import { createStripeCli } from "@/external/connect/createStripeCli";
+import { createPercentCoupon } from "../utils/discounts/discountTestUtils";
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// TEST 1: Cancel immediately prorated with discount — credit from stored charge
+// ═══════════════════════════════════════════════════════════════════════════════
+
+test.concurrent(
+ `${chalk.yellowBright("invoice-matched-credits cancel 1: cancel immediately with discount — credit reflects stored charge")}`,
+ async () => {
+ const customerId = "imc-cancel-imm-disc";
+
+ const pro = products.pro({
+ id: "pro",
+ items: [items.monthlyMessages({ includedUsage: 500 })],
+ });
+
+ const { autumnV1, autumnV2_2, testClockId, advancedTo } =
+ await initScenario({
+ customerId,
+ setup: [
+ s.customer({ paymentMethod: "success" }),
+ s.products({ list: [pro] }),
+ ],
+ actions: [],
+ });
+
+ const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env });
+ const coupon = await createPercentCoupon({
+ stripeCli,
+ percentOff: 20,
+ });
+
+ await autumnV1.billing.attach({
+ customer_id: customerId,
+ product_id: pro.id,
+ discounts: [{ reward_id: coupon.id }],
+ });
+
+ await new Promise((resolve) => setTimeout(resolve, 5000));
+
+ const customerAfterAttach =
+ await autumnV1.customers.get(customerId);
+ await expectCustomerInvoiceCorrect({
+ customer: customerAfterAttach,
+ count: 1,
+ latestTotal: 16,
+ });
+
+ const renewedAt = await advanceToNextInvoice({
+ stripeCli: ctx.stripeCli,
+ testClockId: testClockId!,
+ currentEpochMs: advancedTo,
+ });
+
+ const customerAfterRenewal =
+ await autumnV1.customers.get(customerId);
+ await expectCustomerInvoiceCorrect({
+ customer: customerAfterRenewal,
+ count: 2,
+ latestTotal: 16,
+ });
+
+ await advanceTestClock({
+ stripeCli: ctx.stripeCli,
+ testClockId: testClockId!,
+ startingFrom: new Date(renewedAt),
+ numberOfDays: 15,
+ });
+
+ const cancelParams = {
+ customer_id: customerId,
+ product_id: pro.id,
+ cancel_action: "cancel_immediately" as const,
+ };
+
+ const preview = await autumnV1.subscriptions.previewUpdate(cancelParams);
+
+ expect(preview.total).toBeLessThan(0);
+ expect(preview.total).toBeGreaterThan(-16);
+ expect(preview.total).toBeLessThanOrEqual(-7);
+
+ await autumnV1.subscriptions.update(cancelParams);
+
+ const customerAfterCancel =
+ await autumnV1.customers.get(customerId);
+ await expectProductNotPresent({
+ customer: customerAfterCancel,
+ productId: pro.id,
+ });
+
+ await expectCustomerInvoiceCorrect({
+ customer: customerAfterCancel,
+ count: 3,
+ latestTotal: preview.total,
+ });
+ },
+ 300_000,
+);
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// TEST 2: Cancel end_of_cycle — no immediate credit lines
+// ═══════════════════════════════════════════════════════════════════════════════
+
+test.concurrent(
+ `${chalk.yellowBright("invoice-matched-credits cancel 2: cancel end_of_cycle — no immediate credit line items")}`,
+ async () => {
+ const customerId = "imc-cancel-eoc";
+
+ const pro = products.pro({
+ id: "pro",
+ items: [items.monthlyMessages({ includedUsage: 500 })],
+ });
+
+ const { autumnV1, autumnV2_2 } = await initScenario({
+ customerId,
+ setup: [
+ s.customer({ paymentMethod: "success" }),
+ s.products({ list: [pro] }),
+ ],
+ actions: [
+ s.billing.attach({ productId: pro.id }),
+ s.advanceTestClock({ days: 10 }),
+ ],
+ });
+
+ const customerBeforeCancel =
+ await autumnV1.customers.get(customerId);
+ await expectProductActive({
+ customer: customerBeforeCancel,
+ productId: pro.id,
+ });
+
+ const cancelParams = {
+ customer_id: customerId,
+ product_id: pro.id,
+ cancel_action: "cancel_end_of_cycle" as const,
+ };
+
+ const preview = await autumnV1.subscriptions.previewUpdate(cancelParams);
+
+ expect(preview.total).toBe(0);
+
+ const creditLines = preview.line_items.filter((li: { total: number }) => li.total < 0);
+ expect(creditLines.length).toBe(0);
+
+ await autumnV1.subscriptions.update(cancelParams);
+
+ const customerAfterCancel =
+ await autumnV1.customers.get(customerId);
+
+ await expectCustomerInvoiceCorrect({
+ customer: customerAfterCancel,
+ count: 1,
+ latestTotal: 20,
+ });
+ },
+ 300_000,
+);
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// TEST 3: Cancel discounted plan refund_last_payment prorated — refund based on discounted invoice
+// ═══════════════════════════════════════════════════════════════════════════════
+
+test.concurrent(
+ `${chalk.yellowBright("invoice-matched-credits cancel 3: cancel with refund_last_payment prorated — refund reflects discounted invoice")}`,
+ async () => {
+ const customerId = "imc-cancel-refund-disc";
+
+ const pro = products.pro({
+ id: "pro",
+ items: [items.monthlyMessages({ includedUsage: 500 })],
+ });
+
+ const { autumnV1, autumnV2_2, testClockId, advancedTo } =
+ await initScenario({
+ customerId,
+ setup: [
+ s.customer({ paymentMethod: "success" }),
+ s.products({ list: [pro] }),
+ ],
+ actions: [],
+ });
+
+ const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env });
+ const coupon = await createPercentCoupon({
+ stripeCli,
+ percentOff: 20,
+ });
+
+ await autumnV1.billing.attach({
+ customer_id: customerId,
+ product_id: pro.id,
+ discounts: [{ reward_id: coupon.id }],
+ });
+
+ await new Promise((resolve) => setTimeout(resolve, 5000));
+
+ const customerAfterAttach =
+ await autumnV1.customers.get(customerId);
+ await expectCustomerInvoiceCorrect({
+ customer: customerAfterAttach,
+ count: 1,
+ latestTotal: 16,
+ });
+
+ const renewedAt = await advanceToNextInvoice({
+ stripeCli: ctx.stripeCli,
+ testClockId: testClockId!,
+ currentEpochMs: advancedTo,
+ });
+
+ await advanceTestClock({
+ stripeCli: ctx.stripeCli,
+ testClockId: testClockId!,
+ startingFrom: new Date(renewedAt),
+ numberOfDays: 15,
+ });
+
+ const cancelParams = {
+ customer_id: customerId,
+ product_id: pro.id,
+ cancel_action: "cancel_immediately" as const,
+ refund_last_payment: "prorated" as const,
+ };
+
+ const preview = await autumnV1.subscriptions.previewUpdate(cancelParams);
+
+ expect(preview.total).toBe(0);
+ expect(preview.refund).toBeDefined();
+
+ const refundAmount = preview.refund!.amount;
+ expect(refundAmount).toBeGreaterThan(0);
+ expect(refundAmount).toBeLessThanOrEqual(16);
+ expect(refundAmount).toBeGreaterThanOrEqual(7);
+
+ expect(preview.refund!.invoice.total).toBe(16);
+
+ await autumnV1.subscriptions.update(cancelParams);
+
+ const customerAfterCancel =
+ await autumnV1.customers.get(customerId);
+ await expectProductNotPresent({
+ customer: customerAfterCancel,
+ productId: pro.id,
+ });
+
+ await expectCustomerInvoiceCorrect({
+ customer: customerAfterCancel,
+ count: 2,
+ });
+ },
+ 300_000,
+);
diff --git a/server/tests/integration/billing/invoice-matched-credits/create-schedule.test.ts b/server/tests/integration/billing/invoice-matched-credits/create-schedule.test.ts
new file mode 100644
index 000000000..271e77ceb
--- /dev/null
+++ b/server/tests/integration/billing/invoice-matched-credits/create-schedule.test.ts
@@ -0,0 +1,194 @@
+import { expect, test } from "bun:test";
+import type { AttachPreviewResponse } from "@autumn/shared";
+import { hoursToFinalizeInvoice } from "@tests/utils/constants.js";
+import { items } from "@tests/utils/fixtures/items.js";
+import { products } from "@tests/utils/fixtures/products.js";
+import { advanceTestClock } from "@tests/utils/stripeUtils.js";
+import ctx from "@tests/utils/testInitUtils/createTestContext.js";
+import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
+import chalk from "chalk";
+import { addHours, addMonths } from "date-fns";
+import { createStripeCli } from "@/external/connect/createStripeCli.js";
+import { createPercentCoupon } from "../utils/discounts/discountTestUtils.js";
+
+test.concurrent(
+ `${chalk.yellowBright("invoice-matched-credits create-schedule: immediate phase pro + addon — credits from stored discounted charges")}`,
+ async () => {
+ const customerId = "imc-sched-disc-addon";
+
+ const pro = products.pro({
+ id: "pro",
+ items: [items.monthlyMessages({ includedUsage: 500 })],
+ });
+
+ const addon = products.recurringAddOn({
+ id: "addon",
+ items: [items.monthlyWords({ includedUsage: 200 })],
+ });
+
+ const premium = products.premium({
+ id: "premium",
+ items: [items.monthlyMessages({ includedUsage: 1000 })],
+ });
+
+ const { autumnV1, autumnV2_2, testClockId, advancedTo } =
+ await initScenario({
+ customerId,
+ setup: [
+ s.customer({ testClock: true, paymentMethod: "success" }),
+ s.products({ list: [pro, addon, premium] }),
+ ],
+ actions: [],
+ });
+
+ const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env });
+ const coupon = await createPercentCoupon({ stripeCli, percentOff: 20 });
+
+ const scheduleResponse = await autumnV1.billing.createSchedule(
+ {
+ customer_id: customerId,
+ discounts: [{ reward_id: coupon.id }],
+ phases: [
+ {
+ starts_at: advancedTo,
+ plans: [{ plan_id: pro.id }, { plan_id: addon.id }],
+ },
+ ],
+ },
+ { timeout: 8000 },
+ );
+
+ expect(scheduleResponse.status).toBe("created");
+ expect(scheduleResponse.phases[0]!.customer_product_ids).toHaveLength(2);
+ expect(scheduleResponse.invoice?.total).toBeLessThan(40);
+
+ const renewalTime = await advanceTestClock({
+ stripeCli: ctx.stripeCli,
+ testClockId: testClockId!,
+ advanceTo: addHours(
+ addMonths(new Date(advancedTo), 1),
+ hoursToFinalizeInvoice,
+ ).getTime(),
+ waitForSeconds: 30,
+ });
+
+ await advanceTestClock({
+ stripeCli: ctx.stripeCli,
+ testClockId: testClockId!,
+ startingFrom: new Date(renewalTime),
+ numberOfDays: 15,
+ });
+
+ const preview = (await autumnV2_2.billing.previewAttach({
+ customer_id: customerId,
+ plan_id: premium.id,
+ })) as AttachPreviewResponse;
+
+ const creditLines = preview.line_items.filter((li) => li.total < 0);
+ expect(creditLines.length).toBeGreaterThan(0);
+
+ const creditTotal = creditLines.reduce((sum, li) => sum + li.total, 0);
+ expect(creditTotal).toBeLessThan(0);
+ expect(Math.abs(creditTotal)).toBeLessThan(40);
+
+ for (const creditLine of creditLines) {
+ expect(creditLine.discounts ?? []).toHaveLength(0);
+ }
+
+ const result = await autumnV2_2.billing.attach({
+ customer_id: customerId,
+ plan_id: premium.id,
+ });
+
+ expect(result.invoice?.total).toBeCloseTo(preview.total, 0);
+ },
+ 300_000,
+);
+
+test.concurrent(
+ `${chalk.yellowBright("invoice-matched-credits create-schedule: multi-phase setup then mid-cycle upgrade — preview matches invoice")}`,
+ async () => {
+ const customerId = "imc-sched-multiphase";
+
+ const pro = products.pro({
+ id: "pro",
+ items: [items.monthlyMessages({ includedUsage: 500 })],
+ });
+
+ const addon = products.recurringAddOn({
+ id: "addon",
+ items: [items.monthlyWords({ includedUsage: 200 })],
+ });
+
+ const premium = products.premium({
+ id: "premium",
+ items: [items.monthlyMessages({ includedUsage: 1000 })],
+ });
+
+ const { autumnV1, autumnV2_2, testClockId, advancedTo } =
+ await initScenario({
+ customerId,
+ setup: [
+ s.customer({ testClock: true, paymentMethod: "success" }),
+ s.products({ list: [pro, addon, premium] }),
+ ],
+ actions: [],
+ });
+
+ const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env });
+ const coupon = await createPercentCoupon({ stripeCli, percentOff: 20 });
+ const phase2StartsAt = addMonths(advancedTo, 2).getTime();
+
+ await autumnV1.billing.createSchedule(
+ {
+ customer_id: customerId,
+ discounts: [{ reward_id: coupon.id }],
+ phases: [
+ {
+ starts_at: advancedTo,
+ plans: [{ plan_id: pro.id }, { plan_id: addon.id }],
+ },
+ {
+ starts_at: phase2StartsAt,
+ plans: [{ plan_id: pro.id }],
+ },
+ ],
+ },
+ { timeout: 8000 },
+ );
+
+ const renewalTime = await advanceTestClock({
+ stripeCli: ctx.stripeCli,
+ testClockId: testClockId!,
+ advanceTo: addHours(
+ addMonths(new Date(advancedTo), 1),
+ hoursToFinalizeInvoice,
+ ).getTime(),
+ waitForSeconds: 30,
+ });
+
+ await advanceTestClock({
+ stripeCli: ctx.stripeCli,
+ testClockId: testClockId!,
+ startingFrom: new Date(renewalTime),
+ numberOfDays: 15,
+ });
+
+ const preview = (await autumnV2_2.billing.previewAttach({
+ customer_id: customerId,
+ plan_id: premium.id,
+ })) as AttachPreviewResponse;
+
+ expect(preview.total).toBeDefined();
+ const creditLines = preview.line_items.filter((li) => li.total < 0);
+ expect(creditLines.length).toBeGreaterThan(0);
+
+ const result = await autumnV2_2.billing.attach({
+ customer_id: customerId,
+ plan_id: premium.id,
+ });
+
+ expect(result.invoice?.total).toBeCloseTo(preview.total, 0);
+ },
+ 300_000,
+);
diff --git a/server/tests/integration/billing/invoice-matched-credits/discount-coverage.test.ts b/server/tests/integration/billing/invoice-matched-credits/discount-coverage.test.ts
new file mode 100644
index 000000000..0cc9b1352
--- /dev/null
+++ b/server/tests/integration/billing/invoice-matched-credits/discount-coverage.test.ts
@@ -0,0 +1,282 @@
+import { expect, test } from "bun:test";
+import type { AttachPreviewResponse } from "@autumn/shared";
+import { TestFeature } from "@tests/setup/v2Features.js";
+import { hoursToFinalizeInvoice } from "@tests/utils/constants.js";
+import { items } from "@tests/utils/fixtures/items.js";
+import { products } from "@tests/utils/fixtures/products.js";
+import { advanceTestClock } from "@tests/utils/stripeUtils.js";
+import ctx from "@tests/utils/testInitUtils/createTestContext.js";
+import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
+import chalk from "chalk";
+import { addHours, addMonths } from "date-fns";
+import { createStripeCli } from "@/external/connect/createStripeCli.js";
+import { createPercentCoupon } from "../utils/discounts/discountTestUtils.js";
+
+test.concurrent(
+ `${chalk.yellowBright("invoice-matched-credits discount 1: catalog fallback when no stored row exists")}`,
+ async () => {
+ const customerId = "imc-disc-fallback";
+
+ const pro = products.pro({
+ id: "pro",
+ items: [items.monthlyMessages({ includedUsage: 500 })],
+ });
+
+ const premium = products.premium({
+ id: "premium",
+ items: [items.monthlyMessages({ includedUsage: 1000 })],
+ });
+
+ const { autumnV2_2 } = await initScenario({
+ customerId,
+ setup: [
+ s.customer({ testClock: true, paymentMethod: "success" }),
+ s.products({ list: [pro, premium] }),
+ ],
+ actions: [s.billing.attach({ productId: pro.id })],
+ });
+
+ const preview = (await autumnV2_2.billing.previewAttach({
+ customer_id: customerId,
+ plan_id: `premium_${customerId}`,
+ })) as AttachPreviewResponse;
+
+ expect(preview.line_items.length).toBeGreaterThan(0);
+ expect(preview.total).toBeDefined();
+
+ const creditLines = preview.line_items.filter((li) => li.total < 0);
+ expect(creditLines.length).toBeGreaterThan(0);
+
+ const creditTotal = creditLines.reduce((sum, li) => sum + li.total, 0);
+ expect(creditTotal).toBeLessThan(0);
+ expect(creditTotal).toBeGreaterThanOrEqual(-20);
+ },
+ 300_000,
+);
+
+test.concurrent(
+ `${chalk.yellowBright("invoice-matched-credits discount 2: discounted quantity decrease — refund based on stored discounted charge")}`,
+ async () => {
+ const customerId = "imc-disc-qty-dec";
+
+ const billingUnits = 100;
+ const pricePerPack = 10;
+
+ const prepaidMessages = items.prepaidMessages({
+ includedUsage: 0,
+ billingUnits,
+ price: pricePerPack,
+ });
+
+ const product = products.pro({
+ id: "prepaid-disc",
+ items: [prepaidMessages],
+ });
+
+ const initialQuantity = 500;
+ const decreasedQuantity = 200;
+
+ const { autumnV1, testClockId, advancedTo } = await initScenario({
+ customerId,
+ setup: [
+ s.customer({ testClock: true, paymentMethod: "success" }),
+ s.products({ list: [product] }),
+ ],
+ actions: [],
+ });
+
+ const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env });
+ const coupon = await createPercentCoupon({ stripeCli, percentOff: 20 });
+
+ await autumnV1.billing.attach({
+ customer_id: customerId,
+ product_id: `prepaid-disc_${customerId}`,
+ options: [
+ { feature_id: TestFeature.Messages, quantity: initialQuantity },
+ ],
+ discounts: [{ reward_id: coupon.id }],
+ });
+
+ await new Promise((resolve) => setTimeout(resolve, 5000));
+
+ const renewalTime = await advanceTestClock({
+ stripeCli: ctx.stripeCli,
+ testClockId: testClockId!,
+ advanceTo: addHours(
+ addMonths(new Date(advancedTo), 1),
+ hoursToFinalizeInvoice,
+ ).getTime(),
+ waitForSeconds: 30,
+ });
+
+ await advanceTestClock({
+ stripeCli: ctx.stripeCli,
+ testClockId: testClockId!,
+ startingFrom: new Date(renewalTime),
+ numberOfDays: 15,
+ });
+
+ const preview = await autumnV1.subscriptions.previewUpdate({
+ customer_id: customerId,
+ product_id: `prepaid-disc_${customerId}`,
+ options: [
+ {
+ feature_id: TestFeature.Messages,
+ quantity: decreasedQuantity,
+ },
+ ],
+ });
+
+ expect(preview.total).toBeLessThan(0);
+
+ const fullPriceRefundBound =
+ -((initialQuantity - decreasedQuantity) / billingUnits) * pricePerPack;
+ expect(preview.total).toBeGreaterThan(fullPriceRefundBound);
+ },
+ 300_000,
+);
+
+test.concurrent(
+ `${chalk.yellowBright("invoice-matched-credits discount 3: trial sibling with discount — credit reflects discounted charge")}`,
+ async () => {
+ const customerId = "imc-disc-trial-sib";
+
+ const pro = products.pro({
+ id: "pro",
+ items: [items.monthlyMessages({ includedUsage: 500 })],
+ });
+
+ const premiumTrial = products.premiumWithTrial({
+ id: "premium-trial",
+ items: [items.monthlyMessages({ includedUsage: 1000 })],
+ trialDays: 14,
+ cardRequired: false,
+ });
+
+ const { autumnV1, autumnV2_2, testClockId, advancedTo } =
+ await initScenario({
+ customerId,
+ setup: [
+ s.customer({ testClock: true, paymentMethod: "success" }),
+ s.products({ list: [pro, premiumTrial] }),
+ ],
+ actions: [],
+ });
+
+ const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env });
+ const coupon = await createPercentCoupon({ stripeCli, percentOff: 20 });
+
+ await autumnV1.billing.attach({
+ customer_id: customerId,
+ product_id: `pro_${customerId}`,
+ discounts: [{ reward_id: coupon.id }],
+ });
+
+ await new Promise((resolve) => setTimeout(resolve, 5000));
+
+ await advanceTestClock({
+ stripeCli: ctx.stripeCli,
+ testClockId: testClockId!,
+ advanceTo: addHours(
+ addMonths(new Date(advancedTo), 1),
+ hoursToFinalizeInvoice,
+ ).getTime(),
+ waitForSeconds: 30,
+ });
+
+ const preview = (await autumnV2_2.billing.previewAttach({
+ customer_id: customerId,
+ plan_id: `premium-trial_${customerId}`,
+ })) as AttachPreviewResponse;
+
+ const creditLines = preview.line_items.filter((li) => li.total < 0);
+ expect(creditLines.length).toBeGreaterThan(0);
+
+ const creditTotal = creditLines.reduce((sum, li) => sum + li.total, 0);
+ expect(creditTotal).toBeLessThan(0);
+ expect(creditTotal).toBeGreaterThan(-20);
+
+ await autumnV1.billing.attach({
+ customer_id: customerId,
+ product_id: `premium-trial_${customerId}`,
+ });
+
+ await new Promise((resolve) => setTimeout(resolve, 4000));
+ },
+ 300_000,
+);
+
+test.concurrent(
+ `${chalk.yellowBright("invoice-matched-credits discount 4: discounted credit magnitude bounded by stored charge")}`,
+ async () => {
+ const customerId = "imc-disc-no-double";
+
+ const pro = products.pro({
+ id: "pro",
+ items: [items.monthlyMessages({ includedUsage: 500 })],
+ });
+
+ const premium = products.premium({
+ id: "premium",
+ items: [items.monthlyMessages({ includedUsage: 1000 })],
+ });
+
+ const { autumnV1, autumnV2_2, testClockId, advancedTo } =
+ await initScenario({
+ customerId,
+ setup: [
+ s.customer({ testClock: true, paymentMethod: "success" }),
+ s.products({ list: [pro, premium] }),
+ ],
+ actions: [],
+ });
+
+ const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env });
+ const coupon = await createPercentCoupon({ stripeCli, percentOff: 20 });
+
+ await autumnV1.billing.attach({
+ customer_id: customerId,
+ product_id: `pro_${customerId}`,
+ discounts: [{ reward_id: coupon.id }],
+ });
+
+ await new Promise((resolve) => setTimeout(resolve, 5000));
+
+ const renewalTime = await advanceTestClock({
+ stripeCli: ctx.stripeCli,
+ testClockId: testClockId!,
+ advanceTo: addHours(
+ addMonths(new Date(advancedTo), 1),
+ hoursToFinalizeInvoice,
+ ).getTime(),
+ waitForSeconds: 30,
+ });
+
+ await advanceTestClock({
+ stripeCli: ctx.stripeCli,
+ testClockId: testClockId!,
+ startingFrom: new Date(renewalTime),
+ numberOfDays: 15,
+ });
+
+ const preview = (await autumnV2_2.billing.previewAttach({
+ customer_id: customerId,
+ plan_id: `premium_${customerId}`,
+ })) as AttachPreviewResponse;
+
+ const creditLines = preview.line_items.filter((li) => li.total < 0);
+ expect(creditLines.length).toBeGreaterThan(0);
+
+ const creditTotal = creditLines.reduce((sum, li) => sum + li.total, 0);
+ expect(creditTotal).toBeLessThan(0);
+ expect(creditTotal).toBeGreaterThan(-16.01);
+
+ const result = await autumnV2_2.billing.attach({
+ customer_id: customerId,
+ plan_id: `premium_${customerId}`,
+ });
+
+ expect(result.invoice?.total).toBeCloseTo(preview.total, 0);
+ },
+ 300_000,
+);
diff --git a/server/tests/integration/billing/invoice-matched-credits/downgrades.test.ts b/server/tests/integration/billing/invoice-matched-credits/downgrades.test.ts
new file mode 100644
index 000000000..0f22785ef
--- /dev/null
+++ b/server/tests/integration/billing/invoice-matched-credits/downgrades.test.ts
@@ -0,0 +1,171 @@
+/**
+ * Invoice-Matched Proration Credits — Downgrade Tests
+ *
+ * Verifies that scheduled downgrade previews source outgoing credits from
+ * stored invoice line items (actual charged amounts) rather than catalog prices.
+ *
+ * - With discount: outgoing credit reflects the discounted charge ($40, not $50)
+ * - Without discount: outgoing credit reflects the full catalog charge ($50)
+ */
+
+import { expect, test } from "bun:test";
+import type { ApiCustomerV3 } from "@autumn/shared";
+import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
+import { expectPreviewNextCycleCorrect } from "@tests/integration/billing/utils/expectPreviewNextCycleCorrect";
+import { items } from "@tests/utils/fixtures/items";
+import { products } from "@tests/utils/fixtures/products";
+import { advanceTestClock } from "@tests/utils/stripeUtils";
+import { advanceToNextInvoice } from "@tests/utils/testAttachUtils/testAttachUtils";
+import ctx from "@tests/utils/testInitUtils/createTestContext";
+import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
+import chalk from "chalk";
+import { createStripeCli } from "@/external/connect/createStripeCli";
+import { createPercentCoupon } from "../utils/discounts/discountTestUtils";
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// TEST 1: Scheduled downgrade with discount — outgoing credit reflects discounted price
+// ═══════════════════════════════════════════════════════════════════════════════
+
+test.concurrent(
+ `${chalk.yellowBright("invoice-matched-credits downgrade 1: scheduled downgrade with discount — next_cycle outgoing credit reflects discounted price")}`,
+ async () => {
+ const customerId = "imc-down-disc";
+
+ const premium = products.premium({
+ id: "premium",
+ items: [items.monthlyMessages({ includedUsage: 1000 })],
+ });
+
+ const pro = products.pro({
+ id: "pro",
+ items: [items.monthlyMessages({ includedUsage: 500 })],
+ });
+
+ const { autumnV1, autumnV2_2, testClockId, advancedTo } =
+ await initScenario({
+ customerId,
+ setup: [
+ s.customer({ paymentMethod: "success" }),
+ s.products({ list: [premium, pro] }),
+ ],
+ actions: [],
+ });
+
+ const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env });
+ const coupon = await createPercentCoupon({
+ stripeCli,
+ percentOff: 20,
+ });
+
+ await autumnV1.billing.attach({
+ customer_id: customerId,
+ product_id: premium.id,
+ discounts: [{ reward_id: coupon.id }],
+ });
+
+ await new Promise((resolve) => setTimeout(resolve, 5000));
+
+ const customerAfterAttach =
+ await autumnV1.customers.get(customerId);
+ await expectCustomerInvoiceCorrect({
+ customer: customerAfterAttach,
+ count: 1,
+ latestTotal: 40,
+ });
+
+ const renewedAt = await advanceToNextInvoice({
+ stripeCli: ctx.stripeCli,
+ testClockId: testClockId!,
+ currentEpochMs: advancedTo,
+ });
+
+ const customerAfterRenewal =
+ await autumnV1.customers.get(customerId);
+ await expectCustomerInvoiceCorrect({
+ customer: customerAfterRenewal,
+ count: 2,
+ latestTotal: 40,
+ });
+
+ await advanceTestClock({
+ stripeCli: ctx.stripeCli,
+ testClockId: testClockId!,
+ startingFrom: new Date(renewedAt),
+ numberOfDays: 5,
+ });
+
+ const preview = await autumnV2_2.billing.previewAttach({
+ customer_id: customerId,
+ plan_id: `pro_${customerId}`,
+ });
+
+ expect(preview.total).toBe(0);
+
+ const nextCycle = expectPreviewNextCycleCorrect({
+ preview,
+ expectDefined: true,
+ })!;
+
+ expect(nextCycle.total).toBeLessThan(50);
+ expect(nextCycle.total).toBeGreaterThan(0);
+ },
+ 300_000,
+);
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// TEST 2: Scheduled downgrade without discount — outgoing credit reflects full price
+// ═══════════════════════════════════════════════════════════════════════════════
+
+test.concurrent(
+ `${chalk.yellowBright("invoice-matched-credits downgrade 2: scheduled downgrade without discount — next_cycle outgoing credit reflects full price")}`,
+ async () => {
+ const customerId = "imc-down-full";
+
+ const premium = products.premium({
+ id: "premium",
+ items: [items.monthlyMessages({ includedUsage: 1000 })],
+ });
+
+ const pro = products.pro({
+ id: "pro",
+ items: [items.monthlyMessages({ includedUsage: 500 })],
+ });
+
+ const { autumnV1, autumnV2_2 } = await initScenario({
+ customerId,
+ setup: [
+ s.customer({ paymentMethod: "success" }),
+ s.products({ list: [premium, pro] }),
+ ],
+ actions: [
+ s.billing.attach({ productId: premium.id }),
+ s.advanceToNextInvoice(),
+ s.advanceTestClock({ days: 5 }),
+ ],
+ });
+
+ const customerAfterRenewal =
+ await autumnV1.customers.get(customerId);
+ await expectCustomerInvoiceCorrect({
+ customer: customerAfterRenewal,
+ count: 2,
+ latestTotal: 50,
+ });
+
+ const preview = await autumnV2_2.billing.previewAttach({
+ customer_id: customerId,
+ plan_id: `pro_${customerId}`,
+ });
+
+ expect(preview.total).toBe(0);
+
+ const nextCycle = expectPreviewNextCycleCorrect({
+ preview,
+ expectDefined: true,
+ })!;
+
+ expect(nextCycle.total).toBeLessThan(50);
+ expect(nextCycle.total).toBeGreaterThan(0);
+ },
+ 300_000,
+);
diff --git a/server/tests/integration/billing/invoice-matched-credits/edge-cases.test.ts b/server/tests/integration/billing/invoice-matched-credits/edge-cases.test.ts
new file mode 100644
index 000000000..5c2f18af3
--- /dev/null
+++ b/server/tests/integration/billing/invoice-matched-credits/edge-cases.test.ts
@@ -0,0 +1,281 @@
+import { expect, test } from "bun:test";
+import type { ApiCustomerV3 } from "@autumn/shared";
+import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect.js";
+import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect.js";
+import {
+ expectCustomerProducts,
+ expectProductActive,
+} from "@tests/integration/billing/utils/expectCustomerProductCorrect.js";
+import { TestFeature } from "@tests/setup/v2Features.js";
+import { items } from "@tests/utils/fixtures/items.js";
+import { products } from "@tests/utils/fixtures/products.js";
+import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
+import chalk from "chalk";
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// TEST 1: Catalog fallback when no stored row exists
+// ═══════════════════════════════════════════════════════════════════════════════
+
+test.concurrent(
+ `${chalk.yellowBright("invoice-matched edge 1: catalog fallback when no stored row exists")}`,
+ async () => {
+ const customerId = "inv-match-edge-fallback";
+
+ const pro = products.pro({
+ id: "pro",
+ items: [items.monthlyMessages({ includedUsage: 500 })],
+ });
+
+ const premium = products.premium({
+ id: "premium",
+ items: [items.monthlyMessages({ includedUsage: 1000 })],
+ });
+
+ const { autumnV1 } = await initScenario({
+ customerId,
+ setup: [
+ s.customer({ paymentMethod: "success" }),
+ s.products({ list: [pro, premium] }),
+ ],
+ actions: [s.billing.attach({ productId: pro.id })],
+ });
+
+ const upgradeResult = await autumnV1.billing.attach({
+ customer_id: customerId,
+ product_id: premium.id,
+ });
+
+ expect(upgradeResult).toBeDefined();
+
+ const customer = await autumnV1.customers.get(customerId);
+
+ await expectCustomerProducts({
+ customer,
+ active: [premium.id],
+ notPresent: [pro.id],
+ });
+
+ expectCustomerFeatureCorrect({
+ customer,
+ featureId: TestFeature.Messages,
+ includedUsage: 1000,
+ balance: 1000,
+ usage: 0,
+ });
+
+ await expectCustomerInvoiceCorrect({
+ customer,
+ count: 2,
+ });
+ },
+ 300_000,
+);
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// TEST 2: Multi-attach with outgoing credit
+// ═══════════════════════════════════════════════════════════════════════════════
+
+test.concurrent(
+ `${chalk.yellowBright("invoice-matched edge 2: multi-attach with outgoing credit from stored charge")}`,
+ async () => {
+ const customerId = "inv-match-edge-multi";
+
+ const pro = products.pro({
+ id: "pro",
+ items: [items.monthlyMessages({ includedUsage: 500 })],
+ });
+
+ const premium = products.premium({
+ id: "premium",
+ items: [items.monthlyMessages({ includedUsage: 1000 })],
+ });
+
+ const addon = products.recurringAddOn({
+ id: "addon",
+ items: [items.monthlyWords({ includedUsage: 200 })],
+ });
+
+ const { autumnV1, advancedTo } = await initScenario({
+ customerId,
+ setup: [
+ s.customer({ paymentMethod: "success", testClock: true }),
+ s.products({ list: [pro, premium, addon] }),
+ ],
+ actions: [
+ s.billing.attach({ productId: pro.id }),
+ s.advanceTestClock({ months: 1 }),
+ s.advanceTestClock({ days: 15 }),
+ ],
+ });
+
+ const customerBefore =
+ await autumnV1.customers.get(customerId);
+ const invoiceCountBefore = customerBefore.invoices?.length ?? 0;
+
+ const preview = await autumnV1.billing.previewMultiAttach({
+ customer_id: customerId,
+ plans: [{ plan_id: premium.id }, { plan_id: addon.id }],
+ });
+
+ expect(preview.total).toBeDefined();
+ expect(preview.outgoing.length).toBeGreaterThanOrEqual(1);
+
+ const outgoingPro = preview.outgoing.find((c: { plan_id: string }) => c.plan_id === pro.id);
+ expect(outgoingPro).toBeDefined();
+
+ await autumnV1.billing.multiAttach({
+ customer_id: customerId,
+ plans: [{ plan_id: premium.id }, { plan_id: addon.id }],
+ });
+
+ const customer = await autumnV1.customers.get(customerId);
+
+ await expectCustomerProducts({
+ customer,
+ active: [premium.id, addon.id],
+ notPresent: [pro.id],
+ });
+
+ expectCustomerFeatureCorrect({
+ customer,
+ featureId: TestFeature.Messages,
+ includedUsage: 1000,
+ });
+
+ expectCustomerFeatureCorrect({
+ customer,
+ featureId: TestFeature.Words,
+ includedUsage: 200,
+ });
+
+ await expectCustomerInvoiceCorrect({
+ customer,
+ count: invoiceCountBefore + 1,
+ });
+
+ const latestInvoice = customer.invoices?.[0];
+ expect(latestInvoice).toBeDefined();
+ expect(latestInvoice!.total).toBeDefined();
+
+ expect(latestInvoice!.total).toBeLessThan(70);
+ },
+ 300_000,
+);
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// TEST 3: No-op re-attach — filterUnchangedPrices cancels
+// ═══════════════════════════════════════════════════════════════════════════════
+
+test.concurrent(
+ `${chalk.yellowBright("invoice-matched edge 3: no-op re-attach — filterUnchangedPrices cancels")}`,
+ async () => {
+ const customerId = "inv-match-edge-noop";
+
+ const pro = products.pro({
+ id: "pro",
+ items: [items.monthlyMessages({ includedUsage: 500 })],
+ });
+
+ const { autumnV1 } = await initScenario({
+ customerId,
+ setup: [
+ s.customer({ paymentMethod: "success", testClock: true }),
+ s.products({ list: [pro] }),
+ ],
+ actions: [
+ s.billing.attach({ productId: pro.id }),
+ s.advanceTestClock({ months: 1 }),
+ ],
+ });
+
+ await expectProductActive({
+ customer: await autumnV1.customers.get(customerId),
+ productId: pro.id,
+ });
+
+ let threw = false;
+ try {
+ await autumnV1.billing.previewAttach({
+ customer_id: customerId,
+ product_id: pro.id,
+ });
+ } catch (err: any) {
+ threw = true;
+ expect(err.code).toBe("plan_already_attached");
+ }
+ expect(threw).toBe(true);
+ },
+ 300_000,
+);
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// TEST 4: Preview/execute rounding parity on upgrade
+// ═══════════════════════════════════════════════════════════════════════════════
+
+test.concurrent(
+ `${chalk.yellowBright("invoice-matched edge 4: preview/execute rounding parity on mid-cycle upgrade")}`,
+ async () => {
+ const customerId = "inv-match-edge-rounding";
+
+ const pro = products.pro({
+ id: "pro",
+ items: [items.monthlyMessages({ includedUsage: 500 })],
+ });
+
+ const premium = products.premium({
+ id: "premium",
+ items: [items.monthlyMessages({ includedUsage: 1000 })],
+ });
+
+ const { autumnV1, advancedTo } = await initScenario({
+ customerId,
+ setup: [
+ s.customer({ paymentMethod: "success", testClock: true }),
+ s.products({ list: [pro, premium] }),
+ ],
+ actions: [
+ s.billing.attach({ productId: pro.id }),
+ s.advanceTestClock({ months: 1 }),
+ s.advanceTestClock({ days: 15 }),
+ ],
+ });
+
+ const customerBefore =
+ await autumnV1.customers.get(customerId);
+ const invoiceCountBefore = customerBefore.invoices?.length ?? 0;
+
+ const preview = await autumnV1.billing.previewAttach({
+ customer_id: customerId,
+ product_id: premium.id,
+ });
+
+ expect(preview.total).toBeDefined();
+ expect(preview.total).toBeGreaterThan(0);
+
+ await autumnV1.billing.attach({
+ customer_id: customerId,
+ product_id: premium.id,
+ });
+
+ const customer = await autumnV1.customers.get(customerId);
+
+ await expectCustomerProducts({
+ customer,
+ active: [premium.id],
+ notPresent: [pro.id],
+ });
+
+ await expectCustomerInvoiceCorrect({
+ customer,
+ count: invoiceCountBefore + 1,
+ });
+
+ const latestInvoice = customer.invoices?.[0];
+ expect(latestInvoice).toBeDefined();
+ expect(latestInvoice!.total).toBeCloseTo(preview.total, 0);
+
+ const diff = Math.abs(latestInvoice!.total - preview.total);
+ expect(diff).toBeLessThanOrEqual(0.01);
+ },
+ 300_000,
+);
diff --git a/server/tests/integration/billing/invoice-matched-credits/entities.test.ts b/server/tests/integration/billing/invoice-matched-credits/entities.test.ts
new file mode 100644
index 000000000..7f8d7a501
--- /dev/null
+++ b/server/tests/integration/billing/invoice-matched-credits/entities.test.ts
@@ -0,0 +1,203 @@
+import { expect, test } from "bun:test";
+import type { AttachPreviewResponse } from "@autumn/shared";
+import { TestFeature } from "@tests/setup/v2Features.js";
+import { hoursToFinalizeInvoice } from "@tests/utils/constants.js";
+import { items } from "@tests/utils/fixtures/items.js";
+import { products } from "@tests/utils/fixtures/products.js";
+import { advanceTestClock } from "@tests/utils/stripeUtils.js";
+import ctx from "@tests/utils/testInitUtils/createTestContext.js";
+import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
+import chalk from "chalk";
+import { addHours, addMonths } from "date-fns";
+import { createStripeCli } from "@/external/connect/createStripeCli.js";
+import { createPercentCoupon } from "../utils/discounts/discountTestUtils.js";
+
+test.concurrent(
+ `${chalk.yellowBright("invoice-matched-credits entities 1: single entity upgrade — credit from stored charge")}`,
+ async () => {
+ const customerId = "imc-ent-single-upg";
+
+ const pro = products.pro({
+ id: "pro",
+ items: [items.monthlyMessages({ includedUsage: 500 })],
+ });
+
+ const premium = products.premium({
+ id: "premium",
+ items: [items.monthlyMessages({ includedUsage: 1000 })],
+ });
+
+ const { autumnV1, autumnV2_2, entities, testClockId, advancedTo } =
+ await initScenario({
+ customerId,
+ setup: [
+ s.customer({ testClock: true, paymentMethod: "success" }),
+ s.products({ list: [pro, premium] }),
+ s.entities({ count: 1, featureId: TestFeature.Users }),
+ ],
+ actions: [
+ s.billing.attach({ productId: pro.id, entityIndex: 0 }),
+ ],
+ });
+
+ const renewalTime = await advanceTestClock({
+ stripeCli: ctx.stripeCli,
+ testClockId: testClockId!,
+ advanceTo: addHours(
+ addMonths(new Date(advancedTo), 1),
+ hoursToFinalizeInvoice,
+ ).getTime(),
+ waitForSeconds: 30,
+ });
+
+ await advanceTestClock({
+ stripeCli: ctx.stripeCli,
+ testClockId: testClockId!,
+ startingFrom: new Date(renewalTime),
+ numberOfDays: 15,
+ });
+
+ const preview = (await autumnV2_2.billing.previewAttach({
+ customer_id: customerId,
+ plan_id: `premium_${customerId}`,
+ entity_id: entities[0].id,
+ })) as AttachPreviewResponse;
+
+ const creditLines = preview.line_items.filter((li) => li.total < 0);
+ expect(creditLines.length).toBeGreaterThan(0);
+
+ const creditTotal = creditLines.reduce((sum, li) => sum + li.total, 0);
+ expect(creditTotal).toBeLessThan(0);
+ expect(creditTotal).toBeGreaterThan(-20);
+
+ expect(preview.total).toBeDefined();
+ },
+ 300_000,
+);
+
+test.concurrent(
+ `${chalk.yellowBright("invoice-matched-credits entities 2: entity with discount — credit reflects discounted amount")}`,
+ async () => {
+ const customerId = "imc-ent-disc-upg";
+
+ const pro = products.pro({
+ id: "pro",
+ items: [items.monthlyMessages({ includedUsage: 500 })],
+ });
+
+ const premium = products.premium({
+ id: "premium",
+ items: [items.monthlyMessages({ includedUsage: 1000 })],
+ });
+
+ const { autumnV1, autumnV2_2, entities, testClockId, advancedTo } =
+ await initScenario({
+ customerId,
+ setup: [
+ s.customer({ testClock: true, paymentMethod: "success" }),
+ s.products({ list: [pro, premium] }),
+ s.entities({ count: 1, featureId: TestFeature.Users }),
+ ],
+ actions: [],
+ });
+
+ const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env });
+ const coupon = await createPercentCoupon({ stripeCli, percentOff: 20 });
+
+ await autumnV1.billing.attach({
+ customer_id: customerId,
+ product_id: `pro_${customerId}`,
+ entity_id: entities[0].id,
+ discounts: [{ reward_id: coupon.id }],
+ });
+
+ await new Promise((resolve) => setTimeout(resolve, 5000));
+
+ const renewalTime = await advanceTestClock({
+ stripeCli: ctx.stripeCli,
+ testClockId: testClockId!,
+ advanceTo: addHours(
+ addMonths(new Date(advancedTo), 1),
+ hoursToFinalizeInvoice,
+ ).getTime(),
+ waitForSeconds: 30,
+ });
+
+ await advanceTestClock({
+ stripeCli: ctx.stripeCli,
+ testClockId: testClockId!,
+ startingFrom: new Date(renewalTime),
+ numberOfDays: 15,
+ });
+
+ const preview = (await autumnV2_2.billing.previewAttach({
+ customer_id: customerId,
+ plan_id: `premium_${customerId}`,
+ entity_id: entities[0].id,
+ })) as AttachPreviewResponse;
+
+ const creditLines = preview.line_items.filter((li) => li.total < 0);
+ expect(creditLines.length).toBeGreaterThan(0);
+
+ const creditTotal = creditLines.reduce((sum, li) => sum + li.total, 0);
+ expect(creditTotal).toBeLessThan(0);
+ expect(creditTotal).toBeGreaterThan(-16.01);
+ },
+ 300_000,
+);
+
+test.concurrent(
+ `${chalk.yellowBright("invoice-matched-credits entities 3: entity add mid-cycle — no credit for new entity")}`,
+ async () => {
+ const customerId = "imc-ent-add-midcycle";
+
+ const pro = products.pro({
+ id: "pro",
+ items: [items.monthlyMessages({ includedUsage: 500 })],
+ });
+
+ const { autumnV1, entities, testClockId, advancedTo } =
+ await initScenario({
+ customerId,
+ setup: [
+ s.customer({ testClock: true, paymentMethod: "success" }),
+ s.products({ list: [pro] }),
+ s.entities({ count: 2, featureId: TestFeature.Users }),
+ ],
+ actions: [
+ s.billing.attach({ productId: pro.id, entityIndex: 0 }),
+ ],
+ });
+
+ const renewalTime = await advanceTestClock({
+ stripeCli: ctx.stripeCli,
+ testClockId: testClockId!,
+ advanceTo: addHours(
+ addMonths(new Date(advancedTo), 1),
+ hoursToFinalizeInvoice,
+ ).getTime(),
+ waitForSeconds: 30,
+ });
+
+ await advanceTestClock({
+ stripeCli: ctx.stripeCli,
+ testClockId: testClockId!,
+ startingFrom: new Date(renewalTime),
+ numberOfDays: 15,
+ });
+
+ const result = await autumnV1.billing.attach({
+ customer_id: customerId,
+ product_id: `pro_${customerId}`,
+ entity_id: entities[1].id,
+ });
+
+ const creditLines = (result.invoice?.line_items ?? []).filter(
+ (li: { total: number }) => li.total < 0,
+ );
+ expect(creditLines.length).toBe(0);
+
+ expect(result.invoice?.total).toBeGreaterThanOrEqual(0);
+ },
+ 300_000,
+);
diff --git a/server/tests/integration/billing/invoice-matched-credits/multi-attach.test.ts b/server/tests/integration/billing/invoice-matched-credits/multi-attach.test.ts
new file mode 100644
index 000000000..56c6c09e3
--- /dev/null
+++ b/server/tests/integration/billing/invoice-matched-credits/multi-attach.test.ts
@@ -0,0 +1,157 @@
+import { expect, test } from "bun:test";
+import type { ApiCustomerV3 } from "@autumn/shared";
+import {
+ expectCustomerProducts,
+ expectProductActive,
+} from "@tests/integration/billing/utils/expectCustomerProductCorrect.js";
+import { hoursToFinalizeInvoice } from "@tests/utils/constants.js";
+import { items } from "@tests/utils/fixtures/items.js";
+import { products } from "@tests/utils/fixtures/products.js";
+import { advanceTestClock } from "@tests/utils/stripeUtils.js";
+import ctx from "@tests/utils/testInitUtils/createTestContext.js";
+import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
+import chalk from "chalk";
+import { addHours, addMonths } from "date-fns";
+import { createStripeCli } from "@/external/connect/createStripeCli.js";
+import { createPercentCoupon } from "../utils/discounts/discountTestUtils.js";
+
+test.concurrent(
+ `${chalk.yellowBright("invoice-matched-credits multi-attach 1: discounted outgoing — credit reflects stored charge")}`,
+ async () => {
+ const customerId = "imc-multi-disc-out";
+
+ const pro = products.pro({
+ id: "pro",
+ items: [items.monthlyMessages({ includedUsage: 500 })],
+ });
+
+ const premium = products.premium({
+ id: "premium",
+ items: [items.monthlyMessages({ includedUsage: 1000 })],
+ });
+
+ const addon = products.recurringAddOn({
+ id: "addon",
+ items: [items.monthlyWords({ includedUsage: 200 })],
+ });
+
+ const { autumnV1, testClockId, advancedTo } = await initScenario({
+ customerId,
+ setup: [
+ s.customer({ testClock: true, paymentMethod: "success" }),
+ s.products({ list: [pro, premium, addon] }),
+ ],
+ actions: [],
+ });
+
+ const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env });
+ const coupon = await createPercentCoupon({ stripeCli, percentOff: 20 });
+
+ await autumnV1.billing.attach({
+ customer_id: customerId,
+ product_id: `pro_${customerId}`,
+ discounts: [{ reward_id: coupon.id }],
+ });
+
+ await new Promise((resolve) => setTimeout(resolve, 5000));
+
+ const renewalTime = await advanceTestClock({
+ stripeCli: ctx.stripeCli,
+ testClockId: testClockId!,
+ advanceTo: addHours(
+ addMonths(new Date(advancedTo), 1),
+ hoursToFinalizeInvoice,
+ ).getTime(),
+ waitForSeconds: 30,
+ });
+
+ await advanceTestClock({
+ stripeCli: ctx.stripeCli,
+ testClockId: testClockId!,
+ startingFrom: new Date(renewalTime),
+ numberOfDays: 15,
+ });
+
+ const preview = await autumnV1.billing.previewMultiAttach({
+ customer_id: customerId,
+ plans: [{ plan_id: `premium_${customerId}` }, { plan_id: `addon_${customerId}` }],
+ });
+
+ expect(preview.total).toBeDefined();
+ expect(preview.outgoing.length).toBeGreaterThanOrEqual(1);
+
+ const outgoingPro = preview.outgoing.find(
+ (c: { plan_id: string }) => c.plan_id === `pro_${customerId}`,
+ );
+ expect(outgoingPro).toBeDefined();
+
+ const catalogTotal = 50 + 20;
+ expect(preview.total).toBeLessThan(catalogTotal);
+
+ await autumnV1.billing.multiAttach({
+ customer_id: customerId,
+ plans: [{ plan_id: `premium_${customerId}` }, { plan_id: `addon_${customerId}` }],
+ });
+
+ const customer = await autumnV1.customers.get(customerId);
+
+ await expectCustomerProducts({
+ customer,
+ active: [`premium_${customerId}`, `addon_${customerId}`],
+ notPresent: [`pro_${customerId}`],
+ });
+ },
+ 300_000,
+);
+
+test.concurrent(
+ `${chalk.yellowBright("invoice-matched-credits multi-attach 2: add-only — no credit lines")}`,
+ async () => {
+ const customerId = "imc-multi-add-only";
+
+ const pro = products.pro({
+ id: "pro",
+ items: [items.monthlyMessages({ includedUsage: 500 })],
+ });
+
+ const addon = products.recurringAddOn({
+ id: "addon",
+ items: [items.monthlyWords({ includedUsage: 200 })],
+ });
+
+ const { autumnV1 } = await initScenario({
+ customerId,
+ setup: [
+ s.customer({ testClock: true, paymentMethod: "success" }),
+ s.products({ list: [pro, addon] }),
+ ],
+ actions: [s.billing.attach({ productId: pro.id })],
+ });
+
+ await new Promise((resolve) => setTimeout(resolve, 5000));
+
+ await autumnV1.billing.attach({
+ customer_id: customerId,
+ product_id: `addon_${customerId}`,
+ });
+
+ await new Promise((resolve) => setTimeout(resolve, 5000));
+
+ const customer = await autumnV1.customers.get(customerId);
+
+ await expectProductActive({
+ customer,
+ productId: `pro_${customerId}`,
+ });
+
+ await expectProductActive({
+ customer,
+ productId: `addon_${customerId}`,
+ });
+
+ const latestInvoice = customer.invoices?.[0];
+ expect(latestInvoice).toBeDefined();
+ expect(latestInvoice!.total).toBeGreaterThanOrEqual(0);
+ },
+ 300_000,
+);
diff --git a/server/tests/integration/billing/invoice-matched-credits/quantity.test.ts b/server/tests/integration/billing/invoice-matched-credits/quantity.test.ts
new file mode 100644
index 000000000..305dcd757
--- /dev/null
+++ b/server/tests/integration/billing/invoice-matched-credits/quantity.test.ts
@@ -0,0 +1,197 @@
+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 { advanceTestClock } from "@tests/utils/stripeUtils.js";
+import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
+import chalk from "chalk";
+
+const BILLING_UNITS = 12;
+const PRICE_PER_UNIT = 8;
+
+test.concurrent(
+ `${chalk.yellowBright("invoice-matched qty 1: prepaid quantity decrease — credit from stored charge")}`,
+ async () => {
+ const customerId = "inv-match-qty-decrease";
+
+ const product = products.base({
+ id: "prepaid",
+ items: [
+ items.prepaid({
+ featureId: TestFeature.Messages,
+ billingUnits: BILLING_UNITS,
+ price: PRICE_PER_UNIT,
+ }),
+ ],
+ });
+
+ const { autumnV1, testClockId, ctx } = await initScenario({
+ customerId,
+ setup: [
+ s.customer({ testClock: true, paymentMethod: "success" }),
+ s.products({ list: [product] }),
+ ],
+ actions: [
+ s.attach({
+ productId: product.id,
+ options: [
+ { feature_id: TestFeature.Messages, quantity: 20 * BILLING_UNITS },
+ ],
+ }),
+ ],
+ });
+
+ // Advance a full cycle (clean renewal charge stored) then mid-cycle.
+ let advancedTo = await advanceTestClock({
+ stripeCli: ctx.stripeCli,
+ testClockId: testClockId as string,
+ numberOfMonths: 1,
+ waitForSeconds: 30,
+ });
+ advancedTo = await advanceTestClock({
+ stripeCli: ctx.stripeCli,
+ testClockId: testClockId as string,
+ startingFrom: new Date(advancedTo),
+ numberOfDays: 15,
+ waitForSeconds: 20,
+ });
+
+ const customerBefore = await autumnV1.customers.get(
+ customerId,
+ );
+ const invoiceCountBefore = customerBefore.invoices?.length ?? 0;
+
+ const preview = await autumnV1.subscriptions.previewUpdate({
+ customer_id: customerId,
+ product_id: product.id,
+ options: [{ feature_id: TestFeature.Messages, quantity: 5 * BILLING_UNITS }],
+ });
+
+ // Decreasing units mid-cycle yields a prorated credit.
+ expect(preview.total).toBeLessThan(0);
+
+ await autumnV1.subscriptions.update({
+ customer_id: customerId,
+ product_id: product.id,
+ options: [{ feature_id: TestFeature.Messages, quantity: 5 * BILLING_UNITS }],
+ });
+
+ const customer = await autumnV1.customers.get(customerId);
+
+ expect(customer.features?.[TestFeature.Messages]?.balance).toBe(
+ 5 * BILLING_UNITS,
+ );
+
+ // Preview must match what was actually invoiced (credit sourced from the
+ // stored renewal charge, not catalog re-synthesis).
+ expect(customer.invoices?.length ?? 0).toBe(invoiceCountBefore + 1);
+ const latestInvoice = customer.invoices?.[0];
+ expect(latestInvoice).toBeDefined();
+ expect(Math.abs(latestInvoice!.total - preview.total)).toBeLessThanOrEqual(
+ 0.01,
+ );
+ },
+ 300_000,
+);
+
+test.concurrent(
+ `${chalk.yellowBright("invoice-matched qty 2: prepaid decrease after mid-cycle increase — nets across stored charges")}`,
+ async () => {
+ const customerId = "inv-match-qty-netting";
+
+ const product = products.base({
+ id: "prepaid",
+ items: [
+ items.prepaid({
+ featureId: TestFeature.Messages,
+ billingUnits: BILLING_UNITS,
+ price: PRICE_PER_UNIT,
+ }),
+ ],
+ });
+
+ const { autumnV1, testClockId, ctx } = await initScenario({
+ customerId,
+ setup: [
+ s.customer({ testClock: true, paymentMethod: "success" }),
+ s.products({ list: [product] }),
+ ],
+ actions: [
+ s.attach({
+ productId: product.id,
+ options: [
+ { feature_id: TestFeature.Messages, quantity: 10 * BILLING_UNITS },
+ ],
+ }),
+ ],
+ });
+
+ // Renew so there is a full-period stored charge for the current cycle.
+ let advancedTo = await advanceTestClock({
+ stripeCli: ctx.stripeCli,
+ testClockId: testClockId as string,
+ numberOfMonths: 1,
+ waitForSeconds: 30,
+ });
+
+ // Mid-cycle increase: creates a SECOND stored charge row for this price.
+ advancedTo = await advanceTestClock({
+ stripeCli: ctx.stripeCli,
+ testClockId: testClockId as string,
+ startingFrom: new Date(advancedTo),
+ numberOfDays: 10,
+ waitForSeconds: 20,
+ });
+ await autumnV1.subscriptions.update({
+ customer_id: customerId,
+ product_id: product.id,
+ options: [
+ { feature_id: TestFeature.Messages, quantity: 20 * BILLING_UNITS },
+ ],
+ });
+
+ advancedTo = await advanceTestClock({
+ stripeCli: ctx.stripeCli,
+ testClockId: testClockId as string,
+ startingFrom: new Date(advancedTo),
+ numberOfDays: 10,
+ waitForSeconds: 20,
+ });
+
+ const customerBefore = await autumnV1.customers.get(
+ customerId,
+ );
+ const invoiceCountBefore = customerBefore.invoices?.length ?? 0;
+
+ const preview = await autumnV1.subscriptions.previewUpdate({
+ customer_id: customerId,
+ product_id: product.id,
+ options: [{ feature_id: TestFeature.Messages, quantity: 5 * BILLING_UNITS }],
+ });
+
+ expect(preview.total).toBeLessThan(0);
+
+ await autumnV1.subscriptions.update({
+ customer_id: customerId,
+ product_id: product.id,
+ options: [{ feature_id: TestFeature.Messages, quantity: 5 * BILLING_UNITS }],
+ });
+
+ const customer = await autumnV1.customers.get(customerId);
+
+ expect(customer.features?.[TestFeature.Messages]?.balance).toBe(
+ 5 * BILLING_UNITS,
+ );
+
+ // The credit must net both stored charge rows (renewal + mid-cycle increase);
+ // with single-row crediting this preview/execute parity would break.
+ expect(customer.invoices?.length ?? 0).toBe(invoiceCountBefore + 1);
+ const latestInvoice = customer.invoices?.[0];
+ expect(latestInvoice).toBeDefined();
+ expect(Math.abs(latestInvoice!.total - preview.total)).toBeLessThanOrEqual(
+ 0.01,
+ );
+ },
+ 300_000,
+);
diff --git a/server/tests/integration/billing/invoice-matched-credits/trials.test.ts b/server/tests/integration/billing/invoice-matched-credits/trials.test.ts
new file mode 100644
index 000000000..dfcf81f32
--- /dev/null
+++ b/server/tests/integration/billing/invoice-matched-credits/trials.test.ts
@@ -0,0 +1,252 @@
+/**
+ * Invoice-Matched Proration Credits — Trial Tests
+ *
+ * Verifies correct credit behavior when trials interact with the
+ * invoice-matched credit system:
+ *
+ * - Upgrade during trial: no credit (no stored charge for a $0 trial)
+ * - Paid product switched to trial sibling: paid product credited from stored charge
+ * - End trial: no refund-direction line items emitted
+ */
+
+import { expect, test } from "bun:test";
+import type { ApiCustomerV3 } from "@autumn/shared";
+import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
+import {
+ expectCustomerProducts,
+ expectProductActive,
+} from "@tests/integration/billing/utils/expectCustomerProductCorrect";
+import {
+ expectProductNotTrialing,
+ expectProductTrialing,
+} from "@tests/integration/billing/utils/expectCustomerProductTrialing";
+import { expectPreviewNextCycleCorrect } from "@tests/integration/billing/utils/expectPreviewNextCycleCorrect";
+import { items } from "@tests/utils/fixtures/items";
+import { products } from "@tests/utils/fixtures/products";
+import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
+import chalk from "chalk";
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// TEST 1: Upgrade during trial — no credit (trial product has no stored charge)
+// ═══════════════════════════════════════════════════════════════════════════════
+
+test.concurrent(
+ `${chalk.yellowBright("invoice-matched-credits trial 1: upgrade during trial — no credit for outgoing trial product")}`,
+ async () => {
+ const customerId = "imc-trial-upgrade";
+
+ const proTrial = products.proWithTrial({
+ id: "pro-trial",
+ items: [items.monthlyMessages({ includedUsage: 500 })],
+ trialDays: 14,
+ cardRequired: false,
+ });
+
+ const premium = products.premium({
+ id: "premium",
+ items: [items.monthlyMessages({ includedUsage: 1000 })],
+ });
+
+ const { autumnV1, autumnV2_2, advancedTo } = await initScenario({
+ customerId,
+ setup: [
+ s.customer({ paymentMethod: "success" }),
+ s.products({ list: [proTrial, premium] }),
+ ],
+ actions: [s.billing.attach({ productId: proTrial.id })],
+ });
+
+ const customerTrialing =
+ await autumnV1.customers.get(customerId);
+ await expectCustomerProducts({
+ customer: customerTrialing,
+ active: [proTrial.id],
+ });
+
+ await expectCustomerInvoiceCorrect({
+ customer: customerTrialing,
+ count: 1,
+ latestTotal: 0,
+ });
+
+ const preview = await autumnV2_2.billing.previewAttach({
+ customer_id: customerId,
+ plan_id: `premium_${customerId}`,
+ });
+
+ expect(preview.total).toBe(50);
+
+ const creditLines = preview.line_items.filter((li: { total: number }) => li.total < 0);
+ expect(creditLines.length).toBe(0);
+
+ await autumnV1.billing.attach({
+ customer_id: customerId,
+ product_id: premium.id,
+ });
+
+ const customerAfterUpgrade =
+ await autumnV1.customers.get(customerId);
+ await expectCustomerProducts({
+ customer: customerAfterUpgrade,
+ active: [premium.id],
+ notPresent: [proTrial.id],
+ });
+
+ await expectProductNotTrialing({
+ customer: customerAfterUpgrade,
+ productId: premium.id,
+ nowMs: advancedTo,
+ });
+
+ await expectCustomerInvoiceCorrect({
+ customer: customerAfterUpgrade,
+ count: 2,
+ latestTotal: 50,
+ });
+ },
+ 300_000,
+);
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// TEST 2: Paid product switched to trial sibling — sibling credited from stored charge
+// ═══════════════════════════════════════════════════════════════════════════════
+
+test.concurrent(
+ `${chalk.yellowBright("invoice-matched-credits trial 2: paid product switched to trial — credit from stored charge")}`,
+ async () => {
+ const customerId = "imc-trial-sibling";
+
+ const pro = products.pro({
+ id: "pro",
+ items: [items.monthlyMessages({ includedUsage: 500 })],
+ });
+
+ const premiumTrial = products.premiumWithTrial({
+ id: "premium-trial",
+ items: [items.monthlyMessages({ includedUsage: 1000 })],
+ trialDays: 14,
+ cardRequired: true,
+ });
+
+ const { autumnV1, autumnV2_2, advancedTo } = await initScenario({
+ customerId,
+ setup: [
+ s.customer({ paymentMethod: "success" }),
+ s.products({ list: [pro, premiumTrial] }),
+ ],
+ actions: [s.billing.attach({ productId: pro.id })],
+ });
+
+ const customerAfterAttach =
+ await autumnV1.customers.get