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(customerId); + await expectProductActive({ + customer: customerAfterAttach, + productId: pro.id, + }); + await expectCustomerInvoiceCorrect({ + customer: customerAfterAttach, + count: 1, + latestTotal: 20, + }); + + const preview = await autumnV2_2.billing.previewAttach({ + customer_id: customerId, + plan_id: `premium-trial_${customerId}`, + }); + + expect(preview.total).toBe(-20); + + const creditLines = preview.line_items.filter((li: { total: number }) => li.total < 0); + expect(creditLines.length).toBeGreaterThan(0); + + const creditTotal = creditLines.reduce((sum: number, li: { total: number }) => sum + li.total, 0); + expect(creditTotal).toBe(-20); + + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: premiumTrial.id, + }); + + await new Promise((resolve) => setTimeout(resolve, 4000)); + + const customerAfterSwitch = + await autumnV1.customers.get(customerId); + await expectCustomerProducts({ + customer: customerAfterSwitch, + active: [premiumTrial.id], + notPresent: [pro.id], + }); + + await expectProductTrialing({ + customer: customerAfterSwitch, + productId: premiumTrial.id, + trialEndsAt: advancedTo + 14 * 24 * 60 * 60 * 1000, + }); + }, + 300_000, +); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 3: End trial — no refund lines emitted +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent( + `${chalk.yellowBright("invoice-matched-credits trial 3: end trial — no refund-direction line items")}`, + async () => { + const customerId = "imc-trial-end"; + + const proTrial = products.proWithTrial({ + id: "pro-trial", + items: [items.monthlyMessages({ includedUsage: 500 })], + trialDays: 7, + cardRequired: true, + }); + + const { autumnV1, autumnV2_2, advancedTo } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [proTrial] }), + ], + actions: [s.billing.attach({ productId: proTrial.id })], + }); + + const customerTrialing = + await autumnV1.customers.get(customerId); + await expectProductTrialing({ + customer: customerTrialing, + productId: proTrial.id, + trialEndsAt: advancedTo + 7 * 24 * 60 * 60 * 1000, + }); + + await expectCustomerInvoiceCorrect({ + customer: customerTrialing, + count: 1, + latestTotal: 0, + }); + + const previewBeforeTrialEnd = await autumnV2_2.subscriptions.previewUpdate({ + customer_id: customerId, + product_id: proTrial.id, + recalculate_balances: { enabled: true }, + }); + + const refundLines = previewBeforeTrialEnd.line_items.filter( + (li: { total: number }) => li.total < 0, + ); + expect(refundLines.length).toBe(0); + + const nextCyclePreview = expectPreviewNextCycleCorrect({ + preview: previewBeforeTrialEnd, + expectDefined: true, + })!; + + const nextCycleRefundLines = nextCyclePreview.line_items.filter( + (li) => li.total < 0, + ); + expect(nextCycleRefundLines.length).toBe(0); + + expect(nextCyclePreview.total).toBeGreaterThanOrEqual(0); + }, + 300_000, +); diff --git a/server/tests/integration/billing/invoice-matched-credits/upgrades.test.ts b/server/tests/integration/billing/invoice-matched-credits/upgrades.test.ts new file mode 100644 index 000000000..e7f645049 --- /dev/null +++ b/server/tests/integration/billing/invoice-matched-credits/upgrades.test.ts @@ -0,0 +1,355 @@ +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 { + createAmountCoupon, + createPercentCoupon, +} from "../utils/discounts/discountTestUtils.js"; + +test.concurrent( + `${chalk.yellowBright("invoice-matched-credits upgrade 1: percent-off forever discount — credit reflects discounted price")}`, + async () => { + const customerId = "inv-cred-upg-pct"; + + 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).toBeCloseTo(-8, 0); + + 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, +); + +test.concurrent( + `${chalk.yellowBright("invoice-matched-credits upgrade 2: no discount — credit reflects full price")}`, + async () => { + const customerId = "inv-cred-upg-full"; + + 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 }), + s.advanceTestClock({ toNextInvoice: true }), + s.advanceTestClock({ days: 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(-20); + + const result = await autumnV2_2.billing.attach({ + customer_id: customerId, + plan_id: `premium_${customerId}`, + }); + + expect(result.invoice?.total).toBeCloseTo(preview.total, 0); + }, + 300_000, +); + +test.concurrent( + `${chalk.yellowBright("invoice-matched-credits upgrade 3: amount-off coupon — credit reflects discounted price")}`, + async () => { + const customerId = "inv-cred-upg-amt"; + + 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: 500, + }); + + 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(-15); + + const result = await autumnV2_2.billing.attach({ + customer_id: customerId, + plan_id: `premium_${customerId}`, + }); + + expect(result.invoice?.total).toBeCloseTo(preview.total, 0); + }, + 300_000, +); + +test.concurrent( + `${chalk.yellowBright("invoice-matched-credits upgrade 4: at cycle start — full credit equals full charged amount")}`, + async () => { + const customerId = "inv-cred-upg-start"; + + 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)); + + 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_${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.01); + + const result = await autumnV2_2.billing.attach({ + customer_id: customerId, + plan_id: `premium_${customerId}`, + }); + + expect(Math.abs((result.invoice?.total ?? 0) - preview.total)).toBeLessThan( + 2, + ); + }, + 300_000, +); + +test.concurrent( + `${chalk.yellowBright("invoice-matched-credits upgrade 5: upgrade twice in one period — second upgrade nets prior refund")}`, + async () => { + const customerId = "inv-cred-upg-twice"; + + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const premium = products.premium({ + id: "premium", + items: [items.monthlyMessages({ includedUsage: 1000 })], + }); + + const growth = products.growth({ + id: "growth", + items: [items.monthlyMessages({ includedUsage: 2000 })], + }); + + const { autumnV2_2 } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: true, paymentMethod: "success" }), + s.products({ list: [pro, premium, growth] }), + ], + actions: [ + s.billing.attach({ productId: pro.id }), + s.advanceTestClock({ toNextInvoice: true }), + s.advanceTestClock({ days: 10 }), + ], + }); + + const firstUpgradeResult = await autumnV2_2.billing.attach({ + customer_id: customerId, + plan_id: `premium_${customerId}`, + }); + + expect(firstUpgradeResult.invoice).toBeDefined(); + + await new Promise((resolve) => setTimeout(resolve, 5000)); + + const secondPreview = (await autumnV2_2.billing.previewAttach({ + customer_id: customerId, + plan_id: `growth_${customerId}`, + })) as AttachPreviewResponse; + + const creditLines = secondPreview.line_items.filter((li) => li.total < 0); + expect(creditLines.length).toBeGreaterThan(0); + + const positiveLines = secondPreview.line_items.filter((li) => li.total > 0); + expect(positiveLines.length).toBeGreaterThan(0); + + const secondResult = await autumnV2_2.billing.attach({ + customer_id: customerId, + plan_id: `growth_${customerId}`, + }); + + expect(secondResult.invoice?.total).toBeCloseTo(secondPreview.total, 0); + }, + 300_000, +); diff --git a/server/tests/integration/billing/migrations-v2/trial/migration-paid-recurring-trial-carryover.test.ts b/server/tests/integration/billing/migrations-v2/trial/migration-paid-recurring-trial-carryover.test.ts new file mode 100644 index 000000000..d3972a26f --- /dev/null +++ b/server/tests/integration/billing/migrations-v2/trial/migration-paid-recurring-trial-carryover.test.ts @@ -0,0 +1,374 @@ +/** + * Regression coverage for paid recurring trials during update_plan migrations. + * Migrations must preserve active Stripe trial state in normal and entity-scoped setups. + */ + +import { expect, test } from "bun:test"; +import type { ApiCustomerV3, ApiEntityV0, Migration } from "@autumn/shared"; +import type { MigrationFilter } from "@autumn/shared/api/migrations/filters/migrationFilter.js"; +import type { Operations } from "@autumn/shared/api/migrations/operations/operations.js"; +import { expectCustomerProducts } from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +import { expectProductTrialing } from "@tests/integration/billing/utils/expectCustomerProductTrialing"; +import { expectStripeSubscriptionUnchanged } from "@tests/integration/billing/utils/stripe/expectStripeSubscriptionUnchanged"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import type { TestContext } from "@tests/utils/testInitUtils/createTestContext"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import type Stripe from "stripe"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { prepare } from "@/internal/migrations/v2/prepare/prepare.js"; +import { migrateCustomer } from "@/internal/migrations/v2/run/migrateCustomer/index.js"; +import { preProcessMigration } from "@/internal/migrations/v2/run/preProcess/index.js"; + +type MigrationClient = { + migrationsV2: { + deleteAndCreate: (params: { + id: string; + filter?: MigrationFilter | null; + operations?: Operations | null; + }) => Promise; + }; +}; + +type TrialSubSnapshot = { + id: string; + trialEnd: number | null; + subscription: Stripe.Subscription; +}; + +const activeOrTrialing = (sub: Stripe.Subscription) => + sub.status === "active" || sub.status === "trialing"; + +const getTrialSubSnapshots = async ({ + ctx, + stripeCustomerId, +}: { + ctx: TestContext; + stripeCustomerId: string; +}): Promise => { + const subscriptions = await ctx.stripeCli.subscriptions.list({ + customer: stripeCustomerId, + status: "all", + }); + const activeSubs = subscriptions.data.filter(activeOrTrialing); + expect(activeSubs.length).toBeGreaterThan(0); + + return activeSubs.map((subscription) => { + expect(subscription.status).toBe("trialing"); + expect(subscription.trial_end).toBeDefined(); + return { + id: subscription.id, + trialEnd: subscription.trial_end, + subscription, + }; + }); +}; + +const expectTrialSubsPreserved = async ({ + ctx, + before, + expectUnchanged, +}: { + ctx: TestContext; + before: TrialSubSnapshot[]; + expectUnchanged: boolean; +}) => { + for (const snapshot of before) { + const after = await ctx.stripeCli.subscriptions.retrieve(snapshot.id); + expect(after.status).toBe("trialing"); + expect(after.trial_end).toBe(snapshot.trialEnd); + + if (expectUnchanged) { + expectStripeSubscriptionUnchanged({ + before: snapshot.subscription, + after, + }); + } + } +}; + +const runVersionMigration = async ({ + ctx, + migrationClient, + migrationId, + customerId, + filter, + operations, + noBillingChanges, +}: { + ctx: AutumnContext; + migrationClient: MigrationClient; + migrationId: string; + customerId: string; + filter: MigrationFilter; + operations: Operations; + noBillingChanges: boolean; +}) => { + const migration = await migrationClient.migrationsV2.deleteAndCreate({ + id: migrationId, + filter, + operations, + }); + const processedMigration = preProcessMigration({ + ...migration, + no_billing_changes: noBillingChanges, + }); + const { preparedState } = await prepare({ + ctx, + migration: processedMigration, + dryRun: false, + }); + + await migrateCustomer({ + ctx, + customerId, + migration: { + ...processedMigration, + prepared_state: preparedState, + }, + }); +}; + +const updateTrialProductsToV2 = async ({ + autumnV1, + proId, + addonId, +}: { + autumnV1: Awaited>["autumnV1"]; + proId: string; + addonId: string; +}) => { + await autumnV1.products.update(proId, { + items: [ + items.monthlyPrice({ price: 20 }), + items.monthlyMessages({ includedUsage: 600 }), + ], + }); + await autumnV1.products.update(addonId, { + items: [ + items.monthlyPrice({ price: 20 }), + items.monthlyWords({ includedUsage: 300 }), + ], + }); +}; + +const migrationOps = ({ + proId, + addonId, +}: { + proId: string; + addonId: string; +}): Operations => ({ + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: proId }, + version: 2, + }, + { + type: "update_plan", + plan_filter: { plan_id: addonId }, + version: 2, + }, + ], +}); + +for (const noBillingChanges of [false, true]) { + test.concurrent( + `${chalk.yellowBright(`migrations trial: paid pro + addon preserves trial (${noBillingChanges ? "no billing changes" : "billing changes"})`)}`, + async () => { + const suffix = noBillingChanges ? "db-only" : "billing"; + const customerId = `mig-paid-trial-regular-${suffix}`; + const proTrial = products.proWithTrial({ + id: "mig-paid-trial-pro", + items: [items.monthlyMessages({ includedUsage: 500 })], + trialDays: 14, + cardRequired: true, + }); + const addon = products.recurringAddOn({ + id: "mig-paid-trial-addon", + items: [items.monthlyWords({ includedUsage: 200 })], + }); + + const { autumnV1, autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [proTrial, addon] }), + ], + actions: [ + s.billing.attach({ productId: proTrial.id }), + s.billing.attach({ productId: addon.id }), + ], + }); + + const customerBefore = + await autumnV1.customers.get(customerId); + const trialEndsAt = await expectProductTrialing({ + customer: customerBefore, + productId: proTrial.id, + }); + expect(trialEndsAt).toBeDefined(); + await expectProductTrialing({ + customer: customerBefore, + productId: addon.id, + trialEndsAt: trialEndsAt!, + }); + expect(customerBefore.stripe_id).toBeDefined(); + const subSnapshots = await getTrialSubSnapshots({ + ctx, + stripeCustomerId: customerBefore.stripe_id as string, + }); + + await updateTrialProductsToV2({ + autumnV1, + proId: proTrial.id, + addonId: addon.id, + }); + + await runVersionMigration({ + ctx, + migrationClient: autumnV2_2, + migrationId: `${customerId}-mig`, + customerId, + filter: { customer: { plan: { plan_id: proTrial.id } } }, + operations: migrationOps({ proId: proTrial.id, addonId: addon.id }), + noBillingChanges, + }); + + const customerAfter = + await autumnV1.customers.get(customerId); + await expectCustomerProducts({ + customer: customerAfter, + active: [proTrial.id, addon.id], + }); + await expectProductTrialing({ + customer: customerAfter, + productId: proTrial.id, + trialEndsAt: trialEndsAt!, + }); + await expectProductTrialing({ + customer: customerAfter, + productId: addon.id, + trialEndsAt: trialEndsAt!, + }); + await expectTrialSubsPreserved({ + ctx, + before: subSnapshots, + expectUnchanged: noBillingChanges, + }); + }, + ); + + test.concurrent( + `${chalk.yellowBright(`migrations trial: multi-entity pro + addon preserves trial (${noBillingChanges ? "no billing changes" : "billing changes"})`)}`, + async () => { + const suffix = noBillingChanges ? "db-only" : "billing"; + const customerId = `mig-paid-trial-entities-${suffix}`; + const proTrial = products.proWithTrial({ + id: "mig-paid-trial-ent-pro", + items: [items.monthlyMessages({ includedUsage: 500 })], + trialDays: 14, + cardRequired: true, + }); + const addon = products.recurringAddOn({ + id: "mig-paid-trial-ent-addon", + items: [items.monthlyWords({ includedUsage: 200 })], + }); + + const { autumnV1, autumnV2_2, ctx, entities } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [proTrial, addon] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [ + s.billing.attach({ productId: proTrial.id, entityIndex: 0 }), + s.billing.attach({ productId: addon.id, entityIndex: 0 }), + s.billing.attach({ productId: proTrial.id, entityIndex: 1 }), + s.billing.attach({ productId: addon.id, entityIndex: 1 }), + ], + }); + + const entityBefore = await autumnV1.entities.get( + customerId, + entities[0].id, + ); + const trialEndsAt = await expectProductTrialing({ + customer: entityBefore, + productId: proTrial.id, + }); + expect(trialEndsAt).toBeDefined(); + + for (const entity of entities) { + const entityCustomer = await autumnV1.entities.get( + customerId, + entity.id, + ); + await expectProductTrialing({ + customer: entityCustomer, + productId: proTrial.id, + trialEndsAt: trialEndsAt!, + }); + await expectProductTrialing({ + customer: entityCustomer, + productId: addon.id, + trialEndsAt: trialEndsAt!, + }); + } + + const customerBefore = + await autumnV1.customers.get(customerId); + expect(customerBefore.stripe_id).toBeDefined(); + const subSnapshots = await getTrialSubSnapshots({ + ctx, + stripeCustomerId: customerBefore.stripe_id as string, + }); + + await updateTrialProductsToV2({ + autumnV1, + proId: proTrial.id, + addonId: addon.id, + }); + + await runVersionMigration({ + ctx, + migrationClient: autumnV2_2, + migrationId: `${customerId}-mig`, + customerId, + filter: { customer: { plan: { plan_id: proTrial.id } } }, + operations: migrationOps({ proId: proTrial.id, addonId: addon.id }), + noBillingChanges, + }); + + for (const entity of entities) { + const entityCustomer = await autumnV1.entities.get( + customerId, + entity.id, + ); + await expectCustomerProducts({ + customer: entityCustomer, + active: [proTrial.id, addon.id], + }); + await expectProductTrialing({ + customer: entityCustomer, + productId: proTrial.id, + trialEndsAt: trialEndsAt!, + }); + await expectProductTrialing({ + customer: entityCustomer, + productId: addon.id, + trialEndsAt: trialEndsAt!, + }); + } + await expectTrialSubsPreserved({ + ctx, + before: subSnapshots, + expectUnchanged: noBillingChanges, + }); + }, + ); +} diff --git a/server/tests/integration/billing/migrations-v2/update-plan-operation/update-plan-op-states.test.ts b/server/tests/integration/billing/migrations-v2/update-plan-operation/update-plan-op-states.test.ts index 28a4cf79d..4f1cbcc13 100644 --- a/server/tests/integration/billing/migrations-v2/update-plan-operation/update-plan-op-states.test.ts +++ b/server/tests/integration/billing/migrations-v2/update-plan-operation/update-plan-op-states.test.ts @@ -9,19 +9,23 @@ */ import { expect, test } from "bun:test"; -import type { ApiCustomerV3, ApiEntityV0 } from "@autumn/shared"; import { CusProductStatus, + findActiveCustomerProductById, customerPrices, customerProducts, customers, prices, + type ApiCustomerV3, + type ApiEntityV0, } from "@autumn/shared"; import { + expectCustomerProducts, expectProductCanceling, expectProductNotPresent, expectProductScheduled, } from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +import { expectCustomerProductStatuses } from "@tests/integration/billing/utils/expectCustomerProductStatuses"; import { expectNoExpiredCustomerProducts } from "@tests/integration/billing/utils/expectNoExpiredCustomerProducts"; import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect"; import { TestFeature } from "@tests/setup/v2Features"; @@ -31,6 +35,8 @@ import { products } from "@tests/utils/fixtures/products"; import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; import chalk from "chalk"; import { and, eq, isNull } from "drizzle-orm"; +import { CusService } from "@/internal/customers/CusService"; +import { CusProductService } from "@/internal/customers/cusProducts/CusProductService"; import { runUpdatePlanMigration } from "../utils/runUpdatePlanMigration"; const getScheduledIds = async ({ @@ -111,6 +117,90 @@ const getCustomerProductPriceAmounts = async ({ .filter((amount): amount is number => typeof amount === "number") .sort((a, b) => a - b); +// Red: version update_plan replacement reset a past_due cusProduct to active. +// Green: the replacement inherits past_due while the old row expires. +test.concurrent(`${chalk.yellowBright("migrations update_plan states: past_due survives version update")}`, async () => { + const customerId = "migration-update-state-past-due"; + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const { autumnV1, autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [s.billing.attach({ productId: pro.id })], + }); + + const fullCustomerBefore = await CusService.getFull({ + ctx, + idOrInternalId: customerId, + }); + const cusProductBefore = findActiveCustomerProductById({ + fullCus: fullCustomerBefore, + productId: pro.id, + }); + expect(cusProductBefore).toBeDefined(); + + await CusProductService.update({ + ctx, + cusProductId: cusProductBefore!.id, + updates: { status: CusProductStatus.PastDue }, + }); + + const invoiceCountBefore = + (await autumnV1.customers.get(customerId)).invoices + ?.length ?? 0; + + await autumnV1.products.update(pro.id, { + items: [ + items.monthlyPrice({ price: 20 }), + items.monthlyMessages({ includedUsage: 600 }), + ], + }); + + await runUpdatePlanMigration({ + ctx, + migrationClient: autumnV2_2, + migrationId: `${customerId}-mig`, + customerId, + runOnServer: false, + filter: { customer: { plan: { plan_id: pro.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: pro.id }, + version: 2, + }, + ], + }, + }); + + const customerAfter = await autumnV1.customers.get(customerId); + await expectCustomerProducts({ + customer: customerAfter, + pastDue: [pro.id], + }); + + const { byStatus } = await expectCustomerProductStatuses({ + ctx, + customerId, + productId: pro.id, + expected: { + [CusProductStatus.PastDue]: 1, + [CusProductStatus.Expired]: 1, + }, + }); + + expect(byStatus[CusProductStatus.PastDue]?.[0]?.product.version).toBe(2); + expect(customerAfter.invoices?.length ?? 0).toBe(invoiceCountBefore); + await expectStripeSubscriptionCorrect({ ctx, customerId }); +}); + test.concurrent(`${chalk.yellowBright("migrations update_plan states: scheduled downgrade survives active plan price update")}`, async () => { const customerId = "migration-update-state-downgrade"; const pro = products.pro({ diff --git a/server/tests/integration/billing/migrations-v2/update-plan-version/migration-free-trial-carryover.test.ts b/server/tests/integration/billing/migrations-v2/update-plan-version/migration-free-trial-carryover.test.ts new file mode 100644 index 000000000..b7735a3f0 --- /dev/null +++ b/server/tests/integration/billing/migrations-v2/update-plan-version/migration-free-trial-carryover.test.ts @@ -0,0 +1,137 @@ +/** + * Regression: update_plan version migrations must carry active free-product trial_ends_at. + * Pre-fix replacements became active without a trial; post-fix they keep the trial isolated from paid subscriptions. + */ + +import { expect, test } from "bun:test"; +import type { ApiCustomerV3 } from "@autumn/shared"; +import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect"; +import { expectCustomerProducts } from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +import { + expectProductNotTrialing, + expectProductTrialing, +} from "@tests/integration/billing/utils/expectCustomerProductTrialing"; +import { expectStripeSubscriptionUnchanged } from "@tests/integration/billing/utils/stripe/expectStripeSubscriptionUnchanged"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { runUpdatePlanMigration } from "../utils/runUpdatePlanMigration"; + +test.concurrent( + `${chalk.yellowBright("migrations update_plan: free trial v1->v2 carries trial without trialing paid subscription")}`, + async () => { + const customerId = "mig-free-trial-carryover-paid-guard"; + const freeTrial = products.baseWithTrial({ + id: "mig-free-trial-carryover", + items: [items.monthlyMessages({ includedUsage: 100 })], + trialDays: 14, + cardRequired: false, + }); + const paidAddon = products.recurringAddOn({ + id: "mig-free-trial-paid-addon", + items: [items.monthlyCredits({ includedUsage: 50 })], + }); + + const { autumnV1, autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [freeTrial, paidAddon] }), + ], + actions: [ + s.billing.attach({ productId: freeTrial.id }), + s.billing.attach({ productId: paidAddon.id }), + ], + }); + + const customerBefore = + await autumnV1.customers.get(customerId); + const trialEndsAt = await expectProductTrialing({ + customer: customerBefore, + productId: freeTrial.id, + }); + expect(trialEndsAt).toBeDefined(); + await expectProductNotTrialing({ + customer: customerBefore, + productId: paidAddon.id, + }); + + const stripeCustomerId = customerBefore.stripe_id; + expect(stripeCustomerId).toBeDefined(); + const subsBefore = await ctx.stripeCli.subscriptions.list({ + customer: stripeCustomerId as string, + status: "all", + }); + const paidSubBefore = subsBefore.data.find( + (sub) => sub.status === "active" || sub.status === "trialing", + ); + expect(paidSubBefore).toBeDefined(); + expect(paidSubBefore!.status).not.toBe("trialing"); + + await autumnV1.products.update(freeTrial.id, { + items: [ + items.monthlyMessages({ includedUsage: 200 }), + items.monthlyUsers({ includedUsage: 10 }), + ], + }); + + await runUpdatePlanMigration({ + ctx, + migrationClient: autumnV2_2, + migrationId: `${customerId}-mig`, + customerId, + filter: { customer: { plan: { plan_id: freeTrial.id } } }, + operations: { + customer: [ + { + type: "update_plan", + plan_filter: { plan_id: freeTrial.id }, + version: 2, + }, + ], + }, + runOnServer: false, + }); + + const customerAfter = + await autumnV1.customers.get(customerId); + await expectCustomerProducts({ + customer: customerAfter, + active: [freeTrial.id, paidAddon.id], + }); + await expectProductTrialing({ + customer: customerAfter, + productId: freeTrial.id, + trialEndsAt: trialEndsAt!, + }); + await expectProductNotTrialing({ + customer: customerAfter, + productId: paidAddon.id, + }); + expectCustomerFeatureCorrect({ + customer: customerAfter, + featureId: TestFeature.Messages, + includedUsage: 200, + balance: 200, + usage: 0, + }); + expectCustomerFeatureCorrect({ + customer: customerAfter, + featureId: TestFeature.Users, + includedUsage: 10, + balance: 10, + usage: 0, + }); + + const paidSubAfter = await ctx.stripeCli.subscriptions.retrieve( + paidSubBefore!.id, + ); + expect(paidSubAfter.status).not.toBe("trialing"); + expectStripeSubscriptionUnchanged({ + before: paidSubBefore!, + after: paidSubAfter, + }); + }, +); diff --git a/server/tests/integration/billing/stripe-webhooks/invoice-created/invoice-created-rollover-expiry.test.ts b/server/tests/integration/billing/stripe-webhooks/invoice-created/invoice-created-rollover-expiry.test.ts new file mode 100644 index 000000000..ef73c3341 --- /dev/null +++ b/server/tests/integration/billing/stripe-webhooks/invoice-created/invoice-created-rollover-expiry.test.ts @@ -0,0 +1,125 @@ +// Red: usage-based rollovers expired from wall-clock time. +// Green: prepaid and usage-based one-month rollovers expire at next_reset_at. + +import { expect, test } from "bun:test"; +import { + type ApiCustomerV5, + type RolloverConfig, + RolloverExpiryDurationType, +} from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { + constructArrearItem, + constructPrepaidItem, +} from "@/utils/scriptUtils/constructItem.js"; +import { expectBalanceCorrect } from "../../../utils/expectBalanceCorrect.js"; + +const rolloverConfig: RolloverConfig = { + max: null, + length: 1, + duration: RolloverExpiryDurationType.Month, +}; + +const expectOneMonthRolloverExpiresAtNextReset = ({ + customer, +}: { + customer: ApiCustomerV5; +}) => { + const balance = customer.balances[TestFeature.Messages]; + expect(balance).toBeDefined(); + expect(balance.next_reset_at).not.toBeNull(); + expect(balance.rollovers?.length ?? 0).toBeGreaterThan(0); + + const nextResetAt = balance.next_reset_at!; + expectBalanceCorrect({ + customer, + featureId: TestFeature.Messages, + nextResetAt, + positiveRolloverCount: 1, + }); + + const positiveRollovers = balance.rollovers!.filter( + (item) => item.balance > 0, + ); + const rollover = positiveRollovers[0]; + const expectedExpiry = nextResetAt; + const actualExpiry = rollover.expires_at; + const diff = Math.abs(actualExpiry - expectedExpiry); + + expect( + diff, + `Expected rollover to expire at ${new Date(expectedExpiry).toISOString()}, got ${new Date(actualExpiry).toISOString()}`, + ).toBeLessThanOrEqual(10 * 60 * 1000); +}; + +test.concurrent( + `${chalk.yellowBright("invoice.created rollover expiry: prepaid uses next reset boundary")}`, + async () => { + const prepaidItem = constructPrepaidItem({ + featureId: TestFeature.Messages, + includedUsage: 100, + billingUnits: 100, + price: 10, + rolloverConfig, + }); + const pro = products.pro({ + id: "pro-prepaid-rollover-expiry", + items: [prepaidItem], + }); + + const { customerId, autumnV2_2 } = await initScenario({ + customerId: "invoice-created-prepaid-rollover-expiry", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [ + s.billing.attach({ + productId: pro.id, + options: [{ feature_id: TestFeature.Messages, quantity: 200 }], + }), + s.track({ featureId: TestFeature.Messages, value: 50, timeout: 2000 }), + s.advanceToNextInvoice({ withPause: true }), + ], + }); + + const after = await autumnV2_2.customers.get(customerId); + expectOneMonthRolloverExpiresAtNextReset({ customer: after }); + }, +); + +test.concurrent( + `${chalk.yellowBright("invoice.created rollover expiry: usage-based uses next reset boundary")}`, + async () => { + const consumableItem = constructArrearItem({ + featureId: TestFeature.Messages, + includedUsage: 200, + price: 0.1, + billingUnits: 1, + rolloverConfig, + }); + const pro = products.pro({ + id: "pro-consumable-rollover-expiry", + items: [consumableItem], + }); + + const { customerId, autumnV2_2 } = await initScenario({ + customerId: "invoice-created-consumable-rollover-expiry", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [ + s.billing.attach({ productId: pro.id }), + s.track({ featureId: TestFeature.Messages, value: 50, timeout: 2000 }), + s.advanceToNextInvoice({ withPause: true }), + ], + }); + + const after = await autumnV2_2.customers.get(customerId); + expectOneMonthRolloverExpiresAtNextReset({ customer: after }); + }, +); diff --git a/server/tests/integration/billing/update-subscription/params/update-processor-no-billing-changes.test.ts b/server/tests/integration/billing/update-subscription/params/update-processor-no-billing-changes.test.ts index 39e0ea717..718e8b1a3 100644 --- a/server/tests/integration/billing/update-subscription/params/update-processor-no-billing-changes.test.ts +++ b/server/tests/integration/billing/update-subscription/params/update-processor-no-billing-changes.test.ts @@ -1,14 +1,18 @@ import { expect, test } from "bun:test"; import { + findActiveCustomerProductById, CusProductStatus, type UpdateSubscriptionV1ParamsInput, } from "@autumn/shared"; +import { expectCustomerProducts } from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +import { expectCustomerProductStatuses } from "@tests/integration/billing/utils/expectCustomerProductStatuses"; import { items } from "@tests/utils/fixtures/items"; import { itemsV2 } from "@tests/utils/fixtures/itemsV2"; import { products } from "@tests/utils/fixtures/products"; import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; import chalk from "chalk"; import { CusService } from "@/internal/customers/CusService"; +import { CusProductService } from "@/internal/customers/cusProducts/CusProductService"; test(`${chalk.yellowBright("processor_subscription_id: attach with existing stripe subscription anchors reset cycle")}`, async () => {}); @@ -35,10 +39,10 @@ test(`${chalk.yellowBright("update no_billing_changes: customize preserves subsc ctx, idOrInternalId: customerId, }); - const cusProductBefore = fullCustomerBefore.customer_products.find( - (cp) => - cp.product_id === pro.id && cp.status === CusProductStatus.Active, - ); + const cusProductBefore = findActiveCustomerProductById({ + fullCus: fullCustomerBefore, + productId: pro.id, + }); expect(cusProductBefore).toBeDefined(); const originalSubIds = cusProductBefore?.subscription_ids ?? []; expect(originalSubIds.length).toBeGreaterThan(0); @@ -54,16 +58,85 @@ test(`${chalk.yellowBright("update no_billing_changes: customize preserves subsc }, }); - const fullCustomerAfter = await CusService.getFull({ + await expectCustomerProducts({ + customer: await autumnV2.customers.get(customerId), + active: [pro.id], + }); + + const { byStatus } = await expectCustomerProductStatuses({ + ctx, + customerId, + productId: pro.id, + expected: { + [CusProductStatus.Active]: 1, + }, + }); + expect(byStatus[CusProductStatus.Active]?.[0]?.subscription_ids).toEqual( + originalSubIds, + ); +}); + +// Red: replacement-style updates reset a past_due cusProduct to active. +// Green: the replacement inherits status and keeps the subscription link. +test(`${chalk.yellowBright("update no_billing_changes: replacement customize preserves past_due status")}`, async () => { + const customerId = "update-no-billing-preserves-past-due"; + + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const priceItem = items.monthlyPrice({ price: 20 }); + const pro = products.base({ id: "pro", items: [messagesItem, priceItem] }); + + const { autumnV1, autumnV2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [s.attach({ productId: pro.id })], + }); + + const fullCustomerBefore = await CusService.getFull({ ctx, idOrInternalId: customerId, }); - const activeProRows = fullCustomerAfter.customer_products.filter( - (cp) => - cp.product_id === pro.id && cp.status === CusProductStatus.Active, - ); - expect(activeProRows.length).toBe(1); - const cusProductAfter = activeProRows[0]; + const cusProductBefore = findActiveCustomerProductById({ + fullCus: fullCustomerBefore, + productId: pro.id, + }); + expect(cusProductBefore).toBeDefined(); + const originalSubIds = cusProductBefore?.subscription_ids ?? []; + expect(originalSubIds.length).toBeGreaterThan(0); - expect(cusProductAfter.subscription_ids).toEqual(originalSubIds); + await CusProductService.update({ + ctx, + cusProductId: cusProductBefore!.id, + updates: { status: CusProductStatus.PastDue }, + }); + + await autumnV2.subscriptions.update({ + customer_id: customerId, + plan_id: pro.id, + no_billing_changes: true, + customize: { + price: itemsV2.monthlyPrice({ amount: 20 }), + items: [itemsV2.monthlyMessages({ included: 250 })], + }, + }); + + await expectCustomerProducts({ + customer: await autumnV1.customers.get(customerId), + pastDue: [pro.id], + }); + + const { byStatus } = await expectCustomerProductStatuses({ + ctx, + customerId, + productId: pro.id, + expected: { + [CusProductStatus.PastDue]: 1, + [CusProductStatus.Expired]: 1, + }, + }); + expect(byStatus[CusProductStatus.PastDue]?.[0]?.subscription_ids).toEqual( + originalSubIds, + ); }); diff --git a/server/tests/integration/billing/utils/expectCustomerProductStatuses.ts b/server/tests/integration/billing/utils/expectCustomerProductStatuses.ts new file mode 100644 index 000000000..93688283c --- /dev/null +++ b/server/tests/integration/billing/utils/expectCustomerProductStatuses.ts @@ -0,0 +1,73 @@ +import { expect } from "bun:test"; +import { + CusProductStatus, + type FullCusProduct, + type CusProductStatus as CusProductStatusType, +} from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { CusService } from "@/internal/customers/CusService"; + +type CustomerProductStatusesResult = { + customerProducts: FullCusProduct[]; + byStatus: Partial>; +}; + +export const expectCustomerProductStatuses = async ({ + ctx, + customerId, + productId, + entityId, + expected, +}: { + ctx: AutumnContext; + customerId: string; + productId: string; + entityId?: string; + expected: Partial>; +}): Promise => { + const fullCustomer = await CusService.getFull({ + ctx, + idOrInternalId: customerId, + inStatuses: [ + CusProductStatus.Active, + CusProductStatus.PastDue, + CusProductStatus.Scheduled, + CusProductStatus.Expired, + ], + withEntities: true, + }); + + const customerProducts = fullCustomer.customer_products.filter( + (customerProduct) => + customerProduct.product_id === productId && + (entityId ? customerProduct.entity_id === entityId : true), + ); + + const byStatus = customerProducts.reduce< + Partial> + >((acc, customerProduct) => { + acc[customerProduct.status] = [ + ...(acc[customerProduct.status] ?? []), + customerProduct, + ]; + return acc; + }, {}); + + for (const [status, count] of Object.entries(expected)) { + const matchingCustomerProducts = + byStatus[status as CusProductStatusType] ?? []; + + expect( + matchingCustomerProducts.length, + `Expected ${count} ${status} rows for ${productId}; got ${JSON.stringify( + customerProducts.map((customerProduct) => ({ + id: customerProduct.id, + status: customerProduct.status, + version: customerProduct.product.version, + })), + )}`, + ).toBe(count); + } + + return { customerProducts, byStatus }; +}; diff --git a/server/tests/integration/crud/customers/get-customer-entity-rollover-granted.test.ts b/server/tests/integration/crud/customers/get-customer-entity-rollover-granted.test.ts new file mode 100644 index 000000000..ad97b2f88 --- /dev/null +++ b/server/tests/integration/crud/customers/get-customer-entity-rollover-granted.test.ts @@ -0,0 +1,77 @@ +// Red: customer aggregation omitted rollover grant from entity-scoped products. +// Green: customer granted includes active entity rollover balance and usage. + +import { expect, test } from "bun:test"; +import { + type ApiCustomerV5, + type LimitedItem, + ProductItemInterval, + RolloverExpiryDurationType, +} from "@autumn/shared"; +import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect.js"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { expireAllCusEntsForReset } from "@tests/utils/cusProductUtils/resetTestUtils.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; + +test.concurrent( + `${chalk.yellowBright("get-customer: entity product rollovers contribute to granted")}`, + async () => { + const customerId = "get-customer-entity-rollover-granted"; + const rolloverConfig = { + max: 500, + length: 1, + duration: RolloverExpiryDurationType.Month, + }; + const creditsItem = constructFeatureItem({ + featureId: TestFeature.Credits, + includedUsage: 100, + interval: ProductItemInterval.Month, + rolloverConfig, + }) as LimitedItem; + const base = products.base({ + id: "entity-product-rollover-granted", + items: [creditsItem], + }); + + const { autumnV1, autumnV2_2, ctx, entities } = await initScenario({ + customerId, + setup: [ + s.deleteCustomer({ customerId }), + s.customer({ paymentMethod: "success", testClock: false }), + s.products({ list: [base] }), + s.entities({ count: 1, featureId: TestFeature.Users }), + ], + actions: [s.billing.attach({ productId: base.id, entityIndex: 0 })], + }); + + await autumnV1.track({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Credits, + value: 40, + }); + await new Promise((resolve) => setTimeout(resolve, 2000)); + + await expireAllCusEntsForReset({ + ctx, + customerId, + featureId: TestFeature.Credits, + }); + await autumnV2_2.entities.get(customerId, entities[0].id); + + const after = await autumnV2_2.customers.get(customerId, { + skip_cache: "true", + }); + + expectBalanceCorrect({ + customer: after, + featureId: TestFeature.Credits, + remaining: 160, + usage: 0, + }); + expect(after.balances[TestFeature.Credits].granted).toBe(160); + }, +); diff --git a/server/tests/integration/db/invoice-line-items/get-by-customer-product-ids.test.ts b/server/tests/integration/db/invoice-line-items/get-by-customer-product-ids.test.ts new file mode 100644 index 000000000..b45cfe14e --- /dev/null +++ b/server/tests/integration/db/invoice-line-items/get-by-customer-product-ids.test.ts @@ -0,0 +1,31 @@ +import { expect, test } from "bun:test"; +import { invoiceLineItems } from "@autumn/shared"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import { eq } from "drizzle-orm"; +import { invoiceLineItemRepo } from "@/internal/invoices/lineItems/repos/index.js"; + +test.concurrent("invoice line items: get by single customer product id", async () => { + const lineItemId = "invoice_li_get_by_cus_prod_ids"; + const customerProductId = "cus_prod_3EfbA8teNA8ColQSwRemt4BevN9"; + + await ctx.db.delete(invoiceLineItems).where(eq(invoiceLineItems.id, lineItemId)); + await ctx.db.insert(invoiceLineItems).values({ + id: lineItemId, + amount: 100, + amount_after_discounts: 100, + description: "Test line item", + direction: "charge", + customer_product_ids: [customerProductId], + }); + + try { + const rows = await invoiceLineItemRepo.getByCustomerProductIds({ + db: ctx.db, + customerProductIds: [customerProductId], + }); + + expect(rows.map((row) => row.id)).toContain(lineItemId); + } finally { + await ctx.db.delete(invoiceLineItems).where(eq(invoiceLineItems.id, lineItemId)); + } +}); diff --git a/server/tests/integration/external-psps/revenuecat-product-sync.test.ts b/server/tests/integration/external-psps/revenuecat-product-sync.test.ts new file mode 100644 index 000000000..27edbb1e0 --- /dev/null +++ b/server/tests/integration/external-psps/revenuecat-product-sync.test.ts @@ -0,0 +1,375 @@ +/** + * Tests for the on-demand RevenueCat product sync (per-product layer). + * + * Run in-process against the real DB (shared ctx, for the mapping row) with RC + * fetch mocked. Covered: + * - creates an RC product per app + UNIONS the minted store id into the mapping + * - sandbox does NOT call create_in_store; live DOES (with group name + duration) + * - existing manual mapping is preserved (union, never clobbered) + * - when the RC product already exists with a different name, the name is patched + */ + +import { + BillingInterval, + type FullProduct, + type Price, + PriceType, +} from "@autumn/shared"; +import { afterEach, beforeEach, expect, mock, test } from "bun:test"; +import chalk from "chalk"; +import { initRevenuecatCli } from "@/external/revenueCat/misc/initRevenuecatCli"; +import { RCMappingService } from "@/external/revenueCat/misc/RCMappingService"; +import { syncProductToRevenueCat } from "@/external/revenueCat/sync/syncRevenueCatProducts"; +import type { RevenueCatApp } from "@/external/revenueCat/revenuecatTypes"; +import ctx from "@tests/utils/testInitUtils/createTestContext"; + +const APPS: RevenueCatApp[] = [ + { + object: "app", + id: "app_ios", + name: "iOS", + type: "app_store", + project_id: "proj_test", + created_at: 0, + }, + { + object: "app", + id: "app_android", + name: "Android", + type: "play_store", + project_id: "proj_test", + created_at: 0, + }, +]; + +type FetchCall = { method: string; url: string; body: unknown }; +let fetchCalls: FetchCall[] = []; +let existingProducts: Array<{ + id: string; + app_id: string; + store_identifier: string; + display_name: string; +}> = []; +let productCounter = 0; +let mcpError = false; +let originalFetch: typeof fetch; + +const json = (b: unknown, status = 200) => + new Response(JSON.stringify(b), { + status, + headers: { "Content-Type": "application/json" }, + }); + +beforeEach(() => { + originalFetch = globalThis.fetch; + fetchCalls = []; + productCounter = 0; + mcpError = false; + existingProducts = []; + globalThis.fetch = mock(async (input: unknown, init?: RequestInit) => { + const url = input?.toString() ?? ""; + const path = url.split("?")[0]; + const method = (init?.method ?? "GET").toUpperCase(); + const body = init?.body ? JSON.parse(init.body as string) : undefined; + fetchCalls.push({ method, url, body }); + + if (method === "GET" && path.endsWith("/products")) { + return json({ object: "list", items: existingProducts, next_page: null }); + } + if (method === "POST" && path.endsWith("/products")) { + productCounter += 1; + return json({ object: "product", id: `prod_${productCounter}` }, 201); + } + if (method === "POST" && path.includes("/create_in_store")) { + return json({ created_product: { id: "1" } }, 201); + } + if (path.startsWith("https://mcp.revenuecat.ai")) { + return json({ result: { isError: mcpError, content: [] } }); + } + if (method === "POST" && path.includes("/products/")) { + return json({ object: "product", id: "prod_x" }); + } + return json({}); + }) as unknown as typeof fetch; +}); + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +const rcCli = () => + initRevenuecatCli({ projectId: "proj_test", accessToken: "tok" }); + +const price = (interval: BillingInterval, amount = 15): Price => + ({ + config: { type: PriceType.Fixed, amount, interval, interval_count: 1 }, + }) as unknown as Price; + +const buildProduct = ( + id: string, + name: string, + group?: string, + amount = 15, +): FullProduct => + ({ + id, + name, + group: group ?? "", + prices: [price(BillingInterval.Month, amount)], + entitlements: [], + free_trial: null, + }) as unknown as FullProduct; + +const storeId = (planId: string) => `autumn.${ctx.env}.${ctx.org.id}.${planId}`; + +const getMappingIds = async (planId: string) => { + const rows = await RCMappingService.get({ + db: ctx.db, + orgId: ctx.org.id, + env: ctx.env, + autumnProductId: planId, + }); + return rows[0]?.revenuecat_product_ids ?? []; +}; + +const cleanup = (planId: string) => + RCMappingService.delete({ + db: ctx.db, + orgId: ctx.org.id, + env: ctx.env, + autumnProductId: planId, + }); + +test(`${chalk.yellowBright("rc sync: creates a product per app, unions the minted id, no create_in_store in sandbox")}`, async () => { + const planId = `rc-sync-create-${Date.now()}`; + await cleanup(planId); + + const result = await syncProductToRevenueCat({ + ctx, + rcCli: rcCli(), + apps: APPS, + isLive: false, + projectId: "proj_test", + product: buildProduct(planId, "Pro"), + }); + + const creates = fetchCalls.filter( + (c) => c.method === "POST" && c.url.split("?")[0].endsWith("/products"), + ); + expect(creates).toHaveLength(APPS.length); + for (const c of creates) { + expect(c.body).toMatchObject({ + store_identifier: storeId(planId), + type: "subscription", + display_name: "Pro", + }); + // real store apps: subscription params are NOT sent on create (RC rejects them) + expect((c.body as { subscription?: unknown }).subscription).toBeUndefined(); + } + expect(fetchCalls.some((c) => c.url.includes("/create_in_store"))).toBe(false); + // real stores own their prices — never call the MCP price tool + expect(fetchCalls.some((c) => c.url.startsWith("https://mcp.revenuecat.ai"))).toBe( + false, + ); + + expect(result.status).toBe("synced"); + expect(await getMappingIds(planId)).toContain(storeId(planId)); + + await cleanup(planId); +}); + +test(`${chalk.yellowBright("rc sync: test_store app gets subscription params on create and no store push")}`, async () => { + const planId = `rc-sync-teststore-${Date.now()}`; + await cleanup(planId); + + const testStoreApps: RevenueCatApp[] = [ + { + object: "app", + id: "app_test", + name: "Test Store", + type: "test_store", + project_id: "proj_test", + created_at: 0, + }, + ]; + + await syncProductToRevenueCat({ + ctx, + rcCli: rcCli(), + apps: testStoreApps, + isLive: true, + projectId: "proj_test", + product: buildProduct(planId, "Pro"), + }); + + const create = fetchCalls.find( + (c) => c.method === "POST" && c.url.split("?")[0].endsWith("/products"), + ); + expect(create?.body).toMatchObject({ + type: "subscription", + subscription: { duration: "P1M" }, + }); + // simulated store is already usable — no create_in_store even on live + expect(fetchCalls.some((c) => c.url.includes("/create_in_store"))).toBe(false); + + // test-store price IS set via the RC MCP server (create-product-prices) + const priceCall = fetchCalls.find((c) => + c.url.startsWith("https://mcp.revenuecat.ai"), + ); + expect(priceCall).toBeDefined(); + const params = (priceCall?.body as { params?: { name?: string; arguments?: any } }) + ?.params; + expect(params?.name).toBe("create-product-prices"); + expect(params?.arguments).toMatchObject({ + project_id: "proj_test", + product_id: "prod_1", + prices: [{ amount_micros: 15_000_000 }], + }); + expect(params?.arguments.prices[0].currency).toMatch(/^[A-Z]{3}$/); + + await cleanup(planId); +}); + +test(`${chalk.yellowBright("rc sync: live env pushes to the store with group name + duration enum")}`, async () => { + const planId = `rc-sync-live-${Date.now()}`; + await cleanup(planId); + + await syncProductToRevenueCat({ + ctx, + rcCli: rcCli(), + apps: APPS, + isLive: true, + projectId: "proj_test", + product: buildProduct(planId, "Pro", "Premium"), + }); + + const storePushes = fetchCalls.filter((c) => + c.url.includes("/create_in_store"), + ); + expect(storePushes).toHaveLength(APPS.length); + expect(storePushes[0].body).toEqual({ + store_information: { + duration: "ONE_MONTH", + subscription_group_name: "Autumn - Premium Group", + }, + }); + + await cleanup(planId); +}); + +test(`${chalk.yellowBright("rc sync: unions into an existing manual mapping without clobbering it")}`, async () => { + const planId = `rc-sync-union-${Date.now()}`; + await cleanup(planId); + + await RCMappingService.upsert({ + db: ctx.db, + data: { + org_id: ctx.org.id, + env: ctx.env, + autumn_product_id: planId, + revenuecat_product_ids: ["com.legacy.manual.id"], + }, + }); + + await syncProductToRevenueCat({ + ctx, + rcCli: rcCli(), + apps: APPS, + isLive: false, + projectId: "proj_test", + product: buildProduct(planId, "Pro"), + }); + + const ids = await getMappingIds(planId); + expect(ids).toContain("com.legacy.manual.id"); + expect(ids).toContain(storeId(planId)); + + await cleanup(planId); +}); + +test(`${chalk.yellowBright("rc sync: patches name when the RC product already exists with a different name")}`, async () => { + const planId = `rc-sync-rename-${Date.now()}`; + await cleanup(planId); + + existingProducts = APPS.map((app, i) => ({ + id: `existing_${i}`, + app_id: app.id, + store_identifier: storeId(planId), + display_name: "Old Name", + })); + + await syncProductToRevenueCat({ + ctx, + rcCli: rcCli(), + apps: APPS, + isLive: false, + projectId: "proj_test", + product: buildProduct(planId, "Pro"), + }); + + expect( + fetchCalls.filter( + (c) => c.method === "POST" && c.url.split("?")[0].endsWith("/products"), + ), + ).toHaveLength(0); + const updates = fetchCalls.filter( + (c) => c.method === "POST" && /\/products\/existing_\d+$/.test(c.url), + ); + expect(updates).toHaveLength(APPS.length); + expect(updates[0].body).toEqual({ display_name: "Pro" }); + + await cleanup(planId); +}); + +const testStoreApp: RevenueCatApp = { + object: "app", + id: "app_test", + name: "Test Store", + type: "test_store", + project_id: "proj_test", + created_at: 0, +}; + +test(`${chalk.yellowBright("rc sync: test_store plan with no base price (free) sets no MCP price")}`, async () => { + const planId = `rc-sync-noprice-${Date.now()}`; + await cleanup(planId); + + await syncProductToRevenueCat({ + ctx, + rcCli: rcCli(), + apps: [testStoreApp], + isLive: false, + projectId: "proj_test", + product: buildProduct(planId, "Free", undefined, 0), // amount 0 → no base price + }); + + expect(fetchCalls.some((c) => c.url.startsWith("https://mcp.revenuecat.ai"))).toBe( + false, + ); + + await cleanup(planId); +}); + +test(`${chalk.yellowBright("rc sync: MCP price failure is best-effort — sync still succeeds")}`, async () => { + const planId = `rc-sync-pricefail-${Date.now()}`; + await cleanup(planId); + mcpError = true; + + const result = await syncProductToRevenueCat({ + ctx, + rcCli: rcCli(), + apps: [testStoreApp], + isLive: false, + projectId: "proj_test", + product: buildProduct(planId, "Pro"), + }); + + // the MCP call was attempted, but a failure doesn't fail the sync + expect(fetchCalls.some((c) => c.url.startsWith("https://mcp.revenuecat.ai"))).toBe( + true, + ); + expect(result.status).toBe("synced"); + expect(result.apps?.[0].price).toBe("failed"); + + await cleanup(planId); +}); diff --git a/server/tests/integration/external-psps/revenuecat/revenuecat-webhooks.test.ts b/server/tests/integration/external-psps/revenuecat/revenuecat-webhooks.test.ts index 2e0be2e2e..20ae3a27e 100644 --- a/server/tests/integration/external-psps/revenuecat/revenuecat-webhooks.test.ts +++ b/server/tests/integration/external-psps/revenuecat/revenuecat-webhooks.test.ts @@ -62,6 +62,24 @@ type CustomerProductsUpdatedPayload = { const RC_WEBHOOK_SECRET = "test_rc_webhook_secret_12345"; +const rcProMonthly = ({ id = "pro-monthly" }: { id?: string } = {}) => + products.base({ + id, + items: [ + items.monthlyMessages({ includedUsage: 100 }), + items.monthlyPrice({ price: 10 }), + ], + }); + +const rcProYearly = ({ id = "pro-yearly" }: { id?: string } = {}) => + products.base({ + id, + items: [ + items.monthlyMessages({ includedUsage: 1000 }), + items.annualPrice({ price: 1000 }), + ], + }); + // ─── Helpers ───────────────────────────────────────────────────────────────── const setupRevenueCatOrg = async () => { @@ -117,11 +135,7 @@ test.concurrent(`${chalk.yellowBright("rc-webhook: initial purchase → scenario const customerId = "rc-webhook-initial-purchase"; const RC_PRO_MONTHLY_ID = "com.app.rcwh1_pro_monthly"; - const messagesItem = items.monthlyMessages({ includedUsage: 1000 }); - const proMonthly = products.pro({ - id: "pro-monthly", - items: [messagesItem], - }); + const proMonthly = rcProMonthly(); await initScenario({ customerId, @@ -178,11 +192,7 @@ test.concurrent(`${chalk.yellowBright("rc-webhook: renewal → scenario: renew") const customerId = "rc-webhook-renewal"; const RC_PRO_MONTHLY_ID = "com.app.rcwh2_pro_monthly"; - const messagesItem = items.monthlyMessages({ includedUsage: 1000 }); - const proMonthly = products.pro({ - id: "pro-monthly", - items: [messagesItem], - }); + const proMonthly = rcProMonthly(); await initScenario({ customerId, @@ -258,15 +268,8 @@ test.concurrent(`${chalk.yellowBright("rc-webhook: upgrade (monthly → yearly) const RC_PRO_MONTHLY_ID = "com.app.rcwh3_pro_monthly"; const RC_PRO_YEARLY_ID = "com.app.rcwh3_pro_yearly"; - const messagesItem = items.monthlyMessages({ includedUsage: 1000 }); - const proMonthly = products.pro({ - id: "pro-monthly", - items: [messagesItem], - }); - const proYearly = products.proAnnual({ - id: "pro-yearly", - items: [items.monthlyMessages({ includedUsage: 1000 })], - }); + const proMonthly = rcProMonthly(); + const proYearly = rcProYearly(); await initScenario({ customerId, @@ -344,30 +347,26 @@ test.concurrent(`${chalk.yellowBright("rc-webhook: upgrade (monthly → yearly) }); // ═══════════════════════════════════════════════════════════════════════════════ -// TEST 4: Downgrade → scenario: downgrade -// Uses premium ($50/mo) → pro ($20/mo) so the price decrease is a genuine downgrade +// TEST 4: Downgrade (yearly → monthly) applies immediately for RevenueCat. +// RC is the payment source-of-truth, so the transition is forced immediate +// (plan_schedule: "immediate"): yearly is expired now and monthly is inserted +// active. The inserted product is cheaper than the expired one, so the insert +// scenario is "new" (not "upgrade"), and updated_product is the new monthly. // ═══════════════════════════════════════════════════════════════════════════════ -test.concurrent(`${chalk.yellowBright("rc-webhook: downgrade (premium → pro) → scenario: downgrade")}`, async () => { +test.concurrent(`${chalk.yellowBright("rc-webhook: downgrade (yearly → monthly) applies immediately → scenario: new")}`, async () => { const customerId = "rc-webhook-downgrade"; - const RC_PRO_ID = "com.app.rcwh4_pro"; - const RC_PREMIUM_ID = "com.app.rcwh4_premium"; + const RC_PRO_MONTHLY_ID = "com.app.rcwh4_pro_monthly"; + const RC_PRO_YEARLY_ID = "com.app.rcwh4_pro_yearly"; - const messagesItem = items.monthlyMessages({ includedUsage: 1000 }); - const pro = products.pro({ - id: "pro", - items: [messagesItem], - }); - const premium = products.premium({ - id: "premium", - items: [messagesItem], - }); + const proMonthly = rcProMonthly(); + const proYearly = rcProYearly(); await initScenario({ customerId, setup: [ s.customer({ testClock: false, skipWebhooks: true }), - s.products({ list: [pro, premium] }), + s.products({ list: [proMonthly, proYearly] }), ], actions: [], }); @@ -378,8 +377,8 @@ test.concurrent(`${chalk.yellowBright("rc-webhook: downgrade (premium → pro) data: { org_id: ctx.org.id, env: AppEnv.Sandbox, - autumn_product_id: pro.id, - revenuecat_product_ids: [RC_PRO_ID], + autumn_product_id: proMonthly.id, + revenuecat_product_ids: [RC_PRO_MONTHLY_ID], }, }), RCMappingService.upsert({ @@ -387,8 +386,8 @@ test.concurrent(`${chalk.yellowBright("rc-webhook: downgrade (premium → pro) data: { org_id: ctx.org.id, env: AppEnv.Sandbox, - autumn_product_id: premium.id, - revenuecat_product_ids: [RC_PREMIUM_ID], + autumn_product_id: proYearly.id, + revenuecat_product_ids: [RC_PRO_YEARLY_ID], }, }), ]); @@ -399,9 +398,9 @@ test.concurrent(`${chalk.yellowBright("rc-webhook: downgrade (premium → pro) webhookSecret: RC_WEBHOOK_SECRET, }); - // First: initial purchase on premium ($50/mo) + // First: initial purchase on yearly ($1000/yr) await rcClient.initialPurchase({ - productId: RC_PREMIUM_ID, + productId: RC_PRO_YEARLY_ID, appUserId: customerId, originalTransactionId: "rcwh4_tx_001", }); @@ -415,9 +414,10 @@ test.concurrent(`${chalk.yellowBright("rc-webhook: downgrade (premium → pro) timeoutMs: 15000, }); - // Then: renewal to pro ($20/mo) — genuine downgrade + // Then: switch to monthly ($10/mo). For RC this applies immediately rather + // than scheduling a downgrade, so the inserted monthly product is active. await rcClient.renewal({ - productId: RC_PRO_ID, + productId: RC_PRO_MONTHLY_ID, appUserId: customerId, originalTransactionId: "rcwh4_tx_001", }); @@ -427,14 +427,15 @@ test.concurrent(`${chalk.yellowBright("rc-webhook: downgrade (premium → pro) predicate: (payload) => payload.type === "customer.products.updated" && payload.data?.customer?.id === customerId && - payload.data?.scenario === "downgrade", + payload.data?.scenario === "new" && + payload.data?.updated_product?.id === proMonthly.id, timeoutMs: 15000, }); expect(result).not.toBeNull(); const { data } = result!.payload; - expect(data.scenario).toBe("downgrade"); - expect(data.updated_product.id).toBe(pro.id); + expect(data.scenario).toBe("new"); + expect(data.updated_product.id).toBe(proMonthly.id); expect(data.customer.id).toBe(customerId); }); @@ -446,11 +447,7 @@ test.concurrent(`${chalk.yellowBright("rc-webhook: cancellation → scenario: ca const customerId = "rc-webhook-cancel"; const RC_PRO_MONTHLY_ID = "com.app.rcwh5_pro_monthly"; - const messagesItem = items.monthlyMessages({ includedUsage: 1000 }); - const proMonthly = products.pro({ - id: "pro-monthly", - items: [messagesItem], - }); + const proMonthly = rcProMonthly(); await initScenario({ customerId, @@ -525,11 +522,7 @@ test.concurrent(`${chalk.yellowBright("rc-webhook: uncancellation → scenario: const customerId = "rc-webhook-uncancel"; const RC_PRO_MONTHLY_ID = "com.app.rcwh6_pro_monthly"; - const messagesItem = items.monthlyMessages({ includedUsage: 1000 }); - const proMonthly = products.pro({ - id: "pro-monthly", - items: [messagesItem], - }); + const proMonthly = rcProMonthly(); await initScenario({ customerId, @@ -619,11 +612,7 @@ test.concurrent(`${chalk.yellowBright("rc-webhook: billing issue → scenario: p const customerId = "rc-webhook-billing-issue"; const RC_PRO_MONTHLY_ID = "com.app.rcwh7_pro_monthly"; - const messagesItem = items.monthlyMessages({ includedUsage: 1000 }); - const proMonthly = products.pro({ - id: "pro-monthly", - items: [messagesItem], - }); + const proMonthly = rcProMonthly(); await initScenario({ customerId, @@ -697,11 +686,7 @@ test.concurrent(`${chalk.yellowBright("rc-webhook: expiration → scenario: expi const customerId = "rc-webhook-expire"; const RC_PRO_MONTHLY_ID = "com.app.rcwh8_pro_monthly"; - const messagesItem = items.monthlyMessages({ includedUsage: 1000 }); - const proMonthly = products.pro({ - id: "pro-monthly", - items: [messagesItem], - }); + const proMonthly = rcProMonthly(); await initScenario({ customerId, diff --git a/server/tests/integration/external-psps/revenuecat/revenuecat.test.ts b/server/tests/integration/external-psps/revenuecat/revenuecat.test.ts index a779d8569..9b013343e 100644 --- a/server/tests/integration/external-psps/revenuecat/revenuecat.test.ts +++ b/server/tests/integration/external-psps/revenuecat/revenuecat.test.ts @@ -30,8 +30,9 @@ import { timeout } from "@tests/utils/genUtils"; import ctx from "@tests/utils/testInitUtils/createTestContext"; import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; import chalk from "chalk"; -import { eq } from "drizzle-orm"; +import { eq, inArray } from "drizzle-orm"; import { RCMappingService } from "@/external/revenueCat/misc/RCMappingService"; +import { deleteCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/deleteCachedFullCustomer"; import { CusService } from "@/internal/customers/CusService"; import { CusProductService } from "@/internal/customers/cusProducts/CusProductService"; import { OrgService } from "@/internal/orgs/OrgService"; @@ -44,6 +45,24 @@ import { TestFeature } from "@tests/setup/v2Features"; const RC_WEBHOOK_SECRET = "test_rc_webhook_secret_12345"; +const rcProMonthly = ({ id = "pro-monthly" }: { id?: string } = {}) => + products.base({ + id, + items: [ + items.monthlyMessages({ includedUsage: 100 }), + items.monthlyPrice({ price: 10 }), + ], + }); + +const rcProYearly = ({ id = "pro-yearly" }: { id?: string } = {}) => + products.base({ + id, + items: [ + items.monthlyMessages({ includedUsage: 1000 }), + items.annualPrice({ price: 1000 }), + ], + }); + const setupRevenueCatOrg = async () => { if ( ctx.org.processor_configs?.revenuecat?.sandbox_webhook_secret !== @@ -69,6 +88,32 @@ const setupRevenueCatOrg = async () => { } }; +// This test reuses fixed customer + transaction ids. RC invoices upsert by stripe_id +// WITHOUT repointing internal_customer_id, so a prior run leaves orphaned invoice rows +// that make the freshly-created customer read 0 invoices. Clear those by stripe_id, and +// bust the customers' cached full-customer entries so the re-created customers are clean +// (s.deleteCustomer + s.customer in setup handle the DB rows). +const INVOICE_TEST_CUSTOMER_IDS = ["rc-invoices-1", "rc-invoices-nonrenewing-1"]; +const INVOICE_TEST_TX_IDS = [ + "rc3_tx_initial_001", + "rc3_tx_renewal_002", + "rc3_tx_nonrenewing_001", +]; + +const clearInvoiceTestData = async () => { + await ctx.db + .delete(invoices) + .where(inArray(invoices.stripe_id, INVOICE_TEST_TX_IDS)); + + for (const customerId of INVOICE_TEST_CUSTOMER_IDS) { + await deleteCachedFullCustomer({ + ctx, + customerId, + source: "revenuecat-test-cleanup", + }).catch(() => {}); + } +}; + // ═══════════════════════════════════════════════════════════════════════════════ // TEST 1: RevenueCat webhook lifecycle (purchase, upgrade, cancel, expire, add-on) // (from revenuecat-webhooks.test.ts) @@ -87,9 +132,8 @@ test.concurrent(`${chalk.yellowBright("revenuecat 1: webhook lifecycle")}`, asyn const RC_ADD_ON_ID = "com.app.rc1_add_on_pack"; // Autumn products - const messagesItem = items.monthlyMessages({ includedUsage: 1000 }); - const proMonthly = products.pro({ id: "pro-monthly", items: [messagesItem] }); - const proYearly = products.proAnnual({ id: "pro-yearly", items: [items.monthlyMessages({ includedUsage: 1000 })] }); + const proMonthly = rcProMonthly(); + const proYearly = rcProYearly(); const addOnPack = products.base({ id: "add-on", items: [items.lifetimeMessages({ includedUsage: 100 })], @@ -427,6 +471,10 @@ test.concurrent(`${chalk.yellowBright("revenuecat 2: customer migration v1 to v2 test.concurrent( `${chalk.yellowBright("revenuecat 3: writes invoice rows for INITIAL_PURCHASE / RENEWAL / NON_RENEWING_PURCHASE, refunds existing invoice for CANCELLATION-as-refund")}`, async () => { + // Clear stale invoices/customers from prior runs (fixed ids + invoice + // upsert-by-stripe_id leave orphans that otherwise make this read 0 invoices). + await clearInvoiceTestData(); + const customerId = "rc-invoices-1"; const nonRenewingCustomerId = "rc-invoices-nonrenewing-1"; @@ -448,6 +496,7 @@ test.concurrent( const { autumnV1, autumnV2_1 } = await initScenario({ customerId, setup: [ + s.deleteCustomer({ customerId }), s.customer({ testClock: false }), s.products({ list: [proMonthly, addOnPack] }), ], @@ -457,7 +506,10 @@ test.concurrent( // Initialize the second (non-renewing) customer in the same scenario context await initScenario({ customerId: nonRenewingCustomerId, - setup: [s.customer({ testClock: false })], + setup: [ + s.deleteCustomer({ customerId: nonRenewingCustomerId }), + s.customer({ testClock: false }), + ], actions: [], }); @@ -491,7 +543,10 @@ test.concurrent( // ─── Assertion 1: INITIAL_PURCHASE writes an invoice row ──────────────── const initialTxId = "rc3_tx_initial_001"; const initialPrice = 9.99; - const initialCurrency = "usd"; + // RevenueCat's `price` is normalized to USD; `currency` describes the + // purchase currency only. A non-USD purchase must still record total in + // USD with currency "usd" (regression: INR-labeled USD amounts). + const initialCurrency = "inr"; const initialPurchasedAt = Date.now(); expectWebhookSuccess( @@ -513,7 +568,7 @@ test.concurrent( const initialInvoiceV1 = v1Customer.invoices![0]!; expect(initialInvoiceV1.stripe_id).toBe(initialTxId); expect(initialInvoiceV1.total).toBe(initialPrice); - expect(initialInvoiceV1.currency).toBe(initialCurrency); + expect(initialInvoiceV1.currency).toBe("usd"); expect(initialInvoiceV1.status).toBe("paid"); // V5 fetch exposes processor_type @@ -529,7 +584,7 @@ test.concurrent( expect(initialInvoiceV5.processor_type).toBe(ProcessorType.RevenueCat); expect(initialInvoiceV5.stripe_id).toBe(initialTxId); expect(initialInvoiceV5.total).toBe(initialPrice); - expect(initialInvoiceV5.currency).toBe(initialCurrency); + expect(initialInvoiceV5.currency).toBe("usd"); expect(initialInvoiceV5.status).toBe("paid"); // ─── Assertion 2: RENEWAL with new transaction_id writes a second row ── @@ -654,5 +709,7 @@ test.concurrent( expect(inv.processor_type).toBe(ProcessorType.RevenueCat); } } + + await clearInvoiceTestData(); }, ); diff --git a/server/tests/integration/platform/link-revenuecat.test.ts b/server/tests/integration/platform/link-revenuecat.test.ts new file mode 100644 index 000000000..e58e7a43c --- /dev/null +++ b/server/tests/integration/platform/link-revenuecat.test.ts @@ -0,0 +1,160 @@ +/** + * Integration coverage for the platform RevenueCat link flow against the live + * server (atmn-srv on :8080). + * + * `POST /v1/platform.link_revenuecat` makes NO RevenueCat HTTP call — it only + * builds the authorize URL and writes OAuth state to Redis — so the request + * half is fully testable here. The callback's happy path (real token exchange + + * project creation) needs a real OAuth code and stays unit-tested; only its + * deterministic guard branches (which return before any RC call) are exercised. + */ + +import { beforeAll, describe, expect, test } from "bun:test"; +import { AppEnv } from "@autumn/shared"; +import defaultCtx, { + type TestContext, +} from "@tests/utils/testInitUtils/createTestContext.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { OrgService } from "@/internal/orgs/OrgService.js"; +import { + consumeOAuthState, + generateOAuthState, +} from "@/internal/platform/platformBeta/utils/oauthStateUtils.js"; + +const SERVER_BASE = ( + process.env.AUTUMN_TEST_BASE_URL ?? "http://localhost:8080" +).replace(/\/$/, ""); + +const REDIRECT_URL = "https://platform.example.com/callback/revenuecat"; +const REDIRECT_STATUSES = [301, 302, 303, 307, 308]; + +let subCtx: TestContext; +let bareSlug: string; +let masterAutumn: AutumnInt; + +beforeAll(async () => { + const { ctx } = await initScenario({ + setup: [s.platform.create({})], + actions: [], + }); + subCtx = ctx; + // Must pass the bare slug: validatePlatformOrg re-appends `|`. + bareSlug = ctx.org.slug.split("|")[0]; + masterAutumn = new AutumnInt({ secretKey: defaultCtx.orgSecretKey }); +}, 120_000); + +describe("POST /v1/platform.link_revenuecat", () => { + test("returns an RC authorize URL and persists the OAuth state", async () => { + const projectName = `atmn-it-${Math.random().toString(36).slice(2, 8)}`; + + const res = (await masterAutumn.post("/platform.link_revenuecat", { + organization_slug: bareSlug, + env: "test", + project_name: projectName, + redirect_url: REDIRECT_URL, + })) as { oauth_url: string }; + + expect( + res.oauth_url.startsWith("https://api.revenuecat.com/oauth2/authorize"), + ).toBe(true); + + const url = new URL(res.oauth_url); + expect(url.searchParams.get("response_type")).toBe("code"); + expect(url.searchParams.get("client_id")).toBeTruthy(); + expect(url.searchParams.get("code_challenge")).toBeTruthy(); + expect(url.searchParams.get("code_challenge_method")).toBe("S256"); + expect(url.searchParams.get("scope")).toBeTruthy(); + + const stateKey = url.searchParams.get("state"); + expect(stateKey).toBeTruthy(); + + // State was written by the server into the shared Redis; read it back. + const state = await consumeOAuthState({ stateKey: stateKey as string }); + expect(state).not.toBeNull(); + expect(state?.env).toBe(AppEnv.Sandbox); + expect(state?.master_org_id).toBe(defaultCtx.org.id); + expect(state?.provider).toBe("revenuecat"); + expect(state?.revenuecat_project_name).toBe(projectName); + expect(state?.redirect_uri).toBe(REDIRECT_URL); + expect(state?.organization_slug).toBe(subCtx.org.slug); + }); + + test("rejects when RevenueCat is already linked for the env", async () => { + // Mark the LIVE env linked, keeping sandbox free so this is order-independent. + await OrgService.update({ + db: subCtx.db, + orgId: subCtx.org.id, + updates: { + processor_configs: { + ...subCtx.org.processor_configs, + revenuecat: { + ...(subCtx.org.processor_configs?.revenuecat ?? {}), + oauth: { + access_token: "enc", + refresh_token: "enc", + expires_at: Date.now() + 3_600_000, + }, + }, + }, + }, + }); + + await expect( + masterAutumn.post("/platform.link_revenuecat", { + organization_slug: bareSlug, + env: "live", + project_name: "Already Linked", + redirect_url: REDIRECT_URL, + }), + ).rejects.toThrow(); + }); + + test("rejects an org not owned by the master org", async () => { + await expect( + masterAutumn.post("/platform.link_revenuecat", { + organization_slug: `atmn-it-missing-${Math.random().toString(36).slice(2, 8)}`, + env: "sandbox", + project_name: "Orphan", + redirect_url: REDIRECT_URL, + }), + ).rejects.toThrow(); + }); +}); + +describe("GET /revenuecat/oauth_callback (guard branches)", () => { + test("redirects with error=invalid_state for an unknown state", async () => { + const res = await fetch( + `${SERVER_BASE}/revenuecat/oauth_callback?code=x&state=missing-${Math.random().toString(36).slice(2)}`, + { redirect: "manual" }, + ); + + expect(REDIRECT_STATUSES).toContain(res.status); + expect(res.headers.get("location") ?? "").toContain("error=invalid_state"); + }); + + test("platform flow: redirects org_permission_denied when master_org_id mismatches", async () => { + // Real state, but a master_org_id that does not own subCtx's org → the + // callback returns at the permission check, before any RevenueCat call. + const stateKey = await generateOAuthState({ + organizationSlug: subCtx.org.slug, + env: AppEnv.Sandbox, + redirectUri: REDIRECT_URL, + masterOrgId: "org_not_the_owner", + codeVerifier: "test-verifier", + provider: "revenuecat", + revenuecatProjectName: "Mismatch Project", + }); + + const res = await fetch( + `${SERVER_BASE}/revenuecat/oauth_callback?code=x&state=${stateKey}`, + { redirect: "manual" }, + ); + + expect(REDIRECT_STATUSES).toContain(res.status); + const location = res.headers.get("location") ?? ""; + expect(location).toContain("success=false"); + expect(location).toContain("provider=revenuecat"); + expect(location).toContain("message=org_permission_denied"); + }); +}); diff --git a/server/tests/integration/scopes/scope-403.test.ts b/server/tests/integration/scopes/scope-403.test.ts index 37263f265..577bdb35a 100644 --- a/server/tests/integration/scopes/scope-403.test.ts +++ b/server/tests/integration/scopes/scope-403.test.ts @@ -1355,6 +1355,51 @@ const ROUTES = [ needsScopes: true, isWebhookExempt: false, }, + { + handlerName: "handleLinkRevenueCat", + handlerFile: + "src/internal/platform/platformBeta/handlers/handleLinkRevenueCat.ts", + method: "POST", + path: "/v1/platform.link_revenuecat", + style: "RPC", + group: "v1/platform", + mountChain: ["/v1", "", "", "/platform.link_revenuecat"], + sourceRouterFile: + "src/internal/platform/platformBeta/platformRpcRouter.ts", + routeKind: "createRoute", + needsScopes: true, + isWebhookExempt: false, + }, + { + handlerName: "handleSyncRevenueCat", + handlerFile: + "src/internal/platform/platformBeta/handlers/handleSyncRevenueCat.ts", + method: "POST", + path: "/v1/platform.sync_revenuecat", + style: "RPC", + group: "v1/platform", + mountChain: ["/v1", "", "", "/platform.sync_revenuecat"], + sourceRouterFile: + "src/internal/platform/platformBeta/platformRpcRouter.ts", + routeKind: "createRoute", + needsScopes: true, + isWebhookExempt: false, + }, + { + handlerName: "handleGetRevenueCatKeys", + handlerFile: + "src/internal/platform/platformBeta/handlers/handleGetRevenueCatKeys.ts", + method: "POST", + path: "/v1/platform.get_revenuecat_keys", + style: "RPC", + group: "v1/platform", + mountChain: ["/v1", "", "", "/platform.get_revenuecat_keys"], + sourceRouterFile: + "src/internal/platform/platformBeta/platformRpcRouter.ts", + routeKind: "createRoute", + needsScopes: true, + isWebhookExempt: false, + }, { handlerName: "handleCreateSchedule", handlerFile: "src/internal/billing/v2/handlers/handleCreateSchedule.ts", @@ -4247,6 +4292,27 @@ const SCOPE_DECISIONS: Record< shape: "array", decidedAt: "2026-04-24T15:32:37.301Z", }, + "POST|/v1/platform.link_revenuecat|handleLinkRevenueCat": { + decision: "decided", + scopes: ["platform:write"], + shape: "array", + note: "platform RPC route — write", + decidedAt: "2026-06-01T00:00:00.000Z", + }, + "POST|/v1/platform.sync_revenuecat|handleSyncRevenueCat": { + decision: "decided", + scopes: ["platform:write"], + shape: "array", + note: "platform RPC route — write", + decidedAt: "2026-06-01T00:00:00.000Z", + }, + "POST|/v1/platform.get_revenuecat_keys|handleGetRevenueCatKeys": { + decision: "decided", + scopes: ["platform:write"], + shape: "array", + note: "platform RPC route — write", + decidedAt: "2026-06-01T00:00:00.000Z", + }, "POST|/v1/billing.create_schedule|handleCreateSchedule": { decision: "decided", scopes: ["billing:write"], diff --git a/server/tests/integration/utils/expectBalanceCorrect.ts b/server/tests/integration/utils/expectBalanceCorrect.ts index 096959591..baed0fc22 100644 --- a/server/tests/integration/utils/expectBalanceCorrect.ts +++ b/server/tests/integration/utils/expectBalanceCorrect.ts @@ -8,10 +8,10 @@ import { type ResetInterval, } from "@autumn/shared"; -const roundTo8Dp = (value: number) => - Math.round(value * 1e8) / 1e8; +const roundTo8Dp = (value: number) => Math.round(value * 1e8) / 1e8; type BucketExpectation = { + granted?: number; included_grant?: number; prepaid_grant?: number; remaining?: number; @@ -28,6 +28,7 @@ type BreakdownExpectation = Partial>; export const expectBalanceCorrect = ({ customer, featureId, + granted, remaining, planId, usage, @@ -35,10 +36,12 @@ export const expectBalanceCorrect = ({ toleranceMs = TEN_MINUTES_MS, breakdown, rollovers, + positiveRolloverCount, }: { customer: ApiCustomerV5 | ApiEntityV2; featureId: string; - remaining: number; + granted?: number; + remaining?: number; planId?: string | null; usage?: number; nextResetAt?: number | null; @@ -46,10 +49,18 @@ export const expectBalanceCorrect = ({ breakdown?: BreakdownExpectation; /** Expected rollovers in order (oldest first). Only specified fields are checked. */ rollovers?: Partial[]; + positiveRolloverCount?: number; }) => { const balance = customer.balances[featureId]; expect(balance).toBeDefined(); - expect(roundTo8Dp(balance.remaining)).toBe(roundTo8Dp(remaining)); + + if (typeof granted !== "undefined") { + expect(roundTo8Dp(balance.granted)).toBe(roundTo8Dp(granted)); + } + + if (typeof remaining !== "undefined") { + expect(roundTo8Dp(balance.remaining)).toBe(roundTo8Dp(remaining)); + } if (typeof planId !== "undefined") { expect(balance.breakdown?.[0]?.plan_id ?? null).toBe(planId); @@ -107,4 +118,11 @@ export const expectBalanceCorrect = ({ expect(actual![i]).toMatchObject(rollovers[i]); } } + + if (typeof positiveRolloverCount !== "undefined") { + const actual = balance.rollovers ?? []; + expect(actual.filter((item) => item.balance > 0).length).toBe( + positiveRolloverCount, + ); + } }; diff --git a/server/tests/scenarios/attach/prepaid-consumable-rollover-scenario.test.ts b/server/tests/scenarios/attach/prepaid-consumable-rollover-scenario.test.ts index e70b903ef..d7444c7c1 100644 --- a/server/tests/scenarios/attach/prepaid-consumable-rollover-scenario.test.ts +++ b/server/tests/scenarios/attach/prepaid-consumable-rollover-scenario.test.ts @@ -1,13 +1,14 @@ import { test } from "bun:test"; -import { RolloverExpiryDurationType } from "@autumn/shared"; +import { type ApiCustomerV5, RolloverExpiryDurationType } from "@autumn/shared"; +import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect"; +import { TestFeature } from "@tests/setup/v2Features"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; import { constructArrearItem, constructPrepaidItem, } from "@/utils/scriptUtils/constructItem"; -import { products } from "@tests/utils/fixtures/products"; -import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; -import chalk from "chalk"; -import { TestFeature } from "@tests/setup/v2Features"; /** * Scenario: Prepaid + Consumable messages on the same plan, both with rollovers. @@ -48,7 +49,7 @@ test(`${chalk.yellowBright("scenario: prepaid + consumable messages with rollove items: [prepaidMessages, consumableMessages], }); - await initScenario({ + const { autumnV2_2 } = await initScenario({ customerId: "combo-rollover", setup: [ s.customer({ paymentMethod: "success" }), @@ -63,4 +64,13 @@ test(`${chalk.yellowBright("scenario: prepaid + consumable messages with rollove s.advanceToNextInvoice(), ], }); + + const customer = + await autumnV2_2.customers.get("combo-rollover"); + + expectBalanceCorrect({ + customer, + featureId: TestFeature.Messages, + positiveRolloverCount: 2, + }); }); diff --git a/server/tests/unit/analytics/period-grid-timezone.test.ts b/server/tests/unit/analytics/period-grid-timezone.test.ts new file mode 100644 index 000000000..0a344b19d --- /dev/null +++ b/server/tests/unit/analytics/period-grid-timezone.test.ts @@ -0,0 +1,74 @@ +// generateAllPeriods must build the day/month grid in the viewer's timezone so +// it lines up with the pipe's toStartOfDay(hour, tz) buckets; a UTC grid drops +// the newest local day for non-UTC viewers. +// Ref: tickets/ANALYTICS_TIMEZONE_BUCKET_OFFSET.md + +import { expect, test } from "bun:test"; +import chalk from "chalk"; +import { generateAllPeriods } from "@/internal/analytics/actions/aggregate.js"; + +// Window expressed in UTC wall-clock (what calculateDateRange produces and the +// Tinybird pipe filters `hour` on). 2026-06-05 03:00 UTC is still 2026-06-04 +// 23:00 in America/New_York (EDT, UTC-4) -> the viewer's "today" is Jun 4. +const START_UTC = "2026-05-29 03:00:00"; +const END_UTC = "2026-06-05 03:00:00"; + +test(`${chalk.yellowBright( + "analytics period grid: non-UTC viewer's latest day labeled by local calendar day", +)}`, () => { + const periods = generateAllPeriods({ + startDate: START_UTC, + endDate: END_UTC, + binSize: "day", + timezone: "America/New_York", + }); + + // The pipe buckets the live "today" data into the viewer's local day + // (Jun 4 in New York). The grid's newest bucket must match that string, + // not the UTC day (Jun 5). + expect(periods[periods.length - 1]).toBe("2026-06-04 00:00:00"); + // And the earliest bucket should be the viewer's local start day, not the + // UTC start day. + expect(periods[0]).toBe("2026-05-28 00:00:00"); +}); + +test(`${chalk.yellowBright( + "analytics period grid: UTC viewer unchanged (no regression)", +)}`, () => { + const periods = generateAllPeriods({ + startDate: START_UTC, + endDate: END_UTC, + binSize: "day", + timezone: "UTC", + }); + + expect(periods[0]).toBe("2026-05-29 00:00:00"); + expect(periods[periods.length - 1]).toBe("2026-06-05 00:00:00"); +}); + +// Spot-check the acceptance-criteria zones at one instant just past UTC +// midnight (2026-06-05 02:00 UTC). West-of-UTC viewers are still on Jun 4 +// locally; UTC / UTC+1 viewers have rolled to Jun 5. +const BOUNDARY_END_UTC = "2026-06-05 02:00:00"; +const BOUNDARY_START_UTC = "2026-06-01 02:00:00"; + +const zoneCases: { timezone: string; expectedLatest: string }[] = [ + { timezone: "America/Los_Angeles", expectedLatest: "2026-06-04 00:00:00" }, + { timezone: "America/New_York", expectedLatest: "2026-06-04 00:00:00" }, + { timezone: "UTC", expectedLatest: "2026-06-05 00:00:00" }, + { timezone: "Europe/London", expectedLatest: "2026-06-05 00:00:00" }, +]; + +for (const { timezone, expectedLatest } of zoneCases) { + test(`${chalk.yellowBright( + `analytics period grid: latest local day for ${timezone}`, + )}`, () => { + const periods = generateAllPeriods({ + startDate: BOUNDARY_START_UTC, + endDate: BOUNDARY_END_UTC, + binSize: "day", + timezone, + }); + expect(periods[periods.length - 1]).toBe(expectedLatest); + }); +} diff --git a/server/tests/unit/auth/atmnOAuthClients.test.ts b/server/tests/unit/auth/atmnOAuthClients.test.ts new file mode 100644 index 000000000..afa752f3a --- /dev/null +++ b/server/tests/unit/auth/atmnOAuthClients.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from "bun:test"; +import { isAtmnOAuthClientRecord } from "@/internal/auth/oauth/atmnOAuthClients.js"; + +describe("isAtmnOAuthClientRecord", () => { + test("does not classify arbitrary metadata values as atmn", () => { + expect( + isAtmnOAuthClientRecord({ + clientId: "client_123", + name: "Third Party App", + metadata: { description: "connects to atmn projects" }, + }), + ).toBe(false); + }); + + test("classifies explicit atmn metadata and names", () => { + expect( + isAtmnOAuthClientRecord({ + clientId: "client_123", + name: "Third Party App", + metadata: { kind: "atmn" }, + }), + ).toBe(true); + + expect( + isAtmnOAuthClientRecord({ + clientId: "client_123", + name: "atmn", + }), + ).toBe(true); + }); +}); diff --git a/server/tests/unit/auth/oauthApiKeyRepo.test.ts b/server/tests/unit/auth/oauthApiKeyRepo.test.ts new file mode 100644 index 000000000..6a2aabc25 --- /dev/null +++ b/server/tests/unit/auth/oauthApiKeyRepo.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, test } from "bun:test"; +import { AppEnv } from "@autumn/shared"; +import { isOAuthConsentLinkedApiKey } from "@/internal/auth/repos/oauthApiKeyRepo.js"; + +type GuardApiKey = Parameters[0]["apiKey"]; + +const oauthMeta = { + created_via: "oauth", + oauth_consent_id: "consent_123", + oauth_client_id: "autumn_mcp_cursor", + oauth_redirect_uri: "cursor://oauth/callback", + env: AppEnv.Sandbox, +}; + +const baseApiKey: GuardApiKey = { + id: "key_123", + orgId: "org_123", + userId: "user_123", + env: AppEnv.Sandbox, + hashedKey: "hashed", + meta: oauthMeta, +}; + +const matchesConsent = (apiKey: GuardApiKey) => + isOAuthConsentLinkedApiKey({ + apiKey, + consentId: "consent_123", + clientId: "autumn_mcp_cursor", + redirectUri: "cursor://oauth/callback", + orgId: "org_123", + userId: "user_123", + env: AppEnv.Sandbox, + }); + +describe("isOAuthConsentLinkedApiKey", () => { + test("accepts an OAuth-created key linked to the same consent", () => { + expect(matchesConsent(baseApiKey)).toBe(true); + }); + + test("rejects a user-created key even if it is the stored api key id", () => { + expect( + matchesConsent({ + ...baseApiKey, + meta: { created_via: "dashboard" }, + }), + ).toBe(false); + }); + + test("rejects an OAuth key linked to a different consent", () => { + expect( + matchesConsent({ + ...baseApiKey, + meta: { + ...oauthMeta, + oauth_consent_id: "consent_other", + }, + }), + ).toBe(false); + }); + + test("rejects an OAuth key linked to a different redirect URI", () => { + expect( + isOAuthConsentLinkedApiKey({ + apiKey: baseApiKey, + consentId: "consent_123", + clientId: "autumn_mcp_cursor", + redirectUri: "cursor://oauth/other-callback", + orgId: "org_123", + userId: "user_123", + env: AppEnv.Sandbox, + }), + ).toBe(false); + }); + + test("rejects an OAuth key with different ownership or env", () => { + expect(matchesConsent({ ...baseApiKey, orgId: "org_other" })).toBe(false); + expect(matchesConsent({ ...baseApiKey, userId: "user_other" })).toBe(false); + expect(matchesConsent({ ...baseApiKey, env: AppEnv.Live })).toBe(false); + }); +}); diff --git a/server/tests/unit/billing/invoice-matched-credits/invoice-credit-matcher.spec.ts b/server/tests/unit/billing/invoice-matched-credits/invoice-credit-matcher.spec.ts new file mode 100644 index 000000000..59a9bbb0d --- /dev/null +++ b/server/tests/unit/billing/invoice-matched-credits/invoice-credit-matcher.spec.ts @@ -0,0 +1,317 @@ +import { describe, expect, test } from "bun:test"; +import type { BillingContext, DbInvoiceLineItem } from "@autumn/shared"; +import { contexts } from "@tests/utils/fixtures/db/contexts"; +import { customerProducts } from "@tests/utils/fixtures/db/customerProducts"; +import { prices } from "@tests/utils/fixtures/db/prices"; +import chalk from "chalk"; +import { getRefundLineItemsForPrice } from "@/internal/billing/v2/utils/lineItems/getRefundLineItemsForPrice"; +import { invoiceCreditFromStoredLineItems } from "@/internal/billing/v2/utils/lineItems/invoiceCreditFromStoredLineItems"; +import { + computeAlreadyRefundedForCharge, + computeProratedCredit, + splitMultiEntityAmount, +} from "@/internal/billing/v2/utils/lineItems/storedLineItemUtils"; + +const PERIOD_START = 1_700_000_000_000; +const PERIOD_END = PERIOD_START + 30 * 24 * 60 * 60 * 1000; +const MID_CYCLE = PERIOD_START + 15 * 24 * 60 * 60 * 1000; + +const makeChargeRow = ( + overrides: Partial = {}, +): DbInvoiceLineItem => + ({ + id: "li_charge_1", + amount: 20, + amount_after_discounts: 20, + effective_period_start: PERIOD_START, + effective_period_end: PERIOD_END, + customer_product_ids: ["cp_1"], + price_id: "price_pro", + stripe_price_id: "stripe_price_pro", + direction: "charge", + discounts: [], + ...overrides, + }) as DbInvoiceLineItem; + +const makeRefundRow = ( + overrides: Partial = {}, +): DbInvoiceLineItem => + ({ + id: "li_refund_1", + amount: -10, + amount_after_discounts: -10, + effective_period_start: PERIOD_START, + effective_period_end: PERIOD_END, + customer_product_ids: ["cp_1"], + price_id: "price_pro", + stripe_price_id: "stripe_price_pro", + direction: "refund", + discounts: [], + ...overrides, + }) as DbInvoiceLineItem; + +describe(chalk.yellowBright("computeProratedCredit"), () => { + test("prorates a full charge at mid-cycle to ~half negative", () => { + const result = computeProratedCredit({ + chargeRow: makeChargeRow(), + now: MID_CYCLE, + alreadyRefunded: 0, + }); + + expect(result).toBeLessThan(0); + expect(result).toBeCloseTo(-10, 0); + }); + + test("returns 0 when period has ended", () => { + const result = computeProratedCredit({ + chargeRow: makeChargeRow(), + now: PERIOD_END + 1000, + alreadyRefunded: 0, + }); + + expect(result).toBe(0); + }); + + test("returns 0 when period is null", () => { + const result = computeProratedCredit({ + chargeRow: makeChargeRow({ effective_period_start: null }), + now: MID_CYCLE, + alreadyRefunded: 0, + }); + + expect(result).toBe(0); + }); + + test("subtracts already-refunded before prorating", () => { + const fullCredit = computeProratedCredit({ + chargeRow: makeChargeRow({ amount_after_discounts: 20 }), + now: MID_CYCLE, + alreadyRefunded: 0, + }); + + const partialCredit = computeProratedCredit({ + chargeRow: makeChargeRow({ amount_after_discounts: 20 }), + now: MID_CYCLE, + alreadyRefunded: 10, + }); + + expect(Math.abs(partialCredit)).toBeLessThan(Math.abs(fullCredit)); + }); + + test("returns 0 when fully refunded", () => { + const result = computeProratedCredit({ + chargeRow: makeChargeRow({ amount_after_discounts: 20 }), + now: MID_CYCLE, + alreadyRefunded: 20, + }); + + expect(result).toBe(0); + }); + + test("uses amount_after_discounts (discounted charge gives smaller credit)", () => { + const fullPriceCredit = computeProratedCredit({ + chargeRow: makeChargeRow({ amount_after_discounts: 20 }), + now: MID_CYCLE, + alreadyRefunded: 0, + }); + + const discountedCredit = computeProratedCredit({ + chargeRow: makeChargeRow({ amount_after_discounts: 16 }), + now: MID_CYCLE, + alreadyRefunded: 0, + }); + + expect(Math.abs(discountedCredit)).toBeLessThan(Math.abs(fullPriceCredit)); + }); +}); + +describe(chalk.yellowBright("computeAlreadyRefundedForCharge"), () => { + test("sums matching refund rows by price and period", () => { + const result = computeAlreadyRefundedForCharge({ + chargeRow: makeChargeRow(), + refundRows: [ + makeRefundRow({ amount_after_discounts: -5 }), + makeRefundRow({ id: "li_refund_2", amount_after_discounts: -3 }), + ], + }); + + expect(result).toBe(8); + }); + + test("excludes refunds with different price_id", () => { + const result = computeAlreadyRefundedForCharge({ + chargeRow: makeChargeRow(), + refundRows: [ + makeRefundRow({ price_id: "price_other", stripe_price_id: "other" }), + ], + }); + + expect(result).toBe(0); + }); + + test("excludes refunds outside the charge period", () => { + const result = computeAlreadyRefundedForCharge({ + chargeRow: makeChargeRow(), + refundRows: [ + makeRefundRow({ + effective_period_start: PERIOD_END + 1000, + effective_period_end: PERIOD_END + 30 * 24 * 60 * 60 * 1000, + }), + ], + }); + + expect(result).toBe(0); + }); + + test("returns 0 with no refund rows", () => { + const result = computeAlreadyRefundedForCharge({ + chargeRow: makeChargeRow(), + refundRows: [], + }); + + expect(result).toBe(0); + }); +}); + +describe(chalk.yellowBright("splitMultiEntityAmount"), () => { + test("returns full amount for single cusProduct", () => { + const result = splitMultiEntityAmount( + makeChargeRow({ amount_after_discounts: 30 }), + ); + + expect(result).toBe(30); + }); + + test("splits evenly across multiple cusProduct ids", () => { + const result = splitMultiEntityAmount( + makeChargeRow({ + amount_after_discounts: 30, + customer_product_ids: ["cp_1", "cp_2", "cp_3"], + }), + ); + + expect(result).toBe(10); + }); + + test("handles empty customer_product_ids", () => { + const result = splitMultiEntityAmount( + makeChargeRow({ + amount_after_discounts: 30, + customer_product_ids: [], + }), + ); + + expect(result).toBe(30); + }); +}); + +describe(chalk.yellowBright("invoiceCreditFromStoredLineItems"), () => { + const buildMultiPriceContext = ({ + storedChargeLineItems, + }: { + storedChargeLineItems: DbInvoiceLineItem[]; + }) => { + const proPrice = prices.createFixed({ id: "price_pro" }); + const addonPrice = prices.createFixed({ id: "price_addon" }); + const customerProduct = customerProducts.create({ + id: "cp_1", + customerPrices: [ + prices.createCustomer({ price: proPrice, customerProductId: "cp_1" }), + prices.createCustomer({ price: addonPrice, customerProductId: "cp_1" }), + ], + }); + const billingContext: BillingContext = { + ...contexts.createBilling({ + customerProducts: [customerProduct], + currentEpochMs: MID_CYCLE, + }), + storedChargeLineItems, + storedRefundLineItems: [], + }; + return { ctx: contexts.create({}), customerProduct, billingContext }; + }; + + test("does not duplicate credits when only some prices have stored rows", () => { + const { ctx, customerProduct, billingContext } = buildMultiPriceContext({ + storedChargeLineItems: [makeChargeRow({ price_id: "price_pro" })], + }); + + const result = invoiceCreditFromStoredLineItems({ + ctx, + customerProduct, + billingContext, + }); + + expect(result.allPricesResolved).toBe(false); + expect(result.resolvedPriceIds).toEqual(["price_pro"]); + expect(result.lineItems).toHaveLength(1); + expect(result.lineItems[0].amount).toBeLessThan(0); + }); + + test("resolves all prices when every price has a stored row", () => { + const { ctx, customerProduct, billingContext } = buildMultiPriceContext({ + storedChargeLineItems: [ + makeChargeRow({ id: "li_charge_pro", price_id: "price_pro" }), + makeChargeRow({ id: "li_charge_addon", price_id: "price_addon" }), + ], + }); + + const result = invoiceCreditFromStoredLineItems({ + ctx, + customerProduct, + billingContext, + }); + + expect(result.allPricesResolved).toBe(true); + expect(result.resolvedPriceIds).toEqual(["price_pro", "price_addon"]); + expect(result.lineItems).toHaveLength(2); + }); +}); + +describe(chalk.yellowBright("getRefundLineItemsForPrice"), () => { + const buildSinglePriceContext = ({ + storedChargeLineItems, + }: { + storedChargeLineItems: DbInvoiceLineItem[]; + }) => { + const proPrice = prices.createFixed({ id: "price_pro" }); + const customerProduct = customerProducts.create({ + id: "cp_1", + customerPrices: [ + prices.createCustomer({ price: proPrice, customerProductId: "cp_1" }), + ], + }); + const billingContext: BillingContext = { + ...contexts.createBilling({ + customerProducts: [customerProduct], + currentEpochMs: MID_CYCLE, + }), + storedChargeLineItems, + storedRefundLineItems: [], + }; + return { ctx: contexts.create({}), customerProduct, billingContext }; + }; + + test("returns every matched credit when a price has multiple stored charge rows", () => { + const { ctx, customerProduct, billingContext } = buildSinglePriceContext({ + storedChargeLineItems: [ + makeChargeRow({ id: "li_charge_initial", price_id: "price_pro" }), + makeChargeRow({ id: "li_charge_topup", price_id: "price_pro" }), + ], + }); + + const result = getRefundLineItemsForPrice({ + ctx, + customerProduct, + billingContext, + priceId: "price_pro", + catalogFallback: undefined, + }); + + expect(result).toHaveLength(2); + for (const lineItem of result) { + expect(lineItem.context.price.id).toBe("price_pro"); + expect(lineItem.amount).toBeLessThan(0); + } + }); +}); diff --git a/server/tests/unit/billing/stripe/subscription-schedules/build-schedule-phases.spec.ts b/server/tests/unit/billing/stripe/subscription-schedules/build-schedule-phases.spec.ts index bf7816190..03ff3d986 100644 --- a/server/tests/unit/billing/stripe/subscription-schedules/build-schedule-phases.spec.ts +++ b/server/tests/unit/billing/stripe/subscription-schedules/build-schedule-phases.spec.ts @@ -144,7 +144,7 @@ describe( // Phase 2: Pro expect(phases[1].start_date).toBe(msToSeconds(proStartMs)); expect(phases[1].end_date).toBeUndefined(); - expect(phases[1].proration_behavior).toBeUndefined(); + expect(phases[1].proration_behavior).toBe("always_invoice"); expectPhaseItems(phases[1].items!, getStripePriceIds(pro)); }); diff --git a/server/tests/unit/revenuecat/buildRcPreflightItems.test.ts b/server/tests/unit/revenuecat/buildRcPreflightItems.test.ts new file mode 100644 index 000000000..55daa30ad --- /dev/null +++ b/server/tests/unit/revenuecat/buildRcPreflightItems.test.ts @@ -0,0 +1,109 @@ +/** + * Unit tests for buildRcPreflightItems — the read-only sync preview. Per plan it + * matches the minted store id (autumn.{env}.{org}.{planId}) to an RC product and + * reports Autumn's base price vs RC's, so the sheet can show Create/Rename + price + * mismatch. No network/DB: listPrices is injected. + */ + +import { + AppEnv, + BillingInterval, + type FullProduct, + type Organization, + type Price, + PriceType, +} from "@autumn/shared"; +import { expect, test } from "bun:test"; +import chalk from "chalk"; +import { buildRcPreflightItems } from "@/external/revenueCat/handlers/handlePreflightRevenueCatSync.js"; +import type { RevenueCatProduct } from "@/external/revenueCat/revenuecatTypes.js"; + +const org = { id: "org_1", default_currency: "usd" } as unknown as Organization; +const env = AppEnv.Sandbox; +const storeId = (planId: string) => `autumn.${env}.${org.id}.${planId}`; + +const fixed = (amount: number): Price => + ({ + config: { + type: PriceType.Fixed, + amount, + interval: BillingInterval.Month, + interval_count: 1, + }, + }) as unknown as Price; + +const product = (id: string, name: string, prices: Price[] = [fixed(4.99)]): FullProduct => + ({ id, name, prices }) as unknown as FullProduct; + +const rcProduct = ( + storeIdentifier: string, + display_name: string, + id = "prod_x", +): RevenueCatProduct => + ({ id, store_identifier: storeIdentifier, display_name }) as RevenueCatProduct; + +test(`${chalk.yellowBright("preflight: plan with no RC product -> Create (rc_exists false)")}`, async () => { + const [item] = await buildRcPreflightItems({ + products: [product("pro", "Pro")], + rcProducts: [], + org, + env, + listPrices: async () => [], + }); + + expect(item.rc_exists).toBe(false); + expect(item.rc_name).toBeNull(); + expect(item.autumn_price).toEqual({ amount_micros: 4_990_000, currency: "USD" }); +}); + +test(`${chalk.yellowBright("preflight: matching RC product surfaces name + price for rename/mismatch checks")}`, async () => { + const [item] = await buildRcPreflightItems({ + products: [product("pro", "Pro")], + rcProducts: [rcProduct(storeId("pro"), "Old Name", "prod_1")], + org, + env, + // RC price differs from Autumn's 4.99 -> a mismatch the sheet flags + listPrices: async (id) => + id === "prod_1" ? [{ id: "prc1", amount_micros: 5_990_000, currency: "USD" }] : [], + }); + + expect(item.rc_exists).toBe(true); + expect(item.rc_name).toBe("Old Name"); + expect(item.autumn_price).toEqual({ amount_micros: 4_990_000, currency: "USD" }); + expect(item.rc_price).toEqual({ amount_micros: 5_990_000, currency: "USD" }); +}); + +test(`${chalk.yellowBright("preflight: RC product without a price -> rc_price null")}`, async () => { + const [item] = await buildRcPreflightItems({ + products: [product("pro", "Pro")], + rcProducts: [rcProduct(storeId("pro"), "Pro", "prod_2")], + org, + env, + listPrices: async () => [], + }); + + expect(item.rc_exists).toBe(true); + expect(item.rc_name).toBe("Pro"); + expect(item.rc_price).toBeNull(); +}); + +test(`${chalk.yellowBright("preflight: only the first RC product per store id is priced (one price fetch)")}`, async () => { + let priceCalls = 0; + const items = await buildRcPreflightItems({ + products: [product("pro", "Pro")], + // two apps share the same minted store id + rcProducts: [ + rcProduct(storeId("pro"), "Pro", "prod_ios"), + rcProduct(storeId("pro"), "Pro", "prod_android"), + ], + org, + env, + listPrices: async () => { + priceCalls += 1; + return [{ id: "prc", amount_micros: 4_990_000, currency: "USD" }]; + }, + }); + + expect(items).toHaveLength(1); + expect(priceCalls).toBe(1); +}); diff --git a/server/tests/unit/revenuecat/getRcBasePrice.test.ts b/server/tests/unit/revenuecat/getRcBasePrice.test.ts new file mode 100644 index 000000000..220197b38 --- /dev/null +++ b/server/tests/unit/revenuecat/getRcBasePrice.test.ts @@ -0,0 +1,66 @@ +/** + * Unit tests for getRcBasePrice — extracts an Autumn plan's flat base price as + * RevenueCat micros + uppercased currency, or null for free / usage-only plans. + */ + +import { + BillingInterval, + type FullProduct, + type Organization, + type Price, + PriceType, +} from "@autumn/shared"; +import { expect, test } from "bun:test"; +import chalk from "chalk"; +import { getRcBasePrice } from "@/external/revenueCat/sync/revenuecatProductSyncUtils.js"; + +const org = (currency = "usd") => + ({ default_currency: currency }) as unknown as Organization; + +const fixedPrice = (amount: number): Price => + ({ + config: { + type: PriceType.Fixed, + amount, + interval: BillingInterval.Month, + interval_count: 1, + }, + }) as unknown as Price; + +const usagePrice = (): Price => + ({ + config: { + type: PriceType.Usage, + bill_when: "end_of_period", + usage_tiers: [{ to: -1, amount: 0.1 }], + interval: BillingInterval.Month, + }, + }) as unknown as Price; + +const product = (prices: Price[]): FullProduct => + ({ id: "pro", name: "Pro", prices }) as unknown as FullProduct; + +test(`${chalk.yellowBright("getRcBasePrice: fixed price -> micros + uppercased currency")}`, () => { + expect(getRcBasePrice({ product: product([fixedPrice(4.99)]), org: org() })).toEqual({ + amountMicros: 4_990_000, + currency: "USD", + }); +}); + +test(`${chalk.yellowBright("getRcBasePrice: respects org currency, uppercased")}`, () => { + expect( + getRcBasePrice({ product: product([fixedPrice(9.99)]), org: org("eur") }), + ).toEqual({ amountMicros: 9_990_000, currency: "EUR" }); +}); + +test(`${chalk.yellowBright("getRcBasePrice: usage-only plan -> null")}`, () => { + expect(getRcBasePrice({ product: product([usagePrice()]), org: org() })).toBeNull(); +}); + +test(`${chalk.yellowBright("getRcBasePrice: free plan (no prices) -> null")}`, () => { + expect(getRcBasePrice({ product: product([]), org: org() })).toBeNull(); +}); + +test(`${chalk.yellowBright("getRcBasePrice: zero-amount base -> null")}`, () => { + expect(getRcBasePrice({ product: product([fixedPrice(0)]), org: org() })).toBeNull(); +}); diff --git a/server/tests/unit/revenuecat/getRevenuecatAccessToken.test.ts b/server/tests/unit/revenuecat/getRevenuecatAccessToken.test.ts new file mode 100644 index 000000000..c28862b69 --- /dev/null +++ b/server/tests/unit/revenuecat/getRevenuecatAccessToken.test.ts @@ -0,0 +1,126 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { AppEnv, type Organization } from "@autumn/shared"; +import { OAuth2Tokens } from "arctic"; +import { encryptData } from "@/utils/encryptUtils.js"; + +const mockRefreshRcTokens = mock(() => + Promise.resolve( + new OAuth2Tokens({ + access_token: "atk_refreshed", + token_type: "Bearer", + expires_in: 3600, + refresh_token: "rtk_rotated", + }), + ), +); + +const mockOrgUpdate = mock( + (_args: { updates: Organization }): Promise => Promise.resolve(null), +); + +mock.module("@/external/revenueCat/misc/revenuecatOAuth.js", () => ({ + refreshRcTokens: mockRefreshRcTokens, +})); + +mock.module("@/internal/orgs/OrgService.js", () => ({ + OrgService: { + update: mockOrgUpdate, + }, +})); + +const { getRevenuecatAccessToken } = await import( + "@/external/revenueCat/misc/getRevenuecatAccessToken.js" +); + +const buildOrg = ({ + expiresAt, + withApiKey = false, +}: { + expiresAt: number; + withApiKey?: boolean; +}): Organization => + ({ + id: "org_123", + processor_configs: { + revenuecat: { + ...(withApiKey + ? { sandbox_api_key: encryptData("legacy_api_key") } + : {}), + sandbox_oauth: { + access_token: encryptData("cached_access_token"), + refresh_token: encryptData("cached_refresh_token"), + expires_at: expiresAt, + }, + webhook_secret: "whsec", + sandbox_webhook_secret: "whsec_sandbox", + }, + }, + }) as Organization; + +describe("getRevenuecatAccessToken", () => { + beforeEach(() => { + process.env.ENCRYPTION_PASSWORD = "test-encryption-password"; + mockRefreshRcTokens.mockClear(); + mockOrgUpdate.mockClear(); + }); + + afterEach(() => { + delete process.env.ENCRYPTION_PASSWORD; + }); + + test("returns cached access token when not expired", async () => { + const org = buildOrg({ expiresAt: Date.now() + 60 * 60 * 1000 }); + + const token = await getRevenuecatAccessToken({ + db: {} as never, + org, + env: AppEnv.Sandbox, + }); + + expect(token).toBe("cached_access_token"); + expect(mockRefreshRcTokens).not.toHaveBeenCalled(); + expect(mockOrgUpdate).not.toHaveBeenCalled(); + }); + + test("refreshes and persists rotated tokens when expired", async () => { + const org = buildOrg({ expiresAt: Date.now() - 1000 }); + + const token = await getRevenuecatAccessToken({ + db: {} as never, + org, + env: AppEnv.Sandbox, + }); + + expect(token).toBe("atk_refreshed"); + expect(mockRefreshRcTokens).toHaveBeenCalledTimes(1); + expect(mockOrgUpdate).toHaveBeenCalledTimes(1); + + const updateCall = mockOrgUpdate.mock.calls[0]?.[0]; + const sandboxOauth = + updateCall?.updates.processor_configs?.revenuecat?.sandbox_oauth; + + expect(sandboxOauth?.access_token).toBeDefined(); + expect(sandboxOauth?.refresh_token).toBeDefined(); + expect(sandboxOauth?.expires_at).toBeGreaterThan(Date.now()); + }); + + test("falls back to legacy api_key when oauth is absent", async () => { + const org = { + id: "org_123", + processor_configs: { + revenuecat: { + sandbox_api_key: encryptData("legacy_api_key"), + }, + }, + } as Organization; + + const token = await getRevenuecatAccessToken({ + db: {} as never, + org, + env: AppEnv.Sandbox, + }); + + expect(token).toBe("legacy_api_key"); + expect(mockRefreshRcTokens).not.toHaveBeenCalled(); + }); +}); diff --git a/server/tests/unit/revenuecat/handleLinkRevenueCat.test.ts b/server/tests/unit/revenuecat/handleLinkRevenueCat.test.ts new file mode 100644 index 000000000..eac3e6070 --- /dev/null +++ b/server/tests/unit/revenuecat/handleLinkRevenueCat.test.ts @@ -0,0 +1,140 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test"; + +const mockValidatePlatformOrg = mock( + (): Promise> => + Promise.resolve({ + id: "org_123", + slug: "test-org", + processor_configs: {}, + }), +); + +const mockGenerateOAuthState = mock( + (): Promise => Promise.resolve("state_123"), +); + +const mockCreateRcAuthorizationUrl = mock( + (): URL => + new URL("https://api.revenuecat.com/oauth2/authorize?state=state_123"), +); + +mock.module( + "@/internal/platform/platformBeta/utils/validatePlatformOrg.js", + () => ({ + validatePlatformOrg: mockValidatePlatformOrg, + }), +); + +mock.module( + "@/internal/platform/platformBeta/utils/oauthStateUtils.js", + () => ({ + generateOAuthState: mockGenerateOAuthState, + }), +); + +mock.module("@/external/revenueCat/misc/revenuecatOAuth.js", () => ({ + createRcAuthorizationUrl: mockCreateRcAuthorizationUrl, + generateCodeVerifier: () => "test-verifier", +})); + +const { handleLinkRevenueCat } = await import( + "@/internal/platform/platformBeta/handlers/handleLinkRevenueCat.js" +); + +const handler = handleLinkRevenueCat[handleLinkRevenueCat.length - 1] as ( + c: any, +) => Promise; + +const createContext = (body: Record) => { + let jsonResponse: unknown = null; + return { + req: { + valid: () => body, + query: () => ({}), + }, + json: (data: unknown) => { + jsonResponse = data; + return { status: 200 }; + }, + getJsonResponse: () => jsonResponse, + set: () => {}, + get: () => ({ + db: {}, + org: { id: "master_org_123", slug: "master-org" }, + logger: { info: () => {}, error: () => {} }, + }), + }; +}; + +describe("handleLinkRevenueCat", () => { + beforeEach(() => { + process.env.REVENUECAT_OAUTH_CLIENT_ID = "rc_client"; + process.env.REVENUECAT_OAUTH_CLIENT_SECRET = "rc_secret"; + process.env.BETTER_AUTH_URL = "https://auth.example.com"; + mockValidatePlatformOrg.mockClear(); + mockGenerateOAuthState.mockClear(); + mockCreateRcAuthorizationUrl.mockClear(); + }); + + test("errors when RevenueCat is already linked for env", async () => { + mockValidatePlatformOrg.mockResolvedValueOnce({ + id: "org_123", + slug: "test-org", + processor_configs: { + revenuecat: { + sandbox_oauth: { + access_token: "encrypted", + refresh_token: "encrypted", + expires_at: Date.now() + 3600000, + }, + }, + }, + }); + + const ctx = createContext({ + organization_slug: "test-org", + env: "test", + project_name: "My Project", + redirect_url: "http://localhost:5173/callback", + }); + + await expect(handler(ctx as never)).rejects.toThrow(); + }); + + test("returns oauth_url and stores state with revenuecat_project_name", async () => { + mockValidatePlatformOrg.mockResolvedValueOnce({ + id: "org_123", + slug: "test-org", + processor_configs: {}, + }); + + const ctx = createContext({ + organization_slug: "test-org", + env: "test", + project_name: "My Project", + redirect_url: "http://localhost:5173/callback", + }); + + await handler(ctx as never); + + expect(mockGenerateOAuthState).toHaveBeenCalledWith( + expect.objectContaining({ + organizationSlug: "test-org", + env: "sandbox", + redirectUri: "http://localhost:5173/callback", + masterOrgId: "master_org_123", + provider: "revenuecat", + revenuecatProjectName: "My Project", + }), + ); + expect(mockCreateRcAuthorizationUrl).toHaveBeenCalledWith( + expect.objectContaining({ + state: "state_123", + codeVerifier: "test-verifier", + }), + ); + expect(ctx.getJsonResponse()).toEqual({ + oauth_url: "https://api.revenuecat.com/oauth2/authorize?state=state_123", + }); + }); +}); diff --git a/server/tests/unit/revenuecat/handleRevenueCatOAuthCallback.test.ts b/server/tests/unit/revenuecat/handleRevenueCatOAuthCallback.test.ts new file mode 100644 index 000000000..bba895e64 --- /dev/null +++ b/server/tests/unit/revenuecat/handleRevenueCatOAuthCallback.test.ts @@ -0,0 +1,401 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { OAuth2Tokens } from "arctic"; + +type MockOAuthState = { + organization_slug: string; + env: string; + redirect_uri: string; + master_org_id: string | null; + code_verifier?: string; + provider?: string; + revenuecat_project_name?: string; + migration?: boolean; +}; + +const mockConsumeOAuthState = mock( + (): Promise => Promise.resolve(null), +); +const mockExchangeRcCode = mock(() => + Promise.resolve( + new OAuth2Tokens({ + access_token: "atk_new", + token_type: "Bearer", + expires_in: 3600, + refresh_token: "rtk_new", + scope: + "project_configuration:projects:read_write customer_information:customers:read_write", + }), + ), +); +const mockOrgGetBySlug = mock( + (): Promise | null> => Promise.resolve(null), +); +const mockOrgUpdate = mock( + (_args: { updates: any }): Promise => Promise.resolve(null), +); +const mockClearOrgCache = mock((): Promise => Promise.resolve()); +const mockCreateProject = mock(() => + Promise.resolve({ id: "proj_123", name: "Test Project" }), +); +const mockListProjects = mock( + (): Promise<{ projects: { id: string; name: string }[] }> => + Promise.resolve({ projects: [] }), +); +const mockListProductStoreIdentifiers = mock( + (): Promise> => Promise.resolve(new Set()), +); +const mockMappingsGetAll = mock( + (): Promise<{ revenuecat_product_ids: string[] }[]> => Promise.resolve([]), +); + +mock.module("@/db/initDrizzle.js", () => ({ + initDrizzle: () => ({ db: {} }), +})); + +mock.module( + "@/internal/platform/platformBeta/utils/oauthStateUtils.js", + () => ({ + consumeOAuthState: mockConsumeOAuthState, + }), +); + +mock.module("@/external/revenueCat/misc/revenuecatOAuth.js", () => ({ + exchangeRcCode: mockExchangeRcCode, + RC_OAUTH_SCOPES: [ + "project_configuration:projects:read_write", + "customer_information:customers:read_write", + ], + findMissingRcScopes: (granted: string[]) => + [ + "project_configuration:projects:read_write", + "customer_information:customers:read_write", + ].filter( + (required) => + !granted.some((g) => g === required || g === "*:*:read_write"), + ), +})); + +mock.module("@/external/revenueCat/misc/initRevenuecatCli.js", () => ({ + initRevenuecatCli: () => ({ + createProject: mockCreateProject, + listProducts: async () => [], + listProjects: mockListProjects, + listProductStoreIdentifiers: mockListProductStoreIdentifiers, + }), +})); + +mock.module("@/external/revenueCat/misc/RCMappingService.js", () => ({ + RCMappingService: { getAll: mockMappingsGetAll }, +})); + +mock.module("@/internal/orgs/OrgService.js", () => ({ + OrgService: { + getBySlug: mockOrgGetBySlug, + update: mockOrgUpdate, + }, +})); + +mock.module("@/internal/orgs/orgUtils/clearOrgCache.js", () => ({ + clearOrgCache: mockClearOrgCache, +})); + +const { handleRevenueCatOAuthCallback } = await import( + "@/internal/orgs/handlers/revenueCatHandlers/handleRevenueCatOAuthCallback.js" +); + +const createContext = (query: Record) => { + let redirectUrl = ""; + return { + req: { + query: () => query, + }, + redirect: (url: string) => { + redirectUrl = url; + return { status: 302, location: url }; + }, + getRedirectUrl: () => redirectUrl, + }; +}; + +describe("handleRevenueCatOAuthCallback", () => { + beforeEach(() => { + process.env.CLIENT_URL = "http://localhost:5173"; + process.env.ENCRYPTION_PASSWORD = "test-encryption-password"; + mockConsumeOAuthState.mockClear(); + mockExchangeRcCode.mockClear(); + mockOrgGetBySlug.mockClear(); + mockOrgUpdate.mockClear(); + mockClearOrgCache.mockClear(); + mockCreateProject.mockClear(); + mockListProjects.mockClear(); + mockListProjects.mockResolvedValue({ projects: [] }); + mockListProductStoreIdentifiers.mockClear(); + mockListProductStoreIdentifiers.mockResolvedValue(new Set()); + mockMappingsGetAll.mockClear(); + mockMappingsGetAll.mockResolvedValue([]); + }); + + afterEach(() => { + delete process.env.CLIENT_URL; + delete process.env.ENCRYPTION_PASSWORD; + }); + + test("redirects with error when OAuth provider returns error", async () => { + const ctx = createContext({ error: "access_denied" }); + + await handleRevenueCatOAuthCallback(ctx as never); + + expect(ctx.getRedirectUrl()).toContain("error=access_denied"); + expect(ctx.getRedirectUrl()).toContain("tab=revenuecat"); + }); + + test("redirects with missing_parameters when code or state absent", async () => { + const ctx = createContext({ code: "abc" }); + + await handleRevenueCatOAuthCallback(ctx as never); + + expect(ctx.getRedirectUrl()).toContain("error=missing_parameters"); + }); + + test("redirects with invalid_state when redis state is missing", async () => { + mockConsumeOAuthState.mockResolvedValueOnce(null); + const ctx = createContext({ code: "abc", state: "state_123" }); + + await handleRevenueCatOAuthCallback(ctx as never); + + expect(ctx.getRedirectUrl()).toContain("error=invalid_state"); + }); + + test("redirects with success and updates org on happy path (dashboard flow)", async () => { + mockConsumeOAuthState.mockResolvedValueOnce({ + organization_slug: "test-org", + env: "sandbox", + redirect_uri: "http://localhost:5173/dev?tab=revenuecat", + master_org_id: null, + code_verifier: "verifier_123", + provider: "revenuecat", + }); + mockOrgGetBySlug.mockResolvedValueOnce({ + id: "org_123", + slug: "test-org", + processor_configs: { + revenuecat: { + webhook_secret: "whsec", + sandbox_webhook_secret: "whsec_sandbox", + }, + }, + }); + + const ctx = createContext({ code: "abc", state: "state_123" }); + + await handleRevenueCatOAuthCallback(ctx as never); + + expect(mockExchangeRcCode).toHaveBeenCalledWith({ + code: "abc", + codeVerifier: "verifier_123", + }); + expect(mockOrgUpdate).toHaveBeenCalledTimes(1); + expect(mockClearOrgCache).toHaveBeenCalledTimes(1); + expect(ctx.getRedirectUrl()).toContain("success=true"); + }); + + test("platform flow: rejects when org.created_by does not match master_org_id", async () => { + mockConsumeOAuthState.mockResolvedValueOnce({ + organization_slug: "test-org", + env: "sandbox", + redirect_uri: "https://platform.example.com/callback", + master_org_id: "master_123", + code_verifier: "verifier_123", + provider: "revenuecat", + revenuecat_project_name: "Test Project", + }); + mockOrgGetBySlug.mockResolvedValueOnce({ + id: "org_123", + slug: "test-org", + created_by: "other_master", + processor_configs: {}, + }); + + const ctx = createContext({ code: "abc", state: "state_123" }); + + await handleRevenueCatOAuthCallback(ctx as never); + + expect(mockExchangeRcCode).not.toHaveBeenCalled(); + expect(mockOrgUpdate).not.toHaveBeenCalled(); + expect(ctx.getRedirectUrl()).toContain("success=false"); + expect(ctx.getRedirectUrl()).toContain("provider=revenuecat"); + expect(ctx.getRedirectUrl()).toContain("message=org_permission_denied"); + }); + + test("platform flow: creates project, persists config, and redirects with project id", async () => { + mockConsumeOAuthState.mockResolvedValueOnce({ + organization_slug: "test-org", + env: "sandbox", + redirect_uri: "https://platform.example.com/callback", + master_org_id: "master_123", + code_verifier: "verifier_123", + provider: "revenuecat", + revenuecat_project_name: "Test Project", + }); + mockOrgGetBySlug.mockResolvedValueOnce({ + id: "org_123", + slug: "test-org", + created_by: "master_123", + processor_configs: {}, + }); + + const ctx = createContext({ code: "abc", state: "state_123" }); + + await handleRevenueCatOAuthCallback(ctx as never); + + expect(mockExchangeRcCode).toHaveBeenCalledWith({ + code: "abc", + codeVerifier: "verifier_123", + }); + expect(mockCreateProject).toHaveBeenCalledWith({ name: "Test Project" }); + expect(mockOrgUpdate).toHaveBeenCalledTimes(1); + expect(mockClearOrgCache).toHaveBeenCalledTimes(1); + const updateCall = mockOrgUpdate.mock.calls[0]?.[0]; + const sandboxOauth = + updateCall?.updates.processor_configs?.revenuecat?.sandbox_oauth; + expect(sandboxOauth?.project_id).toBe("proj_123"); + // platform org had no webhook secret → callback generates + persists one + const rc = updateCall?.updates.processor_configs?.revenuecat; + expect(typeof rc?.sandbox_webhook_secret).toBe("string"); + expect(rc?.sandbox_webhook_secret?.length).toBe(64); + expect(ctx.getRedirectUrl()).toContain("success=true"); + expect(ctx.getRedirectUrl()).toContain("provider=revenuecat"); + expect(ctx.getRedirectUrl()).toContain("organization_slug=test-org"); + expect(ctx.getRedirectUrl()).toContain("env=test"); + expect(ctx.getRedirectUrl()).toContain("revenuecat_project_id=proj_123"); + }); + + test("platform flow: redirects with error when project creation fails", async () => { + mockConsumeOAuthState.mockResolvedValueOnce({ + organization_slug: "test-org", + env: "sandbox", + redirect_uri: "https://platform.example.com/callback", + master_org_id: "master_123", + code_verifier: "verifier_123", + provider: "revenuecat", + revenuecat_project_name: "Test Project", + }); + mockOrgGetBySlug.mockResolvedValueOnce({ + id: "org_123", + slug: "test-org", + created_by: "master_123", + processor_configs: {}, + }); + mockCreateProject.mockRejectedValueOnce(new Error("RC API error")); + + const ctx = createContext({ code: "abc", state: "state_123" }); + + await handleRevenueCatOAuthCallback(ctx as never); + + expect(mockOrgUpdate).not.toHaveBeenCalled(); + expect(ctx.getRedirectUrl()).toContain("success=false"); + expect(ctx.getRedirectUrl()).toContain("provider=revenuecat"); + expect(ctx.getRedirectUrl()).toContain("message=RC+API+error"); + }); + + // ── API-key → OAuth migration ──────────────────────────────────────────── + const migrationState = (): MockOAuthState => ({ + organization_slug: "test-org", + env: "sandbox", + redirect_uri: "http://localhost:5173/dev?tab=revenuecat", + master_org_id: null, + code_verifier: "verifier_123", + provider: "revenuecat", + migration: true, + }); + + const legacyApiKeyOrg = () => ({ + id: "org_123", + slug: "test-org", + processor_configs: { + revenuecat: { + sandbox_api_key: "enc_sandbox_key", + sandbox_project_id: "proj_existing", + sandbox_webhook_secret: "whsec_sandbox", + }, + }, + }); + + test("migration: connects OAuth, keeps the project, and strips the legacy api key", async () => { + mockConsumeOAuthState.mockResolvedValueOnce(migrationState()); + mockOrgGetBySlug.mockResolvedValueOnce(legacyApiKeyOrg()); + mockListProjects.mockResolvedValueOnce({ + projects: [{ id: "proj_existing", name: "Existing" }], + }); + mockMappingsGetAll.mockResolvedValueOnce([ + { revenuecat_product_ids: ["com.app.pro", "com.app.premium"] }, + ]); + mockListProductStoreIdentifiers.mockResolvedValueOnce( + new Set(["com.app.pro", "com.app.premium", "com.app.extra"]), + ); + + const ctx = createContext({ code: "abc", state: "state_123" }); + await handleRevenueCatOAuthCallback(ctx as never); + + expect(mockOrgUpdate).toHaveBeenCalledTimes(1); + const rc = + mockOrgUpdate.mock.calls[0]?.[0]?.updates.processor_configs?.revenuecat; + // OAuth connected against the existing project + expect(rc?.sandbox_oauth?.project_id).toBe("proj_existing"); + // legacy api key + project id stripped + expect(rc?.sandbox_api_key).toBeUndefined(); + expect(rc?.sandbox_project_id).toBeUndefined(); + // untouched legacy fields preserved + expect(rc?.sandbox_webhook_secret).toBe("whsec_sandbox"); + expect(ctx.getRedirectUrl()).toContain("success=true"); + }); + + test("migration: blocks when the OAuth account doesn't contain the project", async () => { + mockConsumeOAuthState.mockResolvedValueOnce(migrationState()); + mockOrgGetBySlug.mockResolvedValueOnce(legacyApiKeyOrg()); + mockListProjects.mockResolvedValueOnce({ + projects: [{ id: "some_other_project", name: "Other" }], + }); + + const ctx = createContext({ code: "abc", state: "state_123" }); + await handleRevenueCatOAuthCallback(ctx as never); + + expect(mockOrgUpdate).not.toHaveBeenCalled(); + expect(ctx.getRedirectUrl()).toContain("error=project_not_in_account"); + }); + + test("migration: blocks when mapped products aren't all in the project", async () => { + mockConsumeOAuthState.mockResolvedValueOnce(migrationState()); + mockOrgGetBySlug.mockResolvedValueOnce(legacyApiKeyOrg()); + mockListProjects.mockResolvedValueOnce({ + projects: [{ id: "proj_existing", name: "Existing" }], + }); + mockMappingsGetAll.mockResolvedValueOnce([ + { revenuecat_product_ids: ["com.app.pro", "com.app.missing"] }, + ]); + mockListProductStoreIdentifiers.mockResolvedValueOnce( + new Set(["com.app.pro"]), + ); + + const ctx = createContext({ code: "abc", state: "state_123" }); + await handleRevenueCatOAuthCallback(ctx as never); + + expect(mockOrgUpdate).not.toHaveBeenCalled(); + expect(ctx.getRedirectUrl()).toContain("error=products_mismatch"); + }); + + test("migration: blocks when there is no existing project id", async () => { + mockConsumeOAuthState.mockResolvedValueOnce(migrationState()); + mockOrgGetBySlug.mockResolvedValueOnce({ + id: "org_123", + slug: "test-org", + processor_configs: { revenuecat: { sandbox_api_key: "enc_key" } }, + }); + + const ctx = createContext({ code: "abc", state: "state_123" }); + await handleRevenueCatOAuthCallback(ctx as never); + + expect(mockOrgUpdate).not.toHaveBeenCalled(); + expect(ctx.getRedirectUrl()).toContain("error=no_project_to_migrate"); + }); +}); diff --git a/server/tests/unit/revenuecat/initRevenuecatCli.test.ts b/server/tests/unit/revenuecat/initRevenuecatCli.test.ts new file mode 100644 index 000000000..4ab076e8e --- /dev/null +++ b/server/tests/unit/revenuecat/initRevenuecatCli.test.ts @@ -0,0 +1,185 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test"; +import { initRevenuecatCli } from "@/external/revenueCat/misc/initRevenuecatCli.js"; + +const mockFetch = mock(() => + Promise.resolve( + new Response( + JSON.stringify({ + object: "project", + id: "proj_123", + name: "Test Project", + created_at: Date.now(), + }), + { + status: 201, + headers: { "Content-Type": "application/json" }, + }, + ), + ), +); + +// Injected transport — the unit never touches global fetch. +const fetchImpl = mockFetch as unknown as typeof fetch; + +describe("initRevenuecatCli.createProject", () => { + beforeEach(() => { + mockFetch.mockClear(); + }); + + test("POSTs /v2/projects with {name}", async () => { + const cli = initRevenuecatCli({ accessToken: "test-token", fetchImpl }); + const result = await cli.createProject({ name: "My Project" }); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const [url, init] = mockFetch.mock.calls[0] as unknown as [ + string, + RequestInit, + ]; + expect(url.toString()).toBe("https://api.revenuecat.com/v2/projects"); + expect(init.method).toBe("POST"); + expect(JSON.parse(init.body as string)).toEqual({ name: "My Project" }); + expect(result).toEqual({ + object: "project", + id: "proj_123", + name: "Test Project", + created_at: expect.any(Number), + }); + }); + + test("throws when API returns error", async () => { + mockFetch.mockImplementationOnce(() => + Promise.resolve( + new Response(JSON.stringify({ error: "invalid_name" }), { + status: 422, + headers: { "Content-Type": "application/json" }, + }), + ), + ); + + const cli = initRevenuecatCli({ accessToken: "test-token", fetchImpl }); + await expect(cli.createProject({ name: "bad" })).rejects.toThrow(); + }); +}); + +const jsonResponse = (body: unknown, status = 200) => + Promise.resolve( + new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }), + ); + +describe("initRevenuecatCli.listProductPrices", () => { + beforeEach(() => mockFetch.mockClear()); + + test("parses RC's bare price array", async () => { + mockFetch.mockImplementationOnce(() => + jsonResponse([{ id: "prc1", amount_micros: 4_990_000, currency: "USD" }]), + ); + const cli = initRevenuecatCli({ projectId: "proj_x", accessToken: "t", fetchImpl }); + const prices = await cli.listProductPrices("prod_1"); + + const [url] = mockFetch.mock.calls[0] as unknown as [string]; + expect(url.toString()).toBe( + "https://api.revenuecat.com/v2/projects/proj_x/products/prod_1/prices", + ); + expect(prices).toEqual([ + { id: "prc1", amount_micros: 4_990_000, currency: "USD" }, + ]); + }); + + test("tolerates an { items } envelope", async () => { + mockFetch.mockImplementationOnce(() => + jsonResponse({ items: [{ id: "prc2", amount_micros: 1_000_000, currency: "EUR" }] }), + ); + const cli = initRevenuecatCli({ projectId: "proj_x", accessToken: "t", fetchImpl }); + expect(await cli.listProductPrices("prod_2")).toEqual([ + { id: "prc2", amount_micros: 1_000_000, currency: "EUR" }, + ]); + }); +}); + +describe("initRevenuecatCli.listAllProducts", () => { + beforeEach(() => mockFetch.mockClear()); + + test("follows next_page and concatenates items", async () => { + mockFetch + .mockImplementationOnce(() => + jsonResponse({ + object: "list", + items: [{ id: "p1", store_identifier: "a" }], + next_page: "/v2/projects/proj_x/products?page=2", + }), + ) + .mockImplementationOnce(() => + jsonResponse({ + object: "list", + items: [{ id: "p2", store_identifier: "b" }], + next_page: null, + }), + ); + const cli = initRevenuecatCli({ projectId: "proj_x", accessToken: "t", fetchImpl }); + const products = await cli.listAllProducts(); + + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(products.map((p) => p.id)).toEqual(["p1", "p2"]); + }); +}); + +describe("initRevenuecatCli webhook integrations", () => { + beforeEach(() => mockFetch.mockClear()); + + test("listWebhookIntegrations follows next_page", async () => { + mockFetch + .mockImplementationOnce(() => + jsonResponse({ + object: "list", + items: [{ id: "wh1", url: "https://a/1" }], + next_page: "/v2/projects/proj_x/integrations/webhooks?page=2", + }), + ) + .mockImplementationOnce(() => + jsonResponse({ + object: "list", + items: [{ id: "wh2", url: "https://a/2" }], + next_page: null, + }), + ); + const cli = initRevenuecatCli({ projectId: "proj_x", accessToken: "t", fetchImpl }); + const hooks = await cli.listWebhookIntegrations(); + + expect(mockFetch).toHaveBeenCalledTimes(2); + const [firstUrl] = mockFetch.mock.calls[0] as unknown as [string]; + expect(firstUrl.toString()).toBe( + "https://api.revenuecat.com/v2/projects/proj_x/integrations/webhooks?limit=100", + ); + expect(hooks.map((h) => h.id)).toEqual(["wh1", "wh2"]); + }); + + test("createWebhookIntegration POSTs the body", async () => { + mockFetch.mockImplementationOnce(() => + jsonResponse({ object: "webhook_integration", id: "wh_new" }, 201), + ); + const cli = initRevenuecatCli({ projectId: "proj_x", accessToken: "t", fetchImpl }); + const result = await cli.createWebhookIntegration({ + name: "Autumn (sandbox)", + url: "https://ngrok.test/webhooks/revenuecat/org_1/sandbox", + authorization_header: "whsec_abc", + environment: "sandbox", + }); + + const [url, init] = mockFetch.mock.calls[0] as unknown as [ + string, + RequestInit, + ]; + expect(url.toString()).toBe( + "https://api.revenuecat.com/v2/projects/proj_x/integrations/webhooks", + ); + expect(init.method).toBe("POST"); + expect(JSON.parse(init.body as string)).toMatchObject({ + authorization_header: "whsec_abc", + environment: "sandbox", + }); + expect(result.id).toBe("wh_new"); + }); +}); diff --git a/server/tests/unit/revenuecat/initRevenuecatCliProducts.test.ts b/server/tests/unit/revenuecat/initRevenuecatCliProducts.test.ts new file mode 100644 index 000000000..30b734d55 --- /dev/null +++ b/server/tests/unit/revenuecat/initRevenuecatCliProducts.test.ts @@ -0,0 +1,138 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test"; +import { initRevenuecatCli } from "@/external/revenueCat/misc/initRevenuecatCli.js"; + +const mockFetch = mock(() => + Promise.resolve( + new Response(JSON.stringify({ object: "list", items: [] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ), +); + +// Injected transport — the unit never touches global fetch. +const fetchImpl = mockFetch as unknown as typeof fetch; + +const lastCall = () => + mockFetch.mock.calls[mockFetch.mock.calls.length - 1] as unknown as [ + string, + RequestInit, + ]; + +describe("initRevenuecatCli product/app methods", () => { + beforeEach(() => { + mockFetch.mockClear(); + }); + + test("listApps GETs project apps and returns items", async () => { + mockFetch.mockImplementationOnce(() => + Promise.resolve( + new Response( + JSON.stringify({ + object: "list", + items: [{ object: "app", id: "app_1", type: "app_store" }], + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ), + ); + + const cli = initRevenuecatCli({ projectId: "proj_1", accessToken: "tok", fetchImpl }); + const apps = await cli.listApps(); + + const [url, init] = lastCall(); + expect(url.toString()).toContain( + "https://api.revenuecat.com/v2/projects/proj_1/apps", + ); + expect(init?.method ?? "GET").toBe("GET"); + expect(apps).toHaveLength(1); + expect(apps[0].id).toBe("app_1"); + }); + + test("createProduct POSTs the body and returns the product", async () => { + mockFetch.mockImplementationOnce(() => + Promise.resolve( + new Response( + JSON.stringify({ object: "product", id: "prod_1" }), + { status: 201, headers: { "Content-Type": "application/json" } }, + ), + ), + ); + + const cli = initRevenuecatCli({ projectId: "proj_1", accessToken: "tok", fetchImpl }); + const result = await cli.createProduct({ + app_id: "app_1", + store_identifier: "autumn.live.acme.pro", + type: "subscription", + display_name: "Pro", + subscription: { duration: "P1M" }, + }); + + const [url, init] = lastCall(); + expect(url.toString()).toBe( + "https://api.revenuecat.com/v2/projects/proj_1/products", + ); + expect(init.method).toBe("POST"); + expect(JSON.parse(init.body as string)).toMatchObject({ + app_id: "app_1", + store_identifier: "autumn.live.acme.pro", + type: "subscription", + subscription: { duration: "P1M" }, + }); + expect(result.id).toBe("prod_1"); + }); + + test("updateProduct POSTs display_name to the product url", async () => { + mockFetch.mockImplementationOnce(() => + Promise.resolve( + new Response(JSON.stringify({ object: "product", id: "prod_1" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ), + ); + + const cli = initRevenuecatCli({ projectId: "proj_1", accessToken: "tok", fetchImpl }); + await cli.updateProduct("prod_1", { display_name: "Pro Plus" }); + + const [url, init] = lastCall(); + expect(url.toString()).toBe( + "https://api.revenuecat.com/v2/projects/proj_1/products/prod_1", + ); + expect(init.method).toBe("POST"); + expect(JSON.parse(init.body as string)).toEqual({ + display_name: "Pro Plus", + }); + }); + + test("createInStore POSTs store_information to the create_in_store url", async () => { + mockFetch.mockImplementationOnce(() => + Promise.resolve( + new Response(JSON.stringify({ created_product: { id: "1" } }), { + status: 201, + headers: { "Content-Type": "application/json" }, + }), + ), + ); + + const cli = initRevenuecatCli({ projectId: "proj_1", accessToken: "tok", fetchImpl }); + await cli.createInStore("prod_1", { + store_information: { + duration: "ONE_MONTH", + subscription_group_name: "Autumn - Default Group", + }, + }); + + const [url, init] = lastCall(); + expect(url.toString()).toBe( + "https://api.revenuecat.com/v2/projects/proj_1/products/prod_1/create_in_store", + ); + expect(init.method).toBe("POST"); + expect(JSON.parse(init.body as string)).toEqual({ + store_information: { + duration: "ONE_MONTH", + subscription_group_name: "Autumn - Default Group", + }, + }); + }); +}); diff --git a/server/tests/unit/revenuecat/registerRevenuecatWebhook.test.ts b/server/tests/unit/revenuecat/registerRevenuecatWebhook.test.ts new file mode 100644 index 000000000..2f453083c --- /dev/null +++ b/server/tests/unit/revenuecat/registerRevenuecatWebhook.test.ts @@ -0,0 +1,126 @@ +/** + * Unit tests for registerRevenuecatWebhook — idempotent, one webhook per env, matched + * by URL, secret as the Authorization header. Base URL follows the NODE_ENV rule. + */ + +import { AppEnv } from "@autumn/shared"; +import { afterEach, beforeEach, expect, mock, test } from "bun:test"; +import chalk from "chalk"; +import { + getRevenuecatWebhookUrl, + registerRevenuecatWebhook, +} from "@/external/revenueCat/misc/registerRevenuecatWebhook.js"; +import type { RevenueCatWebhookIntegration } from "@/external/revenueCat/revenuecatTypes.js"; + +const env = { + NODE_ENV: process.env.NODE_ENV, + NGROK_URL: process.env.NGROK_URL, + BETTER_AUTH_URL: process.env.BETTER_AUTH_URL, +}; + +beforeEach(() => { + process.env.NODE_ENV = "development"; + process.env.NGROK_URL = "https://ngrok.test"; + process.env.BETTER_AUTH_URL = "https://api.useautumn.com"; +}); + +afterEach(() => { + process.env.NODE_ENV = env.NODE_ENV; + process.env.NGROK_URL = env.NGROK_URL; + process.env.BETTER_AUTH_URL = env.BETTER_AUTH_URL; +}); + +const makeCli = (existing: RevenueCatWebhookIntegration[] = []) => { + const createWebhookIntegration = mock( + async (body: Record) => + ({ id: "wh_1", ...body }) as RevenueCatWebhookIntegration, + ); + const listWebhookIntegrations = mock(async () => existing); + return { + cli: { listWebhookIntegrations, createWebhookIntegration } as never, + listWebhookIntegrations, + createWebhookIntegration, + }; +}; + +test(`${chalk.yellowBright("webhook url: dev uses NGROK_URL + AppEnv segment")}`, () => { + expect(getRevenuecatWebhookUrl({ orgId: "org_1", env: AppEnv.Sandbox })).toBe( + "https://ngrok.test/webhooks/revenuecat/org_1/sandbox", + ); +}); + +test(`${chalk.yellowBright("webhook url: prod uses BETTER_AUTH_URL")}`, () => { + process.env.NODE_ENV = "production"; + expect(getRevenuecatWebhookUrl({ orgId: "org_1", env: AppEnv.Live })).toBe( + "https://api.useautumn.com/webhooks/revenuecat/org_1/live", + ); +}); + +test(`${chalk.yellowBright("register: no existing webhook → creates with secret + environment, no event/app scoping")}`, async () => { + const { cli, createWebhookIntegration } = makeCli([]); + const status = await registerRevenuecatWebhook({ + rcCli: cli, + orgId: "org_1", + env: AppEnv.Sandbox, + secret: "whsec_abc", + }); + + expect(status).toBe("created"); + const body = createWebhookIntegration.mock.calls[0]?.[0] as Record< + string, + unknown + >; + expect(body).toMatchObject({ + url: "https://ngrok.test/webhooks/revenuecat/org_1/sandbox", + authorization_header: "whsec_abc", + environment: "sandbox", + }); + expect(body.event_types).toBeUndefined(); + expect(body.app_id).toBeUndefined(); +}); + +test(`${chalk.yellowBright("register: live env maps to environment=production")}`, async () => { + const { cli, createWebhookIntegration } = makeCli([]); + await registerRevenuecatWebhook({ + rcCli: cli, + orgId: "org_1", + env: AppEnv.Live, + secret: "whsec_live", + }); + expect( + (createWebhookIntegration.mock.calls[0]?.[0] as { environment: string }) + .environment, + ).toBe("production"); +}); + +test(`${chalk.yellowBright("register: existing webhook with same url → exists, no create")}`, async () => { + const { cli, createWebhookIntegration } = makeCli([ + { + id: "wh_existing", + name: "Autumn (sandbox)", + url: "https://ngrok.test/webhooks/revenuecat/org_1/sandbox", + }, + ]); + const status = await registerRevenuecatWebhook({ + rcCli: cli, + orgId: "org_1", + env: AppEnv.Sandbox, + secret: "whsec_abc", + }); + expect(status).toBe("exists"); + expect(createWebhookIntegration).not.toHaveBeenCalled(); +}); + +test(`${chalk.yellowBright("register: no base url → skipped, no list/create")}`, async () => { + delete process.env.NGROK_URL; + const { cli, listWebhookIntegrations, createWebhookIntegration } = makeCli([]); + const status = await registerRevenuecatWebhook({ + rcCli: cli, + orgId: "org_1", + env: AppEnv.Sandbox, + secret: "whsec_abc", + }); + expect(status).toBe("skipped"); + expect(listWebhookIntegrations).not.toHaveBeenCalled(); + expect(createWebhookIntegration).not.toHaveBeenCalled(); +}); diff --git a/server/tests/unit/revenuecat/revenuecatMcp.test.ts b/server/tests/unit/revenuecat/revenuecatMcp.test.ts new file mode 100644 index 000000000..4c647ef90 --- /dev/null +++ b/server/tests/unit/revenuecat/revenuecatMcp.test.ts @@ -0,0 +1,63 @@ +/** + * Unit tests for callRcMcpTool — JSON-RPC tools/call against RC's MCP server, + * parsing the SSE (`data:`) response and surfacing tool errors. fetch is injected. + */ + +import { expect, mock, test } from "bun:test"; +import chalk from "chalk"; +import { callRcMcpTool } from "@/external/revenueCat/misc/revenuecatMcp.js"; + +const sse = (obj: unknown, status = 200) => + Promise.resolve( + new Response(`event: message\ndata: ${JSON.stringify(obj)}\n\n`, { + status, + headers: { "Content-Type": "text/event-stream" }, + }), + ); + +test(`${chalk.yellowBright("callRcMcpTool: posts JSON-RPC tools/call with bearer + parses SSE result")}`, async () => { + const fetchImpl = mock(() => sse({ result: { isError: false, content: [] } })); + + const result = await callRcMcpTool({ + accessToken: "atk_abc", + name: "create-product-prices", + arguments: { project_id: "proj", product_id: "prod", prices: [] }, + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + + const [url, init] = fetchImpl.mock.calls[0] as unknown as [string, RequestInit]; + expect(url.toString()).toBe("https://mcp.revenuecat.ai/mcp"); + expect((init.headers as Record).Authorization).toBe("Bearer atk_abc"); + const sent = JSON.parse(init.body as string); + expect(sent).toMatchObject({ + method: "tools/call", + params: { name: "create-product-prices" }, + }); + expect(result).toEqual({ isError: false, content: [] }); +}); + +test(`${chalk.yellowBright("callRcMcpTool: throws when the tool reports isError")}`, async () => { + const fetchImpl = mock(() => + sse({ result: { isError: true, content: [{ type: "text", text: "nope" }] } }), + ); + await expect( + callRcMcpTool({ + accessToken: "t", + name: "create-product-prices", + arguments: {}, + fetchImpl: fetchImpl as unknown as typeof fetch, + }), + ).rejects.toThrow(/create-product-prices/); +}); + +test(`${chalk.yellowBright("callRcMcpTool: throws on a JSON-RPC error")}`, async () => { + const fetchImpl = mock(() => sse({ error: { message: "bad token" } })); + await expect( + callRcMcpTool({ + accessToken: "t", + name: "x", + arguments: {}, + fetchImpl: fetchImpl as unknown as typeof fetch, + }), + ).rejects.toThrow(/bad token/); +}); diff --git a/server/tests/unit/revenuecat/revenuecatOAuth.test.ts b/server/tests/unit/revenuecat/revenuecatOAuth.test.ts new file mode 100644 index 000000000..027dd1593 --- /dev/null +++ b/server/tests/unit/revenuecat/revenuecatOAuth.test.ts @@ -0,0 +1,61 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { OAuth2Tokens } from "arctic"; + +const mockValidateAuthorizationCode = mock(() => + Promise.resolve( + new OAuth2Tokens({ + access_token: "atk_test_token", + token_type: "Bearer", + expires_in: 3600, + refresh_token: "rtk_test_token", + scope: "project_configuration:projects:read", + }), + ), +); + +mock.module("arctic", () => ({ + OAuth2Client: class { + createAuthorizationURLWithPKCE() { + return new URL("https://api.revenuecat.com/oauth2/authorize?test=1"); + } + validateAuthorizationCode = mockValidateAuthorizationCode; + refreshAccessToken = mock(() => Promise.resolve(new OAuth2Tokens({}))); + }, + CodeChallengeMethod: { S256: 0, Plain: 1 }, + generateCodeVerifier: () => "test-code-verifier", + generateState: () => "test-state", + OAuth2Tokens, +})); + +const { exchangeRcCode } = await import( + "@/external/revenueCat/misc/revenuecatOAuth.js" +); + +describe("exchangeRcCode", () => { + beforeEach(() => { + process.env.REVENUECAT_OAUTH_CLIENT_ID = "rc_client_id"; + process.env.REVENUECAT_OAUTH_CLIENT_SECRET = "rc_client_secret"; + process.env.BETTER_AUTH_URL = "https://auth.example.com"; + mockValidateAuthorizationCode.mockClear(); + }); + + afterEach(() => { + delete process.env.REVENUECAT_OAUTH_CLIENT_ID; + delete process.env.REVENUECAT_OAUTH_CLIENT_SECRET; + }); + + test("exchanges authorization code for tokens", async () => { + const tokens = await exchangeRcCode({ + code: "auth_code_123", + codeVerifier: "verifier_abc", + }); + + expect(mockValidateAuthorizationCode).toHaveBeenCalledWith( + "https://api.revenuecat.com/oauth2/token", + "auth_code_123", + "verifier_abc", + ); + expect(tokens.accessToken()).toBe("atk_test_token"); + expect(tokens.refreshToken()).toBe("rtk_test_token"); + }); +}); diff --git a/server/tests/unit/revenuecat/syncRevenueCatProducts.test.ts b/server/tests/unit/revenuecat/syncRevenueCatProducts.test.ts new file mode 100644 index 000000000..337004651 --- /dev/null +++ b/server/tests/unit/revenuecat/syncRevenueCatProducts.test.ts @@ -0,0 +1,87 @@ +import { AppEnv, BillingInterval } from "@autumn/shared"; +import { describe, expect, test } from "bun:test"; +import { + autumnIntervalToRcDuration, + autumnIntervalToStoreDuration, + getRcStoreIdentifier, + getSubscriptionGroupName, + isRevenueCatPushEnabled, +} from "@/external/revenueCat/sync/revenuecatProductSyncUtils.js"; + +describe("autumnIntervalToRcDuration (ISO-8601, for createProduct)", () => { + test("maps supported intervals", () => { + expect( + autumnIntervalToRcDuration({ interval: BillingInterval.Month, intervalCount: 1 }), + ).toBe("P1M"); + expect( + autumnIntervalToRcDuration({ interval: BillingInterval.Year, intervalCount: 1 }), + ).toBe("P1Y"); + expect( + autumnIntervalToRcDuration({ interval: BillingInterval.Month, intervalCount: 12 }), + ).toBe("P1Y"); + }); + test("lossy → null", () => { + expect( + autumnIntervalToRcDuration({ interval: BillingInterval.Month, intervalCount: 4 }), + ).toBeNull(); + }); +}); + +describe("autumnIntervalToStoreDuration (enum, for create_in_store)", () => { + test("maps supported intervals to RC store enum", () => { + expect( + autumnIntervalToStoreDuration({ interval: BillingInterval.Month, intervalCount: 1 }), + ).toBe("ONE_MONTH"); + expect( + autumnIntervalToStoreDuration({ interval: BillingInterval.Month, intervalCount: 3 }), + ).toBe("THREE_MONTHS"); + expect( + autumnIntervalToStoreDuration({ interval: BillingInterval.SemiAnnual, intervalCount: 1 }), + ).toBe("SIX_MONTHS"); + expect( + autumnIntervalToStoreDuration({ interval: BillingInterval.Year, intervalCount: 1 }), + ).toBe("ONE_YEAR"); + expect( + autumnIntervalToStoreDuration({ interval: BillingInterval.Week, intervalCount: 1 }), + ).toBe("ONE_WEEK"); + }); + test("lossy → null", () => { + expect( + autumnIntervalToStoreDuration({ interval: BillingInterval.Year, intervalCount: 2 }), + ).toBeNull(); + }); +}); + +describe("getRcStoreIdentifier", () => { + test("uses org id, env, plan id", () => { + expect( + getRcStoreIdentifier({ env: AppEnv.Live, orgId: "org_123", planId: "pro" }), + ).toBe("autumn.live.org_123.pro"); + expect( + getRcStoreIdentifier({ env: AppEnv.Sandbox, orgId: "org_123", planId: "pro" }), + ).toBe("autumn.sandbox.org_123.pro"); + }); +}); + +describe("getSubscriptionGroupName", () => { + test("default when group empty/null", () => { + expect(getSubscriptionGroupName()).toBe("Autumn - Default Group"); + expect(getSubscriptionGroupName(null)).toBe("Autumn - Default Group"); + expect(getSubscriptionGroupName("")).toBe("Autumn - Default Group"); + }); + test("uses the plan group when set", () => { + expect(getSubscriptionGroupName("Premium")).toBe("Autumn - Premium Group"); + }); +}); + +describe("isRevenueCatPushEnabled", () => { + const oauth = { access_token: "a", refresh_token: "r", expires_at: 0 }; + test("live needs oauth, sandbox needs sandbox_oauth", () => { + expect(isRevenueCatPushEnabled({ revenueCatConfig: { oauth }, env: AppEnv.Live })).toBe(true); + expect(isRevenueCatPushEnabled({ revenueCatConfig: {}, env: AppEnv.Live })).toBe(false); + expect( + isRevenueCatPushEnabled({ revenueCatConfig: { sandbox_oauth: oauth }, env: AppEnv.Sandbox }), + ).toBe(true); + expect(isRevenueCatPushEnabled({ revenueCatConfig: { oauth }, env: AppEnv.Sandbox })).toBe(false); + }); +}); diff --git a/shared/api/billing/createSchedule/createScheduleParamsV0.ts b/shared/api/billing/createSchedule/createScheduleParamsV0.ts index 9258870fe..b47caa563 100644 --- a/shared/api/billing/createSchedule/createScheduleParamsV0.ts +++ b/shared/api/billing/createSchedule/createScheduleParamsV0.ts @@ -4,9 +4,9 @@ import { RedirectModeSchema } from "@api/billing/common/redirectMode"; import { BasePriceParamsSchema } from "@api/products/components/basePrice/basePrice"; import { CreatePlanItemParamsV1Schema } from "@api/products/items/crud/createPlanItemParamsV1"; import { z } from "zod/v4"; +import { AttachDiscountSchema } from "../attachV2/attachDiscount"; import { BillingBehaviorSchema } from "../common/billingBehavior"; import { BillingCycleAnchorSchema } from "../common/billingCycleAnchor"; -import { AttachDiscountSchema } from "../attachV2/attachDiscount"; const CreateScheduleCustomizePlanSchema = z .object({ @@ -27,26 +27,25 @@ const CreateScheduleCustomizePlanSchema = z }, ); -export const CreateSchedulePlanSchema = z - .object({ - plan_id: z.string().meta({ - description: "The ID of the plan to schedule in this phase.", - }), - feature_quantities: z.array(FeatureQuantityParamsV0Schema).optional().meta({ - description: "Optional prepaid feature quantities for this phase's plan.", - }), - version: z.number().optional().meta({ - description: "Optional explicit plan version to schedule.", - }), - customize: CreateScheduleCustomizePlanSchema.optional().meta({ - description: - "Customize the plan to schedule. Can override the price, items, or both.", - }), - subscription_id: z.string().optional().meta({ - description: - "A unique ID to identify this subscription. Useful when scheduling the same plan multiple times.", - }), - }); +export const CreateSchedulePlanSchema = z.object({ + plan_id: z.string().meta({ + description: "The ID of the plan to schedule in this phase.", + }), + feature_quantities: z.array(FeatureQuantityParamsV0Schema).optional().meta({ + description: "Optional prepaid feature quantities for this phase's plan.", + }), + version: z.number().optional().meta({ + description: "Optional explicit plan version to schedule.", + }), + customize: CreateScheduleCustomizePlanSchema.optional().meta({ + description: + "Customize the plan to schedule. Can override the price, items, or both.", + }), + subscription_id: z.string().optional().meta({ + description: + "A unique ID to identify this subscription. Useful when scheduling the same plan multiple times.", + }), +}); export const CreateSchedulePhaseSchema = z.object({ starts_at: z.number().meta({ diff --git a/shared/api/platform/platformModels.ts b/shared/api/platform/platformModels.ts index a5970c591..78ff7351e 100644 --- a/shared/api/platform/platformModels.ts +++ b/shared/api/platform/platformModels.ts @@ -88,3 +88,130 @@ export const ListPlatformOrgsResponseSchema = z.object({ export type ListPlatformOrgsResponse = z.infer< typeof ListPlatformOrgsResponseSchema >; + +/** + * Request body for POST /platform.link_revenuecat + */ +export const LinkRevenueCatSchema = z.object({ + organization_slug: z.string().min(1), + env: z.enum(["test", "live"]), + project_name: z.string().min(1).max(255), + redirect_url: z.string().url(), +}); + +export type LinkRevenueCat = z.infer; + +/** + * Response schema for POST /platform.link_revenuecat + */ +export const LinkRevenueCatResponseSchema = z.object({ + oauth_url: z.string(), +}); + +export type LinkRevenueCatResponse = z.infer< + typeof LinkRevenueCatResponseSchema +>; + +/** + * Request body for POST /platform.sync_revenuecat + */ +export const SyncRevenueCatSchema = z.object({ + organization_slug: z.string().min(1), + env: z + .enum(["test", "sandbox", "live"]) + .describe('"test" and "sandbox" both target the sandbox environment'), + product_ids: z + .array(z.string()) + .optional() + .describe("Plans to push. Omit to sync every plan in the org/env."), +}); + +export type SyncRevenueCat = z.infer; + +/** + * Per-app result of a single plan's sync. + */ +export const RevenueCatSyncAppResultSchema = z.object({ + app_id: z.string(), + app_type: z.string(), + product: z.enum(["created", "updated", "exists"]), + store_push: z.enum(["pushed", "failed", "skipped"]).optional(), + price: z.enum(["set", "skipped", "failed"]).optional(), + message: z.string().optional(), +}); + +/** + * Per-plan result of POST /platform.sync_revenuecat. + */ +export const RevenueCatSyncResultSchema = z.object({ + plan_id: z.string(), + status: z.enum(["synced", "skipped", "error"]), + store_identifier: z.string().optional(), + apps: z.array(RevenueCatSyncAppResultSchema).optional(), + message: z.string().optional(), +}); + +/** + * Response schema for POST /platform.sync_revenuecat + */ +export const SyncRevenueCatResponseSchema = z.object({ + results: z.array(RevenueCatSyncResultSchema), +}); + +export type SyncRevenueCatResponse = z.infer< + typeof SyncRevenueCatResponseSchema +>; + +/** + * Request body for POST /platform.get_revenuecat_keys + */ +export const GetRevenueCatKeysSchema = z.object({ + organization_slug: z.string().min(1), + env: z + .enum(["test", "sandbox", "live"]) + .describe('"test" and "sandbox" both target the sandbox environment'), +}); + +export type GetRevenueCatKeys = z.infer; + +/** + * A RevenueCat public (SDK) API key. + */ +export const RevenueCatPublicApiKeySchema = z + .object({ + id: z.string(), + key: z.string().describe("The public SDK API key value"), + environment: z.string().nullish().describe('e.g. "production" / "sandbox"'), + app_id: z.string().nullish(), + created_at: z.number().optional(), + }) + .loose(); + +/** + * Per-app public API keys for a managed org's RevenueCat project. + */ +export const RevenueCatAppKeysSchema = z.object({ + app_id: z.string(), + app_type: z + .string() + .describe("RevenueCat store type, e.g. test_store / app_store / play_store"), + name: z.string(), + api_keys: z.array(RevenueCatPublicApiKeySchema), +}); + +/** + * Response schema for POST /platform.get_revenuecat_keys + */ +export const GetRevenueCatKeysResponseSchema = z.object({ + apps: z.array(RevenueCatAppKeysSchema), + oauth_access_token: z + .string() + .nullable() + .describe( + "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.", + ), +}); + +export type GetRevenueCatKeysResponse = z.infer< + typeof GetRevenueCatKeysResponseSchema +>; diff --git a/shared/db/auth-schema.ts b/shared/db/auth-schema.ts index 3a1a3d902..4052f85f6 100644 --- a/shared/db/auth-schema.ts +++ b/shared/db/auth-schema.ts @@ -9,6 +9,7 @@ import { text, timestamp, } from "drizzle-orm/pg-core"; +import type { AppEnv } from "../models/genModels/genEnums.js"; import { type Organization, organizations, @@ -215,6 +216,7 @@ export const oauthRefreshToken = pgTable("oauth_refresh_token", { expiresAt: timestamp("expires_at", { withTimezone: true }), createdAt: timestamp("created_at", { withTimezone: true }), revoked: timestamp("revoked", { withTimezone: true }), + authTime: timestamp("auth_time", { withTimezone: true }), scopes: text("scopes").array().notNull(), }).enableRLS(); @@ -245,6 +247,9 @@ export const oauthConsent = pgTable("oauth_consent", { userId: text("user_id").references(() => user.id, { onDelete: "cascade" }), referenceId: text("reference_id"), scopes: text("scopes").array().notNull(), + env: text("env").$type(), + redirectUri: text("redirect_uri"), + oauthApiKeyId: text("oauth_api_key_id"), createdAt: timestamp("created_at", { withTimezone: true }), updatedAt: timestamp("updated_at", { withTimezone: true }), }).enableRLS(); diff --git a/shared/db/schema.ts b/shared/db/schema.ts index 02d6d9602..411f75846 100644 --- a/shared/db/schema.ts +++ b/shared/db/schema.ts @@ -5,6 +5,7 @@ import { actions } from "../models/analyticsModels/actionTable.js"; import { chatApprovals, chatInstallations, + chatOAuthCredentials, } from "../models/chatModels/chatTable.js"; import { chatResults } from "../models/chatResultModels/chatResultTable.js"; import { checkoutsRelations } from "../models/checkouts/checkoutRelations.js"; @@ -108,6 +109,7 @@ export { autoTopupLimitStates as autoTopupLimits, chatApprovals, chatInstallations, + chatOAuthCredentials, chatResults, checkouts, checkoutsRelations, diff --git a/shared/drizzle/0001_concerned_ravenous.sql b/shared/drizzle/0001_concerned_ravenous.sql index ca5b510ce..e27559963 100644 --- a/shared/drizzle/0001_concerned_ravenous.sql +++ b/shared/drizzle/0001_concerned_ravenous.sql @@ -16,10 +16,10 @@ CREATE TABLE "passkey" ( ALTER TABLE "passkey" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint ALTER TABLE "migration_runs" ADD COLUMN "target_limit" numeric;--> statement-breakpoint ALTER TABLE "passkey" ADD CONSTRAINT "passkey_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -CREATE INDEX "passkey_userId_idx" ON "passkey" USING btree ("user_id");--> statement-breakpoint -CREATE INDEX "passkey_credentialId_idx" ON "passkey" USING btree ("credential_id");--> statement-breakpoint -CREATE INDEX "idx_customer_products_revenuecat_processor" ON "customer_products" USING btree ("internal_customer_id") WHERE ("customer_products"."processor" ->> 'type') = 'revenuecat';--> statement-breakpoint -CREATE INDEX "idx_customers_cursor" ON "customers" USING btree ("org_id","env","created_at" DESC,"id" DESC);--> statement-breakpoint -CREATE INDEX "idx_entities_cursor" ON "entities" USING btree ("org_id","env","created_at" DESC,"id" DESC);--> statement-breakpoint -CREATE INDEX "idx_entitlements_internal_reward_id_c_partial" ON "entitlements" USING btree ("internal_reward_id" COLLATE "C") WHERE "entitlements"."internal_reward_id" IS NOT NULL;--> statement-breakpoint -CREATE UNIQUE INDEX "vercel_resources_installation_name_unique_idx" ON "vercel_resources" USING btree ("org_id","env","installation_id","name") WHERE status <> 'uninstalled'; \ No newline at end of file +CREATE INDEX CONCURRENTLY "passkey_userId_idx" ON "passkey" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX CONCURRENTLY "passkey_credentialId_idx" ON "passkey" USING btree ("credential_id");--> statement-breakpoint +CREATE INDEX CONCURRENTLY "idx_customer_products_revenuecat_processor" ON "customer_products" USING btree ("internal_customer_id") WHERE ("customer_products"."processor" ->> 'type') = 'revenuecat';--> statement-breakpoint +CREATE INDEX CONCURRENTLY "idx_customers_cursor" ON "customers" USING btree ("org_id","env","created_at" DESC,"id" DESC);--> statement-breakpoint +CREATE INDEX CONCURRENTLY "idx_entities_cursor" ON "entities" USING btree ("org_id","env","created_at" DESC,"id" DESC);--> statement-breakpoint +CREATE INDEX CONCURRENTLY "idx_entitlements_internal_reward_id_c_partial" ON "entitlements" USING btree ("internal_reward_id" COLLATE "C") WHERE "entitlements"."internal_reward_id" IS NOT NULL;--> statement-breakpoint +CREATE UNIQUE INDEX CONCURRENTLY "vercel_resources_installation_name_unique_idx" ON "vercel_resources" USING btree ("org_id","env","installation_id","name") WHERE status <> 'uninstalled'; \ No newline at end of file diff --git a/shared/drizzle/0005_fresh_runaways.sql b/shared/drizzle/0005_fresh_runaways.sql new file mode 100644 index 000000000..b9d89b2fb --- /dev/null +++ b/shared/drizzle/0005_fresh_runaways.sql @@ -0,0 +1 @@ +ALTER TABLE "oauth_refresh_token" ADD COLUMN "auth_time" timestamp with time zone; \ No newline at end of file diff --git a/shared/drizzle/0006_sad_madrox.sql b/shared/drizzle/0006_sad_madrox.sql new file mode 100644 index 000000000..895b1fe67 --- /dev/null +++ b/shared/drizzle/0006_sad_madrox.sql @@ -0,0 +1,3 @@ +ALTER TABLE "oauth_consent" ADD COLUMN "env" text;--> statement-breakpoint +ALTER TABLE "oauth_consent" ADD COLUMN "redirect_uri" text;--> statement-breakpoint +ALTER TABLE "oauth_consent" ADD COLUMN "oauth_api_key_id" text; diff --git a/shared/drizzle/0007_cute_ikaris.sql b/shared/drizzle/0007_cute_ikaris.sql new file mode 100644 index 000000000..043f3063a --- /dev/null +++ b/shared/drizzle/0007_cute_ikaris.sql @@ -0,0 +1 @@ +CREATE INDEX CONCURRENTLY "idx_invoice_line_items_customer_product_ids" ON "invoice_line_items" USING gin ("customer_product_ids"); \ No newline at end of file diff --git a/shared/drizzle/0008_premium_pet_avengers.sql b/shared/drizzle/0008_premium_pet_avengers.sql new file mode 100644 index 000000000..710fe4e91 --- /dev/null +++ b/shared/drizzle/0008_premium_pet_avengers.sql @@ -0,0 +1,18 @@ +CREATE TABLE "chat_oauth_credentials" ( + "id" text PRIMARY KEY NOT NULL, + "chat_installation_id" text NOT NULL, + "org_id" text NOT NULL, + "env" text NOT NULL, + "oauth_client_id" text NOT NULL, + "oauth_consent_id" text, + "access_token" text NOT NULL, + "refresh_token" text NOT NULL, + "access_token_expires_at" numeric NOT NULL, + "scopes" jsonb NOT NULL, + "created_at" numeric DEFAULT ROUND(date_part('epoch', NOW()) * 1000)::BIGINT NOT NULL, + "updated_at" numeric DEFAULT ROUND(date_part('epoch', NOW()) * 1000)::BIGINT NOT NULL, + CONSTRAINT "chat_oauth_credentials_installation_env_key" UNIQUE("chat_installation_id","env") +); +--> statement-breakpoint +ALTER TABLE "chat_oauth_credentials" ADD CONSTRAINT "chat_oauth_credentials_installation_id_fkey" FOREIGN KEY ("chat_installation_id") REFERENCES "public"."chat_installations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "chat_oauth_credentials" ADD CONSTRAINT "chat_oauth_credentials_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/shared/drizzle/0005_legal_logan.sql b/shared/drizzle/0009_usage_windows.sql similarity index 100% rename from shared/drizzle/0005_legal_logan.sql rename to shared/drizzle/0009_usage_windows.sql diff --git a/shared/drizzle/meta/0005_snapshot.json b/shared/drizzle/meta/0005_snapshot.json index fa0fb3c1f..b7b081947 100644 --- a/shared/drizzle/meta/0005_snapshot.json +++ b/shared/drizzle/meta/0005_snapshot.json @@ -1,5 +1,5 @@ { - "id": "0aaa5367-a797-49fa-b0d1-ddb5677edeb7", + "id": "3ee43a45-bd02-43e2-a2d1-2080d51b5674", "prevId": "20fbfba1-ef02-4637-b7f8-4ae1ee5983d7", "version": "7", "dialect": "postgresql", @@ -5049,6 +5049,12 @@ "primaryKey": false, "notNull": false }, + "auth_time": { + "name": "auth_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, "scopes": { "name": "scopes", "type": "text[]", @@ -7001,138 +7007,6 @@ "checkConstraints": {}, "isRLSEnabled": false }, - "public.usage_windows": { - "name": "usage_windows", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "customer_entitlement_id": { - "name": "customer_entitlement_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "feature_id": { - "name": "feature_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "internal_feature_id": { - "name": "internal_feature_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "window_start_at": { - "name": "window_start_at", - "type": "numeric", - "primaryKey": false, - "notNull": true - }, - "window_end_at": { - "name": "window_end_at", - "type": "numeric", - "primaryKey": false, - "notNull": true - }, - "usage": { - "name": "usage", - "type": "numeric", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "updated_at": { - "name": "updated_at", - "type": "numeric", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "idx_usage_windows_customer_entitlement_id": { - "name": "idx_usage_windows_customer_entitlement_id", - "columns": [ - { - "expression": "customer_entitlement_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "idx_usage_windows_cus_ent_feature_window": { - "name": "idx_usage_windows_cus_ent_feature_window", - "columns": [ - { - "expression": "customer_entitlement_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "feature_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "window_start_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "usage_windows_customer_entitlement_id_fkey": { - "name": "usage_windows_customer_entitlement_id_fkey", - "tableFrom": "usage_windows", - "tableTo": "customer_entitlements", - "columnsFrom": [ - "customer_entitlement_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "cascade" - }, - "usage_windows_internal_feature_id_fkey": { - "name": "usage_windows_internal_feature_id_fkey", - "tableFrom": "usage_windows", - "tableTo": "features", - "columnsFrom": [ - "internal_feature_id" - ], - "columnsTo": [ - "internal_id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": true - }, "public.user": { "name": "user", "schema": "", diff --git a/shared/drizzle/meta/0006_snapshot.json b/shared/drizzle/meta/0006_snapshot.json new file mode 100644 index 000000000..c4b1f542f --- /dev/null +++ b/shared/drizzle/meta/0006_snapshot.json @@ -0,0 +1,6987 @@ +{ + "id": "eadc8c95-3f6f-4643-abc3-e90cd56d5ed1", + "prevId": "3ee43a45-bd02-43e2-a2d1-2080d51b5674", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.actions": { + "name": "actions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_slug": { + "name": "org_slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_entity_id": { + "name": "internal_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "properties": { + "name": "properties", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_actions_on_internal_entity_id": { + "name": "idx_actions_on_internal_entity_id", + "columns": [ + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "actions_org_id_fkey": { + "name": "actions_org_id_fkey", + "tableFrom": "actions", + "tableTo": "organizations", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "actions_customer_id_fkey": { + "name": "actions_customer_id_fkey", + "tableFrom": "actions", + "tableTo": "customers", + "columnsFrom": ["internal_customer_id"], + "columnsTo": ["internal_id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "actions_entity_id_fkey": { + "name": "actions_entity_id_fkey", + "tableFrom": "actions", + "tableTo": "entities", + "columnsFrom": ["internal_entity_id"], + "columnsTo": ["internal_id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "hashed_key": { + "name": "hashed_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "meta": { + "name": "meta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "api_keys_org_id_fkey": { + "name": "api_keys_org_id_fkey", + "tableFrom": "api_keys", + "tableTo": "organizations", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_keys_hashed_key_key": { + "name": "api_keys_hashed_key_key", + "nullsNotDistinct": false, + "columns": ["hashed_key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auto_topup_limit_states": { + "name": "auto_topup_limit_states", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "feature_id": { + "name": "feature_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "purchase_window_ends_at": { + "name": "purchase_window_ends_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "purchase_count": { + "name": "purchase_count", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "attempt_window_ends_at": { + "name": "attempt_window_ends_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "attempt_count": { + "name": "attempt_count", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "failed_attempt_window_ends_at": { + "name": "failed_attempt_window_ends_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "failed_attempt_count": { + "name": "failed_attempt_count", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "last_failed_attempt_at": { + "name": "last_failed_attempt_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + } + }, + "indexes": { + "auto_topup_limits_org_env_internal_customer_feature_unique": { + "name": "auto_topup_limits_org_env_internal_customer_feature_unique", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "feature_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "auto_topup_limits_org_id_fkey": { + "name": "auto_topup_limits_org_id_fkey", + "tableFrom": "auto_topup_limit_states", + "tableTo": "organizations", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "auto_topup_limits_internal_customer_id_fkey": { + "name": "auto_topup_limits_internal_customer_id_fkey", + "tableFrom": "auto_topup_limit_states", + "tableTo": "customers", + "columnsFrom": ["internal_customer_id"], + "columnsTo": ["internal_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_approvals": { + "name": "chat_approvals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_ts": { + "name": "message_ts", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_user_id": { + "name": "provider_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_args": { + "name": "tool_args", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "preview": { + "name": "preview", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "expires_at": { + "name": "expires_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "decided_at": { + "name": "decided_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "decided_by_provider_user_id": { + "name": "decided_by_provider_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "chat_approvals_org_id_fkey": { + "name": "chat_approvals_org_id_fkey", + "tableFrom": "chat_approvals", + "tableTo": "organizations", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_installations": { + "name": "chat_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_name": { + "name": "workspace_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_user_id": { + "name": "bot_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bot_access_token": { + "name": "bot_access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "default_env": { + "name": "default_env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sandbox_api_key_id": { + "name": "sandbox_api_key_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sandbox_api_key": { + "name": "sandbox_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "live_api_key_id": { + "name": "live_api_key_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "live_api_key": { + "name": "live_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "installed_by_user_id": { + "name": "installed_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "installed_by_provider_user_id": { + "name": "installed_by_provider_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + } + }, + "indexes": {}, + "foreignKeys": { + "chat_installations_org_id_fkey": { + "name": "chat_installations_org_id_fkey", + "tableFrom": "chat_installations", + "tableTo": "organizations", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "chat_installations_org_provider_key": { + "name": "chat_installations_org_provider_key", + "nullsNotDistinct": false, + "columns": ["org_id", "provider"] + }, + "chat_installations_provider_workspace_key": { + "name": "chat_installations_provider_workspace_key", + "nullsNotDistinct": false, + "columns": ["provider", "workspace_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_results": { + "name": "chat_results", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.checkouts": { + "name": "checkouts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "params_version": { + "name": "params_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "response": { + "name": "response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "stripe_invoice_id": { + "name": "stripe_invoice_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "expires_at": { + "name": "expires_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_checkouts_stripe_invoice_id": { + "name": "idx_checkouts_stripe_invoice_id", + "columns": [ + { + "expression": "stripe_invoice_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.customer_entitlements": { + "name": "customer_entitlements", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "customer_product_id": { + "name": "customer_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entitlement_id": { + "name": "entitlement_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text COLLATE \"C\"", + "primaryKey": false, + "notNull": true + }, + "internal_entity_id": { + "name": "internal_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_feature_id": { + "name": "internal_feature_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "unlimited": { + "name": "unlimited", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "balance": { + "name": "balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "next_reset_at": { + "name": "next_reset_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "usage_allowed": { + "name": "usage_allowed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "adjustment": { + "name": "adjustment", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "additional_balance": { + "name": "additional_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "entities": { + "name": "entities", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "cache_version": { + "name": "cache_version", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "feature_id": { + "name": "feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_customer_entitlements_product_id": { + "name": "idx_customer_entitlements_product_id", + "columns": [ + { + "expression": "customer_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_entitlements_internal_customer_id": { + "name": "idx_customer_entitlements_internal_customer_id", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hash", + "with": {} + }, + "idx_customer_entitlements_internal_customer_id_btree": { + "name": "idx_customer_entitlements_internal_customer_id_btree", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_entitlements_entitlement_id": { + "name": "idx_customer_entitlements_entitlement_id", + "columns": [ + { + "expression": "entitlement_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_entitlements_internal_entity_id": { + "name": "idx_customer_entitlements_internal_entity_id", + "columns": [ + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hash", + "with": {} + }, + "idx_customer_entitlements_on_next_reset_at": { + "name": "idx_customer_entitlements_on_next_reset_at", + "columns": [ + { + "expression": "next_reset_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_entitlements_loose_customer_expires": { + "name": "idx_customer_entitlements_loose_customer_expires", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"customer_entitlements\".\"customer_product_id\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "entitlements_internal_feature_id_fkey": { + "name": "entitlements_internal_feature_id_fkey", + "tableFrom": "customer_entitlements", + "tableTo": "features", + "columnsFrom": ["internal_feature_id"], + "columnsTo": ["internal_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "customer_entitlements_internal_entity_id_fkey": { + "name": "customer_entitlements_internal_entity_id_fkey", + "tableFrom": "customer_entitlements", + "tableTo": "entities", + "columnsFrom": ["internal_entity_id"], + "columnsTo": ["internal_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "customer_entitlements_customer_product_id_fkey": { + "name": "customer_entitlements_customer_product_id_fkey", + "tableFrom": "customer_entitlements", + "tableTo": "customer_products", + "columnsFrom": ["customer_product_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "customer_entitlements_entitlement_id_fkey": { + "name": "customer_entitlements_entitlement_id_fkey", + "tableFrom": "customer_entitlements", + "tableTo": "entitlements", + "columnsFrom": ["entitlement_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.customer_prices": { + "name": "customer_prices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "price_id": { + "name": "price_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "options": { + "name": "options", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_product_id": { + "name": "customer_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_customer_prices_product_id": { + "name": "idx_customer_prices_product_id", + "columns": [ + { + "expression": "customer_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_prices_price_id": { + "name": "idx_customer_prices_price_id", + "columns": [ + { + "expression": "price_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_prices_internal_customer_id": { + "name": "idx_customer_prices_internal_customer_id", + "columns": [ + { + "expression": "\"internal_customer_id\" COLLATE \"C\"", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"customer_prices\".\"internal_customer_id\" IS NOT NULL", + "concurrently": true, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "customer_prices_customer_product_id_fkey": { + "name": "customer_prices_customer_product_id_fkey", + "tableFrom": "customer_prices", + "tableTo": "customer_products", + "columnsFrom": ["customer_product_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "customer_prices_internal_customer_id_fkey": { + "name": "customer_prices_internal_customer_id_fkey", + "tableFrom": "customer_prices", + "tableTo": "customers", + "columnsFrom": ["internal_customer_id"], + "columnsTo": ["internal_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "customer_prices_price_id_fkey": { + "name": "customer_prices_price_id_fkey", + "tableFrom": "customer_prices", + "tableTo": "prices", + "columnsFrom": ["price_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.customer_products": { + "name": "customer_products", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_product_id": { + "name": "internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_entity_id": { + "name": "internal_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processor": { + "name": "processor", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "canceled": { + "name": "canceled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "starts_at": { + "name": "starts_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "access_starts_at": { + "name": "access_starts_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "options": { + "name": "options", + "type": "jsonb[]", + "primaryKey": false, + "notNull": false + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "free_trial_id": { + "name": "free_trial_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trial_ends_at": { + "name": "trial_ends_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "billing_cycle_anchor_resets_at": { + "name": "billing_cycle_anchor_resets_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "collection_method": { + "name": "collection_method", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'charge_automatically'" + }, + "subscription_ids": { + "name": "subscription_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "scheduled_ids": { + "name": "scheduled_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "quantity": { + "name": "quantity", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "is_custom": { + "name": "is_custom", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_version": { + "name": "billing_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "api_version": { + "name": "api_version", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "api_semver": { + "name": "api_semver", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_checkout_session_id": { + "name": "stripe_checkout_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "previous_customer_product_id": { + "name": "previous_customer_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "on_trial_end": { + "name": "on_trial_end", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_customer_products_customer_status": { + "name": "idx_customer_products_customer_status", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_products_on_internal_entity_id": { + "name": "idx_customer_products_on_internal_entity_id", + "columns": [ + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_products_on_internal_product_id": { + "name": "idx_customer_products_on_internal_product_id", + "columns": [ + { + "expression": "internal_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_products_subscription_ids": { + "name": "idx_customer_products_subscription_ids", + "columns": [ + { + "expression": "subscription_ids", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_customer_products_scheduled_ids": { + "name": "idx_customer_products_scheduled_ids", + "columns": [ + { + "expression": "scheduled_ids", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_customer_products_stripe_checkout_session_id": { + "name": "idx_customer_products_stripe_checkout_session_id", + "columns": [ + { + "expression": "stripe_checkout_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_products_revenuecat_processor": { + "name": "idx_customer_products_revenuecat_processor", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "(\"customer_products\".\"processor\" ->> 'type') = 'revenuecat'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "customer_products_free_trial_id_fkey": { + "name": "customer_products_free_trial_id_fkey", + "tableFrom": "customer_products", + "tableTo": "free_trials", + "columnsFrom": ["free_trial_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "customer_products_internal_customer_id_fkey": { + "name": "customer_products_internal_customer_id_fkey", + "tableFrom": "customer_products", + "tableTo": "customers", + "columnsFrom": ["internal_customer_id"], + "columnsTo": ["internal_id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "customer_products_internal_product_id_fkey": { + "name": "customer_products_internal_product_id_fkey", + "tableFrom": "customer_products", + "tableTo": "products", + "columnsFrom": ["internal_product_id"], + "columnsTo": ["internal_id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "customer_products_internal_entity_id_fkey": { + "name": "customer_products_internal_entity_id_fkey", + "tableFrom": "customer_products", + "tableTo": "entities", + "columnsFrom": ["internal_entity_id"], + "columnsTo": ["internal_id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.customers": { + "name": "customers", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "processor": { + "name": "processor", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "processors": { + "name": "processors", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "send_email_receipts": { + "name": "send_email_receipts", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "auto_topups": { + "name": "auto_topups", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "spend_limits": { + "name": "spend_limits", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "usage_alerts": { + "name": "usage_alerts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "overage_allowed": { + "name": "overage_allowed", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "customers_email_null_id_unique": { + "name": "customers_email_null_id_unique", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"email\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"customers\".\"id\" IS NULL AND \"customers\".\"email\" IS NOT NULL AND \"customers\".\"email\" != ''", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_org_env_fingerprint": { + "name": "idx_customers_org_env_fingerprint", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"customers\".\"fingerprint\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_processor_id": { + "name": "idx_customers_processor_id", + "columns": [ + { + "expression": "(\"processor\" ->> 'id')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_composite": { + "name": "idx_customers_composite", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_org_env_internal_id": { + "name": "idx_customers_org_env_internal_id", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"internal_id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_email_trgm": { + "name": "idx_customers_email_trgm", + "columns": [ + { + "expression": "\"email\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"customers\".\"email\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_customers_name_trgm": { + "name": "idx_customers_name_trgm", + "columns": [ + { + "expression": "\"name\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"customers\".\"name\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_customers_id_trgm": { + "name": "idx_customers_id_trgm", + "columns": [ + { + "expression": "\"id\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"customers\".\"id\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_customers_org_id_env_created_at": { + "name": "idx_customers_org_id_env_created_at", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_cursor": { + "name": "idx_customers_cursor", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_processors_revenuecat": { + "name": "idx_customers_processors_revenuecat", + "columns": [ + { + "expression": "(\"processors\" ->> 'revenuecat')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_processors_vercel": { + "name": "idx_customers_processors_vercel", + "columns": [ + { + "expression": "(\"processors\" ->> 'vercel')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "customers_org_id_fkey": { + "name": "customers_org_id_fkey", + "tableFrom": "customers", + "tableTo": "organizations", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "cus_id_constraint": { + "name": "cus_id_constraint", + "nullsNotDistinct": false, + "columns": ["org_id", "id", "env"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.entities": { + "name": "entities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "internal_feature_id": { + "name": "internal_feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "spend_limits": { + "name": "spend_limits", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "usage_alerts": { + "name": "usage_alerts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "overage_allowed": { + "name": "overage_allowed", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "feature_id": { + "name": "feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_entities_internal_customer_id": { + "name": "idx_entities_internal_customer_id", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_entities_customer_internal_desc": { + "name": "idx_entities_customer_internal_desc", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"internal_id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_entities_org_env_id": { + "name": "idx_entities_org_env_id", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_entities_cursor": { + "name": "idx_entities_cursor", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "entities_internal_customer_id_fkey": { + "name": "entities_internal_customer_id_fkey", + "tableFrom": "entities", + "tableTo": "customers", + "columnsFrom": ["internal_customer_id"], + "columnsTo": ["internal_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "entities_internal_feature_id_fkey": { + "name": "entities_internal_feature_id_fkey", + "tableFrom": "entities", + "tableTo": "features", + "columnsFrom": ["internal_feature_id"], + "columnsTo": ["internal_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "entities_org_id_fkey": { + "name": "entities_org_id_fkey", + "tableFrom": "entities", + "tableTo": "organizations", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "entity_id_constraint": { + "name": "entity_id_constraint", + "nullsNotDistinct": false, + "columns": ["org_id", "env", "internal_customer_id", "id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.entitlements": { + "name": "entitlements", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "internal_feature_id": { + "name": "internal_feature_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_product_id": { + "name": "internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_reward_id": { + "name": "internal_reward_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_custom": { + "name": "is_custom", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "allowance_type": { + "name": "allowance_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowance": { + "name": "allowance", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "interval": { + "name": "interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "interval_count": { + "name": "interval_count", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "carry_from_previous": { + "name": "carry_from_previous", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "entity_feature_id": { + "name": "entity_feature_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "feature_id": { + "name": "feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "usage_limit": { + "name": "usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "expiry_duration": { + "name": "expiry_duration", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expiry_length": { + "name": "expiry_length", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "rollover": { + "name": "rollover", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_entitlements_internal_product_id": { + "name": "idx_entitlements_internal_product_id", + "columns": [ + { + "expression": "internal_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_entitlements_internal_reward_id": { + "name": "idx_entitlements_internal_reward_id", + "columns": [ + { + "expression": "internal_reward_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_entitlements_reward_feature": { + "name": "idx_entitlements_reward_feature", + "columns": [ + { + "expression": "internal_reward_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "internal_feature_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_entitlements_internal_reward_id_c_partial": { + "name": "idx_entitlements_internal_reward_id_c_partial", + "columns": [ + { + "expression": "\"internal_reward_id\" COLLATE \"C\"", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"entitlements\".\"internal_reward_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "entitlements_internal_feature_id_fkey": { + "name": "entitlements_internal_feature_id_fkey", + "tableFrom": "entitlements", + "tableTo": "features", + "columnsFrom": ["internal_feature_id"], + "columnsTo": ["internal_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "entitlements_internal_product_id_fkey": { + "name": "entitlements_internal_product_id_fkey", + "tableFrom": "entitlements", + "tableTo": "products", + "columnsFrom": ["internal_product_id"], + "columnsTo": ["internal_id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "entitlements_internal_reward_id_fkey": { + "name": "entitlements_internal_reward_id_fkey", + "tableFrom": "entitlements", + "tableTo": "rewards", + "columnsFrom": ["internal_reward_id"], + "columnsTo": ["internal_id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "entitlements_id_key": { + "name": "entitlements_id_key", + "nullsNotDistinct": false, + "columns": ["id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.events": { + "name": "events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_slug": { + "name": "org_slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "event_name": { + "name": "event_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "value": { + "name": "value", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "set_usage": { + "name": "set_usage", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_entity_id": { + "name": "internal_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_product_id": { + "name": "internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "properties": { + "name": "properties", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "deductions": { + "name": "deductions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_events_internal_customer_id": { + "name": "idx_events_internal_customer_id", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_events_internal_entity_id": { + "name": "idx_events_internal_entity_id", + "columns": [ + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_events_customer_non_usage_ts": { + "name": "idx_events_customer_non_usage_ts", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"timestamp\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"events\".\"set_usage\" = false", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "events_internal_customer_id_fkey": { + "name": "events_internal_customer_id_fkey", + "tableFrom": "events", + "tableTo": "customers", + "columnsFrom": ["internal_customer_id"], + "columnsTo": ["internal_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_event_constraint": { + "name": "unique_event_constraint", + "nullsNotDistinct": false, + "columns": [ + "org_id", + "env", + "customer_id", + "event_name", + "idempotency_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.features": { + "name": "features", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "display": { + "name": "display", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "event_names": { + "name": "event_names", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + } + }, + "indexes": {}, + "foreignKeys": { + "features_org_id_fkey": { + "name": "features_org_id_fkey", + "tableFrom": "features", + "tableTo": "organizations", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "feature_id_constraint": { + "name": "feature_id_constraint", + "nullsNotDistinct": false, + "columns": ["org_id", "id", "env"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.free_trials": { + "name": "free_trials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "internal_product_id": { + "name": "internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duration": { + "name": "duration", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'day'" + }, + "length": { + "name": "length", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "unique_fingerprint": { + "name": "unique_fingerprint", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "is_custom": { + "name": "is_custom", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "card_required": { + "name": "card_required", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "on_end": { + "name": "on_end", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_free_trials_internal_product_id": { + "name": "idx_free_trials_internal_product_id", + "columns": [ + { + "expression": "internal_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "free_trials_internal_product_id_fkey": { + "name": "free_trials_internal_product_id_fkey", + "tableFrom": "free_trials", + "tableTo": "products", + "columnsFrom": ["internal_product_id"], + "columnsTo": ["internal_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "invitation_organizationId_idx": { + "name": "invitation_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_organization_id_organizations_id_fk": { + "name": "invitation_organization_id_organizations_id_fk", + "tableFrom": "invitation", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.invoice_line_items": { + "name": "invoice_line_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "invoice_id": { + "name": "invoice_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_id": { + "name": "stripe_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_invoice_id": { + "name": "stripe_invoice_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_invoice_item_id": { + "name": "stripe_invoice_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_item_id": { + "name": "stripe_subscription_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_product_id": { + "name": "stripe_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_price_id": { + "name": "stripe_price_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_discountable": { + "name": "stripe_discountable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "amount": { + "name": "amount", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "amount_after_discounts": { + "name": "amount_after_discounts", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'usd'" + }, + "stripe_quantity": { + "name": "stripe_quantity", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "total_quantity": { + "name": "total_quantity", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "paid_quantity": { + "name": "paid_quantity", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description_source": { + "name": "description_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "direction": { + "name": "direction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billing_timing": { + "name": "billing_timing", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prorated": { + "name": "prorated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "price_id": { + "name": "price_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_product_ids": { + "name": "customer_product_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "customer_price_ids": { + "name": "customer_price_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "customer_entitlement_ids": { + "name": "customer_entitlement_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "internal_product_id": { + "name": "internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_feature_id": { + "name": "internal_feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "feature_id": { + "name": "feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "effective_period_start": { + "name": "effective_period_start", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "effective_period_end": { + "name": "effective_period_end", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "discounts": { + "name": "discounts", + "type": "jsonb[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + } + }, + "indexes": {}, + "foreignKeys": { + "invoice_line_items_invoice_id_fkey": { + "name": "invoice_line_items_invoice_id_fkey", + "tableFrom": "invoice_line_items", + "tableTo": "invoices", + "columnsFrom": ["invoice_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invoice_line_items_stripe_id_unique": { + "name": "invoice_line_items_stripe_id_unique", + "nullsNotDistinct": false, + "columns": ["stripe_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoice_templates": { + "name": "invoice_templates", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "footer": { + "name": "footer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "memo": { + "name": "memo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "net_terms_days": { + "name": "net_terms_days", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_invoice_templates_org_id": { + "name": "idx_invoice_templates_org_id", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invoice_templates_org_id_fkey": { + "name": "invoice_templates_org_id_fkey", + "tableFrom": "invoice_templates", + "tableTo": "organizations", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invoice_templates_id_unique": { + "name": "invoice_templates_id_unique", + "nullsNotDistinct": false, + "columns": ["id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoices": { + "name": "invoices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "product_ids": { + "name": "product_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "internal_product_ids": { + "name": "internal_product_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_entity_id": { + "name": "internal_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_id": { + "name": "stripe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "processor_type": { + "name": "processor_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "hosted_invoice_url": { + "name": "hosted_invoice_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "total": { + "name": "total", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "amount_paid": { + "name": "amount_paid", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "refunded_amount": { + "name": "refunded_amount", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'usd'" + }, + "discounts": { + "name": "discounts", + "type": "jsonb[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "items": { + "name": "items", + "type": "jsonb[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + } + }, + "indexes": { + "idx_invoices_customer_created": { + "name": "idx_invoices_customer_created", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_invoices_internal_entity_id": { + "name": "idx_invoices_internal_entity_id", + "columns": [ + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"invoices\".\"internal_entity_id\" IS NOT NULL", + "concurrently": true, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invoices_internal_customer_id_fkey": { + "name": "invoices_internal_customer_id_fkey", + "tableFrom": "invoices", + "tableTo": "customers", + "columnsFrom": ["internal_customer_id"], + "columnsTo": ["internal_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invoices_internal_entity_id_fkey": { + "name": "invoices_internal_entity_id_fkey", + "tableFrom": "invoices", + "tableTo": "entities", + "columnsFrom": ["internal_entity_id"], + "columnsTo": ["internal_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invoices_stripe_id_key": { + "name": "invoices_stripe_id_key", + "nullsNotDistinct": false, + "columns": ["stripe_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.jwks": { + "name": "jwks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "member_organizationId_idx": { + "name": "member_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_userId_idx": { + "name": "member_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_organization_id_organizations_id_fk": { + "name": "member_organization_id_organizations_id_fk", + "tableFrom": "member", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.metadata": { + "name": "metadata", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "expires_at": { + "name": "expires_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_invoice_id": { + "name": "stripe_invoice_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_checkout_session_id": { + "name": "stripe_checkout_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migration_errors": { + "name": "migration_errors", + "schema": "", + "columns": { + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "migration_job_id": { + "name": "migration_job_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "migration_customers_internal_customer_id_fkey": { + "name": "migration_customers_internal_customer_id_fkey", + "tableFrom": "migration_errors", + "tableTo": "customers", + "columnsFrom": ["internal_customer_id"], + "columnsTo": ["internal_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "migration_customers_migration_job_id_fkey": { + "name": "migration_customers_migration_job_id_fkey", + "tableFrom": "migration_errors", + "tableTo": "migration_jobs", + "columnsFrom": ["migration_job_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "migration_errors_pkey": { + "name": "migration_errors_pkey", + "columns": ["internal_customer_id", "migration_job_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migration_item_runs": { + "name": "migration_item_runs", + "schema": "", + "columns": { + "migration_item_run_id": { + "name": "migration_item_run_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "migration_internal_id": { + "name": "migration_internal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "migration_run_id": { + "name": "migration_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dry_run": { + "name": "dry_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "item_kind": { + "name": "item_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "migration_item_runs_live_unique": { + "name": "migration_item_runs_live_unique", + "columns": [ + { + "expression": "migration_internal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"migration_item_runs\".\"dry_run\" = false", + "concurrently": false, + "method": "btree", + "with": {} + }, + "migration_item_runs_dry_run_unique": { + "name": "migration_item_runs_dry_run_unique", + "columns": [ + { + "expression": "migration_internal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "migration_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"migration_item_runs\".\"dry_run\" = true", + "concurrently": false, + "method": "btree", + "with": {} + }, + "migration_item_runs_customer_recent_idx": { + "name": "migration_item_runs_customer_recent_idx", + "columns": [ + { + "expression": "item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"updated_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"migration_item_runs\".\"item_kind\" = 'customer'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migration_jobs": { + "name": "migration_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "current_step": { + "name": "current_step", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "from_internal_product_id": { + "name": "from_internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "to_internal_product_id": { + "name": "to_internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "step_details": { + "name": "step_details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "migration_jobs_from_internal_product_id_fkey": { + "name": "migration_jobs_from_internal_product_id_fkey", + "tableFrom": "migration_jobs", + "tableTo": "products", + "columnsFrom": ["from_internal_product_id"], + "columnsTo": ["internal_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "migration_jobs_org_id_fkey": { + "name": "migration_jobs_org_id_fkey", + "tableFrom": "migration_jobs", + "tableTo": "organizations", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "migration_jobs_to_internal_product_id_fkey": { + "name": "migration_jobs_to_internal_product_id_fkey", + "tableFrom": "migration_jobs", + "tableTo": "products", + "columnsFrom": ["to_internal_product_id"], + "columnsTo": ["internal_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migration_runs": { + "name": "migration_runs", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "migration_internal_id": { + "name": "migration_internal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dry_run": { + "name": "dry_run", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "lazy_run": { + "name": "lazy_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trigger_run_id": { + "name": "trigger_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "only_ids": { + "name": "only_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "target_limit": { + "name": "target_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "migration_runs_active_per_migration_unique": { + "name": "migration_runs_active_per_migration_unique", + "columns": [ + { + "expression": "migration_internal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"migration_runs\".\"status\" IN ('queued', 'running')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "migration_runs_migration_internal_id_fkey": { + "name": "migration_runs_migration_internal_id_fkey", + "tableFrom": "migration_runs", + "tableTo": "migrations", + "columnsFrom": ["migration_internal_id"], + "columnsTo": ["internal_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "migration_runs_org_id_fkey": { + "name": "migration_runs_org_id_fkey", + "tableFrom": "migration_runs", + "tableTo": "organizations", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migrations": { + "name": "migrations", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filter": { + "name": "filter", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "operations": { + "name": "operations", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "prepared_state": { + "name": "prepared_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "no_billing_changes": { + "name": "no_billing_changes", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "retry_failed": { + "name": "retry_failed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "migrations_org_env_id_unique": { + "name": "migrations_org_env_id_unique", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "migrations_org_id_fkey": { + "name": "migrations_org_id_fkey", + "tableFrom": "migrations", + "tableTo": "organizations", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_access_token": { + "name": "oauth_access_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_id": { + "name": "refresh_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_access_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_access_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_session_id_session_id_fk": { + "name": "oauth_access_token_session_id_session_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_access_token_user_id_user_id_fk": { + "name": "oauth_access_token_user_id_user_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_refresh_id_oauth_refresh_token_id_fk": { + "name": "oauth_access_token_refresh_id_oauth_refresh_token_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_refresh_token", + "columnsFrom": ["refresh_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_access_token_token_unique": { + "name": "oauth_access_token_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.oauth_client": { + "name": "oauth_client", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "skip_consent": { + "name": "skip_consent", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enable_end_session": { + "name": "enable_end_session", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contacts": { + "name": "contacts", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "tos": { + "name": "tos", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_id": { + "name": "software_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_version": { + "name": "software_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_statement": { + "name": "software_statement", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "post_logout_redirect_uris": { + "name": "post_logout_redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_types": { + "name": "grant_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "response_types": { + "name": "response_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_client_user_id_user_id_fk": { + "name": "oauth_client_user_id_user_id_fk", + "tableFrom": "oauth_client", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_client_client_id_unique": { + "name": "oauth_client_client_id_unique", + "nullsNotDistinct": false, + "columns": ["client_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.oauth_consent": { + "name": "oauth_consent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_uri": { + "name": "redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_api_key_id": { + "name": "oauth_api_key_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_consent_client_id_oauth_client_client_id_fk": { + "name": "oauth_consent_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_consent_user_id_user_id_fk": { + "name": "oauth_consent_user_id_user_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.oauth_refresh_token": { + "name": "oauth_refresh_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked": { + "name": "revoked", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "auth_time": { + "name": "auth_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_refresh_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_refresh_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_refresh_token_session_id_session_id_fk": { + "name": "oauth_refresh_token_session_id_session_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_refresh_token_user_id_user_id_fk": { + "name": "oauth_refresh_token_user_id_user_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_currency": { + "name": "default_currency", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'usd'" + }, + "stripe_connected": { + "name": "stripe_connected", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "stripe_config": { + "name": "stripe_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "test_stripe_connect": { + "name": "test_stripe_connect", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "live_stripe_connect": { + "name": "live_stripe_connect", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "processor_configs": { + "name": "processor_configs", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "test_pkey": { + "name": "test_pkey", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "live_pkey": { + "name": "live_pkey", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "svix_config": { + "name": "svix_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "onboarded": { + "name": "onboarded", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "deployed": { + "name": "deployed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "redis_config": { + "name": "redis_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_organizations_name_trgm": { + "name": "idx_organizations_name_trgm", + "columns": [ + { + "expression": "\"name\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"organizations\".\"name\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_organizations_slug_trgm": { + "name": "idx_organizations_slug_trgm", + "columns": [ + { + "expression": "\"slug\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"organizations\".\"slug\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_organizations_created_at_id": { + "name": "idx_organizations_created_at_id", + "columns": [ + { + "expression": "\"createdAt\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + }, + "organizations_test_pkey_key": { + "name": "organizations_test_pkey_key", + "nullsNotDistinct": false, + "columns": ["test_pkey"] + }, + "organizations_live_pkey_key": { + "name": "organizations_live_pkey_key", + "nullsNotDistinct": false, + "columns": ["live_pkey"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.passkey": { + "name": "passkey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "backed_up": { + "name": "backed_up", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "aaguid": { + "name": "aaguid", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "passkey_userId_idx": { + "name": "passkey_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "passkey_credentialId_idx": { + "name": "passkey_credentialId_idx", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "passkey_user_id_user_id_fk": { + "name": "passkey_user_id_user_id_fk", + "tableFrom": "passkey", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "passkey_credential_id_unique": { + "name": "passkey_credential_id_unique", + "nullsNotDistinct": false, + "columns": ["credential_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.prices": { + "name": "prices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_product_id": { + "name": "internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "billing_type": { + "name": "billing_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tier_behavior": { + "name": "tier_behavior", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "is_custom": { + "name": "is_custom", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "entitlement_id": { + "name": "entitlement_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "proration_config": { + "name": "proration_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "null" + } + }, + "indexes": { + "idx_prices_internal_product_id": { + "name": "idx_prices_internal_product_id", + "columns": [ + { + "expression": "internal_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_prices_entitlement_id": { + "name": "idx_prices_entitlement_id", + "columns": [ + { + "expression": "entitlement_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "prices_entitlement_id_fkey": { + "name": "prices_entitlement_id_fkey", + "tableFrom": "prices", + "tableTo": "entitlements", + "columnsFrom": ["entitlement_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "prices_internal_product_id_fkey": { + "name": "prices_internal_product_id_fkey", + "tableFrom": "prices", + "tableTo": "products", + "columnsFrom": ["internal_product_id"], + "columnsTo": ["internal_id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "prices_id_key": { + "name": "prices_id_key", + "nullsNotDistinct": false, + "columns": ["id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.products": { + "name": "products", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_add_on": { + "name": "is_add_on", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "group": { + "name": "group", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "''" + }, + "version": { + "name": "version", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "processor": { + "name": "processor", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "base_variant_id": { + "name": "base_variant_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "idx_products_org_env_id_version": { + "name": "idx_products_org_env_id_version", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "products_org_id_fkey": { + "name": "products_org_id_fkey", + "tableFrom": "products", + "tableTo": "organizations", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_product": { + "name": "unique_product", + "nullsNotDistinct": false, + "columns": ["org_id", "id", "env", "version"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.referral_codes": { + "name": "referral_codes", + "schema": "", + "columns": { + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text COLLATE \"C\"", + "primaryKey": false, + "notNull": false + }, + "internal_reward_program_id": { + "name": "internal_reward_program_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_referral_codes_internal_customer_id": { + "name": "idx_referral_codes_internal_customer_id", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "referral_codes_internal_customer_id_fkey": { + "name": "referral_codes_internal_customer_id_fkey", + "tableFrom": "referral_codes", + "tableTo": "customers", + "columnsFrom": ["internal_customer_id"], + "columnsTo": ["internal_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "referral_codes_internal_reward_program_id_fkey": { + "name": "referral_codes_internal_reward_program_id_fkey", + "tableFrom": "referral_codes", + "tableTo": "reward_programs", + "columnsFrom": ["internal_reward_program_id"], + "columnsTo": ["internal_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "referral_codes_org_id_fkey": { + "name": "referral_codes_org_id_fkey", + "tableFrom": "referral_codes", + "tableTo": "organizations", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "referral_codes_pkey": { + "name": "referral_codes_pkey", + "columns": ["code", "org_id", "env"] + } + }, + "uniqueConstraints": { + "referral_codes_id_key": { + "name": "referral_codes_id_key", + "nullsNotDistinct": false, + "columns": ["id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.replaceables": { + "name": "replaceables", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "cus_ent_id": { + "name": "cus_ent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "from_entity_id": { + "name": "from_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "delete_next_cycle": { + "name": "delete_next_cycle", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "idx_replaceables_cus_ent_id": { + "name": "idx_replaceables_cus_ent_id", + "columns": [ + { + "expression": "cus_ent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "replaceables_cus_ent_id_fkey": { + "name": "replaceables_cus_ent_id_fkey", + "tableFrom": "replaceables", + "tableTo": "customer_entitlements", + "columnsFrom": ["cus_ent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.revenuecat_mappings": { + "name": "revenuecat_mappings", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "autumn_product_id": { + "name": "autumn_product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revenuecat_product_ids": { + "name": "revenuecat_product_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + } + }, + "indexes": {}, + "foreignKeys": { + "revenuecat_mappings_org_id_fkey": { + "name": "revenuecat_mappings_org_id_fkey", + "tableFrom": "revenuecat_mappings", + "tableTo": "organizations", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "revenuecat_mappings_pkey": { + "name": "revenuecat_mappings_pkey", + "columns": ["org_id", "env", "autumn_product_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reward_programs": { + "name": "reward_programs", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "internal_reward_id": { + "name": "internal_reward_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_redemptions": { + "name": "max_redemptions", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "unlimited_redemptions": { + "name": "unlimited_redemptions", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "when": { + "name": "when", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'immediately'" + }, + "product_ids": { + "name": "product_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{\"\"}'" + }, + "exclude_trial": { + "name": "exclude_trial", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "received_by": { + "name": "received_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "reward_triggers_internal_reward_id_fkey": { + "name": "reward_triggers_internal_reward_id_fkey", + "tableFrom": "reward_programs", + "tableTo": "rewards", + "columnsFrom": ["internal_reward_id"], + "columnsTo": ["internal_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reward_triggers_org_id_fkey": { + "name": "reward_triggers_org_id_fkey", + "tableFrom": "reward_programs", + "tableTo": "organizations", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reward_redemptions": { + "name": "reward_redemptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text COLLATE \"C\"", + "primaryKey": false, + "notNull": false + }, + "triggered": { + "name": "triggered", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "internal_reward_program_id": { + "name": "internal_reward_program_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "applied": { + "name": "applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "redeemer_applied": { + "name": "redeemer_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "referral_code_id": { + "name": "referral_code_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reward_internal_id": { + "name": "reward_internal_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "promo_code": { + "name": "promo_code", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_reward_redemptions_referral_code_id": { + "name": "idx_reward_redemptions_referral_code_id", + "columns": [ + { + "expression": "referral_code_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_reward_redemptions_reward_internal_id": { + "name": "idx_reward_redemptions_reward_internal_id", + "columns": [ + { + "expression": "reward_internal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_reward_redemptions_customer_reward": { + "name": "idx_reward_redemptions_customer_reward", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reward_internal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reward_redemptions_internal_customer_id_fkey": { + "name": "reward_redemptions_internal_customer_id_fkey", + "tableFrom": "reward_redemptions", + "tableTo": "customers", + "columnsFrom": ["internal_customer_id"], + "columnsTo": ["internal_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reward_redemptions_internal_reward_program_id_fkey": { + "name": "reward_redemptions_internal_reward_program_id_fkey", + "tableFrom": "reward_redemptions", + "tableTo": "reward_programs", + "columnsFrom": ["internal_reward_program_id"], + "columnsTo": ["internal_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reward_redemptions_referral_code_id_fkey": { + "name": "reward_redemptions_referral_code_id_fkey", + "tableFrom": "reward_redemptions", + "tableTo": "referral_codes", + "columnsFrom": ["referral_code_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rewards": { + "name": "rewards", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "discount_config": { + "name": "discount_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "free_product_config": { + "name": "free_product_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "free_product_id": { + "name": "free_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "promo_codes": { + "name": "promo_codes", + "type": "jsonb[]", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "coupons_org_id_fkey": { + "name": "coupons_org_id_fkey", + "tableFrom": "rewards", + "tableTo": "organizations", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rollovers": { + "name": "rollovers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "cus_ent_id": { + "name": "cus_ent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "balance": { + "name": "balance", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "usage": { + "name": "usage", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "entities": { + "name": "entities", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "idx_rollovers_cus_ent_id": { + "name": "idx_rollovers_cus_ent_id", + "columns": [ + { + "expression": "cus_ent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_rollovers_cus_ent_expires": { + "name": "idx_rollovers_cus_ent_expires", + "columns": [ + { + "expression": "cus_ent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "rollover_cus_ent_id_fkey": { + "name": "rollover_cus_ent_id_fkey", + "tableFrom": "rollovers", + "tableTo": "customer_entitlements", + "columnsFrom": ["cus_ent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.phases": { + "name": "phases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "starts_at": { + "name": "starts_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "customer_product_ids": { + "name": "customer_product_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "phases_schedule_id_fkey": { + "name": "phases_schedule_id_fkey", + "tableFrom": "phases", + "tableTo": "schedules", + "columnsFrom": ["schedule_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "phases_schedule_id_starts_at_key": { + "name": "phases_schedule_id_starts_at_key", + "nullsNotDistinct": false, + "columns": ["schedule_id", "starts_at"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.schedules": { + "name": "schedules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_entity_id": { + "name": "internal_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "schedules_customer_scope_unique": { + "name": "schedules_customer_scope_unique", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"schedules\".\"internal_entity_id\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "schedules_entity_scope_unique": { + "name": "schedules_entity_scope_unique", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"schedules\".\"internal_entity_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_schedules_internal_customer_id": { + "name": "idx_schedules_internal_customer_id", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_schedules_internal_entity_id": { + "name": "idx_schedules_internal_entity_id", + "columns": [ + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "schedules_org_id_fkey": { + "name": "schedules_org_id_fkey", + "tableFrom": "schedules", + "tableTo": "organizations", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "schedules_internal_customer_id_fkey": { + "name": "schedules_internal_customer_id_fkey", + "tableFrom": "schedules", + "tableTo": "customers", + "columnsFrom": ["internal_customer_id"], + "columnsTo": ["internal_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "schedules_internal_entity_id_fkey": { + "name": "schedules_internal_entity_id_fkey", + "tableFrom": "schedules", + "tableTo": "entities", + "columnsFrom": ["internal_entity_id"], + "columnsTo": ["internal_id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "city": { + "name": "city", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country": { + "name": "country", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.subscriptions": { + "name": "subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_id": { + "name": "stripe_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "usage_features": { + "name": "usage_features", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "current_period_start": { + "name": "current_period_start", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "current_period_end": { + "name": "current_period_end", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "subscriptions_org_id_fkey": { + "name": "subscriptions_org_id_fkey", + "tableFrom": "subscriptions", + "tableTo": "organizations", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "subscriptions_stripe_id_key": { + "name": "subscriptions_stripe_id_key", + "nullsNotDistinct": false, + "columns": ["stripe_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_active_at": { + "name": "last_active_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_user_name_trgm": { + "name": "idx_user_name_trgm", + "columns": [ + { + "expression": "\"name\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user\".\"name\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_user_email_trgm": { + "name": "idx_user_email_trgm", + "columns": [ + { + "expression": "\"email\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user\".\"email\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_user_created_at_id": { + "name": "idx_user_created_at_id", + "columns": [ + { + "expression": "\"created_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_created_by_fkey": { + "name": "user_created_by_fkey", + "tableFrom": "user", + "tableTo": "organizations", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.vercel_resources": { + "name": "vercel_resources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "vercel_resources_installation_name_unique_idx": { + "name": "vercel_resources_installation_name_unique_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status <> 'uninstalled'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "vercel_resources_org_id_fkey": { + "name": "vercel_resources_org_id_fkey", + "tableFrom": "vercel_resources", + "tableTo": "organizations", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/shared/drizzle/meta/0007_snapshot.json b/shared/drizzle/meta/0007_snapshot.json new file mode 100644 index 000000000..0058eb623 --- /dev/null +++ b/shared/drizzle/meta/0007_snapshot.json @@ -0,0 +1,7393 @@ +{ + "id": "9e1bb4b2-1869-4ca9-ba67-8fbcea263c37", + "prevId": "eadc8c95-3f6f-4643-abc3-e90cd56d5ed1", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.actions": { + "name": "actions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_slug": { + "name": "org_slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_entity_id": { + "name": "internal_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "properties": { + "name": "properties", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_actions_on_internal_entity_id": { + "name": "idx_actions_on_internal_entity_id", + "columns": [ + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "actions_org_id_fkey": { + "name": "actions_org_id_fkey", + "tableFrom": "actions", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "actions_customer_id_fkey": { + "name": "actions_customer_id_fkey", + "tableFrom": "actions", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "actions_entity_id_fkey": { + "name": "actions_entity_id_fkey", + "tableFrom": "actions", + "tableTo": "entities", + "columnsFrom": [ + "internal_entity_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "hashed_key": { + "name": "hashed_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "meta": { + "name": "meta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "api_keys_org_id_fkey": { + "name": "api_keys_org_id_fkey", + "tableFrom": "api_keys", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_keys_hashed_key_key": { + "name": "api_keys_hashed_key_key", + "nullsNotDistinct": false, + "columns": [ + "hashed_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auto_topup_limit_states": { + "name": "auto_topup_limit_states", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "feature_id": { + "name": "feature_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "purchase_window_ends_at": { + "name": "purchase_window_ends_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "purchase_count": { + "name": "purchase_count", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "attempt_window_ends_at": { + "name": "attempt_window_ends_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "attempt_count": { + "name": "attempt_count", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "failed_attempt_window_ends_at": { + "name": "failed_attempt_window_ends_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "failed_attempt_count": { + "name": "failed_attempt_count", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "last_failed_attempt_at": { + "name": "last_failed_attempt_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + } + }, + "indexes": { + "auto_topup_limits_org_env_internal_customer_feature_unique": { + "name": "auto_topup_limits_org_env_internal_customer_feature_unique", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "feature_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "auto_topup_limits_org_id_fkey": { + "name": "auto_topup_limits_org_id_fkey", + "tableFrom": "auto_topup_limit_states", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "auto_topup_limits_internal_customer_id_fkey": { + "name": "auto_topup_limits_internal_customer_id_fkey", + "tableFrom": "auto_topup_limit_states", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_approvals": { + "name": "chat_approvals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_ts": { + "name": "message_ts", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_user_id": { + "name": "provider_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_args": { + "name": "tool_args", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "preview": { + "name": "preview", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "expires_at": { + "name": "expires_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "decided_at": { + "name": "decided_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "decided_by_provider_user_id": { + "name": "decided_by_provider_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "chat_approvals_org_id_fkey": { + "name": "chat_approvals_org_id_fkey", + "tableFrom": "chat_approvals", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_installations": { + "name": "chat_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_name": { + "name": "workspace_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_user_id": { + "name": "bot_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bot_access_token": { + "name": "bot_access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "default_env": { + "name": "default_env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sandbox_api_key_id": { + "name": "sandbox_api_key_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sandbox_api_key": { + "name": "sandbox_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "live_api_key_id": { + "name": "live_api_key_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "live_api_key": { + "name": "live_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "installed_by_user_id": { + "name": "installed_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "installed_by_provider_user_id": { + "name": "installed_by_provider_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + } + }, + "indexes": {}, + "foreignKeys": { + "chat_installations_org_id_fkey": { + "name": "chat_installations_org_id_fkey", + "tableFrom": "chat_installations", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "chat_installations_org_provider_key": { + "name": "chat_installations_org_provider_key", + "nullsNotDistinct": false, + "columns": [ + "org_id", + "provider" + ] + }, + "chat_installations_provider_workspace_key": { + "name": "chat_installations_provider_workspace_key", + "nullsNotDistinct": false, + "columns": [ + "provider", + "workspace_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_results": { + "name": "chat_results", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.checkouts": { + "name": "checkouts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "params_version": { + "name": "params_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "response": { + "name": "response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "stripe_invoice_id": { + "name": "stripe_invoice_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "expires_at": { + "name": "expires_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_checkouts_stripe_invoice_id": { + "name": "idx_checkouts_stripe_invoice_id", + "columns": [ + { + "expression": "stripe_invoice_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.customer_entitlements": { + "name": "customer_entitlements", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "customer_product_id": { + "name": "customer_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entitlement_id": { + "name": "entitlement_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text COLLATE \"C\"", + "primaryKey": false, + "notNull": true + }, + "internal_entity_id": { + "name": "internal_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_feature_id": { + "name": "internal_feature_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "unlimited": { + "name": "unlimited", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "balance": { + "name": "balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "next_reset_at": { + "name": "next_reset_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "usage_allowed": { + "name": "usage_allowed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "adjustment": { + "name": "adjustment", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "additional_balance": { + "name": "additional_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "entities": { + "name": "entities", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "cache_version": { + "name": "cache_version", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "feature_id": { + "name": "feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_customer_entitlements_product_id": { + "name": "idx_customer_entitlements_product_id", + "columns": [ + { + "expression": "customer_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_entitlements_internal_customer_id": { + "name": "idx_customer_entitlements_internal_customer_id", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hash", + "with": {} + }, + "idx_customer_entitlements_internal_customer_id_btree": { + "name": "idx_customer_entitlements_internal_customer_id_btree", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_entitlements_entitlement_id": { + "name": "idx_customer_entitlements_entitlement_id", + "columns": [ + { + "expression": "entitlement_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_entitlements_internal_entity_id": { + "name": "idx_customer_entitlements_internal_entity_id", + "columns": [ + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hash", + "with": {} + }, + "idx_customer_entitlements_on_next_reset_at": { + "name": "idx_customer_entitlements_on_next_reset_at", + "columns": [ + { + "expression": "next_reset_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_entitlements_loose_customer_expires": { + "name": "idx_customer_entitlements_loose_customer_expires", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"customer_entitlements\".\"customer_product_id\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "entitlements_internal_feature_id_fkey": { + "name": "entitlements_internal_feature_id_fkey", + "tableFrom": "customer_entitlements", + "tableTo": "features", + "columnsFrom": [ + "internal_feature_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "customer_entitlements_internal_entity_id_fkey": { + "name": "customer_entitlements_internal_entity_id_fkey", + "tableFrom": "customer_entitlements", + "tableTo": "entities", + "columnsFrom": [ + "internal_entity_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "customer_entitlements_customer_product_id_fkey": { + "name": "customer_entitlements_customer_product_id_fkey", + "tableFrom": "customer_entitlements", + "tableTo": "customer_products", + "columnsFrom": [ + "customer_product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "customer_entitlements_entitlement_id_fkey": { + "name": "customer_entitlements_entitlement_id_fkey", + "tableFrom": "customer_entitlements", + "tableTo": "entitlements", + "columnsFrom": [ + "entitlement_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.customer_prices": { + "name": "customer_prices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "price_id": { + "name": "price_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "options": { + "name": "options", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_product_id": { + "name": "customer_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_customer_prices_product_id": { + "name": "idx_customer_prices_product_id", + "columns": [ + { + "expression": "customer_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_prices_price_id": { + "name": "idx_customer_prices_price_id", + "columns": [ + { + "expression": "price_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_prices_internal_customer_id": { + "name": "idx_customer_prices_internal_customer_id", + "columns": [ + { + "expression": "\"internal_customer_id\" COLLATE \"C\"", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"customer_prices\".\"internal_customer_id\" IS NOT NULL", + "concurrently": true, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "customer_prices_customer_product_id_fkey": { + "name": "customer_prices_customer_product_id_fkey", + "tableFrom": "customer_prices", + "tableTo": "customer_products", + "columnsFrom": [ + "customer_product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "customer_prices_internal_customer_id_fkey": { + "name": "customer_prices_internal_customer_id_fkey", + "tableFrom": "customer_prices", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "customer_prices_price_id_fkey": { + "name": "customer_prices_price_id_fkey", + "tableFrom": "customer_prices", + "tableTo": "prices", + "columnsFrom": [ + "price_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.customer_products": { + "name": "customer_products", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_product_id": { + "name": "internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_entity_id": { + "name": "internal_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processor": { + "name": "processor", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "canceled": { + "name": "canceled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "starts_at": { + "name": "starts_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "access_starts_at": { + "name": "access_starts_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "options": { + "name": "options", + "type": "jsonb[]", + "primaryKey": false, + "notNull": false + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "free_trial_id": { + "name": "free_trial_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trial_ends_at": { + "name": "trial_ends_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "billing_cycle_anchor_resets_at": { + "name": "billing_cycle_anchor_resets_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "collection_method": { + "name": "collection_method", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'charge_automatically'" + }, + "subscription_ids": { + "name": "subscription_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "scheduled_ids": { + "name": "scheduled_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "quantity": { + "name": "quantity", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "is_custom": { + "name": "is_custom", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_version": { + "name": "billing_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "api_version": { + "name": "api_version", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "api_semver": { + "name": "api_semver", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_checkout_session_id": { + "name": "stripe_checkout_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "previous_customer_product_id": { + "name": "previous_customer_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "on_trial_end": { + "name": "on_trial_end", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_customer_products_customer_status": { + "name": "idx_customer_products_customer_status", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_products_on_internal_entity_id": { + "name": "idx_customer_products_on_internal_entity_id", + "columns": [ + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_products_on_internal_product_id": { + "name": "idx_customer_products_on_internal_product_id", + "columns": [ + { + "expression": "internal_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_products_subscription_ids": { + "name": "idx_customer_products_subscription_ids", + "columns": [ + { + "expression": "subscription_ids", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_customer_products_scheduled_ids": { + "name": "idx_customer_products_scheduled_ids", + "columns": [ + { + "expression": "scheduled_ids", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_customer_products_stripe_checkout_session_id": { + "name": "idx_customer_products_stripe_checkout_session_id", + "columns": [ + { + "expression": "stripe_checkout_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_products_revenuecat_processor": { + "name": "idx_customer_products_revenuecat_processor", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "(\"customer_products\".\"processor\" ->> 'type') = 'revenuecat'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "customer_products_free_trial_id_fkey": { + "name": "customer_products_free_trial_id_fkey", + "tableFrom": "customer_products", + "tableTo": "free_trials", + "columnsFrom": [ + "free_trial_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "customer_products_internal_customer_id_fkey": { + "name": "customer_products_internal_customer_id_fkey", + "tableFrom": "customer_products", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "customer_products_internal_product_id_fkey": { + "name": "customer_products_internal_product_id_fkey", + "tableFrom": "customer_products", + "tableTo": "products", + "columnsFrom": [ + "internal_product_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "customer_products_internal_entity_id_fkey": { + "name": "customer_products_internal_entity_id_fkey", + "tableFrom": "customer_products", + "tableTo": "entities", + "columnsFrom": [ + "internal_entity_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.customers": { + "name": "customers", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "processor": { + "name": "processor", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "processors": { + "name": "processors", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "send_email_receipts": { + "name": "send_email_receipts", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "auto_topups": { + "name": "auto_topups", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "spend_limits": { + "name": "spend_limits", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "usage_alerts": { + "name": "usage_alerts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "overage_allowed": { + "name": "overage_allowed", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "customers_email_null_id_unique": { + "name": "customers_email_null_id_unique", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"email\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"customers\".\"id\" IS NULL AND \"customers\".\"email\" IS NOT NULL AND \"customers\".\"email\" != ''", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_org_env_fingerprint": { + "name": "idx_customers_org_env_fingerprint", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"customers\".\"fingerprint\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_processor_id": { + "name": "idx_customers_processor_id", + "columns": [ + { + "expression": "(\"processor\" ->> 'id')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_composite": { + "name": "idx_customers_composite", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_org_env_internal_id": { + "name": "idx_customers_org_env_internal_id", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"internal_id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_email_trgm": { + "name": "idx_customers_email_trgm", + "columns": [ + { + "expression": "\"email\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"customers\".\"email\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_customers_name_trgm": { + "name": "idx_customers_name_trgm", + "columns": [ + { + "expression": "\"name\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"customers\".\"name\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_customers_id_trgm": { + "name": "idx_customers_id_trgm", + "columns": [ + { + "expression": "\"id\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"customers\".\"id\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_customers_org_id_env_created_at": { + "name": "idx_customers_org_id_env_created_at", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_cursor": { + "name": "idx_customers_cursor", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_processors_revenuecat": { + "name": "idx_customers_processors_revenuecat", + "columns": [ + { + "expression": "(\"processors\" ->> 'revenuecat')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_processors_vercel": { + "name": "idx_customers_processors_vercel", + "columns": [ + { + "expression": "(\"processors\" ->> 'vercel')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "customers_org_id_fkey": { + "name": "customers_org_id_fkey", + "tableFrom": "customers", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "cus_id_constraint": { + "name": "cus_id_constraint", + "nullsNotDistinct": false, + "columns": [ + "org_id", + "id", + "env" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.entities": { + "name": "entities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "internal_feature_id": { + "name": "internal_feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "spend_limits": { + "name": "spend_limits", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "usage_alerts": { + "name": "usage_alerts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "overage_allowed": { + "name": "overage_allowed", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "feature_id": { + "name": "feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_entities_internal_customer_id": { + "name": "idx_entities_internal_customer_id", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_entities_customer_internal_desc": { + "name": "idx_entities_customer_internal_desc", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"internal_id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_entities_org_env_id": { + "name": "idx_entities_org_env_id", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_entities_cursor": { + "name": "idx_entities_cursor", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "entities_internal_customer_id_fkey": { + "name": "entities_internal_customer_id_fkey", + "tableFrom": "entities", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "entities_internal_feature_id_fkey": { + "name": "entities_internal_feature_id_fkey", + "tableFrom": "entities", + "tableTo": "features", + "columnsFrom": [ + "internal_feature_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "entities_org_id_fkey": { + "name": "entities_org_id_fkey", + "tableFrom": "entities", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "entity_id_constraint": { + "name": "entity_id_constraint", + "nullsNotDistinct": false, + "columns": [ + "org_id", + "env", + "internal_customer_id", + "id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.entitlements": { + "name": "entitlements", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "internal_feature_id": { + "name": "internal_feature_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_product_id": { + "name": "internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_reward_id": { + "name": "internal_reward_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_custom": { + "name": "is_custom", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "allowance_type": { + "name": "allowance_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowance": { + "name": "allowance", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "interval": { + "name": "interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "interval_count": { + "name": "interval_count", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "carry_from_previous": { + "name": "carry_from_previous", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "entity_feature_id": { + "name": "entity_feature_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "feature_id": { + "name": "feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "usage_limit": { + "name": "usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "expiry_duration": { + "name": "expiry_duration", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expiry_length": { + "name": "expiry_length", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "rollover": { + "name": "rollover", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_entitlements_internal_product_id": { + "name": "idx_entitlements_internal_product_id", + "columns": [ + { + "expression": "internal_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_entitlements_internal_reward_id": { + "name": "idx_entitlements_internal_reward_id", + "columns": [ + { + "expression": "internal_reward_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_entitlements_reward_feature": { + "name": "idx_entitlements_reward_feature", + "columns": [ + { + "expression": "internal_reward_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "internal_feature_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_entitlements_internal_reward_id_c_partial": { + "name": "idx_entitlements_internal_reward_id_c_partial", + "columns": [ + { + "expression": "\"internal_reward_id\" COLLATE \"C\"", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"entitlements\".\"internal_reward_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "entitlements_internal_feature_id_fkey": { + "name": "entitlements_internal_feature_id_fkey", + "tableFrom": "entitlements", + "tableTo": "features", + "columnsFrom": [ + "internal_feature_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "entitlements_internal_product_id_fkey": { + "name": "entitlements_internal_product_id_fkey", + "tableFrom": "entitlements", + "tableTo": "products", + "columnsFrom": [ + "internal_product_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "entitlements_internal_reward_id_fkey": { + "name": "entitlements_internal_reward_id_fkey", + "tableFrom": "entitlements", + "tableTo": "rewards", + "columnsFrom": [ + "internal_reward_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "entitlements_id_key": { + "name": "entitlements_id_key", + "nullsNotDistinct": false, + "columns": [ + "id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.events": { + "name": "events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_slug": { + "name": "org_slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "event_name": { + "name": "event_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "value": { + "name": "value", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "set_usage": { + "name": "set_usage", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_entity_id": { + "name": "internal_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_product_id": { + "name": "internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "properties": { + "name": "properties", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "deductions": { + "name": "deductions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_events_internal_customer_id": { + "name": "idx_events_internal_customer_id", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_events_internal_entity_id": { + "name": "idx_events_internal_entity_id", + "columns": [ + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_events_customer_non_usage_ts": { + "name": "idx_events_customer_non_usage_ts", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"timestamp\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"events\".\"set_usage\" = false", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "events_internal_customer_id_fkey": { + "name": "events_internal_customer_id_fkey", + "tableFrom": "events", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_event_constraint": { + "name": "unique_event_constraint", + "nullsNotDistinct": false, + "columns": [ + "org_id", + "env", + "customer_id", + "event_name", + "idempotency_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.features": { + "name": "features", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "display": { + "name": "display", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "event_names": { + "name": "event_names", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + } + }, + "indexes": {}, + "foreignKeys": { + "features_org_id_fkey": { + "name": "features_org_id_fkey", + "tableFrom": "features", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "feature_id_constraint": { + "name": "feature_id_constraint", + "nullsNotDistinct": false, + "columns": [ + "org_id", + "id", + "env" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.free_trials": { + "name": "free_trials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "internal_product_id": { + "name": "internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duration": { + "name": "duration", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'day'" + }, + "length": { + "name": "length", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "unique_fingerprint": { + "name": "unique_fingerprint", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "is_custom": { + "name": "is_custom", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "card_required": { + "name": "card_required", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "on_end": { + "name": "on_end", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_free_trials_internal_product_id": { + "name": "idx_free_trials_internal_product_id", + "columns": [ + { + "expression": "internal_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "free_trials_internal_product_id_fkey": { + "name": "free_trials_internal_product_id_fkey", + "tableFrom": "free_trials", + "tableTo": "products", + "columnsFrom": [ + "internal_product_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "invitation_organizationId_idx": { + "name": "invitation_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_organization_id_organizations_id_fk": { + "name": "invitation_organization_id_organizations_id_fk", + "tableFrom": "invitation", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.invoice_line_items": { + "name": "invoice_line_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "invoice_id": { + "name": "invoice_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_id": { + "name": "stripe_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_invoice_id": { + "name": "stripe_invoice_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_invoice_item_id": { + "name": "stripe_invoice_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_item_id": { + "name": "stripe_subscription_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_product_id": { + "name": "stripe_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_price_id": { + "name": "stripe_price_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_discountable": { + "name": "stripe_discountable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "amount": { + "name": "amount", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "amount_after_discounts": { + "name": "amount_after_discounts", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'usd'" + }, + "stripe_quantity": { + "name": "stripe_quantity", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "total_quantity": { + "name": "total_quantity", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "paid_quantity": { + "name": "paid_quantity", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description_source": { + "name": "description_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "direction": { + "name": "direction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billing_timing": { + "name": "billing_timing", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prorated": { + "name": "prorated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "price_id": { + "name": "price_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_product_ids": { + "name": "customer_product_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "customer_price_ids": { + "name": "customer_price_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "customer_entitlement_ids": { + "name": "customer_entitlement_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "internal_product_id": { + "name": "internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_feature_id": { + "name": "internal_feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "feature_id": { + "name": "feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "effective_period_start": { + "name": "effective_period_start", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "effective_period_end": { + "name": "effective_period_end", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "discounts": { + "name": "discounts", + "type": "jsonb[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + } + }, + "indexes": { + "idx_invoice_line_items_customer_product_ids": { + "name": "idx_invoice_line_items_customer_product_ids", + "columns": [ + { + "expression": "customer_product_ids", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "invoice_line_items_invoice_id_fkey": { + "name": "invoice_line_items_invoice_id_fkey", + "tableFrom": "invoice_line_items", + "tableTo": "invoices", + "columnsFrom": [ + "invoice_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invoice_line_items_stripe_id_unique": { + "name": "invoice_line_items_stripe_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoice_templates": { + "name": "invoice_templates", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "footer": { + "name": "footer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "memo": { + "name": "memo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "net_terms_days": { + "name": "net_terms_days", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_invoice_templates_org_id": { + "name": "idx_invoice_templates_org_id", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invoice_templates_org_id_fkey": { + "name": "invoice_templates_org_id_fkey", + "tableFrom": "invoice_templates", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invoice_templates_id_unique": { + "name": "invoice_templates_id_unique", + "nullsNotDistinct": false, + "columns": [ + "id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoices": { + "name": "invoices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "product_ids": { + "name": "product_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "internal_product_ids": { + "name": "internal_product_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_entity_id": { + "name": "internal_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_id": { + "name": "stripe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "processor_type": { + "name": "processor_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "hosted_invoice_url": { + "name": "hosted_invoice_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "total": { + "name": "total", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "amount_paid": { + "name": "amount_paid", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "refunded_amount": { + "name": "refunded_amount", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'usd'" + }, + "discounts": { + "name": "discounts", + "type": "jsonb[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "items": { + "name": "items", + "type": "jsonb[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + } + }, + "indexes": { + "idx_invoices_customer_created": { + "name": "idx_invoices_customer_created", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_invoices_internal_entity_id": { + "name": "idx_invoices_internal_entity_id", + "columns": [ + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"invoices\".\"internal_entity_id\" IS NOT NULL", + "concurrently": true, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invoices_internal_customer_id_fkey": { + "name": "invoices_internal_customer_id_fkey", + "tableFrom": "invoices", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invoices_internal_entity_id_fkey": { + "name": "invoices_internal_entity_id_fkey", + "tableFrom": "invoices", + "tableTo": "entities", + "columnsFrom": [ + "internal_entity_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invoices_stripe_id_key": { + "name": "invoices_stripe_id_key", + "nullsNotDistinct": false, + "columns": [ + "stripe_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.jwks": { + "name": "jwks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "member_organizationId_idx": { + "name": "member_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_userId_idx": { + "name": "member_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_organization_id_organizations_id_fk": { + "name": "member_organization_id_organizations_id_fk", + "tableFrom": "member", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.metadata": { + "name": "metadata", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "expires_at": { + "name": "expires_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_invoice_id": { + "name": "stripe_invoice_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_checkout_session_id": { + "name": "stripe_checkout_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migration_errors": { + "name": "migration_errors", + "schema": "", + "columns": { + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "migration_job_id": { + "name": "migration_job_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "migration_customers_internal_customer_id_fkey": { + "name": "migration_customers_internal_customer_id_fkey", + "tableFrom": "migration_errors", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "migration_customers_migration_job_id_fkey": { + "name": "migration_customers_migration_job_id_fkey", + "tableFrom": "migration_errors", + "tableTo": "migration_jobs", + "columnsFrom": [ + "migration_job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "migration_errors_pkey": { + "name": "migration_errors_pkey", + "columns": [ + "internal_customer_id", + "migration_job_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migration_item_runs": { + "name": "migration_item_runs", + "schema": "", + "columns": { + "migration_item_run_id": { + "name": "migration_item_run_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "migration_internal_id": { + "name": "migration_internal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "migration_run_id": { + "name": "migration_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dry_run": { + "name": "dry_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "item_kind": { + "name": "item_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "migration_item_runs_live_unique": { + "name": "migration_item_runs_live_unique", + "columns": [ + { + "expression": "migration_internal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"migration_item_runs\".\"dry_run\" = false", + "concurrently": false, + "method": "btree", + "with": {} + }, + "migration_item_runs_dry_run_unique": { + "name": "migration_item_runs_dry_run_unique", + "columns": [ + { + "expression": "migration_internal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "migration_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"migration_item_runs\".\"dry_run\" = true", + "concurrently": false, + "method": "btree", + "with": {} + }, + "migration_item_runs_customer_recent_idx": { + "name": "migration_item_runs_customer_recent_idx", + "columns": [ + { + "expression": "item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"updated_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"migration_item_runs\".\"item_kind\" = 'customer'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migration_jobs": { + "name": "migration_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "current_step": { + "name": "current_step", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "from_internal_product_id": { + "name": "from_internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "to_internal_product_id": { + "name": "to_internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "step_details": { + "name": "step_details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "migration_jobs_from_internal_product_id_fkey": { + "name": "migration_jobs_from_internal_product_id_fkey", + "tableFrom": "migration_jobs", + "tableTo": "products", + "columnsFrom": [ + "from_internal_product_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "migration_jobs_org_id_fkey": { + "name": "migration_jobs_org_id_fkey", + "tableFrom": "migration_jobs", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "migration_jobs_to_internal_product_id_fkey": { + "name": "migration_jobs_to_internal_product_id_fkey", + "tableFrom": "migration_jobs", + "tableTo": "products", + "columnsFrom": [ + "to_internal_product_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migration_runs": { + "name": "migration_runs", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "migration_internal_id": { + "name": "migration_internal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dry_run": { + "name": "dry_run", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "lazy_run": { + "name": "lazy_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trigger_run_id": { + "name": "trigger_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "only_ids": { + "name": "only_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "target_limit": { + "name": "target_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "migration_runs_active_per_migration_unique": { + "name": "migration_runs_active_per_migration_unique", + "columns": [ + { + "expression": "migration_internal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"migration_runs\".\"status\" IN ('queued', 'running')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "migration_runs_migration_internal_id_fkey": { + "name": "migration_runs_migration_internal_id_fkey", + "tableFrom": "migration_runs", + "tableTo": "migrations", + "columnsFrom": [ + "migration_internal_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "migration_runs_org_id_fkey": { + "name": "migration_runs_org_id_fkey", + "tableFrom": "migration_runs", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migrations": { + "name": "migrations", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filter": { + "name": "filter", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "operations": { + "name": "operations", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "prepared_state": { + "name": "prepared_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "no_billing_changes": { + "name": "no_billing_changes", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "retry_failed": { + "name": "retry_failed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "migrations_org_env_id_unique": { + "name": "migrations_org_env_id_unique", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "migrations_org_id_fkey": { + "name": "migrations_org_id_fkey", + "tableFrom": "migrations", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_access_token": { + "name": "oauth_access_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_id": { + "name": "refresh_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_access_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_access_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_session_id_session_id_fk": { + "name": "oauth_access_token_session_id_session_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "session", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_access_token_user_id_user_id_fk": { + "name": "oauth_access_token_user_id_user_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_refresh_id_oauth_refresh_token_id_fk": { + "name": "oauth_access_token_refresh_id_oauth_refresh_token_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_refresh_token", + "columnsFrom": [ + "refresh_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_access_token_token_unique": { + "name": "oauth_access_token_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.oauth_client": { + "name": "oauth_client", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "skip_consent": { + "name": "skip_consent", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enable_end_session": { + "name": "enable_end_session", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contacts": { + "name": "contacts", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "tos": { + "name": "tos", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_id": { + "name": "software_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_version": { + "name": "software_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_statement": { + "name": "software_statement", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "post_logout_redirect_uris": { + "name": "post_logout_redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_types": { + "name": "grant_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "response_types": { + "name": "response_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_client_user_id_user_id_fk": { + "name": "oauth_client_user_id_user_id_fk", + "tableFrom": "oauth_client", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_client_client_id_unique": { + "name": "oauth_client_client_id_unique", + "nullsNotDistinct": false, + "columns": [ + "client_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.oauth_consent": { + "name": "oauth_consent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_uri": { + "name": "redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_api_key_id": { + "name": "oauth_api_key_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_consent_client_id_oauth_client_client_id_fk": { + "name": "oauth_consent_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_consent_user_id_user_id_fk": { + "name": "oauth_consent_user_id_user_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.oauth_refresh_token": { + "name": "oauth_refresh_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked": { + "name": "revoked", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "auth_time": { + "name": "auth_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_refresh_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_refresh_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_refresh_token_session_id_session_id_fk": { + "name": "oauth_refresh_token_session_id_session_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "session", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_refresh_token_user_id_user_id_fk": { + "name": "oauth_refresh_token_user_id_user_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_currency": { + "name": "default_currency", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'usd'" + }, + "stripe_connected": { + "name": "stripe_connected", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "stripe_config": { + "name": "stripe_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "test_stripe_connect": { + "name": "test_stripe_connect", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "live_stripe_connect": { + "name": "live_stripe_connect", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "processor_configs": { + "name": "processor_configs", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "test_pkey": { + "name": "test_pkey", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "live_pkey": { + "name": "live_pkey", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "svix_config": { + "name": "svix_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "onboarded": { + "name": "onboarded", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "deployed": { + "name": "deployed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "redis_config": { + "name": "redis_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_organizations_name_trgm": { + "name": "idx_organizations_name_trgm", + "columns": [ + { + "expression": "\"name\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"organizations\".\"name\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_organizations_slug_trgm": { + "name": "idx_organizations_slug_trgm", + "columns": [ + { + "expression": "\"slug\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"organizations\".\"slug\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_organizations_created_at_id": { + "name": "idx_organizations_created_at_id", + "columns": [ + { + "expression": "\"createdAt\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + }, + "organizations_test_pkey_key": { + "name": "organizations_test_pkey_key", + "nullsNotDistinct": false, + "columns": [ + "test_pkey" + ] + }, + "organizations_live_pkey_key": { + "name": "organizations_live_pkey_key", + "nullsNotDistinct": false, + "columns": [ + "live_pkey" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.passkey": { + "name": "passkey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "backed_up": { + "name": "backed_up", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "aaguid": { + "name": "aaguid", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "passkey_userId_idx": { + "name": "passkey_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "passkey_credentialId_idx": { + "name": "passkey_credentialId_idx", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "passkey_user_id_user_id_fk": { + "name": "passkey_user_id_user_id_fk", + "tableFrom": "passkey", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "passkey_credential_id_unique": { + "name": "passkey_credential_id_unique", + "nullsNotDistinct": false, + "columns": [ + "credential_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.prices": { + "name": "prices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_product_id": { + "name": "internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "billing_type": { + "name": "billing_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tier_behavior": { + "name": "tier_behavior", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "is_custom": { + "name": "is_custom", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "entitlement_id": { + "name": "entitlement_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "proration_config": { + "name": "proration_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "null" + } + }, + "indexes": { + "idx_prices_internal_product_id": { + "name": "idx_prices_internal_product_id", + "columns": [ + { + "expression": "internal_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_prices_entitlement_id": { + "name": "idx_prices_entitlement_id", + "columns": [ + { + "expression": "entitlement_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "prices_entitlement_id_fkey": { + "name": "prices_entitlement_id_fkey", + "tableFrom": "prices", + "tableTo": "entitlements", + "columnsFrom": [ + "entitlement_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "prices_internal_product_id_fkey": { + "name": "prices_internal_product_id_fkey", + "tableFrom": "prices", + "tableTo": "products", + "columnsFrom": [ + "internal_product_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "prices_id_key": { + "name": "prices_id_key", + "nullsNotDistinct": false, + "columns": [ + "id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.products": { + "name": "products", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_add_on": { + "name": "is_add_on", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "group": { + "name": "group", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "''" + }, + "version": { + "name": "version", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "processor": { + "name": "processor", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "base_variant_id": { + "name": "base_variant_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "idx_products_org_env_id_version": { + "name": "idx_products_org_env_id_version", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "products_org_id_fkey": { + "name": "products_org_id_fkey", + "tableFrom": "products", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_product": { + "name": "unique_product", + "nullsNotDistinct": false, + "columns": [ + "org_id", + "id", + "env", + "version" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.referral_codes": { + "name": "referral_codes", + "schema": "", + "columns": { + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text COLLATE \"C\"", + "primaryKey": false, + "notNull": false + }, + "internal_reward_program_id": { + "name": "internal_reward_program_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_referral_codes_internal_customer_id": { + "name": "idx_referral_codes_internal_customer_id", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "referral_codes_internal_customer_id_fkey": { + "name": "referral_codes_internal_customer_id_fkey", + "tableFrom": "referral_codes", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "referral_codes_internal_reward_program_id_fkey": { + "name": "referral_codes_internal_reward_program_id_fkey", + "tableFrom": "referral_codes", + "tableTo": "reward_programs", + "columnsFrom": [ + "internal_reward_program_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "referral_codes_org_id_fkey": { + "name": "referral_codes_org_id_fkey", + "tableFrom": "referral_codes", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "referral_codes_pkey": { + "name": "referral_codes_pkey", + "columns": [ + "code", + "org_id", + "env" + ] + } + }, + "uniqueConstraints": { + "referral_codes_id_key": { + "name": "referral_codes_id_key", + "nullsNotDistinct": false, + "columns": [ + "id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.replaceables": { + "name": "replaceables", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "cus_ent_id": { + "name": "cus_ent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "from_entity_id": { + "name": "from_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "delete_next_cycle": { + "name": "delete_next_cycle", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "idx_replaceables_cus_ent_id": { + "name": "idx_replaceables_cus_ent_id", + "columns": [ + { + "expression": "cus_ent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "replaceables_cus_ent_id_fkey": { + "name": "replaceables_cus_ent_id_fkey", + "tableFrom": "replaceables", + "tableTo": "customer_entitlements", + "columnsFrom": [ + "cus_ent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.revenuecat_mappings": { + "name": "revenuecat_mappings", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "autumn_product_id": { + "name": "autumn_product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revenuecat_product_ids": { + "name": "revenuecat_product_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + } + }, + "indexes": {}, + "foreignKeys": { + "revenuecat_mappings_org_id_fkey": { + "name": "revenuecat_mappings_org_id_fkey", + "tableFrom": "revenuecat_mappings", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "revenuecat_mappings_pkey": { + "name": "revenuecat_mappings_pkey", + "columns": [ + "org_id", + "env", + "autumn_product_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reward_programs": { + "name": "reward_programs", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "internal_reward_id": { + "name": "internal_reward_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_redemptions": { + "name": "max_redemptions", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "unlimited_redemptions": { + "name": "unlimited_redemptions", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "when": { + "name": "when", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'immediately'" + }, + "product_ids": { + "name": "product_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{\"\"}'" + }, + "exclude_trial": { + "name": "exclude_trial", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "received_by": { + "name": "received_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "reward_triggers_internal_reward_id_fkey": { + "name": "reward_triggers_internal_reward_id_fkey", + "tableFrom": "reward_programs", + "tableTo": "rewards", + "columnsFrom": [ + "internal_reward_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reward_triggers_org_id_fkey": { + "name": "reward_triggers_org_id_fkey", + "tableFrom": "reward_programs", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reward_redemptions": { + "name": "reward_redemptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text COLLATE \"C\"", + "primaryKey": false, + "notNull": false + }, + "triggered": { + "name": "triggered", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "internal_reward_program_id": { + "name": "internal_reward_program_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "applied": { + "name": "applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "redeemer_applied": { + "name": "redeemer_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "referral_code_id": { + "name": "referral_code_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reward_internal_id": { + "name": "reward_internal_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "promo_code": { + "name": "promo_code", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_reward_redemptions_referral_code_id": { + "name": "idx_reward_redemptions_referral_code_id", + "columns": [ + { + "expression": "referral_code_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_reward_redemptions_reward_internal_id": { + "name": "idx_reward_redemptions_reward_internal_id", + "columns": [ + { + "expression": "reward_internal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_reward_redemptions_customer_reward": { + "name": "idx_reward_redemptions_customer_reward", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reward_internal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reward_redemptions_internal_customer_id_fkey": { + "name": "reward_redemptions_internal_customer_id_fkey", + "tableFrom": "reward_redemptions", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reward_redemptions_internal_reward_program_id_fkey": { + "name": "reward_redemptions_internal_reward_program_id_fkey", + "tableFrom": "reward_redemptions", + "tableTo": "reward_programs", + "columnsFrom": [ + "internal_reward_program_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reward_redemptions_referral_code_id_fkey": { + "name": "reward_redemptions_referral_code_id_fkey", + "tableFrom": "reward_redemptions", + "tableTo": "referral_codes", + "columnsFrom": [ + "referral_code_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rewards": { + "name": "rewards", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "discount_config": { + "name": "discount_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "free_product_config": { + "name": "free_product_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "free_product_id": { + "name": "free_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "promo_codes": { + "name": "promo_codes", + "type": "jsonb[]", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "coupons_org_id_fkey": { + "name": "coupons_org_id_fkey", + "tableFrom": "rewards", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rollovers": { + "name": "rollovers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "cus_ent_id": { + "name": "cus_ent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "balance": { + "name": "balance", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "usage": { + "name": "usage", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "entities": { + "name": "entities", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "idx_rollovers_cus_ent_id": { + "name": "idx_rollovers_cus_ent_id", + "columns": [ + { + "expression": "cus_ent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_rollovers_cus_ent_expires": { + "name": "idx_rollovers_cus_ent_expires", + "columns": [ + { + "expression": "cus_ent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "rollover_cus_ent_id_fkey": { + "name": "rollover_cus_ent_id_fkey", + "tableFrom": "rollovers", + "tableTo": "customer_entitlements", + "columnsFrom": [ + "cus_ent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.phases": { + "name": "phases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "starts_at": { + "name": "starts_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "customer_product_ids": { + "name": "customer_product_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "phases_schedule_id_fkey": { + "name": "phases_schedule_id_fkey", + "tableFrom": "phases", + "tableTo": "schedules", + "columnsFrom": [ + "schedule_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "phases_schedule_id_starts_at_key": { + "name": "phases_schedule_id_starts_at_key", + "nullsNotDistinct": false, + "columns": [ + "schedule_id", + "starts_at" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.schedules": { + "name": "schedules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_entity_id": { + "name": "internal_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "schedules_customer_scope_unique": { + "name": "schedules_customer_scope_unique", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"schedules\".\"internal_entity_id\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "schedules_entity_scope_unique": { + "name": "schedules_entity_scope_unique", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"schedules\".\"internal_entity_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_schedules_internal_customer_id": { + "name": "idx_schedules_internal_customer_id", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_schedules_internal_entity_id": { + "name": "idx_schedules_internal_entity_id", + "columns": [ + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "schedules_org_id_fkey": { + "name": "schedules_org_id_fkey", + "tableFrom": "schedules", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "schedules_internal_customer_id_fkey": { + "name": "schedules_internal_customer_id_fkey", + "tableFrom": "schedules", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "schedules_internal_entity_id_fkey": { + "name": "schedules_internal_entity_id_fkey", + "tableFrom": "schedules", + "tableTo": "entities", + "columnsFrom": [ + "internal_entity_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "city": { + "name": "city", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country": { + "name": "country", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.subscriptions": { + "name": "subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_id": { + "name": "stripe_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "usage_features": { + "name": "usage_features", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "current_period_start": { + "name": "current_period_start", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "current_period_end": { + "name": "current_period_end", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "subscriptions_org_id_fkey": { + "name": "subscriptions_org_id_fkey", + "tableFrom": "subscriptions", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "subscriptions_stripe_id_key": { + "name": "subscriptions_stripe_id_key", + "nullsNotDistinct": false, + "columns": [ + "stripe_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_active_at": { + "name": "last_active_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_user_name_trgm": { + "name": "idx_user_name_trgm", + "columns": [ + { + "expression": "\"name\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user\".\"name\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_user_email_trgm": { + "name": "idx_user_email_trgm", + "columns": [ + { + "expression": "\"email\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user\".\"email\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_user_created_at_id": { + "name": "idx_user_created_at_id", + "columns": [ + { + "expression": "\"created_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_created_by_fkey": { + "name": "user_created_by_fkey", + "tableFrom": "user", + "tableTo": "organizations", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.vercel_resources": { + "name": "vercel_resources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "vercel_resources_installation_name_unique_idx": { + "name": "vercel_resources_installation_name_unique_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status <> 'uninstalled'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "vercel_resources_org_id_fkey": { + "name": "vercel_resources_org_id_fkey", + "tableFrom": "vercel_resources", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/shared/drizzle/meta/0008_snapshot.json b/shared/drizzle/meta/0008_snapshot.json new file mode 100644 index 000000000..b4d482f8a --- /dev/null +++ b/shared/drizzle/meta/0008_snapshot.json @@ -0,0 +1,7516 @@ +{ + "id": "40c5361a-8cff-473f-93c1-4dfbc06b00d7", + "prevId": "9e1bb4b2-1869-4ca9-ba67-8fbcea263c37", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.actions": { + "name": "actions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_slug": { + "name": "org_slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_entity_id": { + "name": "internal_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "properties": { + "name": "properties", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_actions_on_internal_entity_id": { + "name": "idx_actions_on_internal_entity_id", + "columns": [ + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "actions_org_id_fkey": { + "name": "actions_org_id_fkey", + "tableFrom": "actions", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "actions_customer_id_fkey": { + "name": "actions_customer_id_fkey", + "tableFrom": "actions", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "actions_entity_id_fkey": { + "name": "actions_entity_id_fkey", + "tableFrom": "actions", + "tableTo": "entities", + "columnsFrom": [ + "internal_entity_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "hashed_key": { + "name": "hashed_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "meta": { + "name": "meta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "api_keys_org_id_fkey": { + "name": "api_keys_org_id_fkey", + "tableFrom": "api_keys", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_keys_hashed_key_key": { + "name": "api_keys_hashed_key_key", + "nullsNotDistinct": false, + "columns": [ + "hashed_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auto_topup_limit_states": { + "name": "auto_topup_limit_states", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "feature_id": { + "name": "feature_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "purchase_window_ends_at": { + "name": "purchase_window_ends_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "purchase_count": { + "name": "purchase_count", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "attempt_window_ends_at": { + "name": "attempt_window_ends_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "attempt_count": { + "name": "attempt_count", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "failed_attempt_window_ends_at": { + "name": "failed_attempt_window_ends_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "failed_attempt_count": { + "name": "failed_attempt_count", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "last_failed_attempt_at": { + "name": "last_failed_attempt_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + } + }, + "indexes": { + "auto_topup_limits_org_env_internal_customer_feature_unique": { + "name": "auto_topup_limits_org_env_internal_customer_feature_unique", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "feature_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "auto_topup_limits_org_id_fkey": { + "name": "auto_topup_limits_org_id_fkey", + "tableFrom": "auto_topup_limit_states", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "auto_topup_limits_internal_customer_id_fkey": { + "name": "auto_topup_limits_internal_customer_id_fkey", + "tableFrom": "auto_topup_limit_states", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_approvals": { + "name": "chat_approvals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_ts": { + "name": "message_ts", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_user_id": { + "name": "provider_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_args": { + "name": "tool_args", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "preview": { + "name": "preview", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "expires_at": { + "name": "expires_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "decided_at": { + "name": "decided_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "decided_by_provider_user_id": { + "name": "decided_by_provider_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "chat_approvals_org_id_fkey": { + "name": "chat_approvals_org_id_fkey", + "tableFrom": "chat_approvals", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_installations": { + "name": "chat_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_name": { + "name": "workspace_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_user_id": { + "name": "bot_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bot_access_token": { + "name": "bot_access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "default_env": { + "name": "default_env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sandbox_api_key_id": { + "name": "sandbox_api_key_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sandbox_api_key": { + "name": "sandbox_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "live_api_key_id": { + "name": "live_api_key_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "live_api_key": { + "name": "live_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "installed_by_user_id": { + "name": "installed_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "installed_by_provider_user_id": { + "name": "installed_by_provider_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + } + }, + "indexes": {}, + "foreignKeys": { + "chat_installations_org_id_fkey": { + "name": "chat_installations_org_id_fkey", + "tableFrom": "chat_installations", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "chat_installations_org_provider_key": { + "name": "chat_installations_org_provider_key", + "nullsNotDistinct": false, + "columns": [ + "org_id", + "provider" + ] + }, + "chat_installations_provider_workspace_key": { + "name": "chat_installations_provider_workspace_key", + "nullsNotDistinct": false, + "columns": [ + "provider", + "workspace_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_oauth_credentials": { + "name": "chat_oauth_credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "chat_installation_id": { + "name": "chat_installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oauth_consent_id": { + "name": "oauth_consent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + } + }, + "indexes": {}, + "foreignKeys": { + "chat_oauth_credentials_installation_id_fkey": { + "name": "chat_oauth_credentials_installation_id_fkey", + "tableFrom": "chat_oauth_credentials", + "tableTo": "chat_installations", + "columnsFrom": [ + "chat_installation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_oauth_credentials_org_id_fkey": { + "name": "chat_oauth_credentials_org_id_fkey", + "tableFrom": "chat_oauth_credentials", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "chat_oauth_credentials_installation_env_key": { + "name": "chat_oauth_credentials_installation_env_key", + "nullsNotDistinct": false, + "columns": [ + "chat_installation_id", + "env" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_results": { + "name": "chat_results", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.checkouts": { + "name": "checkouts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "params_version": { + "name": "params_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "response": { + "name": "response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "stripe_invoice_id": { + "name": "stripe_invoice_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "expires_at": { + "name": "expires_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_checkouts_stripe_invoice_id": { + "name": "idx_checkouts_stripe_invoice_id", + "columns": [ + { + "expression": "stripe_invoice_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.customer_entitlements": { + "name": "customer_entitlements", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "customer_product_id": { + "name": "customer_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entitlement_id": { + "name": "entitlement_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text COLLATE \"C\"", + "primaryKey": false, + "notNull": true + }, + "internal_entity_id": { + "name": "internal_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_feature_id": { + "name": "internal_feature_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "unlimited": { + "name": "unlimited", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "balance": { + "name": "balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "next_reset_at": { + "name": "next_reset_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "usage_allowed": { + "name": "usage_allowed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "adjustment": { + "name": "adjustment", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "additional_balance": { + "name": "additional_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "entities": { + "name": "entities", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "cache_version": { + "name": "cache_version", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "feature_id": { + "name": "feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_customer_entitlements_product_id": { + "name": "idx_customer_entitlements_product_id", + "columns": [ + { + "expression": "customer_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_entitlements_internal_customer_id": { + "name": "idx_customer_entitlements_internal_customer_id", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hash", + "with": {} + }, + "idx_customer_entitlements_internal_customer_id_btree": { + "name": "idx_customer_entitlements_internal_customer_id_btree", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_entitlements_entitlement_id": { + "name": "idx_customer_entitlements_entitlement_id", + "columns": [ + { + "expression": "entitlement_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_entitlements_internal_entity_id": { + "name": "idx_customer_entitlements_internal_entity_id", + "columns": [ + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hash", + "with": {} + }, + "idx_customer_entitlements_on_next_reset_at": { + "name": "idx_customer_entitlements_on_next_reset_at", + "columns": [ + { + "expression": "next_reset_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_entitlements_loose_customer_expires": { + "name": "idx_customer_entitlements_loose_customer_expires", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"customer_entitlements\".\"customer_product_id\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "entitlements_internal_feature_id_fkey": { + "name": "entitlements_internal_feature_id_fkey", + "tableFrom": "customer_entitlements", + "tableTo": "features", + "columnsFrom": [ + "internal_feature_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "customer_entitlements_internal_entity_id_fkey": { + "name": "customer_entitlements_internal_entity_id_fkey", + "tableFrom": "customer_entitlements", + "tableTo": "entities", + "columnsFrom": [ + "internal_entity_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "customer_entitlements_customer_product_id_fkey": { + "name": "customer_entitlements_customer_product_id_fkey", + "tableFrom": "customer_entitlements", + "tableTo": "customer_products", + "columnsFrom": [ + "customer_product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "customer_entitlements_entitlement_id_fkey": { + "name": "customer_entitlements_entitlement_id_fkey", + "tableFrom": "customer_entitlements", + "tableTo": "entitlements", + "columnsFrom": [ + "entitlement_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.customer_prices": { + "name": "customer_prices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "price_id": { + "name": "price_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "options": { + "name": "options", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_product_id": { + "name": "customer_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_customer_prices_product_id": { + "name": "idx_customer_prices_product_id", + "columns": [ + { + "expression": "customer_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_prices_price_id": { + "name": "idx_customer_prices_price_id", + "columns": [ + { + "expression": "price_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_prices_internal_customer_id": { + "name": "idx_customer_prices_internal_customer_id", + "columns": [ + { + "expression": "\"internal_customer_id\" COLLATE \"C\"", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"customer_prices\".\"internal_customer_id\" IS NOT NULL", + "concurrently": true, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "customer_prices_customer_product_id_fkey": { + "name": "customer_prices_customer_product_id_fkey", + "tableFrom": "customer_prices", + "tableTo": "customer_products", + "columnsFrom": [ + "customer_product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "customer_prices_internal_customer_id_fkey": { + "name": "customer_prices_internal_customer_id_fkey", + "tableFrom": "customer_prices", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "customer_prices_price_id_fkey": { + "name": "customer_prices_price_id_fkey", + "tableFrom": "customer_prices", + "tableTo": "prices", + "columnsFrom": [ + "price_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.customer_products": { + "name": "customer_products", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_product_id": { + "name": "internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_entity_id": { + "name": "internal_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processor": { + "name": "processor", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "canceled": { + "name": "canceled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "starts_at": { + "name": "starts_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "access_starts_at": { + "name": "access_starts_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "options": { + "name": "options", + "type": "jsonb[]", + "primaryKey": false, + "notNull": false + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "free_trial_id": { + "name": "free_trial_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trial_ends_at": { + "name": "trial_ends_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "billing_cycle_anchor_resets_at": { + "name": "billing_cycle_anchor_resets_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "collection_method": { + "name": "collection_method", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'charge_automatically'" + }, + "subscription_ids": { + "name": "subscription_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "scheduled_ids": { + "name": "scheduled_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "quantity": { + "name": "quantity", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "is_custom": { + "name": "is_custom", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_version": { + "name": "billing_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "api_version": { + "name": "api_version", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "api_semver": { + "name": "api_semver", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_checkout_session_id": { + "name": "stripe_checkout_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "previous_customer_product_id": { + "name": "previous_customer_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "on_trial_end": { + "name": "on_trial_end", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_customer_products_customer_status": { + "name": "idx_customer_products_customer_status", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_products_on_internal_entity_id": { + "name": "idx_customer_products_on_internal_entity_id", + "columns": [ + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_products_on_internal_product_id": { + "name": "idx_customer_products_on_internal_product_id", + "columns": [ + { + "expression": "internal_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_products_subscription_ids": { + "name": "idx_customer_products_subscription_ids", + "columns": [ + { + "expression": "subscription_ids", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_customer_products_scheduled_ids": { + "name": "idx_customer_products_scheduled_ids", + "columns": [ + { + "expression": "scheduled_ids", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_customer_products_stripe_checkout_session_id": { + "name": "idx_customer_products_stripe_checkout_session_id", + "columns": [ + { + "expression": "stripe_checkout_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_products_revenuecat_processor": { + "name": "idx_customer_products_revenuecat_processor", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "(\"customer_products\".\"processor\" ->> 'type') = 'revenuecat'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "customer_products_free_trial_id_fkey": { + "name": "customer_products_free_trial_id_fkey", + "tableFrom": "customer_products", + "tableTo": "free_trials", + "columnsFrom": [ + "free_trial_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "customer_products_internal_customer_id_fkey": { + "name": "customer_products_internal_customer_id_fkey", + "tableFrom": "customer_products", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "customer_products_internal_product_id_fkey": { + "name": "customer_products_internal_product_id_fkey", + "tableFrom": "customer_products", + "tableTo": "products", + "columnsFrom": [ + "internal_product_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "customer_products_internal_entity_id_fkey": { + "name": "customer_products_internal_entity_id_fkey", + "tableFrom": "customer_products", + "tableTo": "entities", + "columnsFrom": [ + "internal_entity_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.customers": { + "name": "customers", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "processor": { + "name": "processor", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "processors": { + "name": "processors", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "send_email_receipts": { + "name": "send_email_receipts", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "auto_topups": { + "name": "auto_topups", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "spend_limits": { + "name": "spend_limits", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "usage_alerts": { + "name": "usage_alerts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "overage_allowed": { + "name": "overage_allowed", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "customers_email_null_id_unique": { + "name": "customers_email_null_id_unique", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"email\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"customers\".\"id\" IS NULL AND \"customers\".\"email\" IS NOT NULL AND \"customers\".\"email\" != ''", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_org_env_fingerprint": { + "name": "idx_customers_org_env_fingerprint", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"customers\".\"fingerprint\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_processor_id": { + "name": "idx_customers_processor_id", + "columns": [ + { + "expression": "(\"processor\" ->> 'id')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_composite": { + "name": "idx_customers_composite", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_org_env_internal_id": { + "name": "idx_customers_org_env_internal_id", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"internal_id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_email_trgm": { + "name": "idx_customers_email_trgm", + "columns": [ + { + "expression": "\"email\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"customers\".\"email\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_customers_name_trgm": { + "name": "idx_customers_name_trgm", + "columns": [ + { + "expression": "\"name\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"customers\".\"name\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_customers_id_trgm": { + "name": "idx_customers_id_trgm", + "columns": [ + { + "expression": "\"id\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"customers\".\"id\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_customers_org_id_env_created_at": { + "name": "idx_customers_org_id_env_created_at", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_cursor": { + "name": "idx_customers_cursor", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_processors_revenuecat": { + "name": "idx_customers_processors_revenuecat", + "columns": [ + { + "expression": "(\"processors\" ->> 'revenuecat')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_processors_vercel": { + "name": "idx_customers_processors_vercel", + "columns": [ + { + "expression": "(\"processors\" ->> 'vercel')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "customers_org_id_fkey": { + "name": "customers_org_id_fkey", + "tableFrom": "customers", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "cus_id_constraint": { + "name": "cus_id_constraint", + "nullsNotDistinct": false, + "columns": [ + "org_id", + "id", + "env" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.entities": { + "name": "entities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "internal_feature_id": { + "name": "internal_feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "spend_limits": { + "name": "spend_limits", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "usage_alerts": { + "name": "usage_alerts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "overage_allowed": { + "name": "overage_allowed", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "feature_id": { + "name": "feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_entities_internal_customer_id": { + "name": "idx_entities_internal_customer_id", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_entities_customer_internal_desc": { + "name": "idx_entities_customer_internal_desc", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"internal_id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_entities_org_env_id": { + "name": "idx_entities_org_env_id", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_entities_cursor": { + "name": "idx_entities_cursor", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "entities_internal_customer_id_fkey": { + "name": "entities_internal_customer_id_fkey", + "tableFrom": "entities", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "entities_internal_feature_id_fkey": { + "name": "entities_internal_feature_id_fkey", + "tableFrom": "entities", + "tableTo": "features", + "columnsFrom": [ + "internal_feature_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "entities_org_id_fkey": { + "name": "entities_org_id_fkey", + "tableFrom": "entities", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "entity_id_constraint": { + "name": "entity_id_constraint", + "nullsNotDistinct": false, + "columns": [ + "org_id", + "env", + "internal_customer_id", + "id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.entitlements": { + "name": "entitlements", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "internal_feature_id": { + "name": "internal_feature_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_product_id": { + "name": "internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_reward_id": { + "name": "internal_reward_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_custom": { + "name": "is_custom", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "allowance_type": { + "name": "allowance_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowance": { + "name": "allowance", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "interval": { + "name": "interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "interval_count": { + "name": "interval_count", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "carry_from_previous": { + "name": "carry_from_previous", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "entity_feature_id": { + "name": "entity_feature_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "feature_id": { + "name": "feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "usage_limit": { + "name": "usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "expiry_duration": { + "name": "expiry_duration", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expiry_length": { + "name": "expiry_length", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "rollover": { + "name": "rollover", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_entitlements_internal_product_id": { + "name": "idx_entitlements_internal_product_id", + "columns": [ + { + "expression": "internal_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_entitlements_internal_reward_id": { + "name": "idx_entitlements_internal_reward_id", + "columns": [ + { + "expression": "internal_reward_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_entitlements_reward_feature": { + "name": "idx_entitlements_reward_feature", + "columns": [ + { + "expression": "internal_reward_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "internal_feature_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_entitlements_internal_reward_id_c_partial": { + "name": "idx_entitlements_internal_reward_id_c_partial", + "columns": [ + { + "expression": "\"internal_reward_id\" COLLATE \"C\"", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"entitlements\".\"internal_reward_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "entitlements_internal_feature_id_fkey": { + "name": "entitlements_internal_feature_id_fkey", + "tableFrom": "entitlements", + "tableTo": "features", + "columnsFrom": [ + "internal_feature_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "entitlements_internal_product_id_fkey": { + "name": "entitlements_internal_product_id_fkey", + "tableFrom": "entitlements", + "tableTo": "products", + "columnsFrom": [ + "internal_product_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "entitlements_internal_reward_id_fkey": { + "name": "entitlements_internal_reward_id_fkey", + "tableFrom": "entitlements", + "tableTo": "rewards", + "columnsFrom": [ + "internal_reward_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "entitlements_id_key": { + "name": "entitlements_id_key", + "nullsNotDistinct": false, + "columns": [ + "id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.events": { + "name": "events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_slug": { + "name": "org_slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "event_name": { + "name": "event_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "value": { + "name": "value", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "set_usage": { + "name": "set_usage", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_entity_id": { + "name": "internal_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_product_id": { + "name": "internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "properties": { + "name": "properties", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "deductions": { + "name": "deductions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_events_internal_customer_id": { + "name": "idx_events_internal_customer_id", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_events_internal_entity_id": { + "name": "idx_events_internal_entity_id", + "columns": [ + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_events_customer_non_usage_ts": { + "name": "idx_events_customer_non_usage_ts", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"timestamp\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"events\".\"set_usage\" = false", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "events_internal_customer_id_fkey": { + "name": "events_internal_customer_id_fkey", + "tableFrom": "events", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_event_constraint": { + "name": "unique_event_constraint", + "nullsNotDistinct": false, + "columns": [ + "org_id", + "env", + "customer_id", + "event_name", + "idempotency_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.features": { + "name": "features", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "display": { + "name": "display", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "event_names": { + "name": "event_names", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + } + }, + "indexes": {}, + "foreignKeys": { + "features_org_id_fkey": { + "name": "features_org_id_fkey", + "tableFrom": "features", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "feature_id_constraint": { + "name": "feature_id_constraint", + "nullsNotDistinct": false, + "columns": [ + "org_id", + "id", + "env" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.free_trials": { + "name": "free_trials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "internal_product_id": { + "name": "internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duration": { + "name": "duration", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'day'" + }, + "length": { + "name": "length", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "unique_fingerprint": { + "name": "unique_fingerprint", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "is_custom": { + "name": "is_custom", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "card_required": { + "name": "card_required", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "on_end": { + "name": "on_end", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_free_trials_internal_product_id": { + "name": "idx_free_trials_internal_product_id", + "columns": [ + { + "expression": "internal_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "free_trials_internal_product_id_fkey": { + "name": "free_trials_internal_product_id_fkey", + "tableFrom": "free_trials", + "tableTo": "products", + "columnsFrom": [ + "internal_product_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "invitation_organizationId_idx": { + "name": "invitation_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_organization_id_organizations_id_fk": { + "name": "invitation_organization_id_organizations_id_fk", + "tableFrom": "invitation", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.invoice_line_items": { + "name": "invoice_line_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "invoice_id": { + "name": "invoice_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_id": { + "name": "stripe_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_invoice_id": { + "name": "stripe_invoice_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_invoice_item_id": { + "name": "stripe_invoice_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_item_id": { + "name": "stripe_subscription_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_product_id": { + "name": "stripe_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_price_id": { + "name": "stripe_price_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_discountable": { + "name": "stripe_discountable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "amount": { + "name": "amount", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "amount_after_discounts": { + "name": "amount_after_discounts", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'usd'" + }, + "stripe_quantity": { + "name": "stripe_quantity", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "total_quantity": { + "name": "total_quantity", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "paid_quantity": { + "name": "paid_quantity", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description_source": { + "name": "description_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "direction": { + "name": "direction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billing_timing": { + "name": "billing_timing", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prorated": { + "name": "prorated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "price_id": { + "name": "price_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_product_ids": { + "name": "customer_product_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "customer_price_ids": { + "name": "customer_price_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "customer_entitlement_ids": { + "name": "customer_entitlement_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "internal_product_id": { + "name": "internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_feature_id": { + "name": "internal_feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "feature_id": { + "name": "feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "effective_period_start": { + "name": "effective_period_start", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "effective_period_end": { + "name": "effective_period_end", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "discounts": { + "name": "discounts", + "type": "jsonb[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + } + }, + "indexes": { + "idx_invoice_line_items_customer_product_ids": { + "name": "idx_invoice_line_items_customer_product_ids", + "columns": [ + { + "expression": "customer_product_ids", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "invoice_line_items_invoice_id_fkey": { + "name": "invoice_line_items_invoice_id_fkey", + "tableFrom": "invoice_line_items", + "tableTo": "invoices", + "columnsFrom": [ + "invoice_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invoice_line_items_stripe_id_unique": { + "name": "invoice_line_items_stripe_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoice_templates": { + "name": "invoice_templates", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "footer": { + "name": "footer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "memo": { + "name": "memo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "net_terms_days": { + "name": "net_terms_days", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_invoice_templates_org_id": { + "name": "idx_invoice_templates_org_id", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invoice_templates_org_id_fkey": { + "name": "invoice_templates_org_id_fkey", + "tableFrom": "invoice_templates", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invoice_templates_id_unique": { + "name": "invoice_templates_id_unique", + "nullsNotDistinct": false, + "columns": [ + "id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoices": { + "name": "invoices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "product_ids": { + "name": "product_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "internal_product_ids": { + "name": "internal_product_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_entity_id": { + "name": "internal_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_id": { + "name": "stripe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "processor_type": { + "name": "processor_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "hosted_invoice_url": { + "name": "hosted_invoice_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "total": { + "name": "total", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "amount_paid": { + "name": "amount_paid", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "refunded_amount": { + "name": "refunded_amount", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'usd'" + }, + "discounts": { + "name": "discounts", + "type": "jsonb[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "items": { + "name": "items", + "type": "jsonb[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + } + }, + "indexes": { + "idx_invoices_customer_created": { + "name": "idx_invoices_customer_created", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_invoices_internal_entity_id": { + "name": "idx_invoices_internal_entity_id", + "columns": [ + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"invoices\".\"internal_entity_id\" IS NOT NULL", + "concurrently": true, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invoices_internal_customer_id_fkey": { + "name": "invoices_internal_customer_id_fkey", + "tableFrom": "invoices", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invoices_internal_entity_id_fkey": { + "name": "invoices_internal_entity_id_fkey", + "tableFrom": "invoices", + "tableTo": "entities", + "columnsFrom": [ + "internal_entity_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invoices_stripe_id_key": { + "name": "invoices_stripe_id_key", + "nullsNotDistinct": false, + "columns": [ + "stripe_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.jwks": { + "name": "jwks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "member_organizationId_idx": { + "name": "member_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_userId_idx": { + "name": "member_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_organization_id_organizations_id_fk": { + "name": "member_organization_id_organizations_id_fk", + "tableFrom": "member", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.metadata": { + "name": "metadata", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "expires_at": { + "name": "expires_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_invoice_id": { + "name": "stripe_invoice_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_checkout_session_id": { + "name": "stripe_checkout_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migration_errors": { + "name": "migration_errors", + "schema": "", + "columns": { + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "migration_job_id": { + "name": "migration_job_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "migration_customers_internal_customer_id_fkey": { + "name": "migration_customers_internal_customer_id_fkey", + "tableFrom": "migration_errors", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "migration_customers_migration_job_id_fkey": { + "name": "migration_customers_migration_job_id_fkey", + "tableFrom": "migration_errors", + "tableTo": "migration_jobs", + "columnsFrom": [ + "migration_job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "migration_errors_pkey": { + "name": "migration_errors_pkey", + "columns": [ + "internal_customer_id", + "migration_job_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migration_item_runs": { + "name": "migration_item_runs", + "schema": "", + "columns": { + "migration_item_run_id": { + "name": "migration_item_run_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "migration_internal_id": { + "name": "migration_internal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "migration_run_id": { + "name": "migration_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dry_run": { + "name": "dry_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "item_kind": { + "name": "item_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "migration_item_runs_live_unique": { + "name": "migration_item_runs_live_unique", + "columns": [ + { + "expression": "migration_internal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"migration_item_runs\".\"dry_run\" = false", + "concurrently": false, + "method": "btree", + "with": {} + }, + "migration_item_runs_dry_run_unique": { + "name": "migration_item_runs_dry_run_unique", + "columns": [ + { + "expression": "migration_internal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "migration_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"migration_item_runs\".\"dry_run\" = true", + "concurrently": false, + "method": "btree", + "with": {} + }, + "migration_item_runs_customer_recent_idx": { + "name": "migration_item_runs_customer_recent_idx", + "columns": [ + { + "expression": "item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"updated_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"migration_item_runs\".\"item_kind\" = 'customer'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migration_jobs": { + "name": "migration_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "current_step": { + "name": "current_step", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "from_internal_product_id": { + "name": "from_internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "to_internal_product_id": { + "name": "to_internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "step_details": { + "name": "step_details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "migration_jobs_from_internal_product_id_fkey": { + "name": "migration_jobs_from_internal_product_id_fkey", + "tableFrom": "migration_jobs", + "tableTo": "products", + "columnsFrom": [ + "from_internal_product_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "migration_jobs_org_id_fkey": { + "name": "migration_jobs_org_id_fkey", + "tableFrom": "migration_jobs", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "migration_jobs_to_internal_product_id_fkey": { + "name": "migration_jobs_to_internal_product_id_fkey", + "tableFrom": "migration_jobs", + "tableTo": "products", + "columnsFrom": [ + "to_internal_product_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migration_runs": { + "name": "migration_runs", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "migration_internal_id": { + "name": "migration_internal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dry_run": { + "name": "dry_run", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "lazy_run": { + "name": "lazy_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trigger_run_id": { + "name": "trigger_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "only_ids": { + "name": "only_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "target_limit": { + "name": "target_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "migration_runs_active_per_migration_unique": { + "name": "migration_runs_active_per_migration_unique", + "columns": [ + { + "expression": "migration_internal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"migration_runs\".\"status\" IN ('queued', 'running')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "migration_runs_migration_internal_id_fkey": { + "name": "migration_runs_migration_internal_id_fkey", + "tableFrom": "migration_runs", + "tableTo": "migrations", + "columnsFrom": [ + "migration_internal_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "migration_runs_org_id_fkey": { + "name": "migration_runs_org_id_fkey", + "tableFrom": "migration_runs", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migrations": { + "name": "migrations", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filter": { + "name": "filter", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "operations": { + "name": "operations", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "prepared_state": { + "name": "prepared_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "no_billing_changes": { + "name": "no_billing_changes", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "retry_failed": { + "name": "retry_failed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "migrations_org_env_id_unique": { + "name": "migrations_org_env_id_unique", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "migrations_org_id_fkey": { + "name": "migrations_org_id_fkey", + "tableFrom": "migrations", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_access_token": { + "name": "oauth_access_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_id": { + "name": "refresh_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_access_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_access_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_session_id_session_id_fk": { + "name": "oauth_access_token_session_id_session_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "session", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_access_token_user_id_user_id_fk": { + "name": "oauth_access_token_user_id_user_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_refresh_id_oauth_refresh_token_id_fk": { + "name": "oauth_access_token_refresh_id_oauth_refresh_token_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_refresh_token", + "columnsFrom": [ + "refresh_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_access_token_token_unique": { + "name": "oauth_access_token_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.oauth_client": { + "name": "oauth_client", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "skip_consent": { + "name": "skip_consent", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enable_end_session": { + "name": "enable_end_session", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contacts": { + "name": "contacts", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "tos": { + "name": "tos", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_id": { + "name": "software_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_version": { + "name": "software_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_statement": { + "name": "software_statement", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "post_logout_redirect_uris": { + "name": "post_logout_redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_types": { + "name": "grant_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "response_types": { + "name": "response_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_client_user_id_user_id_fk": { + "name": "oauth_client_user_id_user_id_fk", + "tableFrom": "oauth_client", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_client_client_id_unique": { + "name": "oauth_client_client_id_unique", + "nullsNotDistinct": false, + "columns": [ + "client_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.oauth_consent": { + "name": "oauth_consent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_uri": { + "name": "redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_api_key_id": { + "name": "oauth_api_key_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_consent_client_id_oauth_client_client_id_fk": { + "name": "oauth_consent_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_consent_user_id_user_id_fk": { + "name": "oauth_consent_user_id_user_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.oauth_refresh_token": { + "name": "oauth_refresh_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked": { + "name": "revoked", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "auth_time": { + "name": "auth_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_refresh_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_refresh_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_refresh_token_session_id_session_id_fk": { + "name": "oauth_refresh_token_session_id_session_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "session", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_refresh_token_user_id_user_id_fk": { + "name": "oauth_refresh_token_user_id_user_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_currency": { + "name": "default_currency", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'usd'" + }, + "stripe_connected": { + "name": "stripe_connected", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "stripe_config": { + "name": "stripe_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "test_stripe_connect": { + "name": "test_stripe_connect", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "live_stripe_connect": { + "name": "live_stripe_connect", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "processor_configs": { + "name": "processor_configs", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "test_pkey": { + "name": "test_pkey", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "live_pkey": { + "name": "live_pkey", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "svix_config": { + "name": "svix_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "onboarded": { + "name": "onboarded", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "deployed": { + "name": "deployed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "redis_config": { + "name": "redis_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_organizations_name_trgm": { + "name": "idx_organizations_name_trgm", + "columns": [ + { + "expression": "\"name\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"organizations\".\"name\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_organizations_slug_trgm": { + "name": "idx_organizations_slug_trgm", + "columns": [ + { + "expression": "\"slug\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"organizations\".\"slug\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_organizations_created_at_id": { + "name": "idx_organizations_created_at_id", + "columns": [ + { + "expression": "\"createdAt\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + }, + "organizations_test_pkey_key": { + "name": "organizations_test_pkey_key", + "nullsNotDistinct": false, + "columns": [ + "test_pkey" + ] + }, + "organizations_live_pkey_key": { + "name": "organizations_live_pkey_key", + "nullsNotDistinct": false, + "columns": [ + "live_pkey" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.passkey": { + "name": "passkey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "backed_up": { + "name": "backed_up", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "aaguid": { + "name": "aaguid", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "passkey_userId_idx": { + "name": "passkey_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "passkey_credentialId_idx": { + "name": "passkey_credentialId_idx", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "passkey_user_id_user_id_fk": { + "name": "passkey_user_id_user_id_fk", + "tableFrom": "passkey", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "passkey_credential_id_unique": { + "name": "passkey_credential_id_unique", + "nullsNotDistinct": false, + "columns": [ + "credential_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.prices": { + "name": "prices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_product_id": { + "name": "internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "billing_type": { + "name": "billing_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tier_behavior": { + "name": "tier_behavior", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "is_custom": { + "name": "is_custom", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "entitlement_id": { + "name": "entitlement_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "proration_config": { + "name": "proration_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "null" + } + }, + "indexes": { + "idx_prices_internal_product_id": { + "name": "idx_prices_internal_product_id", + "columns": [ + { + "expression": "internal_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_prices_entitlement_id": { + "name": "idx_prices_entitlement_id", + "columns": [ + { + "expression": "entitlement_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "prices_entitlement_id_fkey": { + "name": "prices_entitlement_id_fkey", + "tableFrom": "prices", + "tableTo": "entitlements", + "columnsFrom": [ + "entitlement_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "prices_internal_product_id_fkey": { + "name": "prices_internal_product_id_fkey", + "tableFrom": "prices", + "tableTo": "products", + "columnsFrom": [ + "internal_product_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "prices_id_key": { + "name": "prices_id_key", + "nullsNotDistinct": false, + "columns": [ + "id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.products": { + "name": "products", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_add_on": { + "name": "is_add_on", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "group": { + "name": "group", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "''" + }, + "version": { + "name": "version", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "processor": { + "name": "processor", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "base_variant_id": { + "name": "base_variant_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "idx_products_org_env_id_version": { + "name": "idx_products_org_env_id_version", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "products_org_id_fkey": { + "name": "products_org_id_fkey", + "tableFrom": "products", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_product": { + "name": "unique_product", + "nullsNotDistinct": false, + "columns": [ + "org_id", + "id", + "env", + "version" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.referral_codes": { + "name": "referral_codes", + "schema": "", + "columns": { + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text COLLATE \"C\"", + "primaryKey": false, + "notNull": false + }, + "internal_reward_program_id": { + "name": "internal_reward_program_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_referral_codes_internal_customer_id": { + "name": "idx_referral_codes_internal_customer_id", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "referral_codes_internal_customer_id_fkey": { + "name": "referral_codes_internal_customer_id_fkey", + "tableFrom": "referral_codes", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "referral_codes_internal_reward_program_id_fkey": { + "name": "referral_codes_internal_reward_program_id_fkey", + "tableFrom": "referral_codes", + "tableTo": "reward_programs", + "columnsFrom": [ + "internal_reward_program_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "referral_codes_org_id_fkey": { + "name": "referral_codes_org_id_fkey", + "tableFrom": "referral_codes", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "referral_codes_pkey": { + "name": "referral_codes_pkey", + "columns": [ + "code", + "org_id", + "env" + ] + } + }, + "uniqueConstraints": { + "referral_codes_id_key": { + "name": "referral_codes_id_key", + "nullsNotDistinct": false, + "columns": [ + "id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.replaceables": { + "name": "replaceables", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "cus_ent_id": { + "name": "cus_ent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "from_entity_id": { + "name": "from_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "delete_next_cycle": { + "name": "delete_next_cycle", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "idx_replaceables_cus_ent_id": { + "name": "idx_replaceables_cus_ent_id", + "columns": [ + { + "expression": "cus_ent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "replaceables_cus_ent_id_fkey": { + "name": "replaceables_cus_ent_id_fkey", + "tableFrom": "replaceables", + "tableTo": "customer_entitlements", + "columnsFrom": [ + "cus_ent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.revenuecat_mappings": { + "name": "revenuecat_mappings", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "autumn_product_id": { + "name": "autumn_product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revenuecat_product_ids": { + "name": "revenuecat_product_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + } + }, + "indexes": {}, + "foreignKeys": { + "revenuecat_mappings_org_id_fkey": { + "name": "revenuecat_mappings_org_id_fkey", + "tableFrom": "revenuecat_mappings", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "revenuecat_mappings_pkey": { + "name": "revenuecat_mappings_pkey", + "columns": [ + "org_id", + "env", + "autumn_product_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reward_programs": { + "name": "reward_programs", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "internal_reward_id": { + "name": "internal_reward_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_redemptions": { + "name": "max_redemptions", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "unlimited_redemptions": { + "name": "unlimited_redemptions", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "when": { + "name": "when", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'immediately'" + }, + "product_ids": { + "name": "product_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{\"\"}'" + }, + "exclude_trial": { + "name": "exclude_trial", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "received_by": { + "name": "received_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "reward_triggers_internal_reward_id_fkey": { + "name": "reward_triggers_internal_reward_id_fkey", + "tableFrom": "reward_programs", + "tableTo": "rewards", + "columnsFrom": [ + "internal_reward_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reward_triggers_org_id_fkey": { + "name": "reward_triggers_org_id_fkey", + "tableFrom": "reward_programs", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reward_redemptions": { + "name": "reward_redemptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text COLLATE \"C\"", + "primaryKey": false, + "notNull": false + }, + "triggered": { + "name": "triggered", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "internal_reward_program_id": { + "name": "internal_reward_program_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "applied": { + "name": "applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "redeemer_applied": { + "name": "redeemer_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "referral_code_id": { + "name": "referral_code_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reward_internal_id": { + "name": "reward_internal_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "promo_code": { + "name": "promo_code", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_reward_redemptions_referral_code_id": { + "name": "idx_reward_redemptions_referral_code_id", + "columns": [ + { + "expression": "referral_code_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_reward_redemptions_reward_internal_id": { + "name": "idx_reward_redemptions_reward_internal_id", + "columns": [ + { + "expression": "reward_internal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_reward_redemptions_customer_reward": { + "name": "idx_reward_redemptions_customer_reward", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reward_internal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reward_redemptions_internal_customer_id_fkey": { + "name": "reward_redemptions_internal_customer_id_fkey", + "tableFrom": "reward_redemptions", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reward_redemptions_internal_reward_program_id_fkey": { + "name": "reward_redemptions_internal_reward_program_id_fkey", + "tableFrom": "reward_redemptions", + "tableTo": "reward_programs", + "columnsFrom": [ + "internal_reward_program_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reward_redemptions_referral_code_id_fkey": { + "name": "reward_redemptions_referral_code_id_fkey", + "tableFrom": "reward_redemptions", + "tableTo": "referral_codes", + "columnsFrom": [ + "referral_code_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rewards": { + "name": "rewards", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "discount_config": { + "name": "discount_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "free_product_config": { + "name": "free_product_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "free_product_id": { + "name": "free_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "promo_codes": { + "name": "promo_codes", + "type": "jsonb[]", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "coupons_org_id_fkey": { + "name": "coupons_org_id_fkey", + "tableFrom": "rewards", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rollovers": { + "name": "rollovers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "cus_ent_id": { + "name": "cus_ent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "balance": { + "name": "balance", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "usage": { + "name": "usage", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "entities": { + "name": "entities", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "idx_rollovers_cus_ent_id": { + "name": "idx_rollovers_cus_ent_id", + "columns": [ + { + "expression": "cus_ent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_rollovers_cus_ent_expires": { + "name": "idx_rollovers_cus_ent_expires", + "columns": [ + { + "expression": "cus_ent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "rollover_cus_ent_id_fkey": { + "name": "rollover_cus_ent_id_fkey", + "tableFrom": "rollovers", + "tableTo": "customer_entitlements", + "columnsFrom": [ + "cus_ent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.phases": { + "name": "phases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "starts_at": { + "name": "starts_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "customer_product_ids": { + "name": "customer_product_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "phases_schedule_id_fkey": { + "name": "phases_schedule_id_fkey", + "tableFrom": "phases", + "tableTo": "schedules", + "columnsFrom": [ + "schedule_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "phases_schedule_id_starts_at_key": { + "name": "phases_schedule_id_starts_at_key", + "nullsNotDistinct": false, + "columns": [ + "schedule_id", + "starts_at" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.schedules": { + "name": "schedules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_entity_id": { + "name": "internal_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "schedules_customer_scope_unique": { + "name": "schedules_customer_scope_unique", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"schedules\".\"internal_entity_id\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "schedules_entity_scope_unique": { + "name": "schedules_entity_scope_unique", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"schedules\".\"internal_entity_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_schedules_internal_customer_id": { + "name": "idx_schedules_internal_customer_id", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_schedules_internal_entity_id": { + "name": "idx_schedules_internal_entity_id", + "columns": [ + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "schedules_org_id_fkey": { + "name": "schedules_org_id_fkey", + "tableFrom": "schedules", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "schedules_internal_customer_id_fkey": { + "name": "schedules_internal_customer_id_fkey", + "tableFrom": "schedules", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "schedules_internal_entity_id_fkey": { + "name": "schedules_internal_entity_id_fkey", + "tableFrom": "schedules", + "tableTo": "entities", + "columnsFrom": [ + "internal_entity_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "city": { + "name": "city", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country": { + "name": "country", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.subscriptions": { + "name": "subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_id": { + "name": "stripe_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "usage_features": { + "name": "usage_features", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "current_period_start": { + "name": "current_period_start", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "current_period_end": { + "name": "current_period_end", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "subscriptions_org_id_fkey": { + "name": "subscriptions_org_id_fkey", + "tableFrom": "subscriptions", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "subscriptions_stripe_id_key": { + "name": "subscriptions_stripe_id_key", + "nullsNotDistinct": false, + "columns": [ + "stripe_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_active_at": { + "name": "last_active_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_user_name_trgm": { + "name": "idx_user_name_trgm", + "columns": [ + { + "expression": "\"name\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user\".\"name\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_user_email_trgm": { + "name": "idx_user_email_trgm", + "columns": [ + { + "expression": "\"email\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user\".\"email\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_user_created_at_id": { + "name": "idx_user_created_at_id", + "columns": [ + { + "expression": "\"created_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_created_by_fkey": { + "name": "user_created_by_fkey", + "tableFrom": "user", + "tableTo": "organizations", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.vercel_resources": { + "name": "vercel_resources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "vercel_resources_installation_name_unique_idx": { + "name": "vercel_resources_installation_name_unique_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status <> 'uninstalled'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "vercel_resources_org_id_fkey": { + "name": "vercel_resources_org_id_fkey", + "tableFrom": "vercel_resources", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/shared/drizzle/meta/0009_snapshot.json b/shared/drizzle/meta/0009_snapshot.json new file mode 100644 index 000000000..196b24e3a --- /dev/null +++ b/shared/drizzle/meta/0009_snapshot.json @@ -0,0 +1,7648 @@ +{ + "id": "a7af3028-b54b-45c6-b82b-585d2ddd6701", + "prevId": "40c5361a-8cff-473f-93c1-4dfbc06b00d7", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.actions": { + "name": "actions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_slug": { + "name": "org_slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_entity_id": { + "name": "internal_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "properties": { + "name": "properties", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_actions_on_internal_entity_id": { + "name": "idx_actions_on_internal_entity_id", + "columns": [ + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "actions_org_id_fkey": { + "name": "actions_org_id_fkey", + "tableFrom": "actions", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "actions_customer_id_fkey": { + "name": "actions_customer_id_fkey", + "tableFrom": "actions", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "actions_entity_id_fkey": { + "name": "actions_entity_id_fkey", + "tableFrom": "actions", + "tableTo": "entities", + "columnsFrom": [ + "internal_entity_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "hashed_key": { + "name": "hashed_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "meta": { + "name": "meta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "api_keys_org_id_fkey": { + "name": "api_keys_org_id_fkey", + "tableFrom": "api_keys", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_keys_hashed_key_key": { + "name": "api_keys_hashed_key_key", + "nullsNotDistinct": false, + "columns": [ + "hashed_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auto_topup_limit_states": { + "name": "auto_topup_limit_states", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "feature_id": { + "name": "feature_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "purchase_window_ends_at": { + "name": "purchase_window_ends_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "purchase_count": { + "name": "purchase_count", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "attempt_window_ends_at": { + "name": "attempt_window_ends_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "attempt_count": { + "name": "attempt_count", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "failed_attempt_window_ends_at": { + "name": "failed_attempt_window_ends_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "failed_attempt_count": { + "name": "failed_attempt_count", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "last_failed_attempt_at": { + "name": "last_failed_attempt_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + } + }, + "indexes": { + "auto_topup_limits_org_env_internal_customer_feature_unique": { + "name": "auto_topup_limits_org_env_internal_customer_feature_unique", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "feature_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "auto_topup_limits_org_id_fkey": { + "name": "auto_topup_limits_org_id_fkey", + "tableFrom": "auto_topup_limit_states", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "auto_topup_limits_internal_customer_id_fkey": { + "name": "auto_topup_limits_internal_customer_id_fkey", + "tableFrom": "auto_topup_limit_states", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_approvals": { + "name": "chat_approvals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_ts": { + "name": "message_ts", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_user_id": { + "name": "provider_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_args": { + "name": "tool_args", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "preview": { + "name": "preview", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "expires_at": { + "name": "expires_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "decided_at": { + "name": "decided_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "decided_by_provider_user_id": { + "name": "decided_by_provider_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "chat_approvals_org_id_fkey": { + "name": "chat_approvals_org_id_fkey", + "tableFrom": "chat_approvals", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_installations": { + "name": "chat_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_name": { + "name": "workspace_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_user_id": { + "name": "bot_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bot_access_token": { + "name": "bot_access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "default_env": { + "name": "default_env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sandbox_api_key_id": { + "name": "sandbox_api_key_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sandbox_api_key": { + "name": "sandbox_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "live_api_key_id": { + "name": "live_api_key_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "live_api_key": { + "name": "live_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "installed_by_user_id": { + "name": "installed_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "installed_by_provider_user_id": { + "name": "installed_by_provider_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + } + }, + "indexes": {}, + "foreignKeys": { + "chat_installations_org_id_fkey": { + "name": "chat_installations_org_id_fkey", + "tableFrom": "chat_installations", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "chat_installations_org_provider_key": { + "name": "chat_installations_org_provider_key", + "nullsNotDistinct": false, + "columns": [ + "org_id", + "provider" + ] + }, + "chat_installations_provider_workspace_key": { + "name": "chat_installations_provider_workspace_key", + "nullsNotDistinct": false, + "columns": [ + "provider", + "workspace_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_oauth_credentials": { + "name": "chat_oauth_credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "chat_installation_id": { + "name": "chat_installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oauth_consent_id": { + "name": "oauth_consent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + } + }, + "indexes": {}, + "foreignKeys": { + "chat_oauth_credentials_installation_id_fkey": { + "name": "chat_oauth_credentials_installation_id_fkey", + "tableFrom": "chat_oauth_credentials", + "tableTo": "chat_installations", + "columnsFrom": [ + "chat_installation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_oauth_credentials_org_id_fkey": { + "name": "chat_oauth_credentials_org_id_fkey", + "tableFrom": "chat_oauth_credentials", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "chat_oauth_credentials_installation_env_key": { + "name": "chat_oauth_credentials_installation_env_key", + "nullsNotDistinct": false, + "columns": [ + "chat_installation_id", + "env" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_results": { + "name": "chat_results", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.checkouts": { + "name": "checkouts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "params_version": { + "name": "params_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "response": { + "name": "response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "stripe_invoice_id": { + "name": "stripe_invoice_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "expires_at": { + "name": "expires_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_checkouts_stripe_invoice_id": { + "name": "idx_checkouts_stripe_invoice_id", + "columns": [ + { + "expression": "stripe_invoice_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.customer_entitlements": { + "name": "customer_entitlements", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "customer_product_id": { + "name": "customer_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entitlement_id": { + "name": "entitlement_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text COLLATE \"C\"", + "primaryKey": false, + "notNull": true + }, + "internal_entity_id": { + "name": "internal_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_feature_id": { + "name": "internal_feature_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "unlimited": { + "name": "unlimited", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "balance": { + "name": "balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "next_reset_at": { + "name": "next_reset_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "usage_allowed": { + "name": "usage_allowed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "adjustment": { + "name": "adjustment", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "additional_balance": { + "name": "additional_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "entities": { + "name": "entities", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "cache_version": { + "name": "cache_version", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "feature_id": { + "name": "feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_customer_entitlements_product_id": { + "name": "idx_customer_entitlements_product_id", + "columns": [ + { + "expression": "customer_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_entitlements_internal_customer_id": { + "name": "idx_customer_entitlements_internal_customer_id", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hash", + "with": {} + }, + "idx_customer_entitlements_internal_customer_id_btree": { + "name": "idx_customer_entitlements_internal_customer_id_btree", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_entitlements_entitlement_id": { + "name": "idx_customer_entitlements_entitlement_id", + "columns": [ + { + "expression": "entitlement_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_entitlements_internal_entity_id": { + "name": "idx_customer_entitlements_internal_entity_id", + "columns": [ + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hash", + "with": {} + }, + "idx_customer_entitlements_on_next_reset_at": { + "name": "idx_customer_entitlements_on_next_reset_at", + "columns": [ + { + "expression": "next_reset_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_entitlements_loose_customer_expires": { + "name": "idx_customer_entitlements_loose_customer_expires", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"customer_entitlements\".\"customer_product_id\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "entitlements_internal_feature_id_fkey": { + "name": "entitlements_internal_feature_id_fkey", + "tableFrom": "customer_entitlements", + "tableTo": "features", + "columnsFrom": [ + "internal_feature_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "customer_entitlements_internal_entity_id_fkey": { + "name": "customer_entitlements_internal_entity_id_fkey", + "tableFrom": "customer_entitlements", + "tableTo": "entities", + "columnsFrom": [ + "internal_entity_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "customer_entitlements_customer_product_id_fkey": { + "name": "customer_entitlements_customer_product_id_fkey", + "tableFrom": "customer_entitlements", + "tableTo": "customer_products", + "columnsFrom": [ + "customer_product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "customer_entitlements_entitlement_id_fkey": { + "name": "customer_entitlements_entitlement_id_fkey", + "tableFrom": "customer_entitlements", + "tableTo": "entitlements", + "columnsFrom": [ + "entitlement_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.customer_prices": { + "name": "customer_prices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "price_id": { + "name": "price_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "options": { + "name": "options", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_product_id": { + "name": "customer_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_customer_prices_product_id": { + "name": "idx_customer_prices_product_id", + "columns": [ + { + "expression": "customer_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_prices_price_id": { + "name": "idx_customer_prices_price_id", + "columns": [ + { + "expression": "price_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_prices_internal_customer_id": { + "name": "idx_customer_prices_internal_customer_id", + "columns": [ + { + "expression": "\"internal_customer_id\" COLLATE \"C\"", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"customer_prices\".\"internal_customer_id\" IS NOT NULL", + "concurrently": true, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "customer_prices_customer_product_id_fkey": { + "name": "customer_prices_customer_product_id_fkey", + "tableFrom": "customer_prices", + "tableTo": "customer_products", + "columnsFrom": [ + "customer_product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "customer_prices_internal_customer_id_fkey": { + "name": "customer_prices_internal_customer_id_fkey", + "tableFrom": "customer_prices", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "customer_prices_price_id_fkey": { + "name": "customer_prices_price_id_fkey", + "tableFrom": "customer_prices", + "tableTo": "prices", + "columnsFrom": [ + "price_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.customer_products": { + "name": "customer_products", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_product_id": { + "name": "internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_entity_id": { + "name": "internal_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processor": { + "name": "processor", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "canceled": { + "name": "canceled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "starts_at": { + "name": "starts_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "access_starts_at": { + "name": "access_starts_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "options": { + "name": "options", + "type": "jsonb[]", + "primaryKey": false, + "notNull": false + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "free_trial_id": { + "name": "free_trial_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trial_ends_at": { + "name": "trial_ends_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "billing_cycle_anchor_resets_at": { + "name": "billing_cycle_anchor_resets_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "collection_method": { + "name": "collection_method", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'charge_automatically'" + }, + "subscription_ids": { + "name": "subscription_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "scheduled_ids": { + "name": "scheduled_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "quantity": { + "name": "quantity", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "is_custom": { + "name": "is_custom", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_version": { + "name": "billing_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "api_version": { + "name": "api_version", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "api_semver": { + "name": "api_semver", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_checkout_session_id": { + "name": "stripe_checkout_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "previous_customer_product_id": { + "name": "previous_customer_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "on_trial_end": { + "name": "on_trial_end", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_customer_products_customer_status": { + "name": "idx_customer_products_customer_status", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_products_on_internal_entity_id": { + "name": "idx_customer_products_on_internal_entity_id", + "columns": [ + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_products_on_internal_product_id": { + "name": "idx_customer_products_on_internal_product_id", + "columns": [ + { + "expression": "internal_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_products_subscription_ids": { + "name": "idx_customer_products_subscription_ids", + "columns": [ + { + "expression": "subscription_ids", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_customer_products_scheduled_ids": { + "name": "idx_customer_products_scheduled_ids", + "columns": [ + { + "expression": "scheduled_ids", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_customer_products_stripe_checkout_session_id": { + "name": "idx_customer_products_stripe_checkout_session_id", + "columns": [ + { + "expression": "stripe_checkout_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customer_products_revenuecat_processor": { + "name": "idx_customer_products_revenuecat_processor", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "(\"customer_products\".\"processor\" ->> 'type') = 'revenuecat'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "customer_products_free_trial_id_fkey": { + "name": "customer_products_free_trial_id_fkey", + "tableFrom": "customer_products", + "tableTo": "free_trials", + "columnsFrom": [ + "free_trial_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "customer_products_internal_customer_id_fkey": { + "name": "customer_products_internal_customer_id_fkey", + "tableFrom": "customer_products", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "customer_products_internal_product_id_fkey": { + "name": "customer_products_internal_product_id_fkey", + "tableFrom": "customer_products", + "tableTo": "products", + "columnsFrom": [ + "internal_product_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "customer_products_internal_entity_id_fkey": { + "name": "customer_products_internal_entity_id_fkey", + "tableFrom": "customer_products", + "tableTo": "entities", + "columnsFrom": [ + "internal_entity_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.customers": { + "name": "customers", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "processor": { + "name": "processor", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "processors": { + "name": "processors", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "send_email_receipts": { + "name": "send_email_receipts", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "auto_topups": { + "name": "auto_topups", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "spend_limits": { + "name": "spend_limits", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "usage_alerts": { + "name": "usage_alerts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "overage_allowed": { + "name": "overage_allowed", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "customers_email_null_id_unique": { + "name": "customers_email_null_id_unique", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"email\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"customers\".\"id\" IS NULL AND \"customers\".\"email\" IS NOT NULL AND \"customers\".\"email\" != ''", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_org_env_fingerprint": { + "name": "idx_customers_org_env_fingerprint", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"customers\".\"fingerprint\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_processor_id": { + "name": "idx_customers_processor_id", + "columns": [ + { + "expression": "(\"processor\" ->> 'id')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_composite": { + "name": "idx_customers_composite", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_org_env_internal_id": { + "name": "idx_customers_org_env_internal_id", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"internal_id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_email_trgm": { + "name": "idx_customers_email_trgm", + "columns": [ + { + "expression": "\"email\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"customers\".\"email\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_customers_name_trgm": { + "name": "idx_customers_name_trgm", + "columns": [ + { + "expression": "\"name\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"customers\".\"name\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_customers_id_trgm": { + "name": "idx_customers_id_trgm", + "columns": [ + { + "expression": "\"id\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"customers\".\"id\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_customers_org_id_env_created_at": { + "name": "idx_customers_org_id_env_created_at", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_cursor": { + "name": "idx_customers_cursor", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_processors_revenuecat": { + "name": "idx_customers_processors_revenuecat", + "columns": [ + { + "expression": "(\"processors\" ->> 'revenuecat')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_customers_processors_vercel": { + "name": "idx_customers_processors_vercel", + "columns": [ + { + "expression": "(\"processors\" ->> 'vercel')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "customers_org_id_fkey": { + "name": "customers_org_id_fkey", + "tableFrom": "customers", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "cus_id_constraint": { + "name": "cus_id_constraint", + "nullsNotDistinct": false, + "columns": [ + "org_id", + "id", + "env" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.entities": { + "name": "entities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "internal_feature_id": { + "name": "internal_feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "spend_limits": { + "name": "spend_limits", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "usage_alerts": { + "name": "usage_alerts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "overage_allowed": { + "name": "overage_allowed", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "feature_id": { + "name": "feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_entities_internal_customer_id": { + "name": "idx_entities_internal_customer_id", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_entities_customer_internal_desc": { + "name": "idx_entities_customer_internal_desc", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"internal_id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_entities_org_env_id": { + "name": "idx_entities_org_env_id", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_entities_cursor": { + "name": "idx_entities_cursor", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "entities_internal_customer_id_fkey": { + "name": "entities_internal_customer_id_fkey", + "tableFrom": "entities", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "entities_internal_feature_id_fkey": { + "name": "entities_internal_feature_id_fkey", + "tableFrom": "entities", + "tableTo": "features", + "columnsFrom": [ + "internal_feature_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "entities_org_id_fkey": { + "name": "entities_org_id_fkey", + "tableFrom": "entities", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "entity_id_constraint": { + "name": "entity_id_constraint", + "nullsNotDistinct": false, + "columns": [ + "org_id", + "env", + "internal_customer_id", + "id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.entitlements": { + "name": "entitlements", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "internal_feature_id": { + "name": "internal_feature_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_product_id": { + "name": "internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_reward_id": { + "name": "internal_reward_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_custom": { + "name": "is_custom", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "allowance_type": { + "name": "allowance_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowance": { + "name": "allowance", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "interval": { + "name": "interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "interval_count": { + "name": "interval_count", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "carry_from_previous": { + "name": "carry_from_previous", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "entity_feature_id": { + "name": "entity_feature_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "feature_id": { + "name": "feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "usage_limit": { + "name": "usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "expiry_duration": { + "name": "expiry_duration", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expiry_length": { + "name": "expiry_length", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "rollover": { + "name": "rollover", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_entitlements_internal_product_id": { + "name": "idx_entitlements_internal_product_id", + "columns": [ + { + "expression": "internal_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_entitlements_internal_reward_id": { + "name": "idx_entitlements_internal_reward_id", + "columns": [ + { + "expression": "internal_reward_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_entitlements_reward_feature": { + "name": "idx_entitlements_reward_feature", + "columns": [ + { + "expression": "internal_reward_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "internal_feature_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_entitlements_internal_reward_id_c_partial": { + "name": "idx_entitlements_internal_reward_id_c_partial", + "columns": [ + { + "expression": "\"internal_reward_id\" COLLATE \"C\"", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"entitlements\".\"internal_reward_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "entitlements_internal_feature_id_fkey": { + "name": "entitlements_internal_feature_id_fkey", + "tableFrom": "entitlements", + "tableTo": "features", + "columnsFrom": [ + "internal_feature_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "entitlements_internal_product_id_fkey": { + "name": "entitlements_internal_product_id_fkey", + "tableFrom": "entitlements", + "tableTo": "products", + "columnsFrom": [ + "internal_product_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "entitlements_internal_reward_id_fkey": { + "name": "entitlements_internal_reward_id_fkey", + "tableFrom": "entitlements", + "tableTo": "rewards", + "columnsFrom": [ + "internal_reward_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "entitlements_id_key": { + "name": "entitlements_id_key", + "nullsNotDistinct": false, + "columns": [ + "id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.events": { + "name": "events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_slug": { + "name": "org_slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "event_name": { + "name": "event_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "value": { + "name": "value", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "set_usage": { + "name": "set_usage", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_entity_id": { + "name": "internal_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_product_id": { + "name": "internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "properties": { + "name": "properties", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "deductions": { + "name": "deductions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_events_internal_customer_id": { + "name": "idx_events_internal_customer_id", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_events_internal_entity_id": { + "name": "idx_events_internal_entity_id", + "columns": [ + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_events_customer_non_usage_ts": { + "name": "idx_events_customer_non_usage_ts", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"timestamp\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"events\".\"set_usage\" = false", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "events_internal_customer_id_fkey": { + "name": "events_internal_customer_id_fkey", + "tableFrom": "events", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_event_constraint": { + "name": "unique_event_constraint", + "nullsNotDistinct": false, + "columns": [ + "org_id", + "env", + "customer_id", + "event_name", + "idempotency_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.features": { + "name": "features", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "display": { + "name": "display", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "event_names": { + "name": "event_names", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + } + }, + "indexes": {}, + "foreignKeys": { + "features_org_id_fkey": { + "name": "features_org_id_fkey", + "tableFrom": "features", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "feature_id_constraint": { + "name": "feature_id_constraint", + "nullsNotDistinct": false, + "columns": [ + "org_id", + "id", + "env" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.free_trials": { + "name": "free_trials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "internal_product_id": { + "name": "internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duration": { + "name": "duration", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'day'" + }, + "length": { + "name": "length", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "unique_fingerprint": { + "name": "unique_fingerprint", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "is_custom": { + "name": "is_custom", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "card_required": { + "name": "card_required", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "on_end": { + "name": "on_end", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_free_trials_internal_product_id": { + "name": "idx_free_trials_internal_product_id", + "columns": [ + { + "expression": "internal_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "free_trials_internal_product_id_fkey": { + "name": "free_trials_internal_product_id_fkey", + "tableFrom": "free_trials", + "tableTo": "products", + "columnsFrom": [ + "internal_product_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "invitation_organizationId_idx": { + "name": "invitation_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_organization_id_organizations_id_fk": { + "name": "invitation_organization_id_organizations_id_fk", + "tableFrom": "invitation", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.invoice_line_items": { + "name": "invoice_line_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "invoice_id": { + "name": "invoice_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_id": { + "name": "stripe_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_invoice_id": { + "name": "stripe_invoice_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_invoice_item_id": { + "name": "stripe_invoice_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_item_id": { + "name": "stripe_subscription_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_product_id": { + "name": "stripe_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_price_id": { + "name": "stripe_price_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_discountable": { + "name": "stripe_discountable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "amount": { + "name": "amount", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "amount_after_discounts": { + "name": "amount_after_discounts", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'usd'" + }, + "stripe_quantity": { + "name": "stripe_quantity", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "total_quantity": { + "name": "total_quantity", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "paid_quantity": { + "name": "paid_quantity", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description_source": { + "name": "description_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "direction": { + "name": "direction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billing_timing": { + "name": "billing_timing", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prorated": { + "name": "prorated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "price_id": { + "name": "price_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_product_ids": { + "name": "customer_product_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "customer_price_ids": { + "name": "customer_price_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "customer_entitlement_ids": { + "name": "customer_entitlement_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "internal_product_id": { + "name": "internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "internal_feature_id": { + "name": "internal_feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "feature_id": { + "name": "feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "effective_period_start": { + "name": "effective_period_start", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "effective_period_end": { + "name": "effective_period_end", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "discounts": { + "name": "discounts", + "type": "jsonb[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + } + }, + "indexes": { + "idx_invoice_line_items_customer_product_ids": { + "name": "idx_invoice_line_items_customer_product_ids", + "columns": [ + { + "expression": "customer_product_ids", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "invoice_line_items_invoice_id_fkey": { + "name": "invoice_line_items_invoice_id_fkey", + "tableFrom": "invoice_line_items", + "tableTo": "invoices", + "columnsFrom": [ + "invoice_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invoice_line_items_stripe_id_unique": { + "name": "invoice_line_items_stripe_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoice_templates": { + "name": "invoice_templates", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "footer": { + "name": "footer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "memo": { + "name": "memo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "net_terms_days": { + "name": "net_terms_days", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_invoice_templates_org_id": { + "name": "idx_invoice_templates_org_id", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invoice_templates_org_id_fkey": { + "name": "invoice_templates_org_id_fkey", + "tableFrom": "invoice_templates", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invoice_templates_id_unique": { + "name": "invoice_templates_id_unique", + "nullsNotDistinct": false, + "columns": [ + "id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoices": { + "name": "invoices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "product_ids": { + "name": "product_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "internal_product_ids": { + "name": "internal_product_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_entity_id": { + "name": "internal_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_id": { + "name": "stripe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "processor_type": { + "name": "processor_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "hosted_invoice_url": { + "name": "hosted_invoice_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "total": { + "name": "total", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "amount_paid": { + "name": "amount_paid", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "refunded_amount": { + "name": "refunded_amount", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'usd'" + }, + "discounts": { + "name": "discounts", + "type": "jsonb[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "items": { + "name": "items", + "type": "jsonb[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + } + }, + "indexes": { + "idx_invoices_customer_created": { + "name": "idx_invoices_customer_created", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_invoices_internal_entity_id": { + "name": "idx_invoices_internal_entity_id", + "columns": [ + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"invoices\".\"internal_entity_id\" IS NOT NULL", + "concurrently": true, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invoices_internal_customer_id_fkey": { + "name": "invoices_internal_customer_id_fkey", + "tableFrom": "invoices", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invoices_internal_entity_id_fkey": { + "name": "invoices_internal_entity_id_fkey", + "tableFrom": "invoices", + "tableTo": "entities", + "columnsFrom": [ + "internal_entity_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invoices_stripe_id_key": { + "name": "invoices_stripe_id_key", + "nullsNotDistinct": false, + "columns": [ + "stripe_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.jwks": { + "name": "jwks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "member_organizationId_idx": { + "name": "member_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_userId_idx": { + "name": "member_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_organization_id_organizations_id_fk": { + "name": "member_organization_id_organizations_id_fk", + "tableFrom": "member", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.metadata": { + "name": "metadata", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "expires_at": { + "name": "expires_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_invoice_id": { + "name": "stripe_invoice_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_checkout_session_id": { + "name": "stripe_checkout_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migration_errors": { + "name": "migration_errors", + "schema": "", + "columns": { + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "migration_job_id": { + "name": "migration_job_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "migration_customers_internal_customer_id_fkey": { + "name": "migration_customers_internal_customer_id_fkey", + "tableFrom": "migration_errors", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "migration_customers_migration_job_id_fkey": { + "name": "migration_customers_migration_job_id_fkey", + "tableFrom": "migration_errors", + "tableTo": "migration_jobs", + "columnsFrom": [ + "migration_job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "migration_errors_pkey": { + "name": "migration_errors_pkey", + "columns": [ + "internal_customer_id", + "migration_job_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migration_item_runs": { + "name": "migration_item_runs", + "schema": "", + "columns": { + "migration_item_run_id": { + "name": "migration_item_run_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "migration_internal_id": { + "name": "migration_internal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "migration_run_id": { + "name": "migration_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dry_run": { + "name": "dry_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "item_kind": { + "name": "item_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "migration_item_runs_live_unique": { + "name": "migration_item_runs_live_unique", + "columns": [ + { + "expression": "migration_internal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"migration_item_runs\".\"dry_run\" = false", + "concurrently": false, + "method": "btree", + "with": {} + }, + "migration_item_runs_dry_run_unique": { + "name": "migration_item_runs_dry_run_unique", + "columns": [ + { + "expression": "migration_internal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "migration_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"migration_item_runs\".\"dry_run\" = true", + "concurrently": false, + "method": "btree", + "with": {} + }, + "migration_item_runs_customer_recent_idx": { + "name": "migration_item_runs_customer_recent_idx", + "columns": [ + { + "expression": "item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"updated_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"migration_item_runs\".\"item_kind\" = 'customer'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migration_jobs": { + "name": "migration_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "current_step": { + "name": "current_step", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "from_internal_product_id": { + "name": "from_internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "to_internal_product_id": { + "name": "to_internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "step_details": { + "name": "step_details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "migration_jobs_from_internal_product_id_fkey": { + "name": "migration_jobs_from_internal_product_id_fkey", + "tableFrom": "migration_jobs", + "tableTo": "products", + "columnsFrom": [ + "from_internal_product_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "migration_jobs_org_id_fkey": { + "name": "migration_jobs_org_id_fkey", + "tableFrom": "migration_jobs", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "migration_jobs_to_internal_product_id_fkey": { + "name": "migration_jobs_to_internal_product_id_fkey", + "tableFrom": "migration_jobs", + "tableTo": "products", + "columnsFrom": [ + "to_internal_product_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migration_runs": { + "name": "migration_runs", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "migration_internal_id": { + "name": "migration_internal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dry_run": { + "name": "dry_run", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "lazy_run": { + "name": "lazy_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trigger_run_id": { + "name": "trigger_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "only_ids": { + "name": "only_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "target_limit": { + "name": "target_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "migration_runs_active_per_migration_unique": { + "name": "migration_runs_active_per_migration_unique", + "columns": [ + { + "expression": "migration_internal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"migration_runs\".\"status\" IN ('queued', 'running')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "migration_runs_migration_internal_id_fkey": { + "name": "migration_runs_migration_internal_id_fkey", + "tableFrom": "migration_runs", + "tableTo": "migrations", + "columnsFrom": [ + "migration_internal_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "migration_runs_org_id_fkey": { + "name": "migration_runs_org_id_fkey", + "tableFrom": "migration_runs", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.migrations": { + "name": "migrations", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filter": { + "name": "filter", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "operations": { + "name": "operations", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "prepared_state": { + "name": "prepared_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "no_billing_changes": { + "name": "no_billing_changes", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "retry_failed": { + "name": "retry_failed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "migrations_org_env_id_unique": { + "name": "migrations_org_env_id_unique", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "migrations_org_id_fkey": { + "name": "migrations_org_id_fkey", + "tableFrom": "migrations", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_access_token": { + "name": "oauth_access_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_id": { + "name": "refresh_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_access_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_access_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_session_id_session_id_fk": { + "name": "oauth_access_token_session_id_session_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "session", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_access_token_user_id_user_id_fk": { + "name": "oauth_access_token_user_id_user_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_refresh_id_oauth_refresh_token_id_fk": { + "name": "oauth_access_token_refresh_id_oauth_refresh_token_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_refresh_token", + "columnsFrom": [ + "refresh_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_access_token_token_unique": { + "name": "oauth_access_token_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.oauth_client": { + "name": "oauth_client", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "skip_consent": { + "name": "skip_consent", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enable_end_session": { + "name": "enable_end_session", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contacts": { + "name": "contacts", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "tos": { + "name": "tos", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_id": { + "name": "software_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_version": { + "name": "software_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_statement": { + "name": "software_statement", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "post_logout_redirect_uris": { + "name": "post_logout_redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_types": { + "name": "grant_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "response_types": { + "name": "response_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_client_user_id_user_id_fk": { + "name": "oauth_client_user_id_user_id_fk", + "tableFrom": "oauth_client", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_client_client_id_unique": { + "name": "oauth_client_client_id_unique", + "nullsNotDistinct": false, + "columns": [ + "client_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.oauth_consent": { + "name": "oauth_consent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_uri": { + "name": "redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_api_key_id": { + "name": "oauth_api_key_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_consent_client_id_oauth_client_client_id_fk": { + "name": "oauth_consent_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_consent_user_id_user_id_fk": { + "name": "oauth_consent_user_id_user_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.oauth_refresh_token": { + "name": "oauth_refresh_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked": { + "name": "revoked", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "auth_time": { + "name": "auth_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_refresh_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_refresh_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_refresh_token_session_id_session_id_fk": { + "name": "oauth_refresh_token_session_id_session_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "session", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_refresh_token_user_id_user_id_fk": { + "name": "oauth_refresh_token_user_id_user_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_currency": { + "name": "default_currency", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'usd'" + }, + "stripe_connected": { + "name": "stripe_connected", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "stripe_config": { + "name": "stripe_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "test_stripe_connect": { + "name": "test_stripe_connect", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "live_stripe_connect": { + "name": "live_stripe_connect", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "processor_configs": { + "name": "processor_configs", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "test_pkey": { + "name": "test_pkey", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "live_pkey": { + "name": "live_pkey", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "svix_config": { + "name": "svix_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "onboarded": { + "name": "onboarded", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "deployed": { + "name": "deployed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "redis_config": { + "name": "redis_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_organizations_name_trgm": { + "name": "idx_organizations_name_trgm", + "columns": [ + { + "expression": "\"name\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"organizations\".\"name\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_organizations_slug_trgm": { + "name": "idx_organizations_slug_trgm", + "columns": [ + { + "expression": "\"slug\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"organizations\".\"slug\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_organizations_created_at_id": { + "name": "idx_organizations_created_at_id", + "columns": [ + { + "expression": "\"createdAt\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + }, + "organizations_test_pkey_key": { + "name": "organizations_test_pkey_key", + "nullsNotDistinct": false, + "columns": [ + "test_pkey" + ] + }, + "organizations_live_pkey_key": { + "name": "organizations_live_pkey_key", + "nullsNotDistinct": false, + "columns": [ + "live_pkey" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.passkey": { + "name": "passkey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "backed_up": { + "name": "backed_up", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "aaguid": { + "name": "aaguid", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "passkey_userId_idx": { + "name": "passkey_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "passkey_credentialId_idx": { + "name": "passkey_credentialId_idx", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "passkey_user_id_user_id_fk": { + "name": "passkey_user_id_user_id_fk", + "tableFrom": "passkey", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "passkey_credential_id_unique": { + "name": "passkey_credential_id_unique", + "nullsNotDistinct": false, + "columns": [ + "credential_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.prices": { + "name": "prices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text COLLATE \"C\"", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_product_id": { + "name": "internal_product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "billing_type": { + "name": "billing_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tier_behavior": { + "name": "tier_behavior", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "is_custom": { + "name": "is_custom", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "entitlement_id": { + "name": "entitlement_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "proration_config": { + "name": "proration_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "null" + } + }, + "indexes": { + "idx_prices_internal_product_id": { + "name": "idx_prices_internal_product_id", + "columns": [ + { + "expression": "internal_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_prices_entitlement_id": { + "name": "idx_prices_entitlement_id", + "columns": [ + { + "expression": "entitlement_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "prices_entitlement_id_fkey": { + "name": "prices_entitlement_id_fkey", + "tableFrom": "prices", + "tableTo": "entitlements", + "columnsFrom": [ + "entitlement_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "prices_internal_product_id_fkey": { + "name": "prices_internal_product_id_fkey", + "tableFrom": "prices", + "tableTo": "products", + "columnsFrom": [ + "internal_product_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "prices_id_key": { + "name": "prices_id_key", + "nullsNotDistinct": false, + "columns": [ + "id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.products": { + "name": "products", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_add_on": { + "name": "is_add_on", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "group": { + "name": "group", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "''" + }, + "version": { + "name": "version", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "processor": { + "name": "processor", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "null" + }, + "base_variant_id": { + "name": "base_variant_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "idx_products_org_env_id_version": { + "name": "idx_products_org_env_id_version", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "products_org_id_fkey": { + "name": "products_org_id_fkey", + "tableFrom": "products", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_product": { + "name": "unique_product", + "nullsNotDistinct": false, + "columns": [ + "org_id", + "id", + "env", + "version" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.referral_codes": { + "name": "referral_codes", + "schema": "", + "columns": { + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text COLLATE \"C\"", + "primaryKey": false, + "notNull": false + }, + "internal_reward_program_id": { + "name": "internal_reward_program_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_referral_codes_internal_customer_id": { + "name": "idx_referral_codes_internal_customer_id", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "referral_codes_internal_customer_id_fkey": { + "name": "referral_codes_internal_customer_id_fkey", + "tableFrom": "referral_codes", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "referral_codes_internal_reward_program_id_fkey": { + "name": "referral_codes_internal_reward_program_id_fkey", + "tableFrom": "referral_codes", + "tableTo": "reward_programs", + "columnsFrom": [ + "internal_reward_program_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "referral_codes_org_id_fkey": { + "name": "referral_codes_org_id_fkey", + "tableFrom": "referral_codes", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "referral_codes_pkey": { + "name": "referral_codes_pkey", + "columns": [ + "code", + "org_id", + "env" + ] + } + }, + "uniqueConstraints": { + "referral_codes_id_key": { + "name": "referral_codes_id_key", + "nullsNotDistinct": false, + "columns": [ + "id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.replaceables": { + "name": "replaceables", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "cus_ent_id": { + "name": "cus_ent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "from_entity_id": { + "name": "from_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "delete_next_cycle": { + "name": "delete_next_cycle", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "idx_replaceables_cus_ent_id": { + "name": "idx_replaceables_cus_ent_id", + "columns": [ + { + "expression": "cus_ent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "replaceables_cus_ent_id_fkey": { + "name": "replaceables_cus_ent_id_fkey", + "tableFrom": "replaceables", + "tableTo": "customer_entitlements", + "columnsFrom": [ + "cus_ent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.revenuecat_mappings": { + "name": "revenuecat_mappings", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "autumn_product_id": { + "name": "autumn_product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revenuecat_product_ids": { + "name": "revenuecat_product_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + } + }, + "indexes": {}, + "foreignKeys": { + "revenuecat_mappings_org_id_fkey": { + "name": "revenuecat_mappings_org_id_fkey", + "tableFrom": "revenuecat_mappings", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "revenuecat_mappings_pkey": { + "name": "revenuecat_mappings_pkey", + "columns": [ + "org_id", + "env", + "autumn_product_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reward_programs": { + "name": "reward_programs", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "internal_reward_id": { + "name": "internal_reward_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_redemptions": { + "name": "max_redemptions", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "unlimited_redemptions": { + "name": "unlimited_redemptions", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "when": { + "name": "when", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'immediately'" + }, + "product_ids": { + "name": "product_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{\"\"}'" + }, + "exclude_trial": { + "name": "exclude_trial", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "received_by": { + "name": "received_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "reward_triggers_internal_reward_id_fkey": { + "name": "reward_triggers_internal_reward_id_fkey", + "tableFrom": "reward_programs", + "tableTo": "rewards", + "columnsFrom": [ + "internal_reward_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reward_triggers_org_id_fkey": { + "name": "reward_triggers_org_id_fkey", + "tableFrom": "reward_programs", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reward_redemptions": { + "name": "reward_redemptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text COLLATE \"C\"", + "primaryKey": false, + "notNull": false + }, + "triggered": { + "name": "triggered", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "internal_reward_program_id": { + "name": "internal_reward_program_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "applied": { + "name": "applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "redeemer_applied": { + "name": "redeemer_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "referral_code_id": { + "name": "referral_code_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reward_internal_id": { + "name": "reward_internal_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "promo_code": { + "name": "promo_code", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_reward_redemptions_referral_code_id": { + "name": "idx_reward_redemptions_referral_code_id", + "columns": [ + { + "expression": "referral_code_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_reward_redemptions_reward_internal_id": { + "name": "idx_reward_redemptions_reward_internal_id", + "columns": [ + { + "expression": "reward_internal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_reward_redemptions_customer_reward": { + "name": "idx_reward_redemptions_customer_reward", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reward_internal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reward_redemptions_internal_customer_id_fkey": { + "name": "reward_redemptions_internal_customer_id_fkey", + "tableFrom": "reward_redemptions", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reward_redemptions_internal_reward_program_id_fkey": { + "name": "reward_redemptions_internal_reward_program_id_fkey", + "tableFrom": "reward_redemptions", + "tableTo": "reward_programs", + "columnsFrom": [ + "internal_reward_program_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reward_redemptions_referral_code_id_fkey": { + "name": "reward_redemptions_referral_code_id_fkey", + "tableFrom": "reward_redemptions", + "tableTo": "referral_codes", + "columnsFrom": [ + "referral_code_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rewards": { + "name": "rewards", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "discount_config": { + "name": "discount_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "free_product_config": { + "name": "free_product_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "free_product_id": { + "name": "free_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "promo_codes": { + "name": "promo_codes", + "type": "jsonb[]", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "coupons_org_id_fkey": { + "name": "coupons_org_id_fkey", + "tableFrom": "rewards", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rollovers": { + "name": "rollovers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "cus_ent_id": { + "name": "cus_ent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "balance": { + "name": "balance", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "usage": { + "name": "usage", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "entities": { + "name": "entities", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "idx_rollovers_cus_ent_id": { + "name": "idx_rollovers_cus_ent_id", + "columns": [ + { + "expression": "cus_ent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_rollovers_cus_ent_expires": { + "name": "idx_rollovers_cus_ent_expires", + "columns": [ + { + "expression": "cus_ent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "rollover_cus_ent_id_fkey": { + "name": "rollover_cus_ent_id_fkey", + "tableFrom": "rollovers", + "tableTo": "customer_entitlements", + "columnsFrom": [ + "cus_ent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.phases": { + "name": "phases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "starts_at": { + "name": "starts_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "customer_product_ids": { + "name": "customer_product_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "phases_schedule_id_fkey": { + "name": "phases_schedule_id_fkey", + "tableFrom": "phases", + "tableTo": "schedules", + "columnsFrom": [ + "schedule_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "phases_schedule_id_starts_at_key": { + "name": "phases_schedule_id_starts_at_key", + "nullsNotDistinct": false, + "columns": [ + "schedule_id", + "starts_at" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.schedules": { + "name": "schedules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_customer_id": { + "name": "internal_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_entity_id": { + "name": "internal_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "schedules_customer_scope_unique": { + "name": "schedules_customer_scope_unique", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"schedules\".\"internal_entity_id\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "schedules_entity_scope_unique": { + "name": "schedules_entity_scope_unique", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"schedules\".\"internal_entity_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_schedules_internal_customer_id": { + "name": "idx_schedules_internal_customer_id", + "columns": [ + { + "expression": "internal_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_schedules_internal_entity_id": { + "name": "idx_schedules_internal_entity_id", + "columns": [ + { + "expression": "internal_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "schedules_org_id_fkey": { + "name": "schedules_org_id_fkey", + "tableFrom": "schedules", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "schedules_internal_customer_id_fkey": { + "name": "schedules_internal_customer_id_fkey", + "tableFrom": "schedules", + "tableTo": "customers", + "columnsFrom": [ + "internal_customer_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "schedules_internal_entity_id_fkey": { + "name": "schedules_internal_entity_id_fkey", + "tableFrom": "schedules", + "tableTo": "entities", + "columnsFrom": [ + "internal_entity_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "city": { + "name": "city", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country": { + "name": "country", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.subscriptions": { + "name": "subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_id": { + "name": "stripe_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "usage_features": { + "name": "usage_features", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "current_period_start": { + "name": "current_period_start", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "current_period_end": { + "name": "current_period_end", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "subscriptions_org_id_fkey": { + "name": "subscriptions_org_id_fkey", + "tableFrom": "subscriptions", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "subscriptions_stripe_id_key": { + "name": "subscriptions_stripe_id_key", + "nullsNotDistinct": false, + "columns": [ + "stripe_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_windows": { + "name": "usage_windows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "customer_entitlement_id": { + "name": "customer_entitlement_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "feature_id": { + "name": "feature_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_feature_id": { + "name": "internal_feature_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "window_start_at": { + "name": "window_start_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "window_end_at": { + "name": "window_end_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "usage": { + "name": "usage", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "numeric", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_usage_windows_customer_entitlement_id": { + "name": "idx_usage_windows_customer_entitlement_id", + "columns": [ + { + "expression": "customer_entitlement_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_windows_cus_ent_feature_window": { + "name": "idx_usage_windows_cus_ent_feature_window", + "columns": [ + { + "expression": "customer_entitlement_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "feature_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "window_start_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_windows_customer_entitlement_id_fkey": { + "name": "usage_windows_customer_entitlement_id_fkey", + "tableFrom": "usage_windows", + "tableTo": "customer_entitlements", + "columnsFrom": [ + "customer_entitlement_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "usage_windows_internal_feature_id_fkey": { + "name": "usage_windows_internal_feature_id_fkey", + "tableFrom": "usage_windows", + "tableTo": "features", + "columnsFrom": [ + "internal_feature_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_active_at": { + "name": "last_active_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_user_name_trgm": { + "name": "idx_user_name_trgm", + "columns": [ + { + "expression": "\"name\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user\".\"name\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_user_email_trgm": { + "name": "idx_user_email_trgm", + "columns": [ + { + "expression": "\"email\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user\".\"email\" IS NOT NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_user_created_at_id": { + "name": "idx_user_created_at_id", + "columns": [ + { + "expression": "\"created_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_created_by_fkey": { + "name": "user_created_by_fkey", + "tableFrom": "user", + "tableTo": "organizations", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.vercel_resources": { + "name": "vercel_resources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "vercel_resources_installation_name_unique_idx": { + "name": "vercel_resources_installation_name_unique_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status <> 'uninstalled'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "vercel_resources_org_id_fkey": { + "name": "vercel_resources_org_id_fkey", + "tableFrom": "vercel_resources", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/shared/drizzle/meta/_journal.json b/shared/drizzle/meta/_journal.json index 546021d8a..f114f8bf1 100644 --- a/shared/drizzle/meta/_journal.json +++ b/shared/drizzle/meta/_journal.json @@ -40,8 +40,36 @@ { "idx": 5, "version": "7", - "when": 1780570563830, - "tag": "0005_legal_logan", + "when": 1780582242747, + "tag": "0005_fresh_runaways", + "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1780584591419, + "tag": "0006_sad_madrox", + "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1780655063264, + "tag": "0007_cute_ikaris", + "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1780679328640, + "tag": "0008_premium_pet_avengers", + "breakpoints": true + }, + { + "idx": 9, + "version": "7", + "when": 1780916308859, + "tag": "0009_usage_windows", "breakpoints": true } ] diff --git a/shared/models/billingModels/context/billingContext.ts b/shared/models/billingModels/context/billingContext.ts index ca0d56309..5cb392d24 100644 --- a/shared/models/billingModels/context/billingContext.ts +++ b/shared/models/billingModels/context/billingContext.ts @@ -6,10 +6,12 @@ import type { FeatureOptions, FreeTrial, Price, + ProcessorType, TrialOnEnd, } from "@autumn/shared"; import type { PaymentBehaviorIntent } from "@models/billingModels/context/paymentBehaviorIntent"; import type { TransitionConfig } from "@models/billingModels/context/transitionConfig"; +import type { DbInvoiceLineItem } from "@models/cusModels/invoiceModels/invoiceLineItemTable"; import type { EntInterval } from "@models/productModels/intervals/entitlementInterval"; import type Stripe from "stripe"; import { z } from "zod/v4"; @@ -112,9 +114,18 @@ export interface BillingContext { anchorResetRefund?: AnchorResetRefund; + storedChargeLineItems?: DbInvoiceLineItem[]; + storedRefundLineItems?: DbInvoiceLineItem[]; + refundLastPayment?: "prorated" | "full"; paymentBehaviorIntent?: PaymentBehaviorIntent; shouldFinalizeFirstInvoice?: boolean; skipCustomPaymentMethodGuard?: boolean; + + /** See `BillingContextOverride.skipExternalPSPGuard`. */ + skipExternalPSPGuard?: boolean; + + /** See `BillingContextOverride.processorTypeOverride`. */ + processorTypeOverride?: ProcessorType; } diff --git a/shared/models/billingModels/context/billingContextOverride.ts b/shared/models/billingModels/context/billingContextOverride.ts index a0d7e8914..2ab6bc3e6 100644 --- a/shared/models/billingModels/context/billingContextOverride.ts +++ b/shared/models/billingModels/context/billingContextOverride.ts @@ -7,6 +7,7 @@ import type { FeatureOptions, FullCusProduct, } from "@models/cusProductModels/cusProductModels"; +import type { ProcessorType } from "@models/genModels/genEnums"; import type { Entitlement } from "@models/productModels/entModels/entModels"; import type { Price } from "@models/productModels/priceModels/priceModels"; import type { FullProduct } from "@models/productModels/productModels"; @@ -50,6 +51,31 @@ export interface BillingContextOverride { * public API schema. */ skipCustomPaymentMethodGuard?: boolean; + + /** + * Skips fetching Stripe state (customer/subscription/schedule/discounts/PM) + * during attach setup. Used by external-PSP origin callers (e.g. RevenueCat + * webhook handlers) whose customers don't have a meaningful Stripe presence. + * Independent from `params.no_billing_changes`, which only blocks writes. + */ + skipBillingFetching?: boolean; + + /** + * Skips the external-PSP guard (`handleExternalPSPErrors`) and the + * "paid current product but no Stripe sub linked" guard. Used by callers + * that ARE the external origin platform (e.g. RevenueCat webhook handlers) + * and so must be allowed to attach onto their own existing non-Stripe + * cus_products. Not exposed via any public API schema. + */ + skipExternalPSPGuard?: boolean; + + /** + * Tags the newly-inserted customer_product's `processor.type` field. Used + * by external-PSP origin callers to mark the cus_product as managed by a + * non-Stripe processor (e.g. RevenueCat). Defaults to leaving `processor` + * unset, which `cusProductToProcessorType` resolves to Stripe. + */ + processorTypeOverride?: ProcessorType; } export interface UpdateSubscriptionBillingContextOverride diff --git a/shared/models/billingModels/customerProduct/initFullCustomerProductContext.ts b/shared/models/billingModels/customerProduct/initFullCustomerProductContext.ts index fb8c6cd36..52b559186 100644 --- a/shared/models/billingModels/customerProduct/initFullCustomerProductContext.ts +++ b/shared/models/billingModels/customerProduct/initFullCustomerProductContext.ts @@ -15,6 +15,7 @@ import type { FeatureOptions, FullCusProduct, } from "../../cusProductModels/cusProductModels"; +import type { ProcessorType } from "../../genModels/genEnums"; import type { FullProduct } from "../../productModels/productModels"; export interface ExistingUsagesConfig { @@ -93,4 +94,12 @@ export interface InitFullCustomerProductOptions { previousCustomerProductId?: string; onTrialEnd?: TrialOnEnd; + + /** + * Tags the customer_product's `processor.type` field. When omitted, the + * processor column is left unwritten (defaults to null in the DB, which + * `cusProductToProcessorType` resolves to Stripe). Used by non-Stripe + * origin flows (e.g. RevenueCat) to mark cus_products explicitly. + */ + processorType?: ProcessorType; } diff --git a/shared/models/chatModels/chatTable.ts b/shared/models/chatModels/chatTable.ts index 3f3e22b1c..a5c261478 100644 --- a/shared/models/chatModels/chatTable.ts +++ b/shared/models/chatModels/chatTable.ts @@ -81,5 +81,42 @@ export const chatApprovals = pgTable( ], ); +export const chatOAuthCredentials = pgTable( + "chat_oauth_credentials", + { + id: text().primaryKey().notNull(), + chat_installation_id: text("chat_installation_id").notNull(), + org_id: text("org_id").notNull(), + env: text("env").$type().notNull(), + oauth_client_id: text("oauth_client_id").notNull(), + oauth_consent_id: text("oauth_consent_id"), + access_token: text("access_token").notNull(), + refresh_token: text("refresh_token").notNull(), + access_token_expires_at: numeric("access_token_expires_at", { + mode: "number", + }).notNull(), + scopes: jsonb().$type().notNull(), + created_at: numeric({ mode: "number" }).notNull().default(sqlNow), + updated_at: numeric({ mode: "number" }).notNull().default(sqlNow), + }, + (table) => [ + foreignKey({ + columns: [table.chat_installation_id], + foreignColumns: [chatInstallations.id], + name: "chat_oauth_credentials_installation_id_fkey", + }).onDelete("cascade"), + foreignKey({ + columns: [table.org_id], + foreignColumns: [organizations.id], + name: "chat_oauth_credentials_org_id_fkey", + }).onDelete("cascade"), + unique("chat_oauth_credentials_installation_env_key").on( + table.chat_installation_id, + table.env, + ), + ], +); + export type ChatInstallation = typeof chatInstallations.$inferSelect; export type ChatApproval = typeof chatApprovals.$inferSelect; +export type ChatOAuthCredential = typeof chatOAuthCredentials.$inferSelect; diff --git a/shared/models/cusModels/invoiceModels/invoiceLineItemTable.ts b/shared/models/cusModels/invoiceModels/invoiceLineItemTable.ts index 929aa1f14..80647fbb6 100644 --- a/shared/models/cusModels/invoiceModels/invoiceLineItemTable.ts +++ b/shared/models/cusModels/invoiceModels/invoiceLineItemTable.ts @@ -2,6 +2,7 @@ import type { InferInsertModel, InferSelectModel } from "drizzle-orm"; import { boolean, foreignKey, + index, jsonb, numeric, pgTable, @@ -82,6 +83,11 @@ export const invoiceLineItems = pgTable( }).onDelete("cascade"), // Unique partial index on stripe_id for upsert support unique("invoice_line_items_stripe_id_unique").on(table.stripe_id), + // GIN index for jsonb containment / array-overlap lookups by customer product + index("idx_invoice_line_items_customer_product_ids").using( + "gin", + table.customer_product_ids, + ), ], ); diff --git a/shared/models/genModels/processorSchemas.ts b/shared/models/genModels/processorSchemas.ts index 47ca55539..b7a6dc72f 100644 --- a/shared/models/genModels/processorSchemas.ts +++ b/shared/models/genModels/processorSchemas.ts @@ -91,17 +91,28 @@ export const UpsertVercelProcessorConfigSchema = z.object({ marketplace_mode: z.enum(VercelMarketplaceMode).optional(), }); +export const RevenueCatOAuthConfigSchema = z.object({ + access_token: z.string(), + refresh_token: z.string(), + expires_at: z.number(), + scope: z.string().optional(), + project_id: z.string().optional(), + connected_at: z.number().optional(), +}); + /** * Organization-level RevenueCat processor configuration * Stores API key, project ID, and webhook secret */ export const RevenueCatProcessorConfigSchema = z.object({ - api_key: z.string(), + api_key: z.string().optional(), sandbox_api_key: z.string().optional(), project_id: z.string().optional(), sandbox_project_id: z.string().optional(), - webhook_secret: z.string(), + webhook_secret: z.string().optional(), sandbox_webhook_secret: z.string().optional(), + oauth: RevenueCatOAuthConfigSchema.optional(), + sandbox_oauth: RevenueCatOAuthConfigSchema.optional(), }); export const UpsertRevenueCatProcessorConfigSchema = z.object({ @@ -132,6 +143,7 @@ export type VercelProcessorConfig = z.infer; export type UpsertVercelProcessorConfig = z.infer< typeof UpsertVercelProcessorConfigSchema >; +export type RevenueCatOAuthConfig = z.infer; export type RevenueCatProcessorConfig = z.infer< typeof RevenueCatProcessorConfigSchema >; diff --git a/shared/utils/billingUtils/index.ts b/shared/utils/billingUtils/index.ts index 09c2b2234..c9e856d0e 100644 --- a/shared/utils/billingUtils/index.ts +++ b/shared/utils/billingUtils/index.ts @@ -7,6 +7,7 @@ export * from "./intervalUtils/intervalArithmetic"; // Invoicing utils export * from "./invoicingUtils/backdateUtils/applyBackdatedLineItemAmount.js"; +export * from "./invoicingUtils/billingConstants.js"; export * from "./invoicingUtils/filterUnchangedPricesFromLineItems.js"; export * from "./invoicingUtils/lineItemBuilders/buildLineItem.js"; export * from "./invoicingUtils/lineItemBuilders/fixedPriceToLineItem.js"; diff --git a/shared/utils/billingUtils/invoicingUtils/billingConstants.ts b/shared/utils/billingUtils/invoicingUtils/billingConstants.ts new file mode 100644 index 000000000..03a33a51c --- /dev/null +++ b/shared/utils/billingUtils/invoicingUtils/billingConstants.ts @@ -0,0 +1 @@ +export const BILLING_AMOUNT_EPSILON = 0.01; diff --git a/shared/utils/billingUtils/invoicingUtils/filterUnchangedPricesFromLineItems.ts b/shared/utils/billingUtils/invoicingUtils/filterUnchangedPricesFromLineItems.ts index 6ee8317b1..cb63d2bed 100644 --- a/shared/utils/billingUtils/invoicingUtils/filterUnchangedPricesFromLineItems.ts +++ b/shared/utils/billingUtils/invoicingUtils/filterUnchangedPricesFromLineItems.ts @@ -1,4 +1,5 @@ import type { LineItem } from "@models/billingModels/lineItem/lineItem"; +import { BILLING_AMOUNT_EPSILON } from "./billingConstants"; /** * Filters out line item pairs where a refund and charge item have the same price ID @@ -32,10 +33,9 @@ export const filterUnchangedPricesFromLineItems = ({ if (matchingChargeIndex !== -1) { const matchingChargeItem = chargeItems[matchingChargeIndex]; - const total = refundItem.amount + matchingChargeItem.amount; + const netAmount = Math.abs(refundItem.amount + matchingChargeItem.amount); - if (total === 0) { - // Amounts cancel out - mark charge item as matched (both will be removed) + if (netAmount < BILLING_AMOUNT_EPSILON) { matchedChargeIndices.add(matchingChargeIndex); continue; } diff --git a/shared/utils/cusProductUtils/getCusProductFromCustomer.ts b/shared/utils/cusProductUtils/getCusProductFromCustomer.ts index 8e6880d03..2bb4c26a2 100644 --- a/shared/utils/cusProductUtils/getCusProductFromCustomer.ts +++ b/shared/utils/cusProductUtils/getCusProductFromCustomer.ts @@ -167,6 +167,14 @@ export const getTargetSubscriptionScheduleCusProduct = ({ return hasSubscriptionSchedule; }); + if (cusProductId) { + const targetCusProduct = cusProducts.find((cp) => cp.id === cusProductId); + const targetExists = fullCus.customer_products.some( + (cp) => cp.id === cusProductId, + ); + if (targetExists && !targetCusProduct) return undefined; + } + // Sort by merge order: // 1. Entity match (highest priority) // 2. Main product (add-ons lowest priority) diff --git a/shared/utils/featureUtils/index.ts b/shared/utils/featureUtils/index.ts index 4d47d8777..f84b715e1 100644 --- a/shared/utils/featureUtils/index.ts +++ b/shared/utils/featureUtils/index.ts @@ -7,6 +7,7 @@ export * from "./apiFeatureToDbFeature"; export * from "./convertFeatureUtils"; export * from "./creditSystemUtils"; export * from "./findFeatureUtils"; +export * from "./sortFeatures"; export const featureUtils = { isConsumable: isConsumableFeature, diff --git a/shared/utils/featureUtils/sortFeatures.ts b/shared/utils/featureUtils/sortFeatures.ts new file mode 100644 index 000000000..6e978a611 --- /dev/null +++ b/shared/utils/featureUtils/sortFeatures.ts @@ -0,0 +1,13 @@ +import type { Feature } from "../../models/featureModels/featureModels.js"; + +export const sortFeatures = ({ features }: { features?: Feature[] }) => { + if (!features) return features; + + features.sort((a, b) => { + if (a.archived && !b.archived) return 1; + if (!a.archived && b.archived) return -1; + return 0; + }); + + return features; +}; diff --git a/shared/utils/scopeDefinitions.ts b/shared/utils/scopeDefinitions.ts index 6aac7915f..1f9b50d32 100644 --- a/shared/utils/scopeDefinitions.ts +++ b/shared/utils/scopeDefinitions.ts @@ -675,6 +675,17 @@ function requirementMentions( return needles.some((n) => hay.includes(n as ScopeString)); } +/** + * Rewrite a legacy CRUDL requirement scope to its modern R/W equivalent so it + * can be matched against an expanded grant (which only ever holds modern + meta + * scopes). Modern and meta scopes pass through unchanged. Deliberately applies + * only LEGACY_SCOPE_ALIASES, not expandScopes, so a required `admin`/`owner` + * is never blown up into "every modern scope". + */ +function normaliseRequiredScope(scope: ScopeString): ScopeString { + return (LEGACY_SCOPE_ALIASES[scope] ?? scope) as ScopeString; +} + /** * Check whether a set of granted scopes satisfies a route's requirement. * @@ -705,7 +716,9 @@ export function checkScopes( // Shorthand: a plain array means ALL required. if (Array.isArray(required)) { - const missing = required.filter((s) => !expanded.has(s)); + const missing = required + .map(normaliseRequiredScope) + .filter((s) => !expanded.has(s)); return { allowed: missing.length === 0, missing }; } @@ -714,8 +727,8 @@ export function checkScopes( ANY?: readonly ScopeString[]; }; - const allList = req.ALL ?? []; - const anyList = req.ANY ?? []; + const allList = (req.ALL ?? []).map(normaliseRequiredScope); + const anyList = (req.ANY ?? []).map(normaliseRequiredScope); const missingAll = allList.filter((s) => !expanded.has(s)); const anySatisfied = diff --git a/vite/src/components/forms/create-schedule/components/CreateScheduleSheetContent.tsx b/vite/src/components/forms/create-schedule/components/CreateScheduleSheetContent.tsx index dc010c87c..2340fb4e5 100644 --- a/vite/src/components/forms/create-schedule/components/CreateScheduleSheetContent.tsx +++ b/vite/src/components/forms/create-schedule/components/CreateScheduleSheetContent.tsx @@ -29,7 +29,7 @@ export function CreateScheduleSheetContent() { const { form, formValues, entityId, handleAddPhase, error, onScopeChange } = useCreateScheduleFormContext(); const { closeSheet, setSheet } = useSheetStore(); - const hasSchedule = useHasSchedule(); + const hasSchedule = useHasSchedule({ entityId }); const { customer } = useCusQuery(); const entities = (customer as FullCustomer | null)?.entities ?? []; @@ -136,10 +136,10 @@ function getConfirmLabel({ } export function CreateScheduleReviewContent() { - const { handleSubmit, isPending, isPreviewLoading, preview, error } = + const { handleSubmit, isPending, isPreviewLoading, preview, error, entityId } = useCreateScheduleFormContext(); const { setSheet } = useSheetStore(); - const hasSchedule = useHasSchedule(); + const hasSchedule = useHasSchedule({ entityId }); const confirmLabel = getConfirmLabel({ preview }); const isZeroAmount = preview && preview.total <= 0; diff --git a/vite/src/components/forms/create-schedule/components/ScheduledPlanGuard.tsx b/vite/src/components/forms/create-schedule/components/ScheduledPlanGuard.tsx index fa9267047..a4c1dd4d2 100644 --- a/vite/src/components/forms/create-schedule/components/ScheduledPlanGuard.tsx +++ b/vite/src/components/forms/create-schedule/components/ScheduledPlanGuard.tsx @@ -5,8 +5,14 @@ import { Button } from "@/components/v2/buttons/Button"; import { useSheetStore } from "@/hooks/stores/useSheetStore"; import { useHasSchedule } from "../hooks/useHasSchedule"; -export function ScheduledPlanGuard({ children }: { children: ReactNode }) { - const hasSchedule = useHasSchedule(); +export function ScheduledPlanGuard({ + children, + entityId, +}: { + children: ReactNode; + entityId?: string | null; +}) { + const hasSchedule = useHasSchedule({ entityId }); const { setSheet } = useSheetStore(); if (!hasSchedule) return <>{children}; diff --git a/vite/src/components/forms/create-schedule/hooks/useHasSchedule.ts b/vite/src/components/forms/create-schedule/hooks/useHasSchedule.ts index c4c204d79..6fb79d6c9 100644 --- a/vite/src/components/forms/create-schedule/hooks/useHasSchedule.ts +++ b/vite/src/components/forms/create-schedule/hooks/useHasSchedule.ts @@ -1,6 +1,14 @@ import { useCusQuery } from "@/views/customers/customer/hooks/useCusQuery"; -export function useHasSchedule() { +export function useHasSchedule({ + entityId, +}: { entityId?: string | null } = {}) { const { schedule, customer } = useCusQuery({ schedule: true }); - return !!schedule || !!customer?.entities?.some((entity) => entity.schedule); + if (entityId) { + const entity = customer?.entities?.find( + (e) => e.id === entityId || e.internal_id === entityId, + ); + return !!entity?.schedule; + } + return !!schedule; } diff --git a/vite/src/components/v2/inline-custom-plan-editor/InlinePlanEditor.tsx b/vite/src/components/v2/inline-custom-plan-editor/InlinePlanEditor.tsx index cea22b142..58c599a7c 100644 --- a/vite/src/components/v2/inline-custom-plan-editor/InlinePlanEditor.tsx +++ b/vite/src/components/v2/inline-custom-plan-editor/InlinePlanEditor.tsx @@ -111,9 +111,10 @@ function InlinePlanEditorContent({ )} - + + diff --git a/vite/src/components/v2/sheets/InlineSheetPanel.tsx b/vite/src/components/v2/sheets/InlineSheetPanel.tsx new file mode 100644 index 000000000..22eb52ed9 --- /dev/null +++ b/vite/src/components/v2/sheets/InlineSheetPanel.tsx @@ -0,0 +1,73 @@ +import { AnimatePresence, motion } from "motion/react"; +import type { ReactNode } from "react"; +import { SheetContainer } from "@/components/v2/sheets/InlineSheet"; +import { SheetCloseButton } from "@/components/v2/sheets/SheetCloseButton"; +import { useIsMobile } from "@/hooks/useIsMobile"; +import { cn } from "@/lib/utils"; + +const SHEET_PANEL_WIDTH = "28rem"; +const SHEET_PANEL_Z_INDEX = 100; +const SHEET_PANEL_TRANSITION = { + duration: 0.3, + ease: [0.32, 0.72, 0, 1] as const, +} as const; + +interface InlineSheetPanelProps { + isOpen: boolean; + onClose: () => void; + children: ReactNode; + className?: string; + width?: string; + zIndex?: number; + transition?: { + duration: number; + ease: readonly [number, number, number, number]; + }; +} + +/** + * Shared right-hand sheet panel used across the app's inline sheet orchestrators. + * Renders a slide-in, rounded, inset panel that floats over the (separately + * rendered) backdrop so the surrounding area reads as dimmed on every side. + */ +export function InlineSheetPanel({ + isOpen, + onClose, + children, + className, + width = SHEET_PANEL_WIDTH, + zIndex = SHEET_PANEL_Z_INDEX, + transition = SHEET_PANEL_TRANSITION, +}: InlineSheetPanelProps) { + const isMobile = useIsMobile(); + return ( + + {isOpen && ( + + + + {children} + + + )} + + ); +} diff --git a/vite/src/components/v2/sheets/Sheet.tsx b/vite/src/components/v2/sheets/Sheet.tsx index 6784a688e..473d4c129 100644 --- a/vite/src/components/v2/sheets/Sheet.tsx +++ b/vite/src/components/v2/sheets/Sheet.tsx @@ -69,10 +69,7 @@ function SheetPortal({ ); } -function SheetOverlay({ - className, - ...props -}: SheetPrimitive.Backdrop.Props) { +function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) { return ( ) { ); } -function SheetTitle({ - className, - ...props -}: SheetPrimitive.Title.Props) { +function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) { return ( void; + zIndex?: number; +} + +/** + * Full-viewport dimming backdrop for inline sheets, portaled to the document body + * so it covers everything behind the floating sheet panel uniformly. + */ +export function SheetBackdrop({ + isOpen, + onClose, + zIndex = SHEET_BACKDROP_Z_INDEX, +}: SheetBackdropProps) { + return createPortal( + + {isOpen && ( + + )} + , + document.body, + ); +} diff --git a/vite/src/hooks/queries/revcat/useRCPreflight.tsx b/vite/src/hooks/queries/revcat/useRCPreflight.tsx new file mode 100644 index 000000000..c7c60b393 --- /dev/null +++ b/vite/src/hooks/queries/revcat/useRCPreflight.tsx @@ -0,0 +1,45 @@ +import { useQuery } from "@tanstack/react-query"; +import { useQueryKeyFactory } from "@/hooks/common/useQueryKeyFactory"; +import { useAxiosInstance } from "@/services/useAxiosInstance"; + +export interface RCPreflightPrice { + amount_micros: number; + currency: string; +} + +export interface RCPreflightItem { + plan_id: string; + autumn_name: string; + autumn_price: RCPreflightPrice | null; + rc_exists: boolean; + rc_name: string | null; + rc_price: RCPreflightPrice | null; +} + +export const useRCPreflight = ({ enabled = true }: { enabled?: boolean } = {}) => { + const axiosInstance = useAxiosInstance(); + const buildKey = useQueryKeyFactory(); + + const fetcher = async () => { + try { + const { data }: { data: { items: RCPreflightItem[] } } = + await axiosInstance.post("/v1/organization/revenuecat/preflight"); + return data.items || []; + } catch (_error) { + return []; + } + }; + + const { + data: items = [], + isLoading, + error, + refetch, + } = useQuery({ + queryKey: buildKey(["revenuecat-preflight"]), + queryFn: fetcher, + enabled, + }); + + return { items, isLoading, error, refetch }; +}; diff --git a/vite/src/hooks/queries/revcat/useRCProjects.tsx b/vite/src/hooks/queries/revcat/useRCProjects.tsx new file mode 100644 index 000000000..9accebb3a --- /dev/null +++ b/vite/src/hooks/queries/revcat/useRCProjects.tsx @@ -0,0 +1,63 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useQueryKeyFactory } from "@/hooks/common/useQueryKeyFactory"; +import { useAxiosInstance } from "@/services/useAxiosInstance"; + +interface RevenueCatProject { + id: string; + name: string; +} + +interface RevenueCatProjectsResponse { + projects: RevenueCatProject[]; +} + +export const useRCProjects = ({ enabled = true }: { enabled?: boolean } = {}) => { + const axiosInstance = useAxiosInstance(); + const queryClient = useQueryClient(); + const buildKey = useQueryKeyFactory(); + + const queryKey = buildKey(["revenuecat-projects"]); + + const fetcher = async () => { + try { + const { data }: { data: RevenueCatProjectsResponse } = + await axiosInstance.get("/v1/organization/revenuecat/projects"); + return data.projects || []; + } catch (_error) { + return []; + } + }; + + const { + data: projects = [], + isLoading, + error, + refetch, + } = useQuery({ + queryKey, + queryFn: fetcher, + enabled, + }); + + const createMutation = useMutation({ + mutationFn: async (name: string) => { + const { data } = await axiosInstance.post( + "/v1/organization/revenuecat/projects", + { name }, + ); + return data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey }); + }, + }); + + return { + projects, + isLoading, + error, + refetch, + createProject: createMutation.mutateAsync, + isCreating: createMutation.isPending, + }; +}; diff --git a/vite/src/hooks/queries/revcat/useRCSync.tsx b/vite/src/hooks/queries/revcat/useRCSync.tsx new file mode 100644 index 000000000..167f227d5 --- /dev/null +++ b/vite/src/hooks/queries/revcat/useRCSync.tsx @@ -0,0 +1,42 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useQueryKeyFactory } from "@/hooks/common/useQueryKeyFactory"; +import { useAxiosInstance } from "@/services/useAxiosInstance"; + +export interface RCSyncAppResult { + app_id: string; + app_type: string; + product: "created" | "updated" | "exists"; + store_push?: "pushed" | "failed" | "skipped"; + message?: string; +} + +export interface RCSyncResult { + plan_id: string; + status: "synced" | "skipped" | "error"; + store_identifier?: string; + apps?: RCSyncAppResult[]; + message?: string; +} + +export const useRCSync = () => { + const axiosInstance = useAxiosInstance(); + const queryClient = useQueryClient(); + const buildKey = useQueryKeyFactory(); + + const mutation = useMutation({ + mutationFn: async (productIds: string[]) => { + const { data } = await axiosInstance.post<{ results: RCSyncResult[] }>( + "/v1/organization/revenuecat/sync", + { product_ids: productIds }, + ); + return data.results; + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: buildKey(["revenuecat-mappings"]), + }); + }, + }); + + return { sync: mutation.mutateAsync, isSyncing: mutation.isPending }; +}; diff --git a/vite/src/hooks/queries/revcat/useRCWebhook.tsx b/vite/src/hooks/queries/revcat/useRCWebhook.tsx new file mode 100644 index 000000000..720e84a64 --- /dev/null +++ b/vite/src/hooks/queries/revcat/useRCWebhook.tsx @@ -0,0 +1,61 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { useQueryKeyFactory } from "@/hooks/common/useQueryKeyFactory"; +import { useAxiosInstance } from "@/services/useAxiosInstance"; +import { getBackendErr } from "@/utils/genUtils"; + +export type RCWebhookStatus = "registered" | "not_registered" | "unknown"; + +interface RCWebhookResponse { + status: RCWebhookStatus; + url: string | null; + secret: string | null; +} + +export const useRCWebhook = () => { + const axiosInstance = useAxiosInstance(); + const queryClient = useQueryClient(); + const buildKey = useQueryKeyFactory(); + const queryKey = buildKey(["revenuecat-webhook"]); + + const { data, isLoading } = useQuery({ + queryKey, + queryFn: async () => { + const { data } = await axiosInstance.get( + "/v1/organization/revenuecat/webhook", + ); + return data; + }, + }); + + const registerMutation = useMutation({ + mutationFn: async () => { + const { data } = await axiosInstance.post( + "/v1/organization/revenuecat/webhook", + ); + return data; + }, + onSuccess: (result) => { + queryClient.invalidateQueries({ queryKey }); + if (result.status === "registered") { + toast.success("Webhook registered with RevenueCat"); + } else { + toast.warning("Couldn't register automatically — set it up manually below"); + } + }, + onError: (error) => { + toast.error( + getBackendErr(error, "Couldn't register automatically — set it up manually below"), + ); + }, + }); + + return { + status: data?.status ?? "unknown", + url: data?.url ?? null, + secret: data?.secret ?? null, + isLoading, + register: registerMutation.mutateAsync, + isRegistering: registerMutation.isPending, + }; +}; diff --git a/vite/src/hooks/queries/revcat/useRevenueCatQuery.tsx b/vite/src/hooks/queries/revcat/useRevenueCatQuery.tsx index 56eee55eb..3abbff731 100644 --- a/vite/src/hooks/queries/revcat/useRevenueCatQuery.tsx +++ b/vite/src/hooks/queries/revcat/useRevenueCatQuery.tsx @@ -4,6 +4,8 @@ import { useAxiosInstance } from "@/services/useAxiosInstance"; interface RevenueCatConfig { connected: boolean; + connection?: "oauth" | "api_key" | "none"; + oauth_connected?: boolean; api_key?: string; sandbox_api_key?: string; project_id?: string; diff --git a/vite/src/services/customers/CusService.tsx b/vite/src/services/customers/CusService.tsx index 075c459a8..2f13b709f 100644 --- a/vite/src/services/customers/CusService.tsx +++ b/vite/src/services/customers/CusService.tsx @@ -120,7 +120,7 @@ export class CusService { axios: AxiosInstance; customer_id: string; }): Promise<{ success: boolean }> { - const { data } = await axios.post(`/customers/clear_cache`, { + const { data } = await axios.post(`/v1/customers/clear_cache`, { customer_id, }); return data; diff --git a/vite/src/views/admin/oauth/OAuthClientsView.tsx b/vite/src/views/admin/oauth/OAuthClientsView.tsx index ef344b182..6b23f234d 100644 --- a/vite/src/views/admin/oauth/OAuthClientsView.tsx +++ b/vite/src/views/admin/oauth/OAuthClientsView.tsx @@ -1,9 +1,10 @@ import { AppEnv } from "@autumn/shared"; -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { ArrowLeft, Globe, Key, + MessageSquare, Pencil, Plus, RefreshCw, @@ -62,6 +63,25 @@ export const OAuthClientsView = () => { }); const clients: OAuthClient[] = data?.clients || []; + const upsertSlackMcpMutation = useMutation({ + mutationFn: async () => { + const { data } = await axiosInstance.post( + "/admin/oauth-clients/slack-mcp", + ); + return data; + }, + onSuccess: (client) => { + toast.success( + `Slack MCP OAuth client ready: ${client.client_id ?? "autumn_mcp_slack"}`, + ); + refetch(); + }, + onError: (error) => { + toast.error( + getBackendErr(error, "Failed to create Slack MCP OAuth client"), + ); + }, + }); const handleDeleteClient = async (client_id: string) => { if (!confirm("Are you sure you want to delete this OAuth client?")) { @@ -157,6 +177,15 @@ export const OAuthClientsView = () => { > Refresh + } + onClick={() => upsertSlackMcpMutation.mutate()} + disabled={upsertSlackMcpMutation.isPending} + > + Add Slack MCP + { const firstLetter = org?.name?.charAt(0).toUpperCase() || "A"; return ( -
+
{org.logo ? ( {org.name} ) : ( - + {firstLetter} )} @@ -102,11 +106,43 @@ const OrgLogo = ({ org }: { org: { name: string; logo?: string | null } }) => { ); }; +const getConsentRedirectUrl = (data: unknown) => { + if (!data || typeof data !== "object") return null; + const response = data as Record; + + return [response.url, response.uri, response.redirectTo].find( + (value): value is string => typeof value === "string" && value.length > 0, + ); +}; + +const isExternalAppRedirect = (redirectUrl: string) => { + if (!URL.canParse(redirectUrl)) return false; + const protocol = new URL(redirectUrl).protocol; + return protocol !== "http:" && protocol !== "https:"; +}; + +const openConsentRedirect = ({ + onExternalRedirectFallback, + redirectUrl, +}: { + onExternalRedirectFallback: () => void; + redirectUrl: string; +}) => { + const shouldShowFallback = isExternalAppRedirect(redirectUrl); + window.location.href = redirectUrl; + + if (shouldShowFallback) { + window.setTimeout(onExternalRedirectFallback, 1200); + } +}; + export const Consent = () => { const [searchParams] = useSearchParams(); const { data: session } = useSession(); const { data: orgs } = useListOrganizations(); const { data: activeOrganization } = authClient.useActiveOrganization(); + const errorIconMaskId = useId(); + const consentIconMaskId = useId(); const [clientInfo, setClientInfo] = useState(null); const [groupedPermissions, setGroupedPermissions] = useState< @@ -115,31 +151,19 @@ export const Consent = () => { const [jokeScope] = useState(() => getRandomJokeScope()); const [isLoading, setIsLoading] = useState(true); const [isSubmitting, setIsSubmitting] = useState(false); - const [orgDropdownOpen, setOrgDropdownOpen] = useState(false); + const [pendingRedirectUrl, setPendingRedirectUrl] = useState( + null, + ); + const [selectedEnv, setSelectedEnv] = useState(AppEnv.Live); const [switchingOrg, setSwitchingOrg] = useState(false); - const orgDropdownRef = useRef(null); const clientId = searchParams.get("client_id"); + const redirectUri = searchParams.get("redirect_uri"); const requestedScopes = searchParams.get("scope")?.split(" ") || []; // Get the current org (active or first available) const currentOrg = activeOrganization || orgs?.[0]; - // Close dropdown when clicking outside - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if ( - orgDropdownRef.current && - !orgDropdownRef.current.contains(event.target as Node) - ) { - setOrgDropdownOpen(false); - } - }; - - document.addEventListener("mousedown", handleClickOutside); - return () => document.removeEventListener("mousedown", handleClickOutside); - }, []); - const handleSwitchOrg = async (orgId: string) => { setSwitchingOrg(true); try { @@ -163,15 +187,21 @@ export const Consent = () => { try { // Fetch client name from our own endpoint - const response = await fetch( + const clientInfoUrl = new URL( `${import.meta.env.VITE_BACKEND_URL}/oauth/client/${encodeURIComponent(clientId)}`, ); + if (redirectUri) { + clientInfoUrl.searchParams.set("redirect_uri", redirectUri); + } + + const response = await fetch(clientInfoUrl.toString()); if (response.ok) { const data = await response.json(); setClientInfo({ client_id: clientId, client_name: data.name || "Unknown Application", + is_atmn: data.is_atmn === true, }); } else { console.error("Error fetching client info:", response.status); @@ -179,6 +209,7 @@ export const Consent = () => { setClientInfo({ client_id: clientId, client_name: "External Application", + is_atmn: false, }); } } catch (error) { @@ -187,6 +218,7 @@ export const Consent = () => { setClientInfo({ client_id: clientId, client_name: "External Application", + is_atmn: false, }); } @@ -197,10 +229,11 @@ export const Consent = () => { } fetchClientInfo(); - }, [clientId, requestedScopes.join(",")]); + }, [clientId, redirectUri, requestedScopes.join(",")]); const handleAuthorize = async () => { setIsSubmitting(true); + setPendingRedirectUrl(null); try { // Use the original requested scopes const grantedScopes = requestedScopes.join(" "); @@ -208,6 +241,13 @@ export const Consent = () => { const { data, error } = await authClient.oauth2.consent({ accept: true, scope: grantedScopes, + client_id: clientId, + redirect_uri: redirectUri, + env: clientInfo.is_atmn ? undefined : selectedEnv, + } as Parameters[0] & { + client_id: string | null; + redirect_uri: string | null; + env?: AppEnv; }); if (error) { @@ -216,12 +256,20 @@ export const Consent = () => { return; } - // Handle redirect - server returns { redirect: true, uri: "..." } - if (data?.uri) { - window.location.href = data.uri; - } else if (data?.redirectTo) { - window.location.href = data.redirectTo; + const redirectUrl = getConsentRedirectUrl(data); + if (redirectUrl) { + if (isExternalAppRedirect(redirectUrl)) { + setPendingRedirectUrl(redirectUrl); + } + openConsentRedirect({ + redirectUrl, + onExternalRedirectFallback: () => setIsSubmitting(false), + }); + return; } + + toast.error("Authorization failed"); + setIsSubmitting(false); } catch (error) { console.error("Authorization error:", error); toast.error("Authorization failed. Please try again."); @@ -231,6 +279,7 @@ export const Consent = () => { const handleCancel = async () => { setIsSubmitting(true); + setPendingRedirectUrl(null); try { const { data, error } = await authClient.oauth2.consent({ accept: false, @@ -242,11 +291,20 @@ export const Consent = () => { return; } - if (data?.uri) { - window.location.href = data.uri; - } else if (data?.redirectTo) { - window.location.href = data.redirectTo; + const redirectUrl = getConsentRedirectUrl(data); + if (redirectUrl) { + if (isExternalAppRedirect(redirectUrl)) { + setPendingRedirectUrl(redirectUrl); + } + openConsentRedirect({ + redirectUrl, + onExternalRedirectFallback: () => setIsSubmitting(false), + }); + return; } + + toast.error("Failed to cancel. Please close this window."); + setIsSubmitting(false); } catch (error) { console.error("Cancel error:", error); toast.error("Failed to cancel. Please close this window."); @@ -254,6 +312,11 @@ export const Consent = () => { } }; + const handleOpenPendingRedirect = () => { + if (!pendingRedirectUrl) return; + window.location.href = pendingRedirectUrl; + }; + if (isLoading) { return (
@@ -271,12 +334,27 @@ export const Consent = () => {
- - - - +

@@ -296,12 +374,27 @@ export const Consent = () => {
{/* Logo */}
- - - - +
@@ -323,70 +416,62 @@ export const Consent = () => { )}
- {/* Organization Selector */} - {currentOrg && ( -
-
-

- Organization -

-
-
- - - {/* Dropdown */} - {orgDropdownOpen && orgs && orgs.length >= 2 && ( -
- {orgs - .filter((org) => org.id !== currentOrg.id) - .map((org) => ( -
{/* Action Buttons */} + {pendingRedirectUrl && !isSubmitting && ( +

+ If {clientInfo.client_name} did not open, use the button below. +

+ )}
diff --git a/vite/src/views/auth/components/AuthBackground.tsx b/vite/src/views/auth/components/AuthBackground.tsx index ff8432cb2..c481a6e24 100644 --- a/vite/src/views/auth/components/AuthBackground.tsx +++ b/vite/src/views/auth/components/AuthBackground.tsx @@ -20,7 +20,7 @@ export function AuthBackground({ children }: AuthBackgroundProps) { aria-hidden="true" className="absolute inset-0 w-full h-full object-cover" /> -
- {!isMobile && - createPortal( - - {sheetType && !isInlineEditorOpen && ( - { - closeProductSheet(); - }} - /> - )} - , - document.body, - )} + {!isMobile && ( + + )} diff --git a/vite/src/views/developer/configure-revenuecat/ConfigureRevenueCat.tsx b/vite/src/views/developer/configure-revenuecat/ConfigureRevenueCat.tsx index 3fe0ce952..c7456dba6 100644 --- a/vite/src/views/developer/configure-revenuecat/ConfigureRevenueCat.tsx +++ b/vite/src/views/developer/configure-revenuecat/ConfigureRevenueCat.tsx @@ -1,36 +1,111 @@ -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; +import { useSearchParams } from "react-router"; import { toast } from "sonner"; import { useOrg } from "@/hooks/common/useOrg"; +import { useRCMappings } from "@/hooks/queries/revcat/useRCMappings"; +import { useRCProjects } from "@/hooks/queries/revcat/useRCProjects"; import { useRevenueCatQuery } from "@/hooks/queries/revcat/useRevenueCatQuery"; import { useAxiosInstance } from "@/services/useAxiosInstance"; import { useEnv } from "@/utils/envUtils"; +import { getBackendErr } from "@/utils/genUtils"; +import { useAdmin } from "@/views/admin/hooks/useAdmin"; import LoadingScreen from "@/views/general/LoadingScreen"; import { ApiKeyDialog } from "./components/ApiKeyDialog"; -import { ProjectIdDialog } from "./components/ProjectIdDialog"; import { RevenueCatConnectionCard } from "./components/RevenueCatConnectionCard"; +import { RevenueCatDisconnectDialog } from "./components/RevenueCatDisconnectDialog"; import { RevenueCatMappingSheet } from "./components/RevenueCatMappingSheet"; -import { RevenueCatWebhookSecret } from "./components/RevenueCatWebhookSecret"; -import { RevenueCatWebhookUrl } from "./components/RevenueCatWebhookUrl"; +import { RevenueCatProjectSheet } from "./components/RevenueCatProjectSheet"; +import { RevenueCatSyncSheet } from "./components/RevenueCatSyncSheet"; +import { RevenueCatWebhookCard } from "./components/RevenueCatWebhookCard"; export const ConfigureRevenueCat = () => { const [showApiKeyDialog, setShowApiKeyDialog] = useState(false); - const [showProjectIdDialog, setShowProjectIdDialog] = useState(false); + const [showDisconnectDialog, setShowDisconnectDialog] = useState(false); + const [disconnecting, setDisconnecting] = useState(false); + const [showProjectSheet, setShowProjectSheet] = useState(false); const [showMappingSheet, setShowMappingSheet] = useState(false); + const [showSyncSheet, setShowSyncSheet] = useState(false); const [connecting, setConnecting] = useState(false); const [apiKeyInput, setApiKeyInput] = useState(""); const [projectIdInput, setProjectIdInput] = useState(""); const { org } = useOrg(); + const { isAdmin } = useAdmin(); + const { mappings } = useRCMappings(); const { revenueCatConfig, isLoading: isLoadingRevenueCatAccount, refetch, } = useRevenueCatQuery(); + const hasMappings = mappings.some( + (m) => m.revenuecat_product_ids.length > 0, + ); const axiosInstance = useAxiosInstance(); const env = useEnv(); + const [searchParams, setSearchParams] = useSearchParams(); const dashboardUrl = "https://app.revenuecat.com/"; + useEffect(() => { + const success = searchParams.get("success"); + const error = searchParams.get("error"); + + if (success === "true") { + toast.success("Successfully connected to RevenueCat"); + void refetch(); + searchParams.delete("success"); + setSearchParams(searchParams); + } + + if (error) { + if (error === "insufficient_scope") { + toast.error( + "RevenueCat connection needs Read & Write access. Please re-authorize and grant all requested permissions.", + ); + } else if (error === "project_not_in_account") { + toast.error( + "That RevenueCat account doesn't contain your current project. Sign in with the account that owns it.", + ); + } else if (error === "products_mismatch") { + toast.error( + "Your mapped RevenueCat products weren't all found in that project. Connect the account/project you currently use.", + ); + } else if (error === "no_project_to_migrate") { + toast.error("No existing project ID to migrate. Set one first."); + } else { + toast.error( + `Failed to connect RevenueCat: ${error.replace(/_/g, " ")}`, + ); + } + searchParams.delete("error"); + searchParams.delete("missing_scopes"); + setSearchParams(searchParams); + } + }, [searchParams, setSearchParams, refetch]); + + const handleRedirectToOAuth = async () => { + try { + const { data } = await axiosInstance.get( + "/v1/organization/revenuecat/oauth_url", + ); + window.open(data.oauth_url, "_blank"); + } catch (error) { + toast.error(getBackendErr(error, "Failed to redirect to OAuth")); + } + }; + + const handleMigrateToOAuth = async () => { + try { + const { data } = await axiosInstance.get( + "/v1/organization/revenuecat/oauth_url", + { params: { migrate: "true" } }, + ); + window.open(data.oauth_url, "_blank"); + } catch (error) { + toast.error(getBackendErr(error, "Failed to start migration")); + } + }; + const handleUpdateApiKey = async () => { if (!apiKeyInput.trim()) return; @@ -43,7 +118,6 @@ export const ConfigureRevenueCat = () => { await axiosInstance.patch("/v1/organization/revenuecat", payload); - // Refetch config await refetch(); setShowApiKeyDialog(false); @@ -55,6 +129,20 @@ export const ConfigureRevenueCat = () => { } }; + const handleDisconnect = async () => { + setDisconnecting(true); + try { + await axiosInstance.post("/v1/organization/revenuecat/disconnect"); + await refetch(); + setShowDisconnectDialog(false); + toast.success("RevenueCat disconnected"); + } catch (error) { + toast.error(getBackendErr(error, "Failed to disconnect RevenueCat")); + } finally { + setDisconnecting(false); + } + }; + const handleUpdateProjectId = async () => { if (!projectIdInput.trim()) return; @@ -67,10 +155,9 @@ export const ConfigureRevenueCat = () => { await axiosInstance.patch("/v1/organization/revenuecat", payload); - // Refetch config await refetch(); - setShowProjectIdDialog(false); + setShowProjectSheet(false); setProjectIdInput(""); } catch (error) { console.error("Failed to update project ID:", error); @@ -79,11 +166,6 @@ export const ConfigureRevenueCat = () => { } }; - const currentWebhookSecret = - env === "live" - ? revenueCatConfig?.webhook_secret - : revenueCatConfig?.sandbox_webhook_secret; - const currentApiKey = env === "live" ? revenueCatConfig?.api_key @@ -94,22 +176,42 @@ export const ConfigureRevenueCat = () => { ? revenueCatConfig?.project_id : revenueCatConfig?.sandbox_project_id; + const { projects: rcProjects } = useRCProjects({ + enabled: !!currentProjectId, + }); + const currentProjectName = currentProjectId + ? rcProjects.find((project) => project.id === currentProjectId)?.name + : undefined; + + const oauthConnected = revenueCatConfig?.oauth_connected ?? false; + const connection = revenueCatConfig?.connection ?? "none"; + const statusDescription = revenueCatConfig?.connected - ? "Your RevenueCat account is connected." - : "Connect your RevenueCat account to start tracking subscriptions."; + ? oauthConnected + ? "Your RevenueCat account is connected via OAuth." + : "Your RevenueCat account is connected." + : oauthConnected + ? "RevenueCat OAuth is connected. Add a project ID to finish setup." + : "Connect your RevenueCat account to start tracking subscriptions."; + + const hasCredentials = oauthConnected || !!currentApiKey; const handleApiKeyClick = useCallback(() => setShowApiKeyDialog(true), []); - const handleProjectIdClick = useCallback( - () => setShowProjectIdDialog(true), - [], - ); + const handleProjectIdClick = useCallback(() => { + setProjectIdInput(currentProjectId ?? ""); + setShowProjectSheet(true); + }, [currentProjectId]); const handleMapProductsClick = useCallback(() => { - if (!currentApiKey) { - toast.error("You need to link your RevenueCat API Key first"); + if (!hasCredentials) { + toast.error("Connect RevenueCat via OAuth or add an API key first"); + return; + } + if (!currentProjectId) { + toast.error("You need to add your RevenueCat project ID first"); return; } setShowMappingSheet(true); - }, [currentApiKey]); + }, [hasCredentials, currentProjectId]); if (isLoadingRevenueCatAccount) { return ; @@ -123,19 +225,23 @@ export const ConfigureRevenueCat = () => { statusDescription={statusDescription} dashboardUrl={dashboardUrl} currentApiKey={currentApiKey} - currentProjectId={currentProjectId} + connection={connection} + oauthConnected={oauthConnected} env={env} + isAdmin={isAdmin} + onOAuthClick={handleRedirectToOAuth} + onMigrateClick={handleMigrateToOAuth} onApiKeyClick={handleApiKeyClick} + onDisconnectClick={() => setShowDisconnectDialog(true)} onProjectIdClick={handleProjectIdClick} onMapProductsClick={handleMapProductsClick} + onSyncClick={() => setShowSyncSheet(true)} + currentProjectId={currentProjectId} + currentProjectName={currentProjectName} + hasMappings={hasMappings} /> - - - +

{ isLoading={connecting} /> - + + @@ -164,6 +278,11 @@ export const ConfigureRevenueCat = () => { open={showMappingSheet} onOpenChange={setShowMappingSheet} /> + +
); }; diff --git a/vite/src/views/developer/configure-revenuecat/components/ProjectIdDialog.tsx b/vite/src/views/developer/configure-revenuecat/components/ProjectIdDialog.tsx deleted file mode 100644 index db2b000d3..000000000 --- a/vite/src/views/developer/configure-revenuecat/components/ProjectIdDialog.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { Button } from "@/components/v2/buttons/Button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "@/components/v2/dialogs/Dialog"; -import { FormLabel } from "@/components/v2/form/FormLabel"; -import { Input } from "@/components/v2/inputs/Input"; - -interface ProjectIdDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - env: string; - currentProjectId?: string; - projectIdInput: string; - onProjectIdInputChange: (value: string) => void; - onSave: () => void; - isLoading: boolean; -} - -export const ProjectIdDialog = ({ - open, - onOpenChange, - env, - currentProjectId, - projectIdInput, - onProjectIdInputChange, - onSave, - isLoading, -}: ProjectIdDialogProps) => { - return ( - - - - - {currentProjectId ? "Update" : "Add"}{" "} - {env === "live" ? "Project ID" : "Sandbox Project ID"} - - - Enter your RevenueCat {env === "live" ? "" : "sandbox "}project ID. - You can find this in your RevenueCat dashboard. - - -
-
- - - {env === "live" ? "Project ID" : "Sandbox Project ID"} - - - onProjectIdInputChange(e.target.value)} - placeholder="Enter project ID..." - /> -
-
- - -
-
-
-
- ); -}; diff --git a/vite/src/views/developer/configure-revenuecat/components/RevenueCatConnectionCard.tsx b/vite/src/views/developer/configure-revenuecat/components/RevenueCatConnectionCard.tsx index 100b1f245..853d17cc7 100644 --- a/vite/src/views/developer/configure-revenuecat/components/RevenueCatConnectionCard.tsx +++ b/vite/src/views/developer/configure-revenuecat/components/RevenueCatConnectionCard.tsx @@ -14,11 +14,20 @@ interface RevenueCatConnectionCardProps { statusDescription: string; dashboardUrl: string; currentApiKey?: string; + connection?: "oauth" | "api_key" | "none"; + oauthConnected?: boolean; env: string; + isAdmin?: boolean; + onOAuthClick: () => void; + onMigrateClick: () => void; onApiKeyClick: () => void; + onDisconnectClick: () => void; onProjectIdClick: () => void; onMapProductsClick: () => void; + onSyncClick: () => void; currentProjectId?: string; + currentProjectName?: string; + hasMappings?: boolean; } export const RevenueCatConnectionCard = ({ @@ -26,12 +35,29 @@ export const RevenueCatConnectionCard = ({ statusDescription, dashboardUrl, currentApiKey, + connection, + oauthConnected, env, + isAdmin, + onOAuthClick, + onMigrateClick, onApiKeyClick, + onDisconnectClick, onProjectIdClick, onMapProductsClick, + onSyncClick, currentProjectId, + currentProjectName, + hasMappings, }: RevenueCatConnectionCardProps) => { + // Don't offer OAuth to legacy API-key orgs — they can't run both flows. + const showOAuthConnect = connection !== "oauth" && !currentApiKey; + // API-key auth is legacy: surface it for orgs that already have a key. + const hasLegacyApiKey = connection !== "oauth" && !!currentApiKey; + // Admins can always connect via secret key (escape hatch) — but never for OAuth orgs, + // which disconnect instead. + const showApiKeyActions = (isAdmin || hasLegacyApiKey) && !oauthConnected; + return ( @@ -64,7 +90,15 @@ export const RevenueCatConnectionCard = ({ )} - {currentApiKey && ( + {oauthConnected && ( +
+ + Connected via OAuth ({env}) + +
+ )} + {/* Once on OAuth the legacy api key is dead — don't surface it. */} + {!oauthConnected && currentApiKey && (
Current API key:{" "} @@ -77,23 +111,52 @@ export const RevenueCatConnectionCard = ({ {currentProjectId && (
- Current project ID:{" "} + Current project:{" "} + {currentProjectName && {currentProjectName} } {currentProjectId}
)} -
- +
+ {showOAuthConnect && ( + + )} + {showApiKeyActions && ( + + )} + {oauthConnected && ( + + )} + {/* Legacy api-key orgs can migrate to OAuth by signing in. */} + {hasLegacyApiKey && ( + + )} - + {/* Push-flow (OAuth) orgs sync products from Autumn — no manual mapping. */} + {oauthConnected ? ( + + ) : ( + + )}
diff --git a/vite/src/views/developer/configure-revenuecat/components/RevenueCatDisconnectDialog.tsx b/vite/src/views/developer/configure-revenuecat/components/RevenueCatDisconnectDialog.tsx new file mode 100644 index 000000000..5ebc5cc6b --- /dev/null +++ b/vite/src/views/developer/configure-revenuecat/components/RevenueCatDisconnectDialog.tsx @@ -0,0 +1,47 @@ +import { Button } from "@/components/v2/buttons/Button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/v2/dialogs/Dialog"; + +interface RevenueCatDisconnectDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + env: string; + onConfirm: () => void; + isLoading: boolean; +} + +export const RevenueCatDisconnectDialog = ({ + open, + onOpenChange, + env, + onConfirm, + isLoading, +}: RevenueCatDisconnectDialogProps) => { + return ( + + + + Disconnect RevenueCat? + + This removes the {env} RevenueCat connection. Autumn will stop + receiving purchase events until you reconnect. Your products and + mappings are kept. + + +
+ + +
+
+
+ ); +}; diff --git a/vite/src/views/developer/configure-revenuecat/components/RevenueCatProjectSheet.tsx b/vite/src/views/developer/configure-revenuecat/components/RevenueCatProjectSheet.tsx new file mode 100644 index 000000000..95ebfbaf8 --- /dev/null +++ b/vite/src/views/developer/configure-revenuecat/components/RevenueCatProjectSheet.tsx @@ -0,0 +1,210 @@ +import { useMemo, useState } from "react"; +import { toast } from "sonner"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Button } from "@/components/v2/buttons/Button"; +import { ShortcutButton } from "@/components/v2/buttons/ShortcutButton"; +import { FormLabel } from "@/components/v2/form/FormLabel"; +import { Input } from "@/components/v2/inputs/Input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/v2/selects/Select"; +import { + SheetFooter, + SheetHeader, +} from "@/components/v2/sheets/SharedSheetComponents"; +import { Sheet, SheetContent } from "@/components/v2/sheets/Sheet"; +import { useRCProjects } from "@/hooks/queries/revcat/useRCProjects"; +import { getBackendErr } from "@/utils/genUtils"; + +interface RevenueCatProjectSheetProps { + open: boolean; + onOpenChange: (open: boolean) => void; + env: string; + oauthConnected: boolean; + value: string; + onValueChange: (value: string) => void; + onSave: () => void; + isLoading: boolean; +} + +export function RevenueCatProjectSheet({ + open, + onOpenChange, + env, + oauthConnected, + value, + onValueChange, + onSave, + isLoading, +}: RevenueCatProjectSheetProps) { + const label = env === "live" ? "Project" : "Sandbox Project"; + + return ( + + + + +
+ {oauthConnected ? ( + + ) : ( +
+ + {`${label} ID`} + + onValueChange(e.target.value)} + placeholder="Enter project ID..." + /> +
+ )} +
+ + + onOpenChange(false)} + singleShortcut="escape" + > + Cancel + + + Save + + +
+
+ ); +} + +function ProjectSelect({ + open, + value, + onValueChange, +}: { + open: boolean; + value: string; + onValueChange: (value: string) => void; +}) { + const { projects, isLoading, createProject, isCreating } = useRCProjects({ + enabled: open, + }); + const [showCreate, setShowCreate] = useState(false); + const [newName, setNewName] = useState(""); + + const items = useMemo( + () => Object.fromEntries(projects.map((p) => [p.id, p.name || p.id])), + [projects], + ); + + const handleCreate = async () => { + const name = newName.trim(); + if (!name) return; + try { + const project = await createProject(name); + onValueChange(project.id); + toast.success(`Created project "${project.name || project.id}"`); + setNewName(""); + setShowCreate(false); + } catch (error) { + toast.error(getBackendErr(error, "Failed to create project")); + } + }; + + if (isLoading) { + return ; + } + + return ( +
+ {projects.length === 0 ? ( +
+ No RevenueCat projects found for this account. +
+ ) : ( + + )} + +
+ {showCreate ? ( +
+ + New project name + + setNewName(e.target.value)} + placeholder="eg. My App" + /> +
+ + +
+
+ ) : ( + + )} +
+
+ ); +} diff --git a/vite/src/views/developer/configure-revenuecat/components/RevenueCatSyncSheet.tsx b/vite/src/views/developer/configure-revenuecat/components/RevenueCatSyncSheet.tsx new file mode 100644 index 000000000..8f1a66cd0 --- /dev/null +++ b/vite/src/views/developer/configure-revenuecat/components/RevenueCatSyncSheet.tsx @@ -0,0 +1,209 @@ +import { WarningIcon } from "@phosphor-icons/react"; +import { useMemo, useState } from "react"; +import { toast } from "sonner"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Badge } from "@/components/v2/badges/Badge"; +import { ShortcutButton } from "@/components/v2/buttons/ShortcutButton"; +import { Checkbox } from "@/components/v2/checkboxes/Checkbox"; +import { + SheetFooter, + SheetHeader, +} from "@/components/v2/sheets/SharedSheetComponents"; +import { Sheet, SheetContent } from "@/components/v2/sheets/Sheet"; +import { useOrg } from "@/hooks/common/useOrg"; +import { + type RCPreflightItem, + useRCPreflight, +} from "@/hooks/queries/revcat/useRCPreflight"; +import { useRCSync } from "@/hooks/queries/revcat/useRCSync"; +import { useProductsQuery } from "@/hooks/queries/useProductsQuery"; +import { cn } from "@/lib/utils"; +import { useEnv } from "@/utils/envUtils"; +import { getBackendErr } from "@/utils/genUtils"; + +interface RevenueCatSyncSheetProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +type SyncAction = "create" | "rename" | "in_sync"; + +const formatPrice = (amountMicros: number, currency: string) => + `${(amountMicros / 1_000_000).toLocaleString(undefined, { + style: "currency", + currency, + })}`; + +const getPriceWarning = (item?: RCPreflightItem): string | null => { + if (!item?.rc_exists || !item.autumn_price) return null; + if (!item.rc_price) return "No store price set"; + if (item.rc_price.amount_micros !== item.autumn_price.amount_micros) { + return `Autumn ${formatPrice(item.autumn_price.amount_micros, item.autumn_price.currency)} ≠ store ${formatPrice(item.rc_price.amount_micros, item.rc_price.currency)}`; + } + return null; +}; + +export function RevenueCatSyncSheet({ + open, + onOpenChange, +}: RevenueCatSyncSheetProps) { + const env = useEnv(); + const { org } = useOrg(); + const { products, isLoading: productsLoading } = useProductsQuery(); + const { items: preflight, isLoading: preflightLoading } = useRCPreflight({ + enabled: open, + }); + const { sync, isSyncing } = useRCSync(); + + const [selected, setSelected] = useState>({}); + + const rows = useMemo(() => { + const byPlan = new Map(preflight.map((item) => [item.plan_id, item])); + return (products ?? []).map((product) => { + const item = byPlan.get(product.id); + const name = product.name || product.id; + let action: SyncAction = "create"; + if (item?.rc_exists) { + action = item.rc_name !== name ? "rename" : "in_sync"; + } + return { + id: product.id, + name, + action, + priceWarning: getPriceWarning(item), + }; + }); + }, [products, preflight]); + + const selectedIds = Object.keys(selected).filter((id) => selected[id]); + const isLoading = productsLoading || preflightLoading; + + const handleSync = async () => { + if (selectedIds.length === 0) return; + try { + const results = await sync(selectedIds); + const synced = results.filter((r) => r.status === "synced").length; + const skipped = results.filter((r) => r.status === "skipped").length; + const errored = results.filter((r) => r.status === "error").length; + toast.success( + `Synced ${synced} plan(s)${skipped ? `, ${skipped} skipped` : ""}${errored ? `, ${errored} failed` : ""}`, + ); + setSelected({}); + onOpenChange(false); + } catch (error) { + toast.error(getBackendErr(error, "Failed to sync products")); + } + }; + + return ( + + + + +
+
+ +

+ Test Store prices are set automatically from each plan's price. Real + App Store / Google Play prices are owned by Apple/Google — set or + confirm those in App Store Connect / Play Console. +

+
+
+ +
+ {isLoading ? ( +
+ + +
+ ) : rows.length === 0 ? ( +
+ No plans found. +
+ ) : ( +
+ {rows.map((row) => { + const isSelected = !!selected[row.id]; + return ( + + ); + })} +
+ )} +
+ + + onOpenChange(false)} + singleShortcut="escape" + > + Cancel + + + Sync {selectedIds.length || ""} + + +
+
+ ); +} diff --git a/vite/src/views/developer/configure-revenuecat/components/RevenueCatWebhookCard.tsx b/vite/src/views/developer/configure-revenuecat/components/RevenueCatWebhookCard.tsx new file mode 100644 index 000000000..71fa268d8 --- /dev/null +++ b/vite/src/views/developer/configure-revenuecat/components/RevenueCatWebhookCard.tsx @@ -0,0 +1,139 @@ +import { useState } from "react"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Badge } from "@/components/v2/badges/Badge"; +import { Button } from "@/components/v2/buttons/Button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/v2/cards/Card"; +import { + CodeGroup, + CodeGroupCodeSolidColour, + CodeGroupContent, + CodeGroupCopyButton, + CodeGroupList, + CodeGroupTab, +} from "@/components/v2/CodeGroup"; +import { useRCWebhook } from "@/hooks/queries/revcat/useRCWebhook"; +import { cn } from "@/lib/utils"; +import { ActiveDot } from "@/views/migrations/migration/live/ActiveDot"; + +const StatusDot = ({ tone }: { tone: "green" | "red" | "muted" }) => { + if (tone === "green") return ; + return ( + + ); +}; + +const WebhookCodeBlock = ({ + url, + secret, +}: { + url: string | null; + secret: string | null; +}) => { + const tabs = [ + url ? { value: "url", label: "Webhook URL", text: url } : null, + secret ? { value: "secret", label: "Webhook Secret", text: secret } : null, + ].filter((tab): tab is { value: string; label: string; text: string } => !!tab); + + const [active, setActive] = useState(tabs[0]?.value ?? "url"); + + if (tabs.length === 0) return null; + const activeText = tabs.find((tab) => tab.value === active)?.text ?? ""; + + return ( + setActive(value as string)} + className="min-w-0" + > + + {tabs.map((tab) => ( + + {tab.label} + + ))} + navigator.clipboard.writeText(activeText)} + /> + + {tabs.map((tab) => ( + + + {tab.text} + + + ))} + + ); +}; + +export function RevenueCatWebhookCard() { + const { status, url, secret, isLoading, register, isRegistering } = + useRCWebhook(); + + const indicator = { + registered: { dot: "green" as const, label: "Active", variant: "green" as const }, + not_registered: { dot: "red" as const, label: "Not set up", variant: "muted" as const }, + unknown: { dot: "muted" as const, label: "Can't verify", variant: "muted" as const }, + }[status]; + + return ( + + +
+ Webhook +
+ + + {indicator.label} + +
+
+ + RevenueCat sends purchase events here so Autumn can grant entitlements. + Autumn registers this automatically. + +
+ + {isLoading ? ( + + ) : ( + <> +
+ +
+ +
+ + Or configure it manually in the RevenueCat console: + + +
+ + )} +
+
+ ); +} diff --git a/vite/src/views/developer/configure-revenuecat/components/RevenueCatWebhookSecret.tsx b/vite/src/views/developer/configure-revenuecat/components/RevenueCatWebhookSecret.tsx deleted file mode 100644 index 9d757b8a8..000000000 --- a/vite/src/views/developer/configure-revenuecat/components/RevenueCatWebhookSecret.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { - CodeGroup, - CodeGroupCodeSolidColour, - CodeGroupContent, - CodeGroupCopyButton, - CodeGroupList, - CodeGroupTab, -} from "@/components/v2/CodeGroup"; -import { Skeleton } from "@/components/ui/skeleton"; -import { FormLabel } from "@/components/v2/form/FormLabel"; - -interface RevenueCatWebhookSecretProps { - env: string; - webhookSecret?: string; -} - -export const RevenueCatWebhookSecret = ({ - env, - webhookSecret, -}: RevenueCatWebhookSecretProps) => { - return ( -
- - - {env === "live" ? "Webhook Secret" : "Sandbox Webhook Secret"} - - -

- This is the webhook secret for RevenueCat events. You must set this - value in the RevenueCat console. -

- {webhookSecret ? ( - - - - {env === "live" ? "Live" : "Sandbox"} - - navigator.clipboard.writeText(webhookSecret)} - /> - - - - {webhookSecret} - - - - ) : ( - - )} -
- ); -}; diff --git a/vite/src/views/developer/configure-revenuecat/components/RevenueCatWebhookUrl.tsx b/vite/src/views/developer/configure-revenuecat/components/RevenueCatWebhookUrl.tsx deleted file mode 100644 index 160b81c68..000000000 --- a/vite/src/views/developer/configure-revenuecat/components/RevenueCatWebhookUrl.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { - CodeGroup, - CodeGroupCodeSolidColour, - CodeGroupContent, - CodeGroupCopyButton, - CodeGroupList, - CodeGroupTab, -} from "@/components/v2/CodeGroup"; -import { FormLabel } from "@/components/v2/form/FormLabel"; - -interface RevenueCatWebhookUrlProps { - env: string; - orgId?: string; -} - -export const RevenueCatWebhookUrl = ({ - env, - orgId, -}: RevenueCatWebhookUrlProps) => { - const webhookUrl = `https://api.useautumn.com/webhooks/revenuecat/${orgId}/${env}`; - - return ( -
- - Webhook URL - -

- This is the webhook URL for your RevenueCat integration. You should - provide this to RevenueCat as the webhook URL in your project settings. -

- - - - {env === "live" ? "Live" : "Sandbox"} - - navigator.clipboard.writeText(webhookUrl)} - /> - - - - {webhookUrl} - - - -
- ); -}; diff --git a/vite/src/views/migrations/migration/MigrationView.tsx b/vite/src/views/migrations/migration/MigrationView.tsx index a14d001fe..a7f42e11f 100644 --- a/vite/src/views/migrations/migration/MigrationView.tsx +++ b/vite/src/views/migrations/migration/MigrationView.tsx @@ -1,6 +1,5 @@ -import { AnimatePresence, motion } from "motion/react"; +import { motion } from "motion/react"; import { useCallback, useEffect } from "react"; -import { createPortal } from "react-dom"; import { useHotkeys } from "react-hotkeys-hook"; import { useNavigate, useParams } from "react-router"; import { @@ -9,8 +8,8 @@ import { BreadcrumbList, BreadcrumbSeparator, } from "@/components/ui/breadcrumb"; -import { SheetContainer } from "@/components/v2/sheets/InlineSheet"; -import { SheetCloseButton } from "@/components/v2/sheets/SheetCloseButton"; +import { InlineSheetPanel } from "@/components/v2/sheets/InlineSheetPanel"; +import { SheetBackdrop } from "@/components/v2/sheets/SheetBackdrop"; import { useMigrationsQuery } from "@/hooks/queries/useMigrationsQuery"; import { navigateTo } from "@/utils/genUtils"; import { SHEET_ANIMATION } from "@/views/customers2/customer/customerAnimations"; @@ -84,44 +83,22 @@ export function MigrationView() {
- {createPortal( - - {selectedCustomer && ( - - )} - , - document.body, - )} + - + {selectedCustomer && ( - - - - - - + )} - +
); } diff --git a/vite/src/views/products/plan/ProductSheets.tsx b/vite/src/views/products/plan/ProductSheets.tsx index 52ad52d74..784a521cd 100644 --- a/vite/src/views/products/plan/ProductSheets.tsx +++ b/vite/src/views/products/plan/ProductSheets.tsx @@ -1,14 +1,11 @@ import { type ProductItem, productV2ToFeatureItems } from "@autumn/shared"; -import { AnimatePresence, motion } from "motion/react"; import { useEffect, useRef } from "react"; import { useDiscardItemAndClose, useProduct, useSheet, } from "@/components/v2/inline-custom-plan-editor/PlanEditorContext"; -import { SheetContainer } from "@/components/v2/sheets/InlineSheet"; -import { SheetCloseButton } from "@/components/v2/sheets/SheetCloseButton"; -import { useIsMobile } from "@/hooks/useIsMobile"; +import { InlineSheetPanel } from "@/components/v2/sheets/InlineSheetPanel"; import { getItemId } from "@/utils/product/productItemUtils"; import { ProductItemContext } from "../product/product-item/ProductItemContext"; @@ -20,7 +17,6 @@ import { SelectFeatureSheet } from "./components/SelectFeatureSheet"; import { SHEET_ANIMATION } from "./planAnimations"; export const ProductSheets = () => { - const isMobile = useIsMobile(); const { product, setProduct } = useProduct(); const { sheetType, @@ -43,13 +39,35 @@ export const ProductSheets = () => { const featureItems = productV2ToFeatureItems({ items: product.items }); - const isCurrentItem = (item: ProductItem) => { - const actualIndex = product.items?.indexOf(item) ?? -1; - const currentItemId = getItemId({ item, itemIndex: actualIndex }); - return itemId === currentItemId; - }; + const matchedItemIndex = product.items + ? product.items.findIndex( + (item, index) => + !!item && + featureItems.includes(item) && + getItemId({ item, itemIndex: index }) === itemId, + ) + : -1; - const currentItem = featureItems.find(isCurrentItem); + const editingIndexRef = useRef(null); + + useEffect(() => { + if (matchedItemIndex !== -1) { + editingIndexRef.current = matchedItemIndex; + } else if (itemId === null) { + editingIndexRef.current = null; + } + }, [matchedItemIndex, itemId]); + + const resolvedItemIndex = + matchedItemIndex !== -1 + ? matchedItemIndex + : editingIndexRef.current !== null && + editingIndexRef.current < (product.items?.length ?? 0) + ? editingIndexRef.current + : -1; + + const currentItem = + resolvedItemIndex !== -1 ? product.items?.[resolvedItemIndex] : undefined; const lastItemIdRef = useRef(null); @@ -101,14 +119,10 @@ export const ProductSheets = () => { return; } - if (!product || !product.items) return; - - const currentItemIndex = product.items.findIndex(isCurrentItem); - - if (currentItemIndex === -1) return; + if (!product || !product.items || resolvedItemIndex === -1) return; const updatedItems = [...product.items]; - updatedItems[currentItemIndex] = updatedItem; + updatedItems[resolvedItemIndex] = updatedItem; setProduct({ ...product, items: updatedItems }); }; @@ -162,22 +176,12 @@ export const ProductSheets = () => { }; return ( - - {sheetType && ( - - - - {renderSheet()} - - - )} - + + {renderSheet()} + ); }; diff --git a/vite/src/views/products/plan/components/PlanEditor.tsx b/vite/src/views/products/plan/components/PlanEditor.tsx index bfc67e404..7f9987070 100644 --- a/vite/src/views/products/plan/components/PlanEditor.tsx +++ b/vite/src/views/products/plan/components/PlanEditor.tsx @@ -45,9 +45,10 @@ export const PlanEditor = () => { )}
- + + ); diff --git a/vite/src/views/products/plan/components/edit-plan-feature/EditPlanFeatureSheet.tsx b/vite/src/views/products/plan/components/edit-plan-feature/EditPlanFeatureSheet.tsx index 95b2d427d..d3982aaf8 100644 --- a/vite/src/views/products/plan/components/edit-plan-feature/EditPlanFeatureSheet.tsx +++ b/vite/src/views/products/plan/components/edit-plan-feature/EditPlanFeatureSheet.tsx @@ -134,12 +134,16 @@ export function EditPlanFeatureSheet({ + <> Define how customers on plan{" "} - {product.name} can - use feature{" "} - {feature?.name} -

+ + {product.name} + {" "} + can use feature{" "} + + {feature?.name} + + } action={ { + // parseLocalTimestamp parses local and formatDateShort renders local, so the + // wall-clock day round-trips regardless of the runner's zone. Before the fix + // (parse-as-UTC) this was a day behind for west-of-UTC viewers. + const label = formatPeriodLabel({ + period: "2026-06-04 00:00:00", + interval: "30d", + }); + expect(label).toBe("4 Jun"); +}); + +test("parseLocalTimestamp keeps the bare string's wall-clock as local", () => { + const date = parseLocalTimestamp("2026-06-04 13:00:00"); + expect(date.getFullYear()).toBe(2026); + expect(date.getMonth()).toBe(5); // June (0-indexed) + expect(date.getDate()).toBe(4); + expect(date.getHours()).toBe(13); +}); + +test("parseUTCTimestamp still treats bare strings as UTC (hour view / raw events)", () => { + // Hour buckets and the raw-events table are genuine UTC and must not change. + const date = parseUTCTimestamp("2026-06-04 13:00:00"); + expect(date.getUTCFullYear()).toBe(2026); + expect(date.getUTCMonth()).toBe(5); + expect(date.getUTCDate()).toBe(4); + expect(date.getUTCHours()).toBe(13); +});